The have bucket and its grants, a module and build per resource, the bus's connect events for the watchman, and a new module registered at its merge's Approve (issues 1136, 1145, 1147, 1146) #383

Merged
mesh-tools merged 12 commits from feat/delivery-have into main 2026-10-18 18:36:13 +00:00
36 changed files with 1940 additions and 86 deletions
+1 -1
View File
@@ -201,7 +201,7 @@ func routerRecordOf(conn *nats.Conn, inv *inventory.Inventory) func(ctx context.
} }
// asksRecords is the bucket the asks seat's declarer keeps its record of asks in. // asksRecords is the bucket the asks seat's declarer keeps its record of asks in.
func asksRecords(ctx context.Context, inv *inventory.Inventory) (string, error) { func asksRecords(ctx context.Context, inv catalogueReader) (string, error) {
declared, err := inv.Catalogue(ctx) declared, err := inv.Catalogue(ctx)
if err != nil { if err != nil {
return "", err return "", err
+34 -6
View File
@@ -389,10 +389,31 @@ func (f *sourceForms) onTheForge(repository, seat string) (owner, name string, o
// least one required status, no administrator merging past one. // least one required status, no administrator merging past one.
// //
// Anything else only when the build request was kept as asked at the controller's terminal, for this very // Anything else only when the build request was kept as asked at the controller's terminal, for this very
// repository and path. path is the module's directory as built; branch the trunk it is registered from. // repository and path; or, for the build of a merge, when mesh-delivery announced it on the operator's Approve of
// that head (novox/hq ADR 0420 §7 for a moved module, ADR 0432 for a new one), the forge's checks still holding. path is the module's directory as built; branch the trunk it is registered from.
func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module string, built inventory.Source, func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module string, built inventory.Source,
buildID, path, branch string) (int64, error) { buildID, path, branch string) (int64, error) {
forms := newSourceForms(ctx, inv) return mayRegisterIn(ctx, inv, newSourceForms(ctx, inv), module, built, buildID, path, branch)
}
// catalogueReader is the catalogue's manifests, as the router's records are found in them.
type catalogueReader interface {
Catalogue(ctx context.Context) (map[string]catalogue.Manifest, error)
}
// registrations is what a registration's decision reads of the mesh: *inventory.Inventory, or a test's stand-in.
type registrations interface {
catalogueReader
SourceOf(ctx context.Context, module string) (inventory.Source, error)
SourceIdentity(ctx context.Context, module string) (int64, error)
Catalogued(ctx context.Context) ([]inventory.Entry, error)
BuildRequestByID(ctx context.Context, id string) (inventory.BuildRequest, bool, error)
MergeOf(ctx context.Context, repository, commit string) (inventory.BatchedMerge, bool, error)
}
// mayRegisterIn is mayRegisterFrom on what it reads of the mesh handed in.
func mayRegisterIn(ctx context.Context, inv registrations, forms *sourceForms, module string, built inventory.Source,
buildID, path, branch string) (int64, error) {
was, err := inv.SourceOf(ctx, module) was, err := inv.SourceOf(ctx, module)
isNew := errors.Is(err, inventory.ErrNoSuchModule) isNew := errors.Is(err, inventory.ErrNoSuchModule)
if err != nil && !isNew { if err != nil && !isNew {
@@ -404,7 +425,8 @@ func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module strin
} }
refuse := func(why string) (int64, error) { refuse := func(why string) (int64, error) {
return 0, fmt.Errorf("%w: %s. A module is registered from such a source only by a build asked at the "+ return 0, fmt.Errorf("%w: %s. A module is registered from such a source only by a build asked at the "+
"controller's terminal, never through a verb: a registered module is what the next push sends, and whoever "+ "controller's terminal, or by the build of a merge mesh-delivery announced on the operator's Approve of that "+
"head, never through a verb: a registered module is what the next push sends, and whoever "+
"may call a verb includes agents (novox/hq ADR 0266)", errNotItsSource, why) "may call a verb includes agents (novox/hq ADR 0266)", errNotItsSource, why)
} }
@@ -438,8 +460,11 @@ func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module strin
// **Or the move the operator approved** (novox/hq ADR 0420 §7, issue 1025): a known module built from another // **Or the move the operator approved** (novox/hq ADR 0420 §7, issue 1025): a known module built from another
// repository by a merge, whose pull request mesh-delivery merged on the operator's Approve of that head. // repository by a merge, whose pull request mesh-delivery merged on the operator's Approve of that head.
// A verb alone, or a merge with no such warrant, is refused as before. // A verb alone, or a merge with no such warrant, is refused as before.
// **Or a new module from a repository nothing registered is built from, on the same warrant** (novox/hq ADR 0432,
// issue 1146): the build of a merge mesh-delivery announced on the operator's Approve of that head registers it.
// The forge's protection and the repository's identity are checked below as for every registration.
movedWhy := "" movedWhy := ""
if why != "" && !terminal && !isNew { if why != "" && !terminal {
accepted, err := approvedMove(ctx, inv, forms, buildID, built) accepted, err := approvedMove(ctx, inv, forms, buildID, built)
switch { switch {
case err == nil: case err == nil:
@@ -507,7 +532,10 @@ func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module strin
owner, name, m, facts.ID, m, id)) owner, name, m, facts.ID, m, id))
} }
} }
if movedWhy != "" { if movedWhy != "" && isNew {
fmt.Printf("%s: %s is new, from %s/%s — registered at the operator's Approve: %s\n", buildID, module, owner, name,
movedWhy)
} else if movedWhy != "" {
fmt.Printf("%s: %s moved from %s to %s/%s — registered at the operator's Approve: %s\n", buildID, module, fmt.Printf("%s: %s moved from %s to %s/%s — registered at the operator's Approve: %s\n", buildID, module,
sourceWords(was.Repository, was.Seat), owner, name, movedWhy) sourceWords(was.Repository, was.Seat), owner, name, movedWhy)
} }
@@ -516,7 +544,7 @@ func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module strin
// askedHereFor says the build was asked at the controller's terminal, for this repository and this path: a kept // askedHereFor says the build was asked at the controller's terminal, for this repository and this path: a kept
// request marked so, whose source is the outcome's (novox/hq ADR 0266). // request marked so, whose source is the outcome's (novox/hq ADR 0266).
func askedHereFor(ctx context.Context, inv *inventory.Inventory, forms *sourceForms, buildID string, func askedHereFor(ctx context.Context, inv registrations, forms *sourceForms, buildID string,
built inventory.Source, path string) (bool, error) { built inventory.Source, path string) (bool, error) {
r, found, err := inv.BuildRequestByID(ctx, buildID) r, found, err := inv.BuildRequestByID(ctx, buildID)
if err != nil || !found || !r.AtTerminal { if err != nil || !found || !r.AtTerminal {
@@ -317,7 +317,7 @@ func TestAModuleMovesOnTheOperatorsApproveOfTheMergeThatMovedIt(t *testing.T) {
keptDeliveryMerge = func(_ context.Context, owner, repo string, number int, head string) (deliveryMerge, bool, error) { keptDeliveryMerge = func(_ context.Context, owner, repo string, number int, head string) (deliveryMerge, bool, error) {
return a.merge, !c.unkept && owner == "novox" && repo == "mesh-host" && number == 7 && head == a.merge.HeadSHA, nil return a.merge, !c.unkept && owner == "novox" && repo == "mesh-host" && number == 7 && head == a.merge.HeadSHA, nil
} }
routerAskOf = func(_ context.Context, _ *inventory.Inventory, id string) (*asks.Ask, *asks.Warrant, error) { routerAskOf = func(_ context.Context, _ catalogueReader, id string) (*asks.Ask, *asks.Warrant, error) {
if id != a.merge.AskID { if id != a.merge.AskID {
t.Errorf("%s: the router was asked for %q", c.what, id) t.Errorf("%s: the router was asked for %q", c.what, id)
} }
@@ -0,0 +1,91 @@
//go:build integration
package main
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/link"
)
// 1145/AC1 on a store: declarationWith itself sets the module of each resource and the build of each module for a
// node recorded as reading them, and for no other. Skips without a store.
func TestAComposedDeclarationCarriesModulesAndBuildsOnlyForANodeThatReadsThem(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
start := time.Now().Add(-time.Hour)
manifest := resticFixture(false)
manifest["module"], manifest["version"] = "restic", "1"
raw, _ := json.Marshal(manifest)
b := link.BuildResult{ID: link.NewBuildID(start), Repository: "novox/mesh-catalog", Path: "modules/restic",
On: "anchor", Module: "restic", Commit: "c1", Manifest: raw,
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}}
if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, b)); err != nil {
t.Fatal(err)
}
for _, n := range []string{"anchor", "laptop"} {
if _, err := inv.Assign(ctx, n, "restic"); err != nil {
t.Fatal(err)
}
}
anchor, err := inv.NodeByName(ctx, "anchor")
if err != nil {
t.Fatal(err)
}
if err := inv.RecordReadsModules(ctx, anchor.ID, true); err != nil {
t.Fatal(err)
}
wantBuild, err := inv.BuildOf(ctx, "restic", "c1")
if err != nil || wantBuild == "" {
t.Fatalf("the build of restic at c1 is %q (%v)", wantBuild, err)
}
declare := func(node string) sendable {
t.Helper()
plan, settings, err := planFor(ctx, open, node)
if err != nil {
t.Fatal(err)
}
gens, err := generators(ctx, open)
if err != nil {
t.Fatal(err)
}
d, err := declarationWith(ctx, open, node, plan, settings, gens, Allocating)
if err != nil {
t.Fatal(err)
}
return d
}
reads := bodyKeys(t, declare("anchor"))
builds, _ := reads["builds"].(map[string]any)
if builds["restic"] != wantBuild {
t.Errorf("1145/AC1: anchor reads modules and was sent builds %v, want restic at %s", reads["builds"], wantBuild)
}
owned := 0
for _, r := range reads["resources"].([]any) {
r := r.(map[string]any)
if strings.HasPrefix(r["id"].(string), "restic.") {
owned++
if r["module"] != "restic" {
t.Errorf("1145/AC1: resource %v carries module %v, want restic", r["id"], r["module"])
}
}
}
if owned == 0 {
t.Fatal("1145/AC1: no resource of restic was composed")
}
plain := bodyKeys(t, declare("laptop"))
if _, there := plain["builds"]; there {
t.Errorf("1145/AC1: laptop never said it reads modules and was sent builds %v", plain["builds"])
}
for _, r := range plain["resources"].([]any) {
if _, there := r.(map[string]any)["module"]; there {
t.Fatalf("1145/AC1: laptop was sent a module on %v", r)
}
}
}
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"encoding/json"
"fmt"
"testing"
"github.com/novox/mesh-controller/internal/chain"
"github.com/novox/mesh-host/validate"
)
// The module of each resource and the build of each module (novox/hq issue 1145, design 47 release 1-2). The
// composer's owner of an id and the module's build go on the wire only to a machine whose node-engine said it reads
// them: an older one decodes strictly and refuses a key it does not know, whole.
func modulesDeclaration(reads bool) sendable {
return sendable{
Resources: []map[string]any{
{"id": "web.app.conf", "type": "file", "path": "/etc/app.conf", "content": "x\n"},
{"id": "web.app.data", "type": "file", "path": "/etc/data", "content": "d\n"},
{"id": "mesh.guard", "type": "file", "path": "/etc/guard", "content": "g\n"}},
moduleOf: map[string]string{"web.app.conf": "web.app", "web.app.data": "web.app"},
buildIDs: map[string]string{"web.app": "build-7"},
ReadsModules: reads,
}
}
// 1145/AC1
func TestAMachineThatReadsModulesIsSentEachResourcesModuleAndEachModulesBuild(t *testing.T) {
keys := bodyKeys(t, modulesDeclaration(true))
builds, _ := keys["builds"].(map[string]any)
if len(builds) != 1 || builds["web.app"] != "build-7" {
t.Fatalf("1145/AC1: the body carries builds %v", keys["builds"])
}
want := map[string]any{"web.app.conf": "web.app", "web.app.data": "web.app", "mesh.guard": nil}
for _, r := range keys["resources"].([]any) {
r := r.(map[string]any)
if r["module"] != want[r["id"].(string)] {
t.Errorf("1145/AC1: resource %v carries module %v, want %v", r["id"], r["module"], want[r["id"].(string)])
}
}
}
// 1145/AC2: the body is byte for byte what it is today.
func TestAMachineThatDoesNotReadModulesIsSentTheBodyItWasSentBefore(t *testing.T) {
d := modulesDeclaration(false)
got, err := d.Body()
if err != nil {
t.Fatal(err)
}
before := sendable{Resources: d.Resources}
want, _ := before.Body()
if string(got) != string(want) {
t.Fatalf("1145/AC2: the body changed:\n%s\nwas\n%s", got, want)
}
}
// 1145/AC4: the node-engine's own validator, as the gate runs it, takes the two words.
func TestTheNodeEnginesValidatorTakesAModulePerResourceAndABuildPerModule(t *testing.T) {
raw, err := modulesDeclaration(true).Body()
if err != nil {
t.Fatal(err)
}
var generic map[string]any
if err := json.Unmarshal(raw, &generic); err != nil {
t.Fatal(err)
}
if problems := validate.Declaration(raw); len(problems) != 0 {
t.Fatalf("1145/AC4: refused: %q\n%s", problems, fmt.Sprint(generic))
}
}
// The build a module carries is the one its assignment names when that is the commit carried, else the one built from
// the commit carried; none when neither is known.
func TestTheBuildOfACarriedCommitIsTheAssignmentsOrTheOneBuiltFromIt(t *testing.T) {
runs := map[string]chain.Ref{"a": {Build: "b-a", Commit: "c1"}, "b": {Build: "b-old", Commit: "c0"}}
looked := map[string]string{"b/c2": "b-new"}
got, err := wireBuilds(map[string]string{"a": "c1", "b": "c2", "c": "c3", "d": ""}, runs,
func(module, commit string) (string, error) { return looked[module+"/"+commit], nil })
if err != nil {
t.Fatal(err)
}
want := map[string]string{"a": "b-a", "b": "b-new"}
if len(got) != len(want) || got["a"] != want["a"] || got["b"] != want["b"] {
t.Fatalf("1145: builds %v, want %v", got, want)
}
}
+2 -2
View File
@@ -168,7 +168,7 @@ var keptDeliveryMerge = func(ctx context.Context, owner, repo string, number int
// routerAskOf is the router's record of one of mesh-delivery's asks: the ask as published and its warrant. A // routerAskOf is the router's record of one of mesh-delivery's asks: the ask as published and its warrant. A
// variable so a test can hand it one. // variable so a test can hand it one.
var routerAskOf = func(ctx context.Context, inv *inventory.Inventory, id string) (*asks.Ask, *asks.Warrant, error) { var routerAskOf = func(ctx context.Context, inv catalogueReader, id string) (*asks.Ask, *asks.Warrant, error) {
serving := servingBus.Load() serving := servingBus.Load()
if serving == nil { if serving == nil {
return nil, nil, errors.New("only the serving controller reads the router's record") return nil, nil, errors.New("only the serving controller reads the router's record")
@@ -216,7 +216,7 @@ var errNotAMerge = errors.New("the build is not of a merge")
// (ADR 0420 §7): the build is of a merge, that merge is a pull request mesh-delivery announced it made, and the // (ADR 0420 §7): the build is of a merge, that merge is a pull request mesh-delivery announced it made, and the
// ask it made it on holds an Approve by the operator of that head. Answers why it was accepted; an error says why // ask it made it on holds an Approve by the operator of that head. Answers why it was accepted; an error says why
// it was not, and the refusal of ADR 0266 stands. // it was not, and the refusal of ADR 0266 stands.
func approvedMove(ctx context.Context, inv *inventory.Inventory, forms *sourceForms, buildID string, func approvedMove(ctx context.Context, inv registrations, forms *sourceForms, buildID string,
built inventory.Source) (string, error) { built inventory.Source) (string, error) {
r, found, err := inv.BuildRequestByID(ctx, buildID) r, found, err := inv.BuildRequestByID(ctx, buildID)
if err != nil { if err != nil {
@@ -0,0 +1,163 @@
package main
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
)
// **The operator's Approve of a merge registers a new module from its repository** (novox/hq ADR 0432, issue 1146).
// The mesh as the decision reads it, with nothing registered from the repository, and the build request and merge the
// controller kept.
type anEmptyMesh struct {
request inventory.BuildRequest
asked bool
merge inventory.BatchedMerge
}
func (m *anEmptyMesh) Catalogue(context.Context) (map[string]catalogue.Manifest, error) {
return nil, nil
}
func (m *anEmptyMesh) SourceOf(context.Context, string) (inventory.Source, error) {
return inventory.Source{}, inventory.ErrNoSuchModule
}
func (m *anEmptyMesh) SourceIdentity(context.Context, string) (int64, error) { return 0, nil }
func (m *anEmptyMesh) Catalogued(context.Context) ([]inventory.Entry, error) { return nil, nil }
func (m *anEmptyMesh) BuildRequestByID(_ context.Context, id string) (inventory.BuildRequest, bool, error) {
return m.request, m.asked && id == m.request.ID, nil
}
func (m *anEmptyMesh) MergeOf(context.Context, string, string) (inventory.BatchedMerge, bool, error) {
return m.merge, true, nil
}
// aDeliveryMergeOf is the Approved merge of a pull request of novox/mesh-delivery, its heads keyed by that repository,
// and the ask and warrant bound to exactly those heads (the digest the Approve left covers them).
func aDeliveryMergeOf(t *testing.T, head string, covered map[string]string) anApprovedMerge {
t.Helper()
a := approvedMerge(t)
a.merge.Owner, a.merge.Repo, a.merge.Number, a.merge.HeadSHA = "novox", "mesh-delivery", 7, head
a.merge.Heads = covered
for n := range a.ask.Options {
d, err := asks.ActDigest(mergeAct(a.merge.AskID, a.ask.Options[n].ID, covered))
if err != nil {
t.Fatal(err)
}
a.ask.Options[n].Binds = d
}
a.warrant.AskDigest = a.ask.Digest()
return a
}
func TestANewModuleIsRegisteredOnTheOperatorsApproveOfItsMergeAndRefusedOtherwise(t *testing.T) {
const commit, head = "c0ffee0123456789", "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
built := inventory.Source{Repository: "novox/mesh-delivery", Seat: "git"}
for _, c := range []struct {
what string
change func(*anApprovedMerge)
request string // "merge", or "build" through a verb
unkept bool
forge any // what the forge says of the repository; nil is protected
built *inventory.Source
accepted bool
ac string
}{
{what: "the Approve of the merged head, a protected trunk on the forge", request: "merge", accepted: true, ac: "1146/AC1"},
{what: "a build asked through a verb", request: "build", ac: "1146/AC2"},
{what: "a merge with no warrant", request: "merge", unkept: true, ac: "1146/AC2"},
{what: "a merged head the Approve did not cover", request: "merge", ac: "1146/AC2", change: func(a *anApprovedMerge) {
*a = aDeliveryMergeOf(t, head, map[string]string{
"novox/mesh-delivery@eeeeeeeeeeee": "e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5"})
}},
{what: "heads other than the ones the Approve was bound to", request: "merge", ac: "1146/AC2", change: func(a *anApprovedMerge) {
a.merge.Heads = map[string]string{"novox/mesh-delivery@eeeeeeeeeeee": "e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5"}
}},
{what: "a repository not on the mesh's forge", request: "merge", ac: "1146/AC3",
built: &inventory.Source{Repository: "http://elsewhere.example/x/y.git"}},
{what: "a forge that cannot be asked", request: "merge", ac: "1146/AC3", forge: errors.New("connection refused")},
{what: "an unprotected trunk", request: "merge", ac: "1146/AC3", forge: forgeFacts{ID: 9, Guarded: false, Why: "direct push"}},
} {
t.Run(c.ac+" "+c.what, func(t *testing.T) {
a := aDeliveryMergeOf(t, head, map[string]string{"novox/mesh-delivery@a1a1a1a1a1a1": head})
if c.change != nil {
c.change(&a)
}
wasKept, wasAsk := keptDeliveryMerge, routerAskOf
t.Cleanup(func() { keptDeliveryMerge, routerAskOf = wasKept, wasAsk })
keptDeliveryMerge = func(context.Context, string, string, int, string) (deliveryMerge, bool, error) {
return a.merge, !c.unkept, nil
}
routerAskOf = func(context.Context, catalogueReader, string) (*asks.Ask, *asks.Warrant, error) {
return &a.ask, &a.warrant, nil
}
if c.forge != nil {
theForge.Store("novox/mesh-delivery", c.forge)
t.Cleanup(func() { theForge.Delete("novox/mesh-delivery") })
}
event, _ := json.Marshal(map[string]any{"owner": "novox", "repo": "mesh-delivery", "base": "main", "number": 7,
"head_sha": head, "merge_commit_sha": commit})
mesh := &anEmptyMesh{asked: true, merge: inventory.BatchedMerge{Repository: "novox/mesh-delivery", Branch: "main",
Commit: commit, Merged: time.Now(), Event: event},
request: inventory.BuildRequest{ID: "build-1", Repository: "novox/mesh-delivery", Seat: "git", Path: "",
Ref: "main", Commit: commit, For: c.request}}
forms := &sourceForms{bases: map[string]string{}, base: func(seat string) string {
if seat == "git" {
return "http://forge.internal:20000"
}
return ""
}}
src := built
if c.built != nil {
src = *c.built
}
id, err := mayRegisterIn(t.Context(), mesh, forms, "mesh-delivery-view", src, "build-1", "", "main")
switch {
case c.accepted && err != nil:
t.Fatalf("%s: the Approved merge's new module was refused: %v", c.ac, err)
case c.accepted && id == 0:
t.Fatalf("%s: registered with no forge identity to record", c.ac)
case !c.accepted && !errors.Is(err, errNotItsSource):
t.Fatalf("%s: %s was not refused: %v", c.ac, c.what, err)
case !c.accepted && c.ac == "1146/AC2" &&
(!strings.Contains(err.Error(), "controller's terminal") || !strings.Contains(err.Error(), "Approve")):
t.Fatalf("%s: the refusal does not name both ways in: %v", c.ac, err)
}
})
}
}
// A second new module from a repository whose first was registered is the case ADR 0266 §44 already has —
// "alongside": registered through a verb with no warrant asked of the router (1146/AC5).
type aMeshWithOneAlready struct{ anEmptyMesh }
func (m *aMeshWithOneAlready) Catalogued(context.Context) ([]inventory.Entry, error) {
e := inventory.Entry{Source: inventory.Source{Repository: "novox/mesh-delivery", Seat: "git"}}
e.Manifest.Module = "mesh-delivery-view"
return []inventory.Entry{e}, nil
}
func TestASecondNewModuleFromARepositoryIsJudgedAlongsideAndAsksNoWarrant(t *testing.T) {
wasKept, wasAsk := keptDeliveryMerge, routerAskOf
t.Cleanup(func() { keptDeliveryMerge, routerAskOf = wasKept, wasAsk })
keptDeliveryMerge = func(context.Context, string, string, int, string) (deliveryMerge, bool, error) {
t.Error("1146/AC5: the announced merges were asked for a module judged alongside")
return deliveryMerge{}, false, nil
}
routerAskOf = func(context.Context, catalogueReader, string) (*asks.Ask, *asks.Warrant, error) {
t.Error("1146/AC5: the router was asked for a module judged alongside")
return nil, nil, nil
}
mesh := &aMeshWithOneAlready{anEmptyMesh{asked: true,
request: inventory.BuildRequest{ID: "build-2", Repository: "novox/mesh-delivery", Seat: "git", For: "build"}}}
forms := &sourceForms{bases: map[string]string{}, base: func(string) string { return "http://forge.internal:20000" }}
built := inventory.Source{Repository: "novox/mesh-delivery", Seat: "git"}
if id, err := mayRegisterIn(t.Context(), mesh, forms, "mesh-delivery-watchman", built, "build-2", "", "main"); err != nil || id == 0 {
t.Fatalf("1146/AC5: a second module from a repository the catalogue builds from was refused (id %d): %v", id, err)
}
}
+127
View File
@@ -0,0 +1,127 @@
//go:build integration
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"testing"
"time"
"github.com/nats-io/nats.go/jetstream"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
"github.com/novox/mesh-controller/internal/testbus"
)
// 1146/AC4: a new module from a repository nothing registered is built from, whole against a real bus and the store
// (novox/hq ADR 0432, issue 1146; the warrant is read as for a move, ADR 0420 §7, issue 1025): mesh-delivery's word that it
// merged a pull request is published on the subject the controller consumes and kept in its bucket; the router's
// record of the ask is read back from the router's bucket by a direct get; and the build of a module from the
// repository that merge was made in is registered from there only when that record holds the operator's Approve of
// the merged head. Store-backed: skips without one.
func TestANewModuleFromANewRepositoryIsRegisteredOnlyOnTheOperatorsApproveOnTheBus(t *testing.T) {
for _, c := range []struct {
what string
answer bool // the router holds the operator's Approve of the merged heads
moved bool
}{
{what: "the router holds the operator's Approve", answer: true, moved: true},
{what: "the router holds no record of the ask"},
} {
t.Run(c.what, func(t *testing.T) {
open := theCatalogue(t)
ctx := t.Context()
inv := open.inventory
// The module declaring the router's records, as the controller finds them.
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "operator-channel", Version: "1",
DefinesSeats: []catalogue.SeatDeclaration{{Name: broker.AsksSeat, Records: []string{"asks"}}}},
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/operator-channel", BuiltFrom: "c0ffee"}); err != nil {
t.Fatal(err)
}
js, err := broker.Dial(testbus.URL(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(js.Close)
if err := broker.AssertMeshStreams(js); err != nil {
t.Fatal(err)
}
if err := js.EnsureControllerBuckets(); err != nil {
t.Fatal(err)
}
api, err := jetstream.New(js.Conn())
if err != nil {
t.Fatal(err)
}
records, err := api.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: broker.BucketName("operator-channel", "asks")})
if err != nil {
t.Fatal(err)
}
a := aDeliveryMergeOf(t, "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
map[string]string{"novox/mesh-delivery@a1a1a1a1a1a1": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"})
if c.answer {
record, _ := json.Marshal(map[string]any{"state": "closed", "ask": a.ask, "warrant": a.warrant})
if _, err := records.Put(ctx, theDelivery+"."+a.merge.AskID, record); err != nil {
t.Fatal(err)
}
}
server := link.ConnectNats(js, nil, noListener{})
if err := server.KeepsDeliveryMerges(keepingDeliveryMerges{js: server.JetStream}); err != nil {
t.Fatal(err)
}
// The events consumer exists, from now on, before the word is published: Receive would make it too late.
if err := broker.AssertServingConsumers(js); err != nil {
t.Fatal(err)
}
serving, stop := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { defer close(done); _ = server.Serve(serving) }()
t.Cleanup(func() { stop(); <-done })
servingBus.Store(js)
t.Cleanup(func() { servingBus.Store(nil) })
// mesh-delivery says it merged the pull request, and the controller keeps what it said.
body, _ := json.Marshal(a.merge)
if _, err := js.Context().Publish(broker.DeliveryMergedSubject, body); err != nil {
t.Fatal(err)
}
becomes(t, 15*time.Second, "the merge being kept", func() bool {
_, kept, err := keptDeliveryMerge(ctx, "novox", "mesh-delivery", 7, a.merge.HeadSHA)
return err == nil && kept
})
// The forge's merge of that pull request, as the controller heard it, and the build it asked for.
const commit = "c0ffee0123456789"
event, _ := json.Marshal(map[string]any{"owner": "novox", "repo": "mesh-delivery", "base": "main", "number": 7,
"head_sha": a.merge.HeadSHA, "merge_commit_sha": commit})
if _, err := inv.AddMerge(ctx, inventory.BatchedMerge{Repository: "novox/mesh-delivery", Branch: "main", Commit: commit,
Merged: time.Now(), Heard: time.Now(), Event: event}); err != nil {
t.Fatal(err)
}
id := fmt.Sprintf("build-%d", time.Now().Add(time.Hour).UnixNano())
if err := inv.RecordBuildRequest(ctx, inventory.BuildRequest{ID: id, Repository: "novox/mesh-delivery", Seat: "git",
Path: "modules/mesh-delivery-view", Ref: "main", Commit: commit, For: "merge"}); err != nil {
t.Fatal(err)
}
moved := onTrunk(id, "novox/mesh-delivery", "git", "modules/mesh-delivery-view", map[string]any{"module": "mesh-delivery-view", "version": "2"})
moved.Commit = commit
_, _, err = takeIn(ctx, inv, moved)
src, _ := inv.SourceOf(ctx, "mesh-delivery-view")
if c.moved && (err != nil || src.Repository != "novox/mesh-delivery") {
t.Fatalf("the Approved move was not registered from its repository: %v (built from %q)", err, src.Repository)
}
if !c.moved && (!errors.Is(err, errNotItsSource) || src.Repository != "") {
t.Fatalf("a new module with no Approve was not refused as it is: %v (built from %q)", err, src.Repository)
}
})
}
}
+89 -22
View File
@@ -16,6 +16,7 @@ import (
"github.com/novox/mesh-controller/internal/broker" "github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/chain"
"github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/licences" "github.com/novox/mesh-controller/internal/licences"
"github.com/novox/mesh-controller/internal/link" "github.com/novox/mesh-controller/internal/link"
@@ -500,33 +501,75 @@ func declarationWith(ctx context.Context, open *stores, node string,
// And which build of each module it carries, for the send to record (novox/hq issue 259, ADR // And which build of each module it carries, for the send to record (novox/hq issue 259, ADR
// 0221). Read only on the send path: a question about what would be sent records nothing. // 0221). Read only on the send path: a question about what would be sent records nothing.
if choosing == Allocating { if choosing == Allocating {
current, err := open.inventory.CurrentBuilds(ctx) carried, err := carriedOn(ctx, open, node, plan, composed.LeftOut, true)
if err != nil { if err != nil {
return sendable{}, err return sendable{}, err
} }
before, known, err := open.inventory.SentBuilds(ctx, node) out.Builds = carried
if err != nil { out.Bindings = boundToData(plan, composed.LeftOut)
return sendable{}, err }
} // And the module of each resource and the build of each module, to a machine whose node-engine said it reads them
if !known { // (novox/hq issue 1145) and to no other; the same whatever the composition is for, so what a question about the
before = nil // send digests is what the send digests.
} reads, err := open.inventory.ReadsModules(ctx, record.ID)
names := make([]string, 0, len(plan.Modules)) if err != nil {
for _, m := range plan.Modules { return sendable{}, err
names = append(names, m.Module) }
} if reads {
out.Builds = carriedBuilds(names, composed.LeftOut, current, before) carried := out.Builds
// A recorded module kept at the build the machine runs is recorded as carrying that one (ADR 0245). if choosing != Allocating {
kept, err := recordedKept(ctx, open, node) if carried, err = carriedOn(ctx, open, node, plan, composed.LeftOut, false); err != nil {
if err != nil { return sendable{}, err
return sendable{}, err
}
for m, was := range kept {
if _, carried := out.Builds[m]; carried {
out.Builds[m] = was
} }
} }
out.Bindings = boundToData(plan, composed.LeftOut) runs, err := open.inventory.AssignmentRuns(ctx, node)
if err != nil {
return sendable{}, err
}
builds, err := wireBuilds(carried, runs, func(module, commit string) (string, error) {
return open.inventory.BuildOf(ctx, module, commit)
})
if err != nil {
return sendable{}, err
}
out.ReadsModules, out.moduleOf, out.buildIDs = true, composed.Owner, builds
}
return out, nil
}
// carriedOn is the build of each module a declaration for node carries (see carriedBuilds), the module kept at the
// build a machine runs included (ADR 0245). Only a send (strict) fails for a kept module that cannot be read: a question
// about it is answered without.
func carriedOn(ctx context.Context, open *stores, node string, plan catalogue.Resolution, leftOut map[string]string,
strict bool) (map[string]string, error) {
current, err := open.inventory.CurrentBuilds(ctx)
if err != nil {
return nil, err
}
before, known, err := open.inventory.SentBuilds(ctx, node)
if err != nil {
return nil, err
}
if !known {
before = nil
}
names := make([]string, 0, len(plan.Modules))
for _, m := range plan.Modules {
names = append(names, m.Module)
}
out := carriedBuilds(names, leftOut, current, before)
// A recorded module kept at the build the machine runs is recorded as carrying that one (ADR 0245).
kept, err := recordedKept(ctx, open, node)
if err != nil {
if strict {
return nil, err
}
kept = nil
}
for m, was := range kept {
if _, carried := out[m]; carried {
out[m] = was
}
} }
return out, nil return out, nil
} }
@@ -2109,3 +2152,27 @@ func sortedNames[V any](m map[string]V) []string {
sort.Strings(out) sort.Strings(out)
return out 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 wireBuilds(carried map[string]string, runs map[string]chain.Ref, lookup func(module, commit string) (string, error)) (map[string]string, error) {
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
}
build, err := lookup(module, commit)
if err != nil {
return nil, err
}
if build != "" {
out[module] = build
}
}
return out, nil
}
+30
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"sort" "sort"
"strings" "strings"
@@ -38,6 +39,13 @@ type sendable struct {
// the send; never on the wire. // the send; never on the wire.
composedFrom int64 composedFrom int64
actingEpoch uint64 actingEpoch uint64
// 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, because an older node-engine refuses a key it does not know, whole.
ReadsModules bool
// moduleOf is the composer's owner of each resource id, and buildIDs the build each module carries (its id):
// kept beside the resources, and on the wire only to a machine that reads them.
moduleOf map[string]string
buildIDs map[string]string
// modules are the modules this declaration names, for the record of the send; never on the wire. // modules are the modules this declaration names, for the record of the send; never on the wire.
modules []string modules []string
// localNetwork is every port admitted from the local network and what it resolved to (novox/hq ADR // localNetwork is every port admitted from the local network and what it resolved to (novox/hq ADR
@@ -111,6 +119,28 @@ type adoptionEnvelope struct {
// Body is the declaration's bytes, as sent and as digested. // Body is the declaration's bytes, as sent and as digested.
func (s sendable) Body() ([]byte, error) { func (s sendable) Body() ([]byte, error) {
envelope := map[string]any{"declaration": 1, "resources": s.Resources} envelope := map[string]any{"declaration": 1, "resources": s.Resources}
if s.ReadsModules {
// The module of each resource the composer owns (what the mesh adds of its own has none), 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(s.Resources))
for n, r := range s.Resources {
module, owned := s.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(s.buildIDs) > 0 {
envelope["builds"] = s.buildIDs
}
}
if s.Adoption != nil { if s.Adoption != nil {
envelope["adoption"] = s.Adoption envelope["adoption"] = s.Adoption
} }
+2 -2
View File
@@ -3,7 +3,7 @@ module github.com/novox/mesh-controller
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018164924-2b420603fe83
github.com/jackc/pgx/v5 v5.10.0 github.com/jackc/pgx/v5 v5.10.0
github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
@@ -36,4 +36,4 @@ require (
// committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and // committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and
// fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without // fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without
// `go mod vendor` fails loudly, at once, everywhere. // `go mod vendor` fails loudly, at once, everywhere.
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261017162006-6469e8238443 replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5
+4 -4
View File
@@ -1,7 +1,7 @@
git.novox.be/novox/mesh-host v0.0.0-20261017162006-6469e8238443 h1:zUtpGbiH9DLjCPY+KJsm+PZ25yW6k/NmGVS3haGFfMM= git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5 h1:E5stfIeCN6uYgA9TCv+4K8JgaURg3zQppvUgJLaDMP4=
git.novox.be/novox/mesh-host v0.0.0-20261017162006-6469e8238443/go.mod h1:5QOm3cIfu/k9fN6QqgDbKGCcDst7Ul1UlcfsiMVm9j4= git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5/go.mod h1:4wyoCr6IIIIarEUuROThWDj7DAvJvIidqMjWcpSnOwE=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19 h1:mAMt3KFmrHzDiBv4MLL94opWcG9y73ISMrBUyisapQE= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018164924-2b420603fe83 h1:BrHyE3ifFMUQLIza6I/iNlEgdcysB9/Y+1FO6Q9xws8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018164924-2b420603fe83/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+25 -2
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/nats-io/nats.go/jetstream" "github.com/nats-io/nats.go/jetstream"
) )
@@ -189,14 +190,15 @@ func IsControllerBucket(bucket string) bool {
bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket || bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket ||
bucket == AssignmentsBucket || bucket == DeclaredConfigBucket || bucket == PredefinedBucket || bucket == AssignmentsBucket || bucket == DeclaredConfigBucket || bucket == PredefinedBucket ||
bucket == MergeWindowBucket || bucket == RolloutSilentBucket || bucket == CheckRunningBucket || bucket == MergeWindowBucket || bucket == RolloutSilentBucket || bucket == CheckRunningBucket ||
bucket == DeliveryMergesBucket || bucket == BuildsBucket || bucket == RolloutsBucket bucket == DeliveryMergesBucket || bucket == BuildsBucket || bucket == RolloutsBucket ||
bucket == delivery.HaveBucket
} }
// ControllerBuckets are the controller's own buckets, in the order they are asserted. // ControllerBuckets are the controller's own buckets, in the order they are asserted.
func ControllerBuckets() []string { func ControllerBuckets() []string {
return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket, return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket,
AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket, MergeWindowBucket, RolloutSilentBucket, CheckRunningBucket, AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket, MergeWindowBucket, RolloutSilentBucket, CheckRunningBucket,
DeliveryMergesBucket, BuildsBucket, RolloutsBucket} DeliveryMergesBucket, BuildsBucket, RolloutsBucket, delivery.HaveBucket}
} }
// ControllerBucketsAsserter is what raising the controller's buckets needs of a connection. // ControllerBucketsAsserter is what raising the controller's buckets needs of a connection.
@@ -370,6 +372,12 @@ func (j *JetStream) EnsureControllerBuckets() error {
return fmt.Errorf("asserting bucket %s: %w", c.bucket, err) return fmt.Errorf("asserting bucket %s: %w", c.bucket, err)
} }
} }
// **The have bucket: what each machine has of each module** (ADR 0429 §1, issue 1136). Twenty revisions of each key and
// no age. Asserted by the controller, written by each machine's node-engine under its own node's keys and by nobody
// else: the controller's grant leaves it out.
if _, err := js.CreateOrUpdateKeyValue(ctx, HaveBucketConfig()); err != nil {
return fmt.Errorf("asserting bucket %s: %w", delivery.HaveBucket, err)
}
// **The assignments keep the marker age they had as a mirror.** A stream that allows messages an age cannot be // **The assignments keep the marker age they had as a mirror.** A stream that allows messages an age cannot be
// made to disallow them (the server refuses: "message TTL status can not be disabled"), so the bucket that was // made to disallow them (the server refuses: "message TTL status can not be disabled"), so the bucket that was
// the assignments mirror is asserted with the age it was made with. Nothing is purged from it, so no marker is ever // the assignments mirror is asserted with the age it was made with. Nothing is purged from it, so no marker is ever
@@ -439,3 +447,18 @@ func EnsureLeaseBucket(ctx context.Context, js jetstream.JetStream) error {
} }
return nil return nil
} }
// HaveBucketConfig is the options of the have bucket (novox/hq ADR 0429 §1): delivery.HaveHistory revisions of each
// key, no age, a value under a kilobyte (a have is JSON of about 500 bytes), 64 MiB on disk like the assignments.
func HaveBucketConfig() jetstream.KeyValueConfig {
return jetstream.KeyValueConfig{
Bucket: delivery.HaveBucket,
Description: "what each machine has of each module, one key `<node>.<module>` (and `<node>.node` for the machine's " +
"own facts) (novox/hq ADR 0429, design 47): written by each machine's node-engine under its own node's keys " +
"alone, never by the controller; read by the delivery view and the watchman",
History: delivery.HaveHistory,
MaxValueSize: 4 << 10,
MaxBytes: 64 << 20,
Storage: jetstream.FileStorage,
}
}
+154
View File
@@ -0,0 +1,154 @@
//go:build integration
package broker
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"golang.org/x/crypto/bcrypt"
)
// **Each node's credential writes its own have keys and no other** (novox/hq ADR 0429 §1, issue 1136, 1136/AC4): on a
// real server whose users are composed as the controller composes them, node alpha's put of alpha.zsh is taken, its
// put of beta.zsh is refused by the server, a module reading node-engine.have is refused any put and its watch sees
// alpha.zsh.
func TestNatsANodePutsOnlyItsOwnHaveKeys(t *testing.T) { // 1136/AC4
hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
users, err := Users(Records{Nodes: []string{"alpha", "beta"}, Assigned: map[string][]Declared{
"alpha": {{Module: RuntimeModule}, {Module: "mesh-delivery-view", Reads: []string{"node-engine.have"}}},
"beta": {{Module: RuntimeModule}},
}})
if err != nil {
t.Fatal(err)
}
for i := range users {
users[i].PasswordHash = string(hash)
}
accounts, err := ComposeAccounts(users)
if err != nil {
t.Fatal(err)
}
conf := filepath.Join(t.TempDir(), "bus.conf")
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: "+
`"`+t.TempDir()+`"`+" }\n"+accounts), 0o600); err != nil {
t.Fatal(err)
}
opts, err := server.ProcessConfigFile(conf)
if err != nil {
t.Fatalf("the composed accounts do not parse: %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(10 * time.Second) {
t.Fatal("the bus did not come up")
}
t.Cleanup(s.Shutdown)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
connect := func(user string) (*nats.Conn, chan error) {
t.Helper()
refused := make(chan error, 8)
nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, "pw"), nats.CustomInboxPrefix("_INBOX."+user),
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { refused <- err }))
if err != nil {
t.Fatalf("%s cannot connect: %v", user, err)
}
t.Cleanup(nc.Close)
return nc, refused
}
controller, _ := connect(ControllerName)
cjs, err := jetstream.New(controller)
if err != nil {
t.Fatal(err)
}
if _, err := cjs.CreateOrUpdateKeyValue(ctx, HaveBucketConfig()); err != nil {
t.Fatalf("the controller cannot make %s: %v", delivery.HaveBucket, err)
}
alpha, _ := connect("node.alpha")
ajs, err := jetstream.New(alpha)
if err != nil {
t.Fatal(err)
}
akv, err := ajs.KeyValue(ctx, delivery.HaveBucket)
if err != nil {
t.Fatalf("node alpha cannot bind %s: %v", delivery.HaveBucket, err)
}
if _, err := akv.Put(ctx, "alpha.zsh", []byte(`{"v":1}`)); err != nil {
t.Fatalf("node alpha cannot put alpha.zsh: %v", err)
}
if e, err := akv.Get(ctx, "alpha.zsh"); err != nil || string(e.Value()) != `{"v":1}` {
t.Errorf("node alpha reads %v (%v) of its own key", e, err)
}
reader, readerRefused := connect("alpha.mesh-delivery-view")
for _, other := range []struct {
name string
nc *nats.Conn
ch chan error
key string
}{{"node alpha putting beta.zsh", alpha, nil, "beta.zsh"},
{"the reading module putting alpha.zsh", reader, readerRefused, "alpha.zsh"}} {
ch := other.ch
if ch == nil {
// alpha's own refusals arrive on its own handler: reconnect with one that is watched.
var nc *nats.Conn
nc, ch = connect("node.alpha")
other.nc = nc
}
if err := other.nc.Publish("$KV."+delivery.HaveBucket+"."+other.key, []byte(`{"v":99}`)); err != nil {
t.Fatal(err)
}
_ = other.nc.Flush()
select {
case err := <-ch:
if !errors.Is(err, nats.ErrPermissionViolation) {
t.Errorf("%s: the server said %v, want a permissions violation", other.name, err)
}
case <-time.After(3 * time.Second):
t.Errorf("the server did not refuse %s", other.name)
}
}
// The reader watches and sees alpha.zsh, and beta.zsh was never taken.
rjs, err := jetstream.New(reader)
if err != nil {
t.Fatal(err)
}
rkv, err := rjs.KeyValue(ctx, delivery.HaveBucket)
if err != nil {
t.Fatalf("a module reading node-engine.have cannot bind it: %v", err)
}
w, err := rkv.WatchAll(ctx)
if err != nil {
t.Fatalf("the reading module cannot watch: %v", err)
}
defer w.Stop()
seen := map[string]string{}
for e := range w.Updates() {
if e == nil {
break
}
seen[e.Key()] = string(e.Value())
}
if seen["alpha.zsh"] != `{"v":1}` || len(seen) != 1 {
t.Errorf("the reading module's watch sees %v, want alpha.zsh alone", seen)
}
}
+106
View File
@@ -0,0 +1,106 @@
package broker
import (
"slices"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/nats-io/nats.go/jetstream"
)
// 1136/AC1: the controller's start asserts node-engine_have with the history delivery keeps, and the bucket is one of
// the controller's own, so raising the buckets does not name it undeclared (ADR 0429 §1).
func TestTheHaveBucketIsTheControllersAndKeepsTwentyRevisions(t *testing.T) { // 1136/AC1
cfg := HaveBucketConfig()
if cfg.Bucket != delivery.HaveBucket || cfg.History != delivery.HaveHistory || delivery.HaveHistory != 20 {
t.Fatalf("the have bucket is asserted as %q with history %d, want %q with 20", cfg.Bucket, cfg.History, delivery.HaveBucket)
}
if cfg.Storage != jetstream.FileStorage || cfg.MaxBytes <= 0 || cfg.MaxValueSize <= 0 {
t.Fatalf("the have bucket is not bounded on disk: %+v", cfg)
}
if !IsControllerBucket(delivery.HaveBucket) || !slices.Contains(ControllerBuckets(), delivery.HaveBucket) {
t.Fatalf("%s is not among the controller's buckets", delivery.HaveBucket)
}
undeclared, err := RaiseBuckets(fakeBuckets{names: []string{delivery.HaveBucket}}, nil)
if err != nil {
t.Fatal(err)
}
if len(undeclared) != 0 {
t.Fatalf("%v are raised as undeclared", undeclared)
}
}
// 1136/AC2: a node may write and read the keys of its own node of the have bucket, with what a get, create, update and
// watch of them need, and no other key of it; the controller does not write it; an unsafe node name is refused.
func TestANodeWritesAndReadsOnlyItsOwnHaveKeys(t *testing.T) { // 1136/AC2
perms, err := PermissionsFor(Principal{Kind: KindNode, Node: "one", PasswordHash: "x"})
if err != nil {
t.Fatal(err)
}
b, stream := delivery.HaveBucket, "KV_"+delivery.HaveBucket
for _, want := range []string{
"$KV." + b + ".one.>",
"$JS.API.STREAM.INFO." + stream,
"$JS.API.DIRECT.GET." + stream + ".$KV." + b + ".one.>",
"$JS.API.CONSUMER.CREATE." + stream + ".*.$KV." + b + ".one.>",
"$JS.API.CONSUMER.DELETE." + stream + ".>",
"$JS.FC." + stream + ".>",
} {
has(t, perms.Publish, want)
}
for _, p := range perms.Publish {
if !strings.Contains(p, delivery.HaveBucket) {
continue
}
if strings.Contains(p, "KV."+b+".>") || strings.Contains(p, "KV."+b+".two") || strings.HasSuffix(p, "."+b+".*") ||
strings.Contains(p, "DIRECT.GET."+stream+".>") || strings.Contains(p, "CONSUMER.CREATE."+stream+".>") {
t.Errorf("the node is granted %q, which reaches keys of other nodes", p)
}
}
if err := CheckWriters(Principal{Kind: KindNode, Node: "one"}, perms.Publish); err != nil {
t.Fatalf("the node's own have keys are refused as a second writer: %v", err)
}
controller, err := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"})
if err != nil {
t.Fatal(err)
}
for _, p := range controller.Publish {
if SubjectsOverlap(p, "$KV."+b+".one.zsh") {
t.Errorf("the controller may write the have keys through %q", p)
}
}
for _, bad := range []string{"on.e", "one.>", "*", "o>ne"} {
if _, err := PermissionsFor(Principal{Kind: KindNode, Node: bad, PasswordHash: "x"}); err == nil {
t.Errorf("node %q was accepted as part of a have subject", bad)
}
}
// A principal that is not a node writes no have key: the writers table refuses it.
if err := CheckWriters(Principal{Kind: KindModule, Module: "billing", Node: "one"}, []string{"$KV." + b + ".one.>"}); err == nil {
t.Error("a module granted the have keys of a node is not refused as a second writer")
}
if err := CheckWriters(Principal{Kind: KindNode, Node: "two"}, []string{"$KV." + b + ".one.>"}); err == nil {
t.Error("a node granted another node's have keys is not refused as a second writer")
}
}
// 1136/AC3: a module whose manifest reads node-engine.have, mesh-controller.assignments and mesh-controller.builds may
// watch and get every key of the three and write none.
func TestAModuleReadingHaveAssignmentsAndBuildsReadsAndWritesNone(t *testing.T) { // 1136/AC3
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "mesh-delivery-view", PasswordHash: "x",
Reads: []string{"node-engine.have", "mesh-controller.assignments", "mesh-controller.builds"}})
if err != nil {
t.Fatal(err)
}
for _, b := range []string{delivery.HaveBucket, AssignmentsBucket, BuildsBucket} {
stream := "KV_" + b
has(t, perms.Publish, "$JS.API.STREAM.INFO."+stream)
has(t, perms.Publish, "$JS.API.DIRECT.GET."+stream+".>")
has(t, perms.Publish, "$JS.API.CONSUMER.CREATE."+stream+".>")
for _, p := range perms.Publish {
if SubjectsOverlap(p, "$KV."+b+".any.key") {
t.Errorf("the reading module may write %s through %q", b, p)
}
}
}
}
+55 -2
View File
@@ -21,6 +21,7 @@ import (
"strings" "strings"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-controller/internal/lease" "github.com/novox/mesh-controller/internal/lease"
) )
@@ -712,6 +713,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
// hand, which it alone writes. A put is a publish to the bucket's subject, which `$JS.API.>` // hand, which it alone writes. A put is a publish to the bucket's subject, which `$JS.API.>`
// does not cover; each bucket named, not `$KV.>`, which would let it write any module's state. // does not cover; each bucket named, not `$KV.>`, which would let it write any module's state.
for _, bucket := range ControllerBuckets() { for _, bucket := range ControllerBuckets() {
if bucket == delivery.HaveBucket {
continue // each node's node-engine writes its own keys of it, the controller none (ADR 0429 §1)
}
pub = append(pub, "$KV."+bucket+".>") pub = append(pub, "$KV."+bucket+".>")
} }
// **And what the bus says about itself, read-only** (novox/hq to-be 45 §3, S9): a durable // **And what the bus says about itself, read-only** (novox/hq to-be 45 §3, S9): a durable
@@ -800,6 +804,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
// contract is lease/witness.go): it asks its own machine's node tools PING, and, where 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. // machine runs the controller, reads the lease's one key — read, never written.
pub = append(pub, WitnessSubjects(p)...) 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, while the vault serves it, the vault's move-in in its own name and nobody else's (issue 676): the // 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. // values sealed to this machine, which only it opens, handed in sealed to the vault's key for the call.
if p.MovesIntoTheVault { if p.MovesIntoTheVault {
@@ -812,6 +823,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
} }
case KindModule: 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)
}
// 1. Its own namespace: it publishes its events there and serves its tools there. Nothing // 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 // 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). // than a claim in the body (design 29 §2).
@@ -1436,7 +1452,9 @@ func ComposeAccounts(principals []Principal) (string, error) {
// JetStream is enabled per account once accounts exist at all: with only the global block set, // 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 // 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). // consumer, which is the first thing every host does (2026-09-28).
b.WriteString("accounts {\n MESH {\n jetstream: enabled\n users = [\n") b.WriteString("accounts {\n MESH {\n jetstream: enabled\n")
b.WriteString(meshSystemImports)
b.WriteString(" users = [\n")
for _, p := range sorted { for _, p := range sorted {
perms, err := PermissionsFor(p) perms, err := PermissionsFor(p)
if err != nil { if err != nil {
@@ -1457,10 +1475,45 @@ func ComposeAccounts(principals []Principal) (string, error) {
} }
b.WriteString(" } }\n") b.WriteString(" } }\n")
} }
b.WriteString(" ]\n }\n}\n") b.WriteString(" ]\n }\n")
b.WriteString(systemAccount)
b.WriteString("}\n")
return b.String(), nil return b.String(), nil
} }
// 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"
// 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 // 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 // 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. // — harmless to the server, and noise in a file that is read as the mesh's authority model.
+2
View File
@@ -28,6 +28,8 @@ func TestTheComposedConfigMatchesTheGolden(t *testing.T) {
Emits: []string{"order.placed"}, PasswordHash: "$2a$11$ssssssssssssssssssssss"}, Emits: []string{"order.placed"}, PasswordHash: "$2a$11$ssssssssssssssssssssss"},
{Kind: KindModule, Node: "two", Module: "audit", {Kind: KindModule, Node: "two", Module: "audit",
Consumes: []string{"shop.order.placed"}, PasswordHash: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa"}, Consumes: []string{"shop.order.placed"}, PasswordHash: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa"},
// The watchman alone hears the bus's connections (novox/hq ADR 0431 §2, issue 1147).
{Kind: KindModule, Node: "one", Module: WatchmanModule, PasswordHash: "$2a$11$wwwwwwwwwwwwwwwwwwwwww"},
// The bus's own module: the snapshot API and its inbox, nothing else (novox/hq ADR 0235). // The bus's own module: the snapshot API and its inbox, nothing else (novox/hq ADR 0235).
{Kind: KindModule, Node: "one", Module: "nats", SnapshotsTheBus: true, {Kind: KindModule, Node: "one", Module: "nats", SnapshotsTheBus: true,
Serves: []string{"nats_streams"}, PasswordHash: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb"}, Serves: []string{"nats_streams"}, PasswordHash: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb"},
+32 -16
View File
@@ -65,6 +65,37 @@ func bucketOfRead(read string) (string, bool) {
return BucketName(module, name), true 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, `<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 // 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, // 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 // created and deleted on the bucket's own stream, with its flow control answered; for a bucket an
@@ -84,22 +115,7 @@ func bucketOfRead(read string) (string, bool) {
// consumer (named at random by the client) and flow control; none of them reads or writes a value. // consumer (named at random by the client) and flow control; none of them reads or writes a value.
func stateGrants(a stateAccess) []string { func stateGrants(a stateAccess) []string {
var out []string var out []string
read := func(bucket, key string) { read := func(bucket, key string) { out = append(out, readGrants(bucket, key)...) }
stream := "KV_" + bucket
out = append(out, "$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)
}
out = append(out,
"$JS.API.CONSUMER.DELETE."+stream+".>",
"$JS.FC."+stream+".>")
}
machineKey := "" machineKey := ""
if safeSubject.MatchString(a.Node) { if safeSubject.MatchString(a.Node) {
machineKey = a.Node machineKey = a.Node
+166
View File
@@ -0,0 +1,166 @@
//go:build integration
package broker
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"golang.org/x/crypto/bcrypt"
)
// **The system account's exports reach MESH on a reload, with no restart** (novox/hq ADR 0431 §3, issue 1147,
// 1147/AC3): the embedded pinned server starts with today's composed accounts (no `$SYS`, no import); the new file is
// written and the server reloaded; the reload succeeds; a user with the watchman's grants hears another user's connect
// and disconnect and gets an answer to `mesh.bus.connz` that lists the open connections; another module's user is
// refused a subscribe on `mesh.bus.connect`.
func TestNatsTheSystemAccountsExportsReachMeshOnAReload(t *testing.T) { // 1147/AC3
hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
users, err := Users(Records{Nodes: []string{"one"}, Assigned: map[string][]Declared{
"one": {{Module: RuntimeModule}, {Module: WatchmanModule}, {Module: "gitea"}, {Module: "shop"}},
}})
if err != nil {
t.Fatal(err)
}
for i := range users {
users[i].PasswordHash = string(hash)
}
accounts, err := ComposeAccounts(users)
if err != nil {
t.Fatal(err)
}
// Today's file: the same accounts without the system account's exports and MESH's imports.
today := strings.Replace(strings.Replace(accounts, meshSystemImports, "", 1), systemAccount, "", 1)
if today == accounts || strings.Contains(today, "$SYS") {
t.Fatal("today's composed file could not be derived from the new one")
}
dir := t.TempDir()
conf := filepath.Join(dir, "bus.conf")
write := func(accounts string) {
t.Helper()
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: "+`"`+dir+`/js"`+" }\n"+accounts), 0o600); err != nil {
t.Fatal(err)
}
}
write(today)
opts, err := server.ProcessConfigFile(conf)
if err != nil {
t.Fatalf("today's accounts do not parse: %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(10 * time.Second) {
t.Fatal("the bus did not come up")
}
t.Cleanup(s.Shutdown)
connect := func(user string) (*nats.Conn, chan error) {
t.Helper()
refused := make(chan error, 8)
nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, "pw"), nats.CustomInboxPrefix("_INBOX."+user),
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { refused <- err }))
if err != nil {
t.Fatalf("%s cannot connect: %v", user, err)
}
t.Cleanup(nc.Close)
return nc, refused
}
// A connection open across the reload: the reload must not drop it.
shop, _ := connect("one.shop")
write(accounts)
if err := s.Reload(); err != nil {
t.Fatalf("the server does not take the system account's exports on a reload: %v", err)
}
if !shop.IsConnected() {
t.Error("the reload dropped a connection that was open")
}
watchman, _ := connect("one." + WatchmanModule)
connects := make(chan *nats.Msg, 16)
disconnects := make(chan *nats.Msg, 16)
if _, err := watchman.ChanSubscribe(BusConnectSubject, connects); err != nil {
t.Fatal(err)
}
if _, err := watchman.ChanSubscribe(BusDisconnectSubject, disconnects); err != nil {
t.Fatal(err)
}
if err := watchman.Flush(); err != nil {
t.Fatal(err)
}
// Another user connects and disconnects: the watchman hears both, and the events name that user.
gitea, _ := connect("one.gitea")
hears := func(ch chan *nats.Msg, what string) {
t.Helper()
select {
case m := <-ch:
if !strings.Contains(string(m.Data), "one.gitea") {
t.Errorf("the %s event does not name the user that connected: %s", what, m.Data)
}
case <-time.After(5 * time.Second):
t.Errorf("the watchman did not hear another user's %s", what)
}
}
hears(connects, "connect")
// The answer to the connections request lists the open connections, the shop's among them. The server names the
// account from the subject the export clamps (MESH), but leaves the user's name out unless the request asks for it
// with "auth": true (monitor.go ConnzOptions.Username), so the watchman asks so.
reply, err := watchman.Request(BusConnzSubject, []byte(`{"auth":true}`), 5*time.Second)
if err != nil {
t.Fatalf("mesh.bus.connz is not answered: %v", err)
}
var answer struct {
Data struct {
Connections []struct {
AuthorizedUser string `json:"authorized_user"`
} `json:"connections"`
} `json:"data"`
}
if err := json.Unmarshal(reply.Data, &answer); err != nil {
t.Fatalf("the connections answer is not JSON: %v: %s", err, reply.Data)
}
var listed bool
for _, c := range answer.Data.Connections {
listed = listed || c.AuthorizedUser == "one.shop"
}
if !listed {
t.Errorf("the connections answer does not list the shop's connection: %s", reply.Data)
}
gitea.Close()
hears(disconnects, "disconnect")
// Another module's user is refused a subscribe on the events and a publish on the request.
other, refused := connect("one.shop")
for _, act := range []func() error{
func() error { _, err := other.SubscribeSync(BusConnectSubject); return err },
func() error { _, err := other.SubscribeSync(BusDisconnectSubject); return err },
func() error { return other.Publish(BusConnzSubject, []byte(`{}`)) },
} {
if err := act(); err != nil {
t.Fatal(err)
}
_ = other.Flush()
select {
case err := <-refused:
if !errors.Is(err, nats.ErrPermissionViolation) {
t.Errorf("the server said %v, want a permissions violation", err)
}
case <-time.After(3 * time.Second):
t.Error("the server did not refuse another module's user the bus's connection subjects")
}
}
}
+145
View File
@@ -0,0 +1,145 @@
package broker
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/nats-io/nats-server/v2/server"
)
// 1147/AC1: the composed accounts file declares $SYS with no user, exporting only the connect and disconnect events of
// MESH and the request listing MESH's connections, and MESH imports exactly those three as mesh.bus.connect,
// mesh.bus.disconnect and mesh.bus.connz (novox/hq ADR 0431 §1); the server's own grammar takes it with $SYS as its
// system account; the golden file shows it (TestTheComposedConfigMatchesTheGolden).
func TestTheComposedAccountsExportMeshsConnectionEventsFromTheSystemAccount(t *testing.T) { // 1147/AC1
text, err := ComposeAccounts([]Principal{{Kind: KindNode, Node: "one", PasswordHash: "x"}})
if err != nil {
t.Fatal(err)
}
sys := between(text, `"$SYS" {`, "\n }\n")
if sys == "" {
t.Fatalf("no account $SYS in the composed accounts:\n%s", text)
}
if strings.Contains(sys, "users") || strings.Contains(sys, "user:") {
t.Errorf("$SYS holds a user:\n%s", sys)
}
for _, want := range []string{
`{ stream: "$SYS.ACCOUNT.MESH.CONNECT", accounts: ["MESH"] }`,
`{ stream: "$SYS.ACCOUNT.MESH.DISCONNECT", accounts: ["MESH"] }`,
`{ service: "$SYS.REQ.ACCOUNT.MESH.CONNZ", accounts: ["MESH"] }`,
} {
if !strings.Contains(sys, want) {
t.Errorf("$SYS does not export %s:\n%s", want, sys)
}
}
if n := strings.Count(sys, "{ stream:") + strings.Count(sys, "{ service:"); n != 3 {
t.Errorf("$SYS exports %d things, want exactly 3:\n%s", n, sys)
}
mesh := between(text, " MESH {", " users = [")
for _, want := range []string{
`{ 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" }`,
} {
if !strings.Contains(mesh, want) {
t.Errorf("MESH does not import %s:\n%s", want, mesh)
}
}
if n := strings.Count(mesh, "{ stream:") + strings.Count(mesh, "{ service:"); n != 3 {
t.Errorf("MESH imports %d things, want exactly 3:\n%s", n, mesh)
}
// The pinned server's grammar takes it, and makes $SYS its system account.
path := filepath.Join(t.TempDir(), "accounts.conf")
if err := os.WriteFile(path, []byte(text), 0o600); err != nil {
t.Fatal(err)
}
opts, err := server.ProcessConfigFile(path)
if err != nil {
t.Fatalf("the server refuses the composed accounts: %v", err)
}
var found bool
for _, a := range opts.Accounts {
if a.Name != "$SYS" {
continue
}
found = true
if !a.IsExportService("$SYS.REQ.ACCOUNT.MESH.CONNZ") {
t.Error("$SYS does not export the connections request as a service")
}
}
if !found {
t.Error("the server read no account $SYS")
}
for _, u := range opts.Users {
if u.Account != nil && u.Account.Name == "$SYS" {
t.Errorf("user %s is in $SYS", u.Username)
}
}
}
// between is the text from the first start to the first end after it, or empty.
func between(s, start, end string) string {
i := strings.Index(s, start)
if i < 0 {
return ""
}
rest := s[i:]
j := strings.Index(rest[len(start):], end)
if j < 0 {
return rest
}
return rest[:len(start)+j]
}
// 1147/AC2: only mesh-delivery-watchman's principal may subscribe to mesh.bus.connect and mesh.bus.disconnect and
// publish to mesh.bus.connz; no other principal's grant covers them by a wildcard (ADR 0431 §2).
func TestOnlyTheWatchmanHearsTheBusConnectionEvents(t *testing.T) { // 1147/AC2
builder := Seat{Name: "node-build-agent", Scope: "node", Accepts: []string{"build"}, Emits: []string{"built"}}
watchman := Principal{Kind: KindModule, Node: "one", Module: "mesh-delivery-watchman", PasswordHash: "x"}
others := []Principal{
{Kind: KindController, PasswordHash: "x"},
{Kind: KindNode, Node: "one", PasswordHash: "x"},
{Kind: KindEnrolment, Node: "two", PasswordHash: "x"},
{Kind: KindPerson, Module: "jochen", Invokes: []string{"*"}, PasswordHash: "x"},
{Kind: KindProvisioner, Node: "one", PasswordHash: "x"},
{Kind: KindModule, Node: "one", Module: "gitea", Emits: []string{"pull.merged"}, PasswordHash: "x"},
{Kind: KindModule, Node: "one", Module: "mesh-delivery-view", PasswordHash: "x",
Reads: []string{"node-engine.have"}, Consumes: []string{"mesh-delivery-watchman.presence"}},
{Kind: KindModule, Node: "one", Module: "build-agent", Holds: []Seat{builder}, PasswordHash: "x"},
{Kind: KindModule, Node: "one", Module: "nats", SnapshotsTheBus: true, PasswordHash: "x"},
{Kind: KindNodeTools, Node: "one", Module: RuntimeModule, PasswordHash: "x", Carries: []Declared{
{Module: "mesh-delivery-watchman"}, {Module: "build-agent", Holds: []Seat{builder}}}},
}
heard := []string{"mesh.bus.connect", "mesh.bus.disconnect"}
perms, err := PermissionsFor(watchman)
if err != nil {
t.Fatal(err)
}
for _, s := range heard {
has(t, perms.Subscribe, s)
}
has(t, perms.Publish, "mesh.bus.connz")
for _, s := range append(heard, "mesh.bus.connz") {
for _, p := range perms.Subscribe {
if p != s && SubjectsOverlap(p, s) && !strings.HasPrefix(p, "_INBOX.") {
t.Errorf("the watchman's subscription %q reaches %s beyond naming it", p, s)
}
}
}
for _, p := range others {
perms, err := PermissionsFor(p)
if err != nil {
t.Fatalf("%s: %v", p.Username(), err)
}
for _, s := range append(heard, "mesh.bus.connz") {
for _, g := range append(append([]string(nil), perms.Publish...), perms.Subscribe...) {
if SubjectsOverlap(g, s) {
t.Errorf("%s is granted %q, which covers %s", p.Username(), g, s)
}
}
}
}
}
+18 -1
View File
@@ -22,6 +22,11 @@ jetstream {
accounts { accounts {
MESH { MESH {
jetstream: enabled 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 = [ users = [
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: { { user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_builds.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_checks-running.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_delivery-merges.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_merge-window.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollout-silent.>", "$KV.mesh-controller_rollouts.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.event.sent", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.check.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] } publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_builds.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_checks-running.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_delivery-merges.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_merge-window.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollout-silent.>", "$KV.mesh-controller_rollouts.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.event.sent", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.check.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] }
@@ -33,10 +38,15 @@ accounts {
subscribe: { allow: ["_INBOX.enrol.one.>"] } subscribe: { allow: ["_INBOX.enrol.one.>"] }
} } } }
{ user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: { { user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: {
publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.INFO.NODES.one", "$SRV.PING.node-tools.one", "mesh.control.one.>"] } publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.one.>", "$JS.API.CONSUMER.DELETE.KV_node-engine_have.>", "$JS.API.CONSUMER.INFO.NODES.one", "$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.one.>", "$JS.API.STREAM.INFO.KV_node-engine_have", "$JS.FC.KV_node-engine_have.>", "$KV.node-engine_have.one.>", "$SRV.PING.node-tools.one", "mesh.control.one.>"] }
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "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"] } subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "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"] }
allow_responses: { max: 1, ttl: "1m" } allow_responses: { max: 1, ttl: "1m" }
} } } }
{ user: "one.mesh-delivery-watchman", password: "$2a$11$wwwwwwwwwwwwwwwwwwwwww", permissions: {
publish: { allow: ["$JS.ACK.EVENTS.one_mesh-delivery-watchman.>", "$JS.API.CONSUMER.INFO.EVENTS.one_mesh-delivery-watchman", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_mesh-delivery-watchman", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.one.mesh-delivery-watchman", "mesh.bus.connz"] }
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.mesh-delivery-watchman", "$SRV.INFO.mesh-delivery-watchman.>", "$SRV.PING", "$SRV.PING.mesh-delivery-watchman", "$SRV.PING.mesh-delivery-watchman.>", "$SRV.STATS", "$SRV.STATS.mesh-delivery-watchman", "$SRV.STATS.mesh-delivery-watchman.>", "_INBOX.one.mesh-delivery-watchman.>", "mesh.assignment.one.mesh-delivery-watchman", "mesh.bus.connect", "mesh.bus.disconnect", "mesh.mod.mesh-delivery-watchman.call.>", "mesh.mod.mesh-delivery-watchman.tool.>"] }
allow_responses: { max: 1, ttl: "1m" }
} }
{ user: "one.nats", password: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb", permissions: { { 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.>"] } publish: { allow: ["$JS.API.STREAM.INFO.*", "$JS.API.STREAM.NAMES", "$JS.API.STREAM.SNAPSHOT.*", "$JS.SNAPSHOT.ACK.>"] }
subscribe: { allow: ["_INBOX.one.nats.>"] } subscribe: { allow: ["_INBOX.one.nats.>"] }
@@ -58,4 +68,11 @@ accounts {
} } } }
] ]
} }
"$SYS" {
exports = [
{ stream: "$SYS.ACCOUNT.MESH.CONNECT", accounts: ["MESH"] }
{ stream: "$SYS.ACCOUNT.MESH.DISCONNECT", accounts: ["MESH"] }
{ service: "$SYS.REQ.ACCOUNT.MESH.CONNZ", accounts: ["MESH"] }
]
}
} }
+14
View File
@@ -4,6 +4,8 @@ import (
"fmt" "fmt"
"slices" "slices"
"strings" "strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
) )
// The writers table (novox/hq to-be 45 §1, ADR 0227 rule 1), compiled in. // The writers table (novox/hq to-be 45 §1, ADR 0227 rule 1), compiled in.
@@ -92,6 +94,13 @@ func ownProvisioner(p Principal, pattern string) bool {
return p.Kind == KindProvisioner && len(tokens) > 3 && tokens[2] == p.Node return p.Kind == KindProvisioner && len(tokens) > 3 && tokens[2] == p.Node
} }
// ownNodeKeys is a node writing the have keys of its own node: `$KV.<bucket>.<node>.…`, 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. // kvOf is a bucket's write subjects.
func kvOf(bucket string) []string { return []string{"$KV." + bucket + ".>"} } func kvOf(bucket string) []string { return []string{"$KV." + bucket + ".>"} }
@@ -205,6 +214,11 @@ var WritersTable = []WriterRow{
{State: "the rollouts of the chain", Writer: "controller (lease holder), compare-and-set on the key's revision", {State: "the rollouts of the chain", Writer: "controller (lease holder), compare-and-set on the key's revision",
KeptIn: "key-value " + RolloutsBucket + ", sixty-four revisions of each key, none removed", KeptIn: "key-value " + RolloutsBucket + ", sixty-four revisions of each key, none removed",
Others: "read through `reads` as mesh-controller." + ChainRollouts, Subjects: kvOf(RolloutsBucket), Writes: isController}, Others: "read through `reads` as mesh-controller." + ChainRollouts, Subjects: kvOf(RolloutsBucket), Writes: isController},
// What each machine has of each module (novox/hq ADR 0429 §1, design 47): written by that machine's node-engine under
// its own node's keys, never by the controller; the view and the watchman read it.
{State: "a machine's have", Writer: "the node-engine of the machine, under its own keys `<node>.>`",
KeptIn: "key-value " + delivery.HaveBucket + ", twenty revisions of each key",
Others: "read through `reads` as node-engine.have", Subjects: kvOf(delivery.HaveBucket), Writes: ownNodeKeys},
{State: "the declared-config mirror", Writer: "controller (lease holder), from its store, only on a change", {State: "the declared-config mirror", Writer: "controller (lease holder), from its store, only on a change",
KeptIn: "key-value " + DeclaredConfigBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredConfig, KeptIn: "key-value " + DeclaredConfigBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredConfig,
Subjects: kvOf(DeclaredConfigBucket), Writes: isController}, Subjects: kvOf(DeclaredConfigBucket), Writes: isController},
+2
View File
@@ -41,6 +41,8 @@ var designRows = []string{
// The chain beside the assignments, the record itself and no mirror of one (ADR 0408, design 47, issue 1089). // The chain beside the assignments, the record itself and no mirror of one (ADR 0408, design 47, issue 1089).
"the builds of the chain", "the builds of the chain",
"the rollouts of the chain", "the rollouts of the chain",
// What each machine has of each module, written by its node-engine under its own keys (ADR 0429 §1, issue 1136).
"a machine's have",
"the declared-config mirror", "the declared-config mirror",
"the predefined-keys mirror", "the predefined-keys mirror",
// The provisioner client's word on what it placed (novox/hq ADR 0349 §4, design 57 P6). // The provisioner client's word on what it placed (novox/hq ADR 0349 §4, design 57 P6).
+9 -1
View File
@@ -7,6 +7,7 @@ import (
"testing" "testing"
"github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/store"
) )
// **Migration 0105 refuses a store whose rows were not moved, and drops the tables of a moved one** (novox/hq ADR 0408 §8, // **Migration 0105 refuses a store whose rows were not moved, and drops the tables of a moved one** (novox/hq ADR 0408 §8,
@@ -23,10 +24,17 @@ func TestMigration0105RefusesAStoreWhoseRowsWereNotMovedAndDropsTheTablesOfAMove
t.Fatal(err) t.Fatal(err)
} }
inv.ExecForTest(t, `insert into assignment (node, module) select id, 'ui' from node where name = 'anchor'`) inv.ExecForTest(t, `insert into assignment (node, module) select id, 'ui' from node where name = 'anchor'`)
all, err := Migrations() every, err := Migrations()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Migration 105 alone: the ones after it (106, issue 1145) are not what this test judges.
var all []store.Migration
for _, m := range every {
if m.Number <= 105 {
all = append(all, m)
}
}
tables := func() map[string]bool { tables := func() map[string]bool {
t.Helper() t.Helper()
out := map[string]bool{} out := map[string]bool{}
+10 -5
View File
@@ -88,16 +88,21 @@ func TestTheSourceScanFindsATablePlanted(t *testing.T) {
} }
} }
// **The last migration drops the tables of the chain, behind its guard** (issue 1096): it names each table, the column and // **Migration 105 drops the tables of the chain, behind its guard** (issue 1096; it was the last until issue 1145 added 106): it names each table, the column and
// the function, drops no build_gate, and raises before it drops anything. // the function, drops no build_gate, and raises before it drops anything.
func TestTheLastMigrationDropsTheTablesOfTheChain(t *testing.T) { func TestMigration105DropsTheTablesOfTheChain(t *testing.T) {
migrations, err := Migrations() migrations, err := Migrations()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
last := migrations[len(migrations)-1] var last struct{ Number int }
if last.Number < 105 { for _, m := range migrations {
t.Fatalf("the last migration is %d", last.Number) if m.Number == 105 {
last.Number = m.Number
}
}
if last.Number != 105 {
t.Fatal("there is no migration 105")
} }
raw, err := os.ReadFile(filepath.Join("migrations", migrationFile(t, last.Number))) raw, err := os.ReadFile(filepath.Join("migrations", migrationFile(t, last.Number)))
if err != nil { if err != nil {
@@ -0,0 +1,4 @@
-- Whether the machine's node-engine said it reads a module on each resource of a declaration and the build of each
-- module (its reports' `reads_modules`, novox/hq issue 1145): until it has, it is sent neither, because an older
-- node-engine refuses a key it does not know, whole. As reads_generation (migration 0094).
alter table node add column reads_modules boolean not null default false;
+15
View File
@@ -75,6 +75,21 @@ func (i *Inventory) RecordReadsGeneration(ctx context.Context, id string, reads
return err return err
} }
// ReadsModules says a machine's node-engine said it reads a module per resource and a build per module (issue 1145).
func (i *Inventory) ReadsModules(ctx context.Context, id string) (bool, error) {
var reads bool
if err := i.store.Pool().QueryRow(ctx, `select reads_modules from node where id = $1`, id).Scan(&reads); err != nil {
return false, fmt.Errorf("reading whether %s reads modules: %w", id, err)
}
return reads, nil
}
// RecordReadsModules keeps what a machine's latest report said of reading modules.
func (i *Inventory) RecordReadsModules(ctx context.Context, id string, reads bool) error {
_, err := i.store.Pool().Exec(ctx, `update node set reads_modules = $2 where id = $1`, id, reads)
return err
}
// Send is one declaration sent to a machine, as the mesh records it. // Send is one declaration sent to a machine, as the mesh records it.
type Send struct { type Send struct {
// Node is the machine's id; NodeName its name, filled where it is read back. // Node is the machine's id; NodeName its name, filled where it is read back.
+17
View File
@@ -323,3 +323,20 @@ func TestAReportIsTheLastSendsAnswerOnlyWhenItIsAboutThatSend(t *testing.T) {
t.Fatalf("an ordered report about sequence 21 is not its answer: %q", a) t.Fatalf("an ordered report about sequence 21 is not its answer: %q", a)
} }
} }
// 1145/AC3: whether a machine reads modules is its latest word, as whether it reads a generation is.
func TestWhetherAMachineReadsModulesIsItsLatestWord(t *testing.T) {
inv, node := aNodeWithModules(t)
record, err := inv.NodeByName(t.Context(), node)
if err != nil {
t.Fatal(err)
}
for _, reads := range []bool{false, true, false} {
if err := inv.RecordReadsModules(t.Context(), record.ID, reads); err != nil {
t.Fatal(err)
}
if got, err := inv.ReadsModules(t.Context(), record.ID); err != nil || got != reads {
t.Fatalf("1145/AC3: recorded %v, read %v (%v)", reads, got, err)
}
}
}
+4
View File
@@ -460,6 +460,10 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (news bool, err err
if err := e.Inventory.RecordReadsGeneration(ctx, node.ID, report.ReadsGeneration); err != nil { if err := e.Inventory.RecordReadsGeneration(ctx, node.ID, report.ReadsGeneration); err != nil {
return false, err return false, err
} }
// And whether it reads a module per resource and a build per module (novox/hq issue 1145), the same way.
if err := e.Inventory.RecordReadsModules(ctx, node.ID, report.ReadsModules); err != nil {
return false, err
}
} }
if report.Ordered() { if report.Ordered() {
account := AccountOf(report) account := AccountOf(report)
+15
View File
@@ -31,3 +31,18 @@ func TestTheGenerationOnTheWire(t *testing.T) {
t.Fatalf("a node-engine that never said it reads a generation reads as one that does: %+v", older) t.Fatalf("a node-engine that never said it reads a generation reads as one that does: %+v", older)
} }
} }
// 1145/AC3: a report's word that the node-engine reads a module per resource and a build per module, as mesh-host's
// report writes it: `reads_modules`, on every report of a node-engine that reads them.
func TestWhetherTheNodeEngineReadsModulesIsOnItsReport(t *testing.T) {
var reads, older Report
if err := json.Unmarshal([]byte(`{"node":"anchor","applied":["a"],"report_sequence":8,"reads_modules":true}`), &reads); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal([]byte(`{"node":"anchor","applied":["a"],"report_sequence":3}`), &older); err != nil {
t.Fatal(err)
}
if !reads.ReadsModules || older.ReadsModules {
t.Fatalf("1145/AC3: reads %v, an older node-engine reads %v", reads.ReadsModules, older.ReadsModules)
}
}
+4
View File
@@ -202,6 +202,10 @@ type Report struct {
// so, because an older node-engine refuses a key it does not know, whole. Mirrors mesh-host // so, because an older node-engine refuses a key it does not know, whole. Mirrors mesh-host
// internal/link/messages.go. // internal/link/messages.go.
ReadsGeneration bool `json:"reads_generation,omitempty"` ReadsGeneration bool `json:"reads_generation,omitempty"`
// ReadsModules says the node-engine reads a module on each resource of a declaration and the build of each module
// (novox/hq issue 1145), on every report it makes: the mesh sends them only to a machine that said so. Mirrors
// mesh-host internal/link/messages.go.
ReadsModules bool `json:"reads_modules,omitempty"`
// OlderGeneration is set on a report refusing a declaration composed from an older assignment // OlderGeneration is set on a report refusing a declaration composed from an older assignment
// generation than the machine applied (novox/hq issue 234). The refused declaration is the report's // generation than the machine applied (novox/hq issue 234). The refused declaration is the report's
// own Declared and Order, and its sender is read from the record of sends by that sequence. // own Declared and Order, and its sender is read from the record of sends by that sequence.
+2 -2
View File
@@ -8,8 +8,8 @@ private addresses. That is acceptable while mesh-controller is private; if it ev
a second place to clean besides the catalogue itself. a second place to clean besides the catalogue itself.
mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile
mesh-host bff1ac14a198a1e2181053582728024c758bb800 examples/foundation-first-node.lock mesh-host 9d9e3a46c4f5943cfffb413d962ce48b75e2fd39 examples/foundation-first-node.lock
mesh-sdk d3e182503c19a73c91ddc7b845651e020dbae22d conformance/events/module-event.json mesh-sdk 2b420603fe835d6b80e2c98080ab3094c6896c50 conformance/events/module-event.json
To move them, from this repository's root, with the two repositories checked out beside it: To move them, from this repository's root, with the two repositories checked out beside it:
+2
View File
@@ -161,3 +161,5 @@
./cmd/mesh-controller TestNoTableOfTheControllersStoreHoldsBuildsRolloutsOrAssignments ./cmd/mesh-controller TestNoTableOfTheControllersStoreHoldsBuildsRolloutsOrAssignments
./internal/inventory TestTheColumnOfASentMachineIsCopiedOntoItsKeysAndEmptied ./internal/inventory TestTheColumnOfASentMachineIsCopiedOntoItsKeysAndEmptied
./internal/inventory TestASendEmptiesTheColumnSoALaterCopyLeavesAClearedKeyBare ./internal/inventory TestASendEmptiesTheColumnSoALaterCopyLeavesAClearedKeyBare
./internal/inventory TestWhetherAMachineReadsModulesIsItsLatestWord
./cmd/mesh-controller TestAComposedDeclarationCarriesModulesAndBuildsOnlyForANodeThatReadsThem
@@ -0,0 +1,378 @@
// Package delivery is the one home of the keys and values the parts of delivery read and write on the bus
// (novox/hq ADR 0429 §2: the key layouts another repository reads live in mesh-sdk; research 063 section 01). The
// node-engine writes the have keys, the watchman of the repository mesh-delivery writes presence and pulse, the
// view reads all of them, and the controller grants them; every one imports this package, and nothing else defines
// these names.
//
// **Buckets** are named `<owner module>_<name>` as the broker names a module's state (provider.BucketOf): the
// node-engine's `have`, and the watchman's `presence` and `pulse`.
//
// **Keys.** A have key is `<node>.<module>`, and a node's own facts are `<node>.node`, so a module may not be named
// `node`. A presence key is `<node>` for a node and `part.<part>.<instance>` for a part's instance, so a node may
// not be named `part`. A pulse key is `<bucket>.<key>`: the full name of the bucket watched, then that bucket's key,
// which may hold dots, so it splits at the first dot. A name is refused when it is empty or holds a dot, a wildcard,
// whitespace or a character a bus key refuses, and the error names it.
//
// **Values** are JSON of under about 500 bytes, each carrying its version in `v`. A reader refuses a value whose
// version is missing or one it does not know, with an error that is ErrUnknownVersion (research 063 section 05
// question 9 item 7): a writer writes a new version only once every reader's have shows a version that reads it. A
// writer writes only when the new value differs from the one before (the Same functions).
//
// Standard library only.
package delivery
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
)
// The buckets of delivery's parts. Each is written by one owner: every node's node-engine writes the have keys of
// its own node only, and the watchman writes presence and pulse.
const (
HaveBucket = "node-engine_have"
PresenceBucket = "mesh-delivery-watchman_presence"
PulseBucket = "mesh-delivery-watchman_pulse"
)
// HaveHistory and PresenceHistory are the revisions the have and presence buckets keep per key (research 063
// section 01).
const (
HaveHistory = 20
PresenceHistory = 20
)
// PulseMoveBound is how long a move may take: a pulse key lives for this time minus the move's age, and the bucket
// expires it on its own (the server writes a delete marker), so a move that overruns it is seen to stall.
const PulseMoveBound = 120 * time.Second
// Version is the version of every value this package writes and the only one it reads.
const Version = 1
// ErrUnknownVersion is what a decode returns for a value whose version is missing or not Version. Test it with
// errors.Is; the message names the version seen and the one read.
var ErrUnknownVersion = errors.New("delivery: unknown value version")
// Phase is how far a module's change has come on a node.
type Phase string
// The phases of a module's change on a node.
const (
PhaseBackingUp Phase = "backing-up"
PhaseApplying Phase = "applying"
PhaseChecking Phase = "checking"
PhaseRunning Phase = "running"
PhaseFailed Phase = "failed"
)
// Health is what the module's own check says.
type Health string
// The healths a have carries.
const (
HealthOK Health = "ok"
HealthFailed Health = "failed"
HealthWaits Health = "waits"
HealthUnknown Health = "unknown"
)
var nameOK = regexp.MustCompile(`^[-/_=a-zA-Z0-9]+$`)
// checkName refuses a name that is empty or holds a dot, a wildcard, whitespace or a character a bus key refuses.
func checkName(kind, name string) error {
if name == "" {
return fmt.Errorf("delivery: the %s name is empty", kind)
}
if !nameOK.MatchString(name) {
return fmt.Errorf("delivery: the %s name %q holds a dot, a wildcard, whitespace or a character a key refuses", kind, name)
}
return nil
}
// HaveKey is the key, in HaveBucket, of a module's have on a node: `<node>.<module>`. A module named `node` is
// refused, since `<node>.node` is the node's own facts.
func HaveKey(node, module string) (string, error) {
if err := checkName("node", node); err != nil {
return "", err
}
if err := checkName("module", module); err != nil {
return "", err
}
if module == "node" {
return "", fmt.Errorf(`delivery: the module name "node" is refused: %s.node is the node's own key`, node)
}
return node + "." + module, nil
}
// HaveNodeKey is the key, in HaveBucket, of a node's own facts: `<node>.node`.
func HaveNodeKey(node string) (string, error) {
if err := checkName("node", node); err != nil {
return "", err
}
return node + ".node", nil
}
// ParseHaveKey is the inverse of HaveKey and HaveNodeKey: isNode is true for `<node>.node`, with module empty.
func ParseHaveKey(key string) (node, module string, isNode bool, err error) {
node, module, ok := strings.Cut(key, ".")
if !ok {
return "", "", false, fmt.Errorf("delivery: %q is not a have key, <node>.<module>", key)
}
if err := checkName("node", node); err != nil {
return "", "", false, fmt.Errorf("delivery: %q is not a have key: %w", key, err)
}
if err := checkName("module", module); err != nil {
return "", "", false, fmt.Errorf("delivery: %q is not a have key: %w", key, err)
}
if module == "node" {
return node, "", true, nil
}
return node, module, false, nil
}
// PresenceNodeKey is the key, in PresenceBucket, of a node: `<node>`. A node named `part` is refused.
func PresenceNodeKey(node string) (string, error) {
if err := checkName("node", node); err != nil {
return "", err
}
if node == "part" {
return "", errors.New(`delivery: the node name "part" is refused: part.<part>.<instance> is a part's key`)
}
return node, nil
}
// PresencePartKey is the key, in PresenceBucket, of a part's instance: `part.<part>.<instance>`.
func PresencePartKey(part, instance string) (string, error) {
if err := checkName("part", part); err != nil {
return "", err
}
if err := checkName("instance", instance); err != nil {
return "", err
}
return "part." + part + "." + instance, nil
}
// ParsePresenceKey is the inverse of PresenceNodeKey and PresencePartKey: for a node's key part and instance are
// empty, for a part's key node is.
func ParsePresenceKey(key string) (node, part, instance string, err error) {
if !strings.Contains(key, ".") {
node, err = PresenceNodeKey(key)
if err != nil {
return "", "", "", fmt.Errorf("delivery: %q is not a presence key: %w", key, err)
}
return node, "", "", nil
}
fields := strings.Split(key, ".")
if len(fields) != 3 || fields[0] != "part" {
return "", "", "", fmt.Errorf("delivery: %q is not a presence key, <node> or part.<part>.<instance>", key)
}
if _, err := PresencePartKey(fields[1], fields[2]); err != nil {
return "", "", "", fmt.Errorf("delivery: %q is not a presence key: %w", key, err)
}
return "", fields[1], fields[2], nil
}
// PulseKey is the key, in PulseBucket, of the move watched at key of bucket: `<bucket>.<key>`. The bucket is the
// full bucket name and holds no dot; the key may.
func PulseKey(bucket, key string) (string, error) {
if err := checkName("bucket", bucket); err != nil {
return "", err
}
if key == "" || strings.HasPrefix(key, ".") || strings.HasSuffix(key, ".") || strings.Contains(key, "..") {
return "", fmt.Errorf("delivery: the pulse key %q is empty or has an empty part", key)
}
for _, part := range strings.Split(key, ".") {
if err := checkName("key", part); err != nil {
return "", err
}
}
return bucket + "." + key, nil
}
// ParsePulseKey is the inverse of PulseKey, splitting at the first dot.
func ParsePulseKey(pulseKey string) (bucket, key string, err error) {
bucket, key, ok := strings.Cut(pulseKey, ".")
if !ok {
return "", "", fmt.Errorf("delivery: %q is not a pulse key, <bucket>.<key>", pulseKey)
}
if _, err := PulseKey(bucket, key); err != nil {
return "", "", fmt.Errorf("delivery: %q is not a pulse key: %w", pulseKey, err)
}
return bucket, key, nil
}
// PulseTTL is the time to live of a pulse key for a move of the given age: PulseMoveBound minus the age. At or past
// the bound the move is stalled, with no time left and nothing to write: the watchman raises it at once.
func PulseTTL(moveAge time.Duration) (ttl time.Duration, stalled bool) {
if moveAge >= PulseMoveBound {
return 0, true
}
return PulseMoveBound - moveAge, false
}
// Have is a module's state on a node, written by that node's node-engine under HaveKey. FP is the module's
// fingerprint once the indexer exists (design 47 release 3); until then Build is the build the node was sent, and
// the view compares by build.
type Have struct {
V int `json:"v"`
FP string `json:"fp,omitempty"`
Build string `json:"build,omitempty"`
Phase Phase `json:"phase"`
Health Health `json:"health"`
Detail string `json:"detail,omitempty"`
}
// HaveNode is a node's own facts, written under HaveNodeKey. Each field is the node-engine's own JSON, carried whole:
// this package names the facts and does not type them.
type HaveNode struct {
V int `json:"v"`
Windows json.RawMessage `json:"windows,omitempty"`
Busy json.RawMessage `json:"busy,omitempty"`
Strays json.RawMessage `json:"strays,omitempty"`
Filters json.RawMessage `json:"filters,omitempty"`
Profile json.RawMessage `json:"profile,omitempty"`
}
// Presence is whether a node or a part's instance is online; the entry's own time says since when. The watchman
// writes it.
type Presence struct {
V int `json:"v"`
Online bool `json:"online"`
}
// Pulse is the value of a pulse key: the watched key mirrored, its revision, and who owes the next move (a node or
// a part instance). The watchman writes it with the time to live PulseTTL gives.
type Pulse struct {
V int `json:"v"`
Bucket string `json:"bucket"`
Key string `json:"key"`
Revision uint64 `json:"revision"`
Owes string `json:"owes,omitempty"`
}
func checkVersion(data []byte) error {
var probe struct {
V *int `json:"v"`
}
if err := json.Unmarshal(data, &probe); err != nil {
return fmt.Errorf("delivery: value is not JSON: %w", err)
}
if probe.V == nil {
return fmt.Errorf("%w: no version in the value, this reader reads %d", ErrUnknownVersion, Version)
}
if *probe.V != Version {
return fmt.Errorf("%w: the value is version %d, this reader reads %d", ErrUnknownVersion, *probe.V, Version)
}
return nil
}
func decode(data []byte, into any) error {
if err := checkVersion(data); err != nil {
return err
}
if err := json.Unmarshal(data, into); err != nil {
return fmt.Errorf("delivery: value does not fit its shape: %w", err)
}
return nil
}
func (h Have) check() error {
switch h.Phase {
case PhaseBackingUp, PhaseApplying, PhaseChecking, PhaseRunning, PhaseFailed:
default:
return fmt.Errorf("delivery: phase %q is not backing-up, applying, checking, running or failed", h.Phase)
}
switch h.Health {
case HealthOK, HealthFailed, HealthWaits, HealthUnknown:
default:
return fmt.Errorf("delivery: health %q is not ok, failed, waits or unknown", h.Health)
}
return nil
}
// EncodeHave is h as JSON at Version; it refuses a phase or health outside the lists.
func EncodeHave(h Have) ([]byte, error) {
if err := h.check(); err != nil {
return nil, err
}
h.V = Version
return json.Marshal(h)
}
// DecodeHave reads a have; it refuses an unknown version (ErrUnknownVersion) and a phase or health outside the lists.
func DecodeHave(data []byte) (Have, error) {
var h Have
if err := decode(data, &h); err != nil {
return Have{}, err
}
if err := h.check(); err != nil {
return Have{}, err
}
return h, nil
}
// EncodeHaveNode is n as JSON at Version.
func EncodeHaveNode(n HaveNode) ([]byte, error) {
n.V = Version
return json.Marshal(n)
}
// DecodeHaveNode reads a node's facts; it refuses an unknown version (ErrUnknownVersion).
func DecodeHaveNode(data []byte) (HaveNode, error) {
var n HaveNode
err := decode(data, &n)
return n, err
}
// EncodePresence is p as JSON at Version.
func EncodePresence(p Presence) ([]byte, error) {
p.V = Version
return json.Marshal(p)
}
// DecodePresence reads a presence; it refuses an unknown version (ErrUnknownVersion).
func DecodePresence(data []byte) (Presence, error) {
var p Presence
err := decode(data, &p)
return p, err
}
// EncodePulse is p as JSON at Version.
func EncodePulse(p Pulse) ([]byte, error) {
p.V = Version
return json.Marshal(p)
}
// DecodePulse reads a pulse; it refuses an unknown version (ErrUnknownVersion).
func DecodePulse(data []byte) (Pulse, error) {
var p Pulse
err := decode(data, &p)
return p, err
}
// SameHave says whether two haves are the same value, so a writer writes only when they differ.
func SameHave(a, b Have) bool { return a == b }
// SameHaveNode says whether two node facts are the same value, comparing each carried JSON without its spacing.
func SameHaveNode(a, b HaveNode) bool {
return a.V == b.V && sameJSON(a.Windows, b.Windows) && sameJSON(a.Busy, b.Busy) &&
sameJSON(a.Strays, b.Strays) && sameJSON(a.Filters, b.Filters) && sameJSON(a.Profile, b.Profile)
}
// SamePresence says whether two presences are the same value.
func SamePresence(a, b Presence) bool { return a == b }
// SamePulse says whether two pulses are the same value.
func SamePulse(a, b Pulse) bool { return a == b }
func sameJSON(a, b json.RawMessage) bool {
var ca, cb bytes.Buffer
if json.Compact(&ca, a) != nil || json.Compact(&cb, b) != nil {
return bytes.Equal(a, b)
}
return bytes.Equal(ca.Bytes(), cb.Bytes())
}
+126 -16
View File
@@ -103,6 +103,8 @@ type Resource interface {
// and not a hash of the content: it is what lets the store say *this is the same resource // and not a hash of the content: it is what lets the store say *this is the same resource
// I applied last time*, which is what makes removal possible at all. // I applied last time*, which is what makes removal possible at all.
Identity() string Identity() string
// DeclaredModule is the module the controller says the resource belongs to, or "" (issue 1132).
DeclaredModule() string
// Kind is the resource's type, for the store and for reporting. // Kind is the resource's type, for the store and for reporting.
Kind() Type Kind() Type
// Target is what the resource acts on, for a person reading a report. // Target is what the resource acts on, for a person reading a report.
@@ -115,12 +117,30 @@ type Resource interface {
// strict decoder would otherwise refuse. `Kind()` returns the constant and is what anything // strict decoder would otherwise refuse. `Kind()` returns the constant and is what anything
// else should read. // else should read.
// DeclaredModule is on every kind: the module the controller says the resource belongs to, or "" when it does not say
// (an older controller). Read through Declaration.ModuleOfID, which falls back to the guess.
func (d *Directory) DeclaredModule() string { return d.Module }
func (f *File) DeclaredModule() string { return f.Module }
func (u *User) DeclaredModule() string { return u.Module }
func (n *Network) DeclaredModule() string { return n.Module }
func (a *Access) DeclaredModule() string { return a.Module }
func (a *Archive) DeclaredModule() string { return a.Module }
func (d *Process) DeclaredModule() string { return d.Module }
func (s *Service) DeclaredModule() string { return s.Module }
func (o *Opening) DeclaredModule() string { return o.Module }
func (p *Package) DeclaredModule() string { return p.Module }
func (c *Container) DeclaredModule() string { return c.Module }
func (a *Action) DeclaredModule() string { return a.Module }
// Directory is a directory that should exist, with a mode. // Directory is a directory that should exist, with a mode.
type Directory struct { type Directory struct {
ID string `json:"id"` ID string `json:"id"`
Type Type `json:"type"` Type Type `json:"type"`
Path string `json:"path"` // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
Mode string `json:"mode,omitempty"` // is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
Path string `json:"path"`
Mode string `json:"mode,omitempty"`
// Owner is the user this belongs to, by name. Absent means root, which is what everything // Owner is the user this belongs to, by name. Absent means root, which is what everything
// managed was until users existed. // managed was until users existed.
Owner string `json:"owner,omitempty"` Owner string `json:"owner,omitempty"`
@@ -201,8 +221,11 @@ func (d *Directory) validate(where string, _ bool) []string {
// File is a file with literal content. The host renders nothing. // File is a file with literal content. The host renders nothing.
type File struct { type File struct {
ID string `json:"id"` ID string `json:"id"`
Type Type `json:"type"` Type Type `json:"type"`
// Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
// is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
Path string `json:"path"` Path string `json:"path"`
Content string `json:"content"` Content string `json:"content"`
Mode string `json:"mode,omitempty"` Mode string `json:"mode,omitempty"`
@@ -520,7 +543,10 @@ func (f *File) checkOwnership(where string) []string {
type User struct { type User struct {
ID string `json:"id"` ID string `json:"id"`
Type Type `json:"type"` Type Type `json:"type"`
Name string `json:"name"` // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
// is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
Name string `json:"name"`
// Shell this user logs in with. Absent means the host asserts nothing and leaves whatever is // Shell this user logs in with. Absent means the host asserts nothing and leaves whatever is
// there — the same rule Service.Boot follows, for the same reason: a field that always // there — the same rule Service.Boot follows, for the same reason: a field that always
@@ -571,7 +597,10 @@ const RootNever = "never"
type Network struct { type Network struct {
ID string `json:"id"` ID string `json:"id"`
Type Type `json:"type"` Type Type `json:"type"`
Name string `json:"name"` // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
// is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
Name string `json:"name"`
} }
func (n *Network) Identity() string { return n.ID } func (n *Network) Identity() string { return n.ID }
@@ -616,7 +645,10 @@ const (
type Access struct { type Access struct {
ID string `json:"id"` ID string `json:"id"`
Type Type `json:"type"` Type Type `json:"type"`
Path string `json:"path"` // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
// is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
Path string `json:"path"`
// Mode is how this module reaches the path: read or read-write. Absent narrows to read. // Mode is how this module reaches the path: read or read-write. Absent narrows to read.
Mode string `json:"mode,omitempty"` Mode string `json:"mode,omitempty"`
@@ -743,6 +775,9 @@ func (u *User) validate(where string, _ bool) []string {
type Archive struct { type Archive struct {
ID string `json:"id"` ID string `json:"id"`
Type Type `json:"type"` Type Type `json:"type"`
// Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
// is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
// Source is where to fetch it from. // Source is where to fetch it from.
Source string `json:"source"` Source string `json:"source"`
// Digest is sha256 of the archive, as "sha256:<hex>". // Digest is sha256 of the archive, as "sha256:<hex>".
@@ -796,6 +831,9 @@ func (a *Archive) validate(where string, _ bool) []string {
type Process struct { type Process struct {
ID string `json:"id"` ID string `json:"id"`
Type Type `json:"type"` Type Type `json:"type"`
// Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
// is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
// Name is what the unit is called, and what an operator will see in the process table. // Name is what the unit is called, and what an operator will see in the process table.
Name string `json:"name"` Name string `json:"name"`
// Source is where to fetch the bundle from, and Digest is what it must hash to. The same // Source is where to fetch the bundle from, and Digest is what it must hash to. The same
@@ -985,7 +1023,10 @@ func (d *Process) validate(where string, _ bool) []string {
type Service struct { type Service struct {
ID string `json:"id"` ID string `json:"id"`
Type Type `json:"type"` Type Type `json:"type"`
Unit string `json:"unit"` // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
// is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
Unit string `json:"unit"`
// State is "running" or "stopped" — or absent, and then **the unit's lifecycle is the // State is "running" or "stopped" — or absent, and then **the unit's lifecycle is the
// machine's; the mesh only reflects its triggers** (novox/hq ADR 0117). The uplink modules // machine's; the mesh only reflects its triggers** (novox/hq ADR 0117). The uplink modules
// declare the machine's own network manager this way: the mesh writes into its configuration // declare the machine's own network manager this way: the mesh writes into its configuration
@@ -1157,8 +1198,11 @@ func BlockMarkers(id string) (begin, end string) {
// firewall sees a published port after the runtime has translated it, so a forwarded opening names // firewall sees a published port after the runtime has translated it, so a forwarded opening names
// the container's own port in To as well as the machine's in Port. // the container's own port in To as well as the machine's in Port.
type Opening struct { type Opening struct {
ID string `json:"id"` ID string `json:"id"`
Type Type `json:"type"` Type Type `json:"type"`
// Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
// is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
Port int `json:"port"` Port int `json:"port"`
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
From string `json:"from"` From string `json:"from"`
@@ -1223,8 +1267,11 @@ func (o *Opening) validate(where string, _ bool) []string {
// Present is the whole of what it asserts, never a version: version is the package manager's // Present is the whole of what it asserts, never a version: version is the package manager's
// business and the mesh does not hold a second opinion about it. // business and the mesh does not hold a second opinion about it.
type Package struct { type Package struct {
ID string `json:"id"` ID string `json:"id"`
Type Type `json:"type"` Type Type `json:"type"`
// Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
// is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
Package string `json:"package"` Package string `json:"package"`
// Absent declares that the package is NOT installed (novox/hq ADR 0180): the host removes it // Absent declares that the package is NOT installed (novox/hq ADR 0180): the host removes it
// when it is, and leaves a machine that never had it alone. For the one case a module replaces // when it is, and leaves a machine that never had it alone. For the one case a module replaces
@@ -1249,7 +1296,10 @@ func (p *Package) validate(where string, _ bool) []string {
type Container struct { type Container struct {
ID string `json:"id"` ID string `json:"id"`
Type Type `json:"type"` Type Type `json:"type"`
Name string `json:"name"` // Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
// is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
Name string `json:"name"`
// Image is pinned by digest (novox/hq ADR 0006) — a tag moves and a digest does not. // Image is pinned by digest (novox/hq ADR 0006) — a tag moves and a digest does not.
Image string `json:"image"` Image string `json:"image"`
Env map[string]string `json:"env,omitempty"` Env map[string]string `json:"env,omitempty"`
@@ -1555,8 +1605,11 @@ func (c *Container) validate(where string, _ bool) []string {
// Action runs something the bundle declared, and the host never learns what it means. // Action runs something the bundle declared, and the host never learns what it means.
type Action struct { type Action struct {
ID string `json:"id"` ID string `json:"id"`
Type Type `json:"type"` Type Type `json:"type"`
// Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
// is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
Command []string `json:"command"` Command []string `json:"command"`
// Verify is not optional and is not a courtesy. It is the read-back AND the idempotency // Verify is not optional and is not a courtesy. It is the read-back AND the idempotency
// check: the host does not know what a database is, so "is it already there" is a question // check: the host does not know what a database is, so "is it already there" is a question
@@ -1727,6 +1780,58 @@ type Declaration struct {
// the only account whose write of a filled file the node-engine's local write socket accepts. Absent is a // the only account whose write of a filled file the node-engine's local write socket accepts. Absent is a
// controller that names none, and every such write is refused. // controller that names none, and every such write is refused.
Provisioner string Provisioner string
// Builds is the build id of every module this declaration names, by module (novox/hq ADR 0429 §4, issue 1132).
Builds map[string]string
// declared is what each resource says of its module, by id; nil on a declaration built by hand.
declared map[string]string
}
// ModuleGuess is the module a resource id names when the declaration does not say: everything before its last dot.
//
// The mesh composes a module's resource ids as `<module>.<its own id>`, and **a module's name may contain a dot**
// (`novox.be` is one on this mesh) while a resource's own id never does, so the owner is everything before the *last*
// dot. False for what the mesh declares in its own right: the foundation's resources carry no dot, and the
// adoption's are named for the mesh. This is the guess an older controller's declaration is read by; a declaration
// that says `module` is read by ModuleOfID, which never guesses for a resource that says (issue 1132).
func ModuleGuess(id string) (string, bool) {
if strings.HasPrefix(id, AdoptionPrefix) {
return "", false
}
at := strings.LastIndex(id, ".")
if at <= 0 {
return "", false
}
return id[:at], true
}
// ModuleOfID is the module a resource of this declaration belongs to: the module it says (novox/hq ADR 0429 §4,
// issue 1132), and otherwise the guess from its id (ModuleGuess), so a declaration from an older controller reads as
// it always did. False for what belongs to no module.
func (d *Declaration) ModuleOfID(id string) (string, bool) {
if d != nil {
if d.declared == nil {
for _, r := range d.Resources {
if r.Identity() == id && r.DeclaredModule() != "" {
return r.DeclaredModule(), true
}
}
} else if m, ok := d.declared[id]; ok {
return m, true
}
}
return ModuleGuess(id)
}
// indexModules keeps what each resource says of its module, so ModuleOfID is a lookup.
func (d *Declaration) indexModules() {
d.declared = map[string]string{}
for _, r := range d.Resources {
if m := r.DeclaredModule(); m != "" {
d.declared[r.Identity()] = m
}
}
} }
// checkProvisioner refuses a provisioner client's account that is no account name, or is root: the account whose // checkProvisioner refuses a provisioner client's account that is no account name, or is root: the account whose
@@ -2126,6 +2231,10 @@ type envelope struct {
// Provisioner is optional too (novox/hq ADR 0349 §4). **An older host refuses this key**; a controller sends it // Provisioner is optional too (novox/hq ADR 0349 §4). **An older host refuses this key**; a controller sends it
// only to a host whose health says link.ProvisionerContract. // only to a host whose health says link.ProvisionerContract.
Provisioner string `json:"provisioner_account,omitempty"` Provisioner string `json:"provisioner_account,omitempty"`
// Builds is optional too (novox/hq ADR 0429 §4, issue 1132): the build id of each module the declaration names, and
// every resource's `module`. **An older host refuses these keys**; a controller sends them only to a host whose
// reports say `reads_modules`.
Builds map[string]string `json:"builds,omitempty"`
} }
func parse(raw []byte, allowActions bool) (*Declaration, error) { func parse(raw []byte, allowActions bool) (*Declaration, error) {
@@ -2145,7 +2254,7 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption, Sequence: env.Sequence, d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption, Sequence: env.Sequence,
Epoch: env.Epoch, LeftOut: env.LeftOut, Generation: env.Generation, PutBack: env.PutBack, Epoch: env.Epoch, LeftOut: env.LeftOut, Generation: env.Generation, PutBack: env.PutBack,
Identities: env.Identities, Groups: env.Groups, Libraries: env.Libraries, Filled: env.Filled, Identities: env.Identities, Groups: env.Groups, Libraries: env.Libraries, Filled: env.Filled,
Provisioner: env.Provisioner} Provisioner: env.Provisioner, Builds: env.Builds}
problems := checkIdentities(env.Identities, env.Groups) problems := checkIdentities(env.Identities, env.Groups)
problems = append(problems, checkProvisioner(env.Provisioner)...) problems = append(problems, checkProvisioner(env.Provisioner)...)
problems = append(problems, checkLibraries(env.Groups, env.Libraries)...) problems = append(problems, checkLibraries(env.Groups, env.Libraries)...)
@@ -2264,6 +2373,7 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
if len(problems) > 0 { if len(problems) > 0 {
return nil, &RefusalError{Problems: problems} return nil, &RefusalError{Problems: problems}
} }
d.indexModules()
return d, nil return d, nil
} }
+4 -3
View File
@@ -1,6 +1,7 @@
# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19 # git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018164924-2b420603fe83
## explicit; go 1.22 ## explicit; go 1.22
git.novox.be/novox/mesh-sdk/go/asks git.novox.be/novox/mesh-sdk/go/asks
git.novox.be/novox/mesh-sdk/go/delivery
git.novox.be/novox/mesh-sdk/go/seal git.novox.be/novox/mesh-sdk/go/seal
# github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op # github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op
## explicit; go 1.24.0 ## explicit; go 1.24.0
@@ -79,7 +80,7 @@ github.com/nats-io/nkeys
# github.com/nats-io/nuid v1.0.1 # github.com/nats-io/nuid v1.0.1
## explicit ## explicit
github.com/nats-io/nuid github.com/nats-io/nuid
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261017162006-6469e8238443 # github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5
## explicit; go 1.26.0 ## explicit; go 1.26.0
github.com/novox/mesh-host/internal/declaration github.com/novox/mesh-host/internal/declaration
github.com/novox/mesh-host/rootsearch github.com/novox/mesh-host/rootsearch
@@ -139,4 +140,4 @@ golang.org/x/text/width
# golang.org/x/time v0.15.0 # golang.org/x/time v0.15.0
## explicit; go 1.25.0 ## explicit; go 1.25.0
golang.org/x/time/rate golang.org/x/time/rate
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261017162006-6469e8238443 # github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5