Compare commits
39
Commits
feat/l5-l7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a4fac090e | ||
|
|
dff0ce2abb | ||
|
|
c816666f83 | ||
|
|
2d717777b1 | ||
|
|
f913918a60 | ||
|
|
38a0bbca44 | ||
|
|
79ad8b539c | ||
|
|
90a5fc5797 | ||
|
|
362dddae40 | ||
|
|
9169830210 | ||
|
|
8c2ce57eee | ||
|
|
69270a30d5 | ||
|
|
d7a00e1f26 | ||
|
|
2ee75954ad | ||
|
|
959f57294a | ||
|
|
59b4c7910b | ||
|
|
5758e9fb57 | ||
|
|
c21d8fd969 | ||
|
|
bbfda29b49 | ||
|
|
3ed41f02d5 | ||
|
|
09a5eb5783 | ||
|
|
a4ed4e6742 | ||
|
|
d7a008bee5 | ||
|
|
feadebd331 | ||
|
|
6f483725e9 | ||
|
|
fdbb3d5784 | ||
|
|
ea8c50136c | ||
|
|
b2a27ed5ce | ||
|
|
5d26c658f5 | ||
|
|
621984614d | ||
|
|
0ef7c0caa4 | ||
|
|
67354f8e01 | ||
|
|
71be15f530 | ||
|
|
a6c6d936d5 | ||
|
|
cad8250aaa | ||
|
|
790d1c82d0 | ||
|
|
8696aa9ba4 | ||
|
|
db4eb3bbf4 | ||
|
|
da41823855 |
+27
-11
@@ -25,7 +25,7 @@ type StepFunc func(ctx context.Context, args map[string]any) (any, error)
|
||||
// AskSteps are the step tools a build module runs an ask with. Copy is the image module's alone; Digest the Go module's.
|
||||
type AskSteps struct {
|
||||
Fetch, Build, Copy, Digest, Test StepFunc
|
||||
// Toolchain is the toolchain the module builds with now; a build ask naming another is refused. Nil checks nothing
|
||||
// Toolchain is the toolchain the module builds with now; a build ask naming another is declined (Declining). Nil checks nothing
|
||||
// (an image or an archive names none).
|
||||
Toolchain func(ctx context.Context) (delivery.ToolchainUsed, error)
|
||||
// Kept looks up a kept verdict by level and digest (the checker's kept bucket, delivery.KeptKey): whether one is
|
||||
@@ -107,19 +107,32 @@ func (s AskSteps) fetch(ctx context.Context, args map[string]any) (json.RawMessa
|
||||
return r, f, nil
|
||||
}
|
||||
|
||||
// onToolchain is the toolchain this node builds with when the ask names one and this module reports its own, nil when
|
||||
// either is absent. A node on another toolchain than the ask names answers a Declining: it is not on the agreed one and
|
||||
// sits out, for a node that is to take the ask (novox/hq ADR 0584 clause 2).
|
||||
func (s AskSteps) onToolchain(ctx context.Context, want *delivery.ToolchainUsed) (*delivery.ToolchainUsed, error) {
|
||||
if want == nil || s.Toolchain == nil {
|
||||
return nil, nil
|
||||
}
|
||||
tc, err := s.Toolchain(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tc.Version != want.Version || tc.Recipe != want.Recipe {
|
||||
return nil, &Declining{Lacks: []string{fmt.Sprintf("the agreed toolchain %s (recipe %s)", want.Version, want.Recipe)},
|
||||
Text: fmt.Sprintf("the ask is for %s (recipe %s) and this node builds with %s (recipe %s): it sits out, and the ask is left to a node on the agreed toolchain",
|
||||
want.Version, want.Recipe, tc.Version, tc.Recipe)}
|
||||
}
|
||||
return &tc, nil
|
||||
}
|
||||
|
||||
func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error) {
|
||||
answer := delivery.BuildAnswer{}
|
||||
if job.Toolchain != nil && s.Toolchain != nil {
|
||||
tc, err := s.Toolchain(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tc.Version != job.Toolchain.Version || tc.Recipe != job.Toolchain.Recipe {
|
||||
return nil, Refuse("the ask is for %s (recipe %s) and this node builds with %s (recipe %s)",
|
||||
job.Toolchain.Version, job.Toolchain.Recipe, tc.Version, tc.Recipe)
|
||||
}
|
||||
answer.Toolchain = &tc
|
||||
tc, err := s.onToolchain(ctx, job.Toolchain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer.Toolchain = tc
|
||||
var f fetched
|
||||
defer func() { s.release(f.Workspace) }()
|
||||
for _, a := range job.Artifacts {
|
||||
@@ -181,6 +194,9 @@ func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error)
|
||||
}
|
||||
|
||||
func (s AskSteps) check(ctx context.Context, job delivery.CheckJob) (any, error) {
|
||||
if _, err := s.onToolchain(ctx, job.Toolchain); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, f, err := s.fetch(ctx, job.Fetch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -103,7 +103,7 @@ build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}`
|
||||
}
|
||||
}
|
||||
|
||||
// A build ask for another toolchain is refused, so its step ends refused and the asker reads why.
|
||||
// A build ask for another toolchain is declined (ADR 0584 clause 2), so the node sits out and the asker puts it again.
|
||||
func TestRunAskRefusesAnotherToolchain(t *testing.T) {
|
||||
r := &recorder{fetch: map[string]any{"workspace": "/w"}}
|
||||
s := r.steps()
|
||||
@@ -112,10 +112,16 @@ func TestRunAskRefusesAnotherToolchain(t *testing.T) {
|
||||
}
|
||||
_, err := RunAsk(context.Background(), s, ask(delivery.BuildJob{Module: "m", Path: ".", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit},
|
||||
Artifacts: []delivery.AskArtifact{{Artifact: "bundle"}}, Toolchain: &delivery.ToolchainUsed{Version: "go1.26", Recipe: "3"}}))
|
||||
var ref *Refusal
|
||||
if !errors.As(err, &ref) || len(r.calls) != 0 {
|
||||
var dec *Declining
|
||||
if !errors.As(err, &dec) || len(dec.Lacks) != 1 || len(r.calls) != 0 {
|
||||
t.Fatalf("err %v, calls %v", err, r.calls)
|
||||
}
|
||||
// A check ask names the toolchain too (ADR 0584), and a node on another declines it before it fetches.
|
||||
_, err = RunAsk(context.Background(), s, ask(delivery.CheckJob{Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}, Levels: []string{delivery.LevelUnit},
|
||||
Toolchain: &delivery.ToolchainUsed{Version: "go1.26", Recipe: "3"}}))
|
||||
if !errors.As(err, &dec) || len(r.calls) != 0 {
|
||||
t.Fatalf("a check ask: err %v, calls %v", err, r.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 1887/A3, A5a: a check ask runs the levels of one check after one fetch: it digests the unit level, takes the kept
|
||||
|
||||
@@ -538,6 +538,28 @@ func (p *Puller) decline(msg *Pulled, ask delivery.StepAsk, lacks []string) {
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// declineRun ends the step of an ask that began and then found this node should not run it (Declining), as decline does
|
||||
// before it begins: refused as declined, the message dropped for its asker to put again, and the worker it came from left
|
||||
// alone until the next pull's wait.
|
||||
func (p *Puller) declineRun(msg *Pulled, ask delivery.StepAsk, key string, step delivery.Step, d *Declining) {
|
||||
result, _ := json.Marshal(delivery.StepDeclined{Declined: true, Needs: ask.Needs, Lacks: d.Lacks})
|
||||
step.Ended = now().UTC()
|
||||
step.State, step.Reason, step.Result = delivery.StepRefused, d.Text, result
|
||||
if err := writeEnd(p.Put, key, step); err != nil {
|
||||
p.logf("the ask %s cannot be declined on %s, so it is given back: %v", ask.ID, p.Node, err)
|
||||
_ = msg.Nak()
|
||||
return
|
||||
}
|
||||
p.logf("the ask %s is declined on %s: %s", ask.ID, p.Node, d.Text)
|
||||
_ = msg.Term()
|
||||
p.mu.Lock()
|
||||
if p.declined == nil {
|
||||
p.declined = map[string]bool{}
|
||||
}
|
||||
p.declined[msg.Kind] = true
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func withKind(msg *Pulled, kind string) *Pulled {
|
||||
if msg != nil {
|
||||
msg.Kind = kind
|
||||
@@ -625,6 +647,11 @@ func (p *Puller) run(ctx context.Context, msg *Pulled, ask delivery.StepAsk, key
|
||||
})
|
||||
step = ph.end() // its end keeps the phase it ended in
|
||||
close(done)
|
||||
var dec *Declining
|
||||
if errors.As(err, &dec) && ctx.Err() == nil {
|
||||
p.declineRun(msg, ask, key, step, dec)
|
||||
return
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
// The process stops: the ask is neither ended nor acknowledged here, so the stream gives it to a node that runs.
|
||||
return
|
||||
|
||||
@@ -230,6 +230,21 @@ func TestPullDropsWhatIsNoAsk(t *testing.T) {
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
|
||||
go q.Pull(ctx)
|
||||
waitFor(t, "the placed ask acked where its need is met", func() bool { a, _, _ := has.Fates(); return len(a) == 1 })
|
||||
// A run that finds its node is not on the agreed toolchain declines the ask after it began (ADR 0584 clause 2); a node on it builds.
|
||||
sit, sitSteps := NewFakeAsks(), newSteps()
|
||||
toolchain := testAsk(delivery.AskDelivery)
|
||||
_ = sit.PublishAsk(toolchain)
|
||||
s2 := &Puller{Source: sit, Language: delivery.AskGo, Node: "ace", Put: sitSteps.put, Capacity: func() int { return 1 }, PullWait: 10 * time.Millisecond,
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
|
||||
return nil, &Declining{Text: "the ask is for 26.13.0 and this node builds with 26.12.1", Lacks: []string{"the agreed toolchain 26.13.0 (recipe 3)"}}
|
||||
}}
|
||||
go s2.Pull(ctx)
|
||||
waitFor(t, "the ask termed on the node that sits out", func() bool { _, tm, _ := sit.Fates(); return len(tm) == 1 })
|
||||
sk, _ := delivery.AskStepKey("ace", toolchain.ID)
|
||||
ss, _ := sitSteps.get(sk)
|
||||
if d, ok := delivery.DeclinedOf(ss); !ok || len(d.Lacks) != 1 || !strings.Contains(ss.Reason, "26.12.1") {
|
||||
t.Fatalf("the sitting-out step = %+v", ss)
|
||||
}
|
||||
}
|
||||
|
||||
// load reads the recorded steps state's raw values: the pause key is not a step, so it is kept apart.
|
||||
|
||||
@@ -16,3 +16,14 @@ func (r *Refusal) Error() string { return r.Text }
|
||||
|
||||
// Refuse is a Refusal with its text formatted.
|
||||
func Refuse(format string, args ...any) *Refusal { return &Refusal{Text: fmt.Sprintf(format, args...)} }
|
||||
|
||||
// Declining is a step a node does not run because another node should: it ends refused as declined (delivery.StepDeclined),
|
||||
// the ask's message is dropped, and the asker puts the ask again under a new id (issue 890). A build ask naming a
|
||||
// toolchain other than this node's is one (novox/hq ADR 0584 clause 2): a node that is not in the toolchain majority
|
||||
// sits out and never fails a build.
|
||||
type Declining struct {
|
||||
Text string
|
||||
Lacks []string // what this node lacks, in words the operator reads
|
||||
}
|
||||
|
||||
func (d *Declining) Error() string { return d.Text }
|
||||
|
||||
@@ -219,6 +219,10 @@ type LeaseHolder struct {
|
||||
// HandedBy is the credential that wrote it (the operator's), and Why its reason.
|
||||
HandedBy string `json:"handed-by,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
// Ask is the operator's ask whose warrant handed it (novox/hq ADR 0582 clause 3: "with the why, the ask and the
|
||||
// time"), and At when it was written. Neither turns the lease: LeaseHandedAway reads HandedTo alone.
|
||||
Ask string `json:"ask,omitempty"`
|
||||
At time.Time `json:"at,omitzero"`
|
||||
}
|
||||
|
||||
// LeaseHandedAway reads the lease key as a getter answered it (its value, whether it was there, the read's error) and
|
||||
|
||||
@@ -3,6 +3,7 @@ package buscompose
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -96,6 +97,9 @@ func engineBeyond(user, g string) bool {
|
||||
strings.Contains(g, "KV_"+EngineActsBucket) || strings.HasPrefix(g, "$KV."+EngineActsBucket+"."+node+".") ||
|
||||
// And its node's declared packages (novox/hq ADR 0565 clause 6).
|
||||
strings.Contains(g, "KV_"+EnginePackagesBucket) || g == "$KV."+EnginePackagesBucket+"."+node ||
|
||||
// And, read, the controller's mirror's key of its node, until the lease write (novox/hq ADR 0565 clause 6).
|
||||
strings.Contains(g, "KV_"+DeclaredPackagesBucket) && !strings.HasPrefix(g, "$KV.") &&
|
||||
(!strings.Contains(g, "$KV.") || strings.HasSuffix(g, "$KV."+DeclaredPackagesBucket+"."+node)) ||
|
||||
// And its own health report (issue 774).
|
||||
strings.Contains(g, "KV_"+delivery.NodeHealthBucket) || g == "$KV."+delivery.NodeHealthBucket+"."+node ||
|
||||
// And the provisions of its node's modules, read (novox/hq ADR 0570 clause 6).
|
||||
@@ -158,8 +162,11 @@ func grantsAsTheControllers(t *testing.T, got, want string) {
|
||||
intended := beyondTheControllers(user)
|
||||
for g := range was {
|
||||
// A runtime's direct get of its membership from the assignments stream went with the stream (research 072
|
||||
// row 33): it reads the grants part's state memberships instead (ADR 0571 clause 7).
|
||||
if !now[g] && !strings.HasPrefix(g, "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment."+strings.SplitN(user, ".", 2)[0]+".") {
|
||||
// row 33): it reads the grants part's state memberships instead (ADR 0571 clause 7). And it no longer hears
|
||||
// the subject, where the grants part stops publishing at the lease write (ADR 0571 clause 10, issue 774).
|
||||
node := strings.SplitN(user, ".", 2)[0]
|
||||
if !now[g] && !strings.HasPrefix(g, "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment."+node+".") &&
|
||||
!strings.HasPrefix(g, "mesh.assignment."+node+".") {
|
||||
t.Errorf("%s: the copy does not grant %s, which the controller's does", user, g)
|
||||
}
|
||||
}
|
||||
@@ -413,6 +420,156 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
})
|
||||
// 774/S39 (ADR 0583 §1): while a node rotates, both its logins are users with the node's permissions and its one
|
||||
// inbox, each calling in its own name; every node reads its own node-logins key alone; with no rotation the list
|
||||
// is the one above (AC2: the controller's golden lists, unchanged).
|
||||
t.Run("a node rotating has two logins of one node", func(t *testing.T) {
|
||||
vault := Seat{Name: VaultSeat, Serves: []string{"rotate-node", "node-account-confirm", "sign", "own"}}
|
||||
since := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC)
|
||||
users, err := Users(Records{Nodes: []string{"ace", "g14"},
|
||||
Assigned: map[string][]Declared{"ace": {{Module: "mesh-vault", Holds: []Seat{vault}}}},
|
||||
NodeLogins: map[string]delivery.NodeLogin{
|
||||
"g14": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: delivery.RotationBegun, Since: since},
|
||||
// A node that rotated before and rotates no more logs in as its second login alone.
|
||||
"ace": {Node: "ace", Login: "node.ace--b", Since: since}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
perms := map[string]Permissions{}
|
||||
for _, u := range users {
|
||||
if u.Kind == KindNode {
|
||||
p, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
perms[u.Username()] = p
|
||||
}
|
||||
}
|
||||
if len(perms) != 3 || perms["node.g14"].Publish == nil || perms["node.g14--b"].Publish == nil || perms["node.ace--b"].Publish == nil {
|
||||
t.Fatalf("the node users are %v", slices.Sorted(maps.Keys(perms)))
|
||||
}
|
||||
// Equal, but for the caller each names in its calls (the bus's proof of which login called, ADR 0583 §3).
|
||||
own := func(p Permissions, login string) Permissions {
|
||||
swap := func(in []string) []string {
|
||||
out := make([]string, len(in))
|
||||
for i, s := range in {
|
||||
out[i] = strings.ReplaceAll(s, CallerToken(login), "<caller>")
|
||||
}
|
||||
return out
|
||||
}
|
||||
return Permissions{Publish: swap(p.Publish), PublishDeny: swap(p.PublishDeny), Subscribe: swap(p.Subscribe),
|
||||
AllowResponses: p.AllowResponses}
|
||||
}
|
||||
if a, b := own(perms["node.g14"], "node.g14"), own(perms["node.g14--b"], "node.g14--b"); !reflect.DeepEqual(a, b) {
|
||||
t.Errorf("the two logins' permissions differ:\n%v\n%v", a, b)
|
||||
}
|
||||
for login, p := range perms {
|
||||
node, _ := delivery.LoginNode(login)
|
||||
if !slices.Contains(p.Subscribe, "_INBOX.node."+node+".>") || slices.Contains(p.Subscribe, "_INBOX."+login+".>") && login != "node."+node {
|
||||
t.Errorf("%s's inbox: %v", login, p.Subscribe)
|
||||
}
|
||||
}
|
||||
// AC3: g14 reads node-logins key g14 and not ace, and writes neither; it confirms in its own login's name.
|
||||
get := func(key string) string {
|
||||
return "$JS.API.DIRECT.GET.KV_" + delivery.NodeLoginsBucket + ".$KV." + delivery.NodeLoginsBucket + "." + key
|
||||
}
|
||||
confirm := func(login string) string {
|
||||
return "mesh.seat.mesh-vault.call.node-account-confirm." + CallerToken(login)
|
||||
}
|
||||
for subject, want := range map[string]bool{get("g14"): true, get("ace"): false, "$KV." + delivery.NodeLoginsBucket + ".g14": false,
|
||||
confirm("node.g14--b"): true, confirm("node.g14"): false} {
|
||||
if MayPublish(perms["node.g14--b"], subject) != want {
|
||||
t.Errorf("node.g14--b may publish %s: %v, want %v", subject, !want, want)
|
||||
}
|
||||
}
|
||||
if plain, _ := PermissionsFor(Principal{Kind: KindNode, Node: "g14"}); MayPublish(plain, get("g14")) {
|
||||
t.Error("a node reads node-logins while the vault does not rotate")
|
||||
}
|
||||
// And nothing but the grants part names node-logins among its reads.
|
||||
if !ReaderMay(GrantsModule, delivery.NodeLoginsRead) || ReaderMay("shop", delivery.NodeLoginsRead) {
|
||||
t.Error("node-logins' named readers")
|
||||
}
|
||||
})
|
||||
// 774/S40/AC1 (ADR 0587 §4, §6): from the lease write an enrolling node's user may publish the redeem call in its
|
||||
// own name and nothing else, and subscribe its own inbox alone; before it, the controller's subject beside.
|
||||
t.Run("an enrolling node redeems at the seat alone", func(t *testing.T) {
|
||||
holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}}
|
||||
for _, handed := range []bool{true, false} {
|
||||
users, err := Users(Records{Nodes: []string{"ace"}, Enrolling: []string{"lab"}, LeaseHandedAway: handed,
|
||||
Assigned: map[string][]Declared{"ace": {{Module: "mesh-enrolment", Holds: []Seat{holder}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var lab *Principal
|
||||
for i := range users {
|
||||
if users[i].Username() == "enrol.lab" {
|
||||
lab = &users[i]
|
||||
}
|
||||
}
|
||||
if lab == nil {
|
||||
t.Fatal("no user enrol.lab")
|
||||
}
|
||||
perms, err := PermissionsFor(*lab)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"mesh.seat.mesh-enrolment.call.redeem.enrol~lab"}
|
||||
if !handed {
|
||||
want = []string{"mesh.control.enrol", "mesh.seat.mesh-enrolment.call.redeem.enrol~lab"}
|
||||
}
|
||||
if !reflect.DeepEqual(perms.Publish, want) || !reflect.DeepEqual(perms.Subscribe, []string{"_INBOX.enrol.lab.>"}) ||
|
||||
perms.AllowResponses {
|
||||
t.Errorf("handed %v: enrol.lab publishes %v, subscribes %v, answers %v", handed, perms.Publish, perms.Subscribe,
|
||||
perms.AllowResponses)
|
||||
}
|
||||
}
|
||||
// No holder serving redeem and the lease handed: the user may enrol nowhere, refused by name.
|
||||
if _, err := PermissionsFor(Principal{Kind: KindEnrolment, Node: "lab", LeaseHandedAway: true}); err == nil {
|
||||
t.Error("an enrolment user that may enrol nowhere was composed")
|
||||
}
|
||||
})
|
||||
// 774/S41/AC1 (ADR 0587 §1-§3, ADR 0561 §5): mesh-enrolment writes its bucket (with per-key expiry) and calls the
|
||||
// vault's enrolment-token, node-account and sign in its own name; no other module or runtime may call those two,
|
||||
// however wide its grant; and its bucket is read by the grants part alone besides it.
|
||||
t.Run("mesh-enrolment alone asks the vault to enrol", func(t *testing.T) {
|
||||
vault := Seat{Name: VaultSeat, Serves: []string{"sign", "enrolment-token", "node-account"}}
|
||||
holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}}
|
||||
calls := []string{"seat:mesh-vault.enrolment-token", "seat:mesh-vault.node-account", "seat:mesh-vault.sign"}
|
||||
users, err := Users(Records{Nodes: []string{"ace"}, Assigned: map[string][]Declared{"ace": {
|
||||
{Module: "mesh-vault", Holds: []Seat{vault}},
|
||||
// One process with a bus account of its own (ADR 0587 §1): never carried by the tool runner.
|
||||
{Module: "mesh-enrolment", RunsAs: "mesh-enrolment", Holds: []Seat{holder}, Invokes: calls,
|
||||
State: []Bucket{{Module: "mesh-enrolment", Name: "enrolments", KeyTTL: true}}},
|
||||
{Module: "shop", Invokes: calls[:2]},
|
||||
{Module: RuntimeModule}, {Module: "agent-tools", Invokes: []string{"*"}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := func(verb, user string) string { return "mesh.seat.mesh-vault.call." + verb + "." + CallerToken(user) }
|
||||
for _, u := range users {
|
||||
perms, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := u.Username()
|
||||
for _, verb := range []string{"enrolment-token", "node-account"} {
|
||||
enrols := name == "ace.mesh-enrolment"
|
||||
if MayPublish(perms, call(verb, name)) != enrols || (!enrols && MayPublish(perms, "mesh.seat.mesh-vault.tool."+verb)) {
|
||||
t.Errorf("%s may call %s: %v", name, verb, !enrols)
|
||||
}
|
||||
}
|
||||
if name == "ace.mesh-enrolment" && (!MayPublish(perms, call("sign", name)) ||
|
||||
!MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") || !MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".address.10.0.0.7")) {
|
||||
t.Errorf("mesh-enrolment does not sign or write its bucket: %v", perms.Publish)
|
||||
}
|
||||
if name != "ace.mesh-enrolment" && MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") {
|
||||
t.Errorf("%s writes the enrolments", name)
|
||||
}
|
||||
}
|
||||
if !ReaderMay(GrantsModule, delivery.EnrolmentsRead) || ReaderMay("shop", delivery.EnrolmentsRead) {
|
||||
t.Error("the enrolments' named readers")
|
||||
}
|
||||
})
|
||||
t.Run("the controller's golden server configuration", func(t *testing.T) {
|
||||
seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}}
|
||||
got, err := Compose(
|
||||
@@ -558,6 +715,9 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
}{
|
||||
{"a controller holds it", `{"instance":"novox/1234/2026-10-27T20:00:00Z","epoch":57}`, true, nil, false},
|
||||
{"handed away", `{"handed-to":"the operator's hand-over","handed-by":"person.jochen"}`, true, nil, true},
|
||||
// ADR 0582 clause 3 (774/S3): the write names the ask and the time, which turn nothing.
|
||||
{"handed to mesh-grants with its ask and time", `{"handed-to":"mesh-grants","handed-by":"person.jochen","why":"w",` +
|
||||
`"ask":"lease-write-1","at":"2026-11-02T10:00:00Z"}`, true, nil, true},
|
||||
{"expired or deleted", ``, false, nil, false},
|
||||
{"unreadable", ``, false, errors.New("refused"), false},
|
||||
{"not a holder", `[1]`, true, nil, false},
|
||||
@@ -633,6 +793,48 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
t.Errorf("the grants part creates consumers as %v", u.CreatesConsumers)
|
||||
}
|
||||
}
|
||||
// ADR 0582 clause 3 (issue 774, 774/S2): the lease write's cut takes from the controller the lease's key and every
|
||||
// update, purge and delete of its bucket, and gives the grants part the key holder alone; without it nothing
|
||||
// changes (the golden lists above are composed without it).
|
||||
lease := "$KV." + LeaseBucket + ".holder"
|
||||
for _, cut := range []bool{false, true} {
|
||||
users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": {{Module: GrantsModule}}},
|
||||
LeaseCut: cut})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
kind Kind
|
||||
subject string
|
||||
may bool
|
||||
}{
|
||||
{KindController, lease, !cut},
|
||||
{KindController, "$JS.API.STREAM.UPDATE.KV_" + LeaseBucket, !cut},
|
||||
{KindController, "$JS.API.STREAM.PURGE.KV_" + LeaseBucket, !cut},
|
||||
{KindController, "$JS.API.STREAM.DELETE.KV_" + LeaseBucket, !cut},
|
||||
{KindController, "$JS.API.STREAM.MSG.DELETE.KV_" + LeaseBucket, !cut},
|
||||
// It still reads the lease, and keeps its other buckets.
|
||||
{KindController, "$JS.API.DIRECT.GET.KV_" + LeaseBucket + ".$KV." + LeaseBucket + ".holder", true},
|
||||
{KindController, "$KV." + CallsBucket + ".x", true},
|
||||
{KindController, "$JS.API.STREAM.UPDATE.KV_" + CallsBucket, true},
|
||||
{KindModule, lease, cut},
|
||||
{KindModule, "$KV." + LeaseBucket + ".other", false},
|
||||
{KindModule, "$JS.API.STREAM.UPDATE.KV_" + LeaseBucket, false},
|
||||
} {
|
||||
for _, u := range users {
|
||||
if u.Kind != c.kind || (c.kind == KindModule && u.Module != GrantsModule) {
|
||||
continue
|
||||
}
|
||||
perms, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatalf("cut %v: %s: %v", cut, u.Username(), err)
|
||||
}
|
||||
if MayPublish(perms, c.subject) != c.may {
|
||||
t.Errorf("cut %v: %s may publish %s: %v, want %v", cut, u.Username(), c.subject, !c.may, c.may)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 1952 S5 (novox/hq ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8): the health holders' credentials.
|
||||
healthChecks(t)
|
||||
q := ConsumerConfig(QueueConsumer(Principal{Kind: KindProcess, Node: "a", Module: "m", Process: "p"},
|
||||
@@ -740,7 +942,9 @@ func healthChecks(t *testing.T) {
|
||||
"a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller",
|
||||
"$KV.mesh-host_acts.g14.a1", "$JS.API.STREAM.INFO.KV_mesh-host_acts",
|
||||
// Research 072 row 33: the ASSIGNMENTS stream is read and removed at the lease write.
|
||||
"$JS.API.STREAM.INFO.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS"},
|
||||
"$JS.API.STREAM.INFO.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS",
|
||||
// ADR 0582 clause 3 (issue 774, 774/S2): the lease's bucket's expiry, read and set by its tool.
|
||||
"$JS.API.STREAM.INFO.KV_mesh-controller_lease", "$JS.API.STREAM.UPDATE.KV_mesh-controller_lease"},
|
||||
}
|
||||
mayNot := map[string][]string{
|
||||
// The keeper writes no findings and no attempts; no observer writes the keeper's buckets (clause 2).
|
||||
@@ -783,8 +987,13 @@ func healthChecks(t *testing.T) {
|
||||
// outside the bus module).
|
||||
"a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.other", "$JS.API.CONSUMER.DELETE.EVENTS.a_m",
|
||||
"$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.PURGE.KV_mesh-host_acts",
|
||||
"$JS.API.STREAM.DELETE.EVENTS", "$JS.API.STREAM.PURGE.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS_X"},
|
||||
"a.nats.other": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"},
|
||||
"$JS.API.STREAM.DELETE.EVENTS", "$JS.API.STREAM.PURGE.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS_X",
|
||||
// It updates the lease's bucket alone, and neither purges nor deletes it nor writes its key (ADR 0582 clause 3).
|
||||
"$JS.API.STREAM.UPDATE.KV_mesh-host_acts", "$JS.API.STREAM.UPDATE.KV_mesh-controller_calls", "$JS.API.STREAM.UPDATE.EVENTS",
|
||||
"$JS.API.STREAM.PURGE.KV_mesh-controller_lease", "$JS.API.STREAM.DELETE.KV_mesh-controller_lease",
|
||||
"$KV.mesh-controller_lease.holder"},
|
||||
"a.nats.other": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1",
|
||||
"$JS.API.STREAM.UPDATE.KV_mesh-controller_lease"},
|
||||
"a.nats": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"},
|
||||
"a.shop.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.ASSIGNMENTS"},
|
||||
}
|
||||
@@ -815,12 +1024,27 @@ func healthChecks(t *testing.T) {
|
||||
MayPublish(perms, "$KV.mesh-host_health.b")) {
|
||||
t.Errorf("the watchman reads the health reports %v, %v", MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.b"), perms.Publish)
|
||||
}
|
||||
if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_packages.a") || MayPublish(perms, "$KV.mesh-host_packages.b")) {
|
||||
// ADR 0565 clause 6: and it reads the controller's mirror's key of its own node alone, and writes none of it.
|
||||
if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_packages.a") || MayPublish(perms, "$KV.mesh-host_packages.b") ||
|
||||
!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-controller_declared-packages.$KV.mesh-controller_declared-packages.a") ||
|
||||
MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-controller_declared-packages.$KV.mesh-controller_declared-packages.b") ||
|
||||
MayPublish(perms, "$KV.mesh-controller_declared-packages.a")) {
|
||||
t.Errorf("node %s writes the packages keys %v", u.Node, perms.Publish)
|
||||
}
|
||||
if u.Kind == KindModule && MayPublish(perms, "$KV.mesh-delivery-assigner_policies.x") {
|
||||
t.Errorf("%s writes the assigner's policies", u.Username())
|
||||
}
|
||||
// ADR 0571 clause 10 (issue 774, 774/S5/AC2): nobody hears memberships on mesh.assignment; they are read from
|
||||
// the grants part's state.
|
||||
if MaySubscribe(perms, "mesh.assignment.a.restic") {
|
||||
t.Errorf("%s hears mesh.assignment", u.Username())
|
||||
}
|
||||
}
|
||||
// Nor the runtime carrying the node's modules, which reads each one's membership from the state.
|
||||
if perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "a", Module: RuntimeModule,
|
||||
Carries: []Declared{{Module: "restic"}}}); err != nil || MaySubscribe(perms, "mesh.assignment.a.restic") ||
|
||||
!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.a.restic") {
|
||||
t.Errorf("the runtime hears mesh.assignment or reads no membership: %v", err)
|
||||
}
|
||||
// 1953 S-age (ADR 0565 §10): a log that says an age keeps its entries that long and no longer; one that says none, as
|
||||
// long as the log (ADR 0297).
|
||||
@@ -878,7 +1102,10 @@ func healthChecks(t *testing.T) {
|
||||
// It hears its watchdogs' words on core subjects (Limits 3, 11).
|
||||
for _, subject := range []string{"mesh.mod.power.event.sleeping", "mesh.control.g14.tools-alive",
|
||||
"mesh.seat.mesh-self-check.event.checked", "mesh.mod.keycloak.event.provisioner.failing",
|
||||
"mesh.mod.nats.event.bus.slow-consumer", "prov.applied.g14.keycloak"} {
|
||||
"mesh.mod.nats.event.bus.slow-consumer", "prov.applied.g14.keycloak",
|
||||
// ADR 0582 clauses 4, 6 (issue 774, 774/S22): the lease write's events, and ASSIGNMENTS deleted.
|
||||
"mesh.seat.mesh-grants.event.lease-altered", "mesh.seat.mesh-grants.event.removal-failed",
|
||||
"mesh.seat.mesh-grants.event.lease-handed", "$JS.EVENT.ADVISORY.STREAM.DELETED.ASSIGNMENTS"} {
|
||||
if !MaySubscribe(perms, subject) {
|
||||
t.Errorf("the watchman may not hear %s", subject)
|
||||
}
|
||||
|
||||
@@ -74,9 +74,11 @@ func engineGrants(p Principal) (pub, sub []string) {
|
||||
if own, err := NodeKeysGrants(EngineDataBucket, p.Node); err == nil {
|
||||
pub = append(pub, own...)
|
||||
}
|
||||
// And its node's declared packages, its own key alone (ADR 0565 clause 6).
|
||||
// And its node's declared packages, its own key alone (ADR 0565 clause 6); and, read, the controller's mirror's key
|
||||
// of its node, which the node seat's verb packages compares it with until the lease write ends the mirror.
|
||||
if safeSubject.MatchString(p.Node) {
|
||||
pub = append(pub, EnginePackagesGrants(p.Node)...)
|
||||
pub = append(pub, readGrants(DeclaredPackagesBucket, p.Node)...)
|
||||
}
|
||||
return append(pub, tp...), append(sub, ts...)
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ func WatchmanDeadLetterGrants(sources []string) []string {
|
||||
// WatchmanHears are the core subjects the watchman subscribes, with no durable consumer (ADR 0564 clauses 4, 5 and
|
||||
// Limits 3, 11): the power seat's words (S1, ADR 0211), the providers' failing and recovered words (S8), each tool
|
||||
// runner's beat (S11), the self-check's checked (S10), a consumer deleted and the bus module's slow consumers and closed
|
||||
// connections (S9), the provisioner clients' reports (S21), and the dead letters kept. Never the bus's API audit
|
||||
// connections (S9), the provisioner clients' reports (S21), the dead letters kept, and the lease write's events. Never the bus's API audit
|
||||
// (`$JS.EVENT.ADVISORY.API`): it carries every API call's request and answer in the account, the vault's hashes among
|
||||
// them (the lead, 2026-10-29).
|
||||
func WatchmanHears() []string {
|
||||
@@ -129,6 +129,12 @@ func WatchmanHears() []string {
|
||||
"mesh.mod.nats.event.bus.connection-closed",
|
||||
ProvAppliedSubjects,
|
||||
deadLetterPrefix + ">",
|
||||
// And the lease write's (ADR 0582 clauses 4, 6; issue 774): the seat mesh-grants' three events, and the stream
|
||||
// ASSIGNMENTS deleted, which with the controller's consumer deleted clears a removal that failed.
|
||||
"mesh.seat." + catalogue.GrantsSeat + ".event." + catalogue.GrantsEventLeaseHanded,
|
||||
"mesh.seat." + catalogue.GrantsSeat + ".event." + catalogue.GrantsEventLeaseAltered,
|
||||
"mesh.seat." + catalogue.GrantsSeat + ".event." + catalogue.GrantsEventRemovalFailed,
|
||||
"$JS.EVENT.ADVISORY.STREAM.DELETED." + AssignmentsStream,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package buscompose
|
||||
|
||||
import "slices"
|
||||
|
||||
// **The lease write's grants** (novox/hq ADR 0582 clause 3, issue 774). Composed with Records.LeaseCut, the list takes
|
||||
// from the controller its write of the lease's key and every update, purge and delete of the lease's bucket, and gives
|
||||
// the grants part the key `holder`, which it creates naming itself and deletes at a hand-back (clause 5). Whatever the
|
||||
// records, the bus module's tools account may read and update the lease's bucket, for the tool that removes and
|
||||
// restores its expiry; no other bucket's.
|
||||
|
||||
// leaseStream is the lease bucket's stream.
|
||||
var leaseStream = "KV_" + LeaseBucket
|
||||
|
||||
// LeaseHolderWrite is the grants part's write of the lease's one key, from the cut.
|
||||
func LeaseHolderWrite() string { return "$KV." + LeaseBucket + "." + leaseKey }
|
||||
|
||||
// LeaseCutDenied are the controller's subjects of the lease's bucket that its `$JS.API.>` reaches and the cut refuses:
|
||||
// the bucket's update (its expiry), purge and delete, and a message's delete (the key's last value).
|
||||
func LeaseCutDenied() []string {
|
||||
return []string{"$JS.API.STREAM.UPDATE." + leaseStream, "$JS.API.STREAM.PURGE." + leaseStream,
|
||||
"$JS.API.STREAM.DELETE." + leaseStream, "$JS.API.STREAM.MSG.DELETE." + leaseStream}
|
||||
}
|
||||
|
||||
// LeaseExpiryGrants are the bus module's tools account's read and update of the lease's bucket, for its expiry.
|
||||
func LeaseExpiryGrants() []string {
|
||||
return []string{"$JS.API.STREAM.INFO." + leaseStream, "$JS.API.STREAM.UPDATE." + leaseStream}
|
||||
}
|
||||
|
||||
// writesTheLease is the controller holding the lease, or, from the cut, the grants part writing its key holder alone.
|
||||
func writesTheLease(p Principal, pattern string) bool {
|
||||
if p.Kind == KindController {
|
||||
return true
|
||||
}
|
||||
return p.Kind == KindModule && p.Module == GrantsModule && p.LeaseCut && pattern == LeaseHolderWrite()
|
||||
}
|
||||
|
||||
// setsLeaseExpiry is the bus module's tools account updating the lease's bucket: the writers table's one exemption from
|
||||
// stream definitions being the controller's beside ASSIGNMENTS' removal (ADR 0582 clause 3).
|
||||
func setsLeaseExpiry(p Principal, pattern string) bool {
|
||||
return p.Kind == KindProcess && len(p.Removes) > 0 && slices.Contains(LeaseExpiryGrants(), pattern)
|
||||
}
|
||||
+131
-10
@@ -22,6 +22,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -201,6 +202,9 @@ type Principal struct {
|
||||
// Removes are, for a process or tools bundle account whose bus declares them, what it may remove from the bus
|
||||
// (novox/hq ADR 0564 Limits 4): its whole authority is then BusRemovalGrants of them.
|
||||
Removes []string
|
||||
// LeaseCut is Records.LeaseCut on the controller and the grants part (novox/hq ADR 0582 clause 3): the controller
|
||||
// loses its write of the lease, and the grants part may write the key `holder`.
|
||||
LeaseCut bool
|
||||
// DeadLetterSources are, for the watchman, the streams a dead letter it keeps may come from, by name
|
||||
// (deadLetterSourcesOf, novox/hq ADR 0564 clause 5): never a bucket's.
|
||||
DeadLetterSources []string
|
||||
@@ -250,6 +254,26 @@ type Principal struct {
|
||||
// stream and name. Created only, never updated or deleted.
|
||||
CreatesConsumers []ModuleConsumer
|
||||
|
||||
// Login is a node principal's login when it is not `node.<n>`: its second login, `node.<n>--b`, composed beside or
|
||||
// in place of the first as the vault's node-logins says (novox/hq ADR 0583 §1, issue 774). Each login is granted
|
||||
// the node's permissions and the node's inbox `_INBOX.node.<n>`; what it calls, it calls in its own name, so the
|
||||
// vault tells the login that confirms a rotation from the one it replaces (§3).
|
||||
Login string
|
||||
// RotatesItsLogin is a machine principal whose node-engine reads its own node's key of the vault's node-logins,
|
||||
// and no other key, and confirms a switch with node-account-confirm in its own name (novox/hq ADR 0583 §1 insight,
|
||||
// §3). Set while the vault's holder serves rotate-node.
|
||||
RotatesItsLogin bool
|
||||
|
||||
// Redeems is an enrolment principal that may call the seat mesh-enrolment's redeem in its own name, and nothing else
|
||||
// of it (novox/hq ADR 0587 §4): set while the seat's holder serves redeem. LeaseHandedAway drops the controller's
|
||||
// enrolment subject from it, which is served no more from the lease write (§6).
|
||||
Redeems bool
|
||||
LeaseHandedAway bool
|
||||
|
||||
// VaultEnrols says the vault's holder serves an enrolment verb (VerbsTheEnrolmentAsksTheVault): every principal but
|
||||
// the module mesh-enrolment and its processes is then denied them, however wide its grant (ADR 0587 §1).
|
||||
VaultEnrols bool
|
||||
|
||||
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
|
||||
// and never appears here: this file is written to a node's disk and read by a server, and a
|
||||
// secret that can be read from a configuration file is a secret with a wider blast radius
|
||||
@@ -300,6 +324,22 @@ func ControllerOnly() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// VerbsTheEnrolmentAsksTheVault are the vault's verbs the seat mesh-enrolment's holder alone calls (novox/hq ADR 0587
|
||||
// §1, §3; ADR 0561 §5): a token and its hash made and dropped, a joining node's account minted and retired.
|
||||
var VerbsTheEnrolmentAsksTheVault = []SeatVerb{{Seat: VaultSeat, Verb: delivery.EnrolmentTokenVerb},
|
||||
{Seat: VaultSeat, Verb: delivery.NodeAccountVerb}}
|
||||
|
||||
// EnrolmentOnly are the subjects the holder of mesh-enrolment alone may publish, however wide another's grant: the
|
||||
// vault's two enrolment verbs, by the tool's and the caller-named shape (Principal.VaultEnrols denies them).
|
||||
func EnrolmentOnly() []string {
|
||||
var out []string
|
||||
for _, v := range VerbsTheEnrolmentAsksTheVault {
|
||||
out = append(out, "mesh.seat."+v.Seat+".tool."+v.Verb, "mesh.seat."+v.Seat+".tool."+v.Verb+".>",
|
||||
"mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+".>")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MayPublish says whether permissions let a principal publish one subject: an allow covers it and no deny does.
|
||||
func MayPublish(perms Permissions, subject string) bool {
|
||||
for _, d := range perms.PublishDeny {
|
||||
@@ -377,6 +417,33 @@ var VaultOwn = SeatVerb{Seat: "mesh-vault", Verb: "own"}
|
||||
// ADR 0550 §2); granted as Principal.AsksTheVaultToSign says.
|
||||
var VaultSign = SeatVerb{Seat: "mesh-vault", Verb: delivery.SignVerb}
|
||||
|
||||
// VaultRotateNode is the vault's verb that begins a node's account rotation; while its holder serves it, every node
|
||||
// principal reads its own node's key of node-logins (novox/hq ADR 0583 §1, §2). VaultNodeAccountConfirm is the verb a
|
||||
// node-engine confirms a switch with, in its new login's name (§3).
|
||||
var (
|
||||
VaultRotateNode = SeatVerb{Seat: "mesh-vault", Verb: delivery.RotateNodeVerb}
|
||||
VaultNodeAccountConfirm = SeatVerb{Seat: "mesh-vault", Verb: delivery.NodeAccountConfirmVerb}
|
||||
)
|
||||
|
||||
// EnrolmentRedeem is the seat mesh-enrolment's verb a joining node-engine calls as enrol.<node> (novox/hq ADR 0587 §4,
|
||||
// §6).
|
||||
var EnrolmentRedeem = SeatVerb{Seat: catalogue.EnrolmentSeat, Verb: catalogue.EnrolmentVerbRedeem}
|
||||
|
||||
// EnrolmentRedeemCall is the one subject an enrolment user may publish once it enrols through the seat: redeem, in its
|
||||
// own name.
|
||||
func EnrolmentRedeemCall(p Principal) string {
|
||||
return "mesh.seat." + EnrolmentRedeem.Seat + "." + CallKind + "." + EnrolmentRedeem.Verb + "." + CallerToken(p.Username())
|
||||
}
|
||||
|
||||
// NodeLoginsReads is what a node's engine is granted of the vault's node-logins: its own node's key, read and watched,
|
||||
// and no other (novox/hq ADR 0583 §1 insight).
|
||||
func NodeLoginsReads(node string) []string {
|
||||
if !safeSubject.MatchString(node) {
|
||||
return nil
|
||||
}
|
||||
return readGrants(delivery.NodeLoginsBucket, node)
|
||||
}
|
||||
|
||||
// VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant
|
||||
// chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the
|
||||
// controller's own `plans`, a machine's held builds sent by its own `push` and the builds held after a failed release
|
||||
@@ -525,6 +592,9 @@ func (p Principal) Username() string {
|
||||
// never the module's own name alone, which is a different account with a different secret.
|
||||
return p.Node + "." + p.Module + "." + p.Process
|
||||
case KindNode:
|
||||
if p.Login != "" {
|
||||
return p.Login
|
||||
}
|
||||
return "node." + p.Node
|
||||
case KindController:
|
||||
return "controller"
|
||||
@@ -584,7 +654,14 @@ func AskAccessActSubject(node string) string { return "mesh.node." + node + ".as
|
||||
// inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25
|
||||
// §4): with one account, inbox privacy is the permission list or it is nothing, so each user's
|
||||
// inbox is derived from its own identity and its permissions name that prefix and no other.
|
||||
func (p Principal) inbox() string { return "_INBOX." + p.Username() + ".>" }
|
||||
//
|
||||
// A node's two logins share the node's inbox (novox/hq ADR 0583 §1): its answers reach it whichever login it dials as.
|
||||
func (p Principal) inbox() string {
|
||||
if p.Kind == KindNode {
|
||||
return "_INBOX.node." + p.Node + ".>"
|
||||
}
|
||||
return "_INBOX." + p.Username() + ".>"
|
||||
}
|
||||
|
||||
// Permissions is what a principal may publish and subscribe, and whether it may answer.
|
||||
type Permissions struct {
|
||||
@@ -668,6 +745,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
if err != nil {
|
||||
return Permissions{}, err
|
||||
}
|
||||
// And the lease's bucket's expiry, read and set, by the bus module's tool the grants part calls at the lease
|
||||
// write and its hand-back (novox/hq ADR 0582 clauses 3, 5): the one bucket whose stream it may update.
|
||||
pub = append(pub, LeaseExpiryGrants()...)
|
||||
sort.Strings(pub)
|
||||
if err := CheckWriters(p, pub); err != nil {
|
||||
return Permissions{}, err
|
||||
@@ -801,6 +881,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
if bucket == delivery.HaveBucket {
|
||||
continue // each node's node-engine writes its own keys of it, the controller none (ADR 0429 §1)
|
||||
}
|
||||
if bucket == LeaseBucket && p.LeaseCut {
|
||||
continue // the lease write took it (novox/hq ADR 0582 clause 3): a controller started fails on the lease
|
||||
}
|
||||
pub = append(pub, "$KV."+bucket+".>")
|
||||
}
|
||||
// **And the object bucket a declaration's large files travel in** (novox/hq ADR 0486 §2, issue 1697): the
|
||||
@@ -880,7 +963,20 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
"enrolment: a token is issued for a node record, and that record's name is " +
|
||||
"the token's id")
|
||||
}
|
||||
pub = []string{"mesh.control.enrol"}
|
||||
// **From the lease write a node enrols through the seat mesh-enrolment alone** (novox/hq ADR 0587 §4, §6): its
|
||||
// user calls redeem in its own name, while the seat's holder serves it, and the controller's enrolment subject is
|
||||
// kept only while the controller holds the lease.
|
||||
pub = nil
|
||||
if !p.LeaseHandedAway {
|
||||
pub = append(pub, "mesh.control.enrol")
|
||||
}
|
||||
if p.Redeems {
|
||||
pub = append(pub, EnrolmentRedeemCall(p))
|
||||
}
|
||||
if len(pub) == 0 {
|
||||
return Permissions{}, fmt.Errorf("the enrolment user of %s may enrol nowhere: the lease is handed away and "+
|
||||
"no holder of %s serves %s", p.Node, EnrolmentRedeem.Seat, EnrolmentRedeem.Verb)
|
||||
}
|
||||
sub = []string{p.inbox()}
|
||||
|
||||
case KindNode:
|
||||
@@ -994,6 +1090,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
pub = append(pub, readGrants(delivery.SignersBucket, "")...)
|
||||
pub = append(pub, readGrants(LeaseBucket, leaseKey)...)
|
||||
}
|
||||
// And, while the vault rotates node accounts, its own node's key of node-logins, read and watched, and no other
|
||||
// key (novox/hq ADR 0583 §1 insight): the login it dials as; and the confirmation of a switch in its own name (§3).
|
||||
if p.RotatesItsLogin {
|
||||
pub = append(pub, NodeLoginsReads(p.Node)...)
|
||||
pub = append(pub, "mesh.seat."+VaultNodeAccountConfirm.Seat+"."+CallKind+"."+VaultNodeAccountConfirm.Verb+"."+
|
||||
CallerToken(p.Username()))
|
||||
}
|
||||
// And its node seat, node-engine, and its asks to the operator in its own name (novox/hq ADR 0563 §1, §3).
|
||||
ep, es := engineGrants(p)
|
||||
pub = append(pub, ep...)
|
||||
@@ -1026,6 +1129,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
pub = append(pub, CreateConsumerGrants(p.CreatesConsumers)...)
|
||||
// And its own object bucket, where it writes the user list sealed to the bus node (ADR 0547 clause 7).
|
||||
pub = append(pub, GrantsUserListWrites()...)
|
||||
// And, from the lease write's cut, the lease's one key, which it creates naming itself and deletes at a
|
||||
// hand-back (novox/hq ADR 0582 clauses 3, 5): never the bucket's other keys or its stream.
|
||||
if p.LeaseCut {
|
||||
pub = append(pub, LeaseHolderWrite())
|
||||
}
|
||||
}
|
||||
// The view alone reads the build seats' workers for the backlog of asks (issue 1887 A8a).
|
||||
if p.Module == ViewModule {
|
||||
@@ -1061,10 +1169,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
}
|
||||
}
|
||||
sub = append(sub, announcing(announced...)...)
|
||||
// Its own membership (ADR 0160): the one subject a runtime derives for itself, heard live. Nothing else's.
|
||||
sub = append(sub, MembershipSubject(p.Node, p.Module))
|
||||
// And from the grants part's state memberships, its own key (novox/hq ADR 0571 clause 7): its one read of it,
|
||||
// since the assignments stream's direct get went with the stream (research 072 row 33).
|
||||
// Its own membership (ADR 0160), from the grants part's state memberships, its own key (novox/hq ADR 0571 clause
|
||||
// 7): its one read of it, since the assignments stream's direct get went with the stream (research 072 row 33),
|
||||
// and no longer heard on its subject, where the grants part stops publishing at the lease write (ADR 0571 clause
|
||||
// 10, issue 774).
|
||||
pub = append(pub, readGrants(GrantsMembershipsBucket, MembershipKey(p.Node, p.Module))...)
|
||||
|
||||
// 1b. The tools it calls, if its manifest says it calls any (novox/hq ADR 0152). The same
|
||||
@@ -1226,10 +1334,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every assigned module's membership on this node (ADR 0160): one per module, heard live and read from the
|
||||
// grants part's state memberships (ADR 0571 clause 7). This node's and no other's — the one token
|
||||
// that varies is the module, so the pattern is the machine's own assignments.
|
||||
sub = append(sub, "mesh.assignment."+p.Node+".*")
|
||||
// Every assigned module's membership on this node (ADR 0160): one per module, read and watched from the
|
||||
// grants part's state memberships (ADR 0571 clause 7), and no longer heard on `mesh.assignment`, where the grants
|
||||
// part stops publishing at the lease write (ADR 0571 clause 10, issue 774). This node's and no other's — the
|
||||
// one token that varies is the module, so the pattern is the machine's own assignments.
|
||||
pub = append(pub, readGrants(GrantsMembershipsBucket, p.Node+".*")...)
|
||||
// And that it is there (novox/hq to-be 45 §3, S11): its own heartbeat, under its machine's
|
||||
// name and no other's, on core NATS like the host's.
|
||||
@@ -1408,6 +1516,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
}
|
||||
// What the controller alone may publish is denied to everybody else whose grant reaches it.
|
||||
var deny []string
|
||||
// And from the lease write's cut, the lease's bucket to the controller, whose `$JS.API.>` reaches it (novox/hq ADR
|
||||
// 0582 clause 3): a controller started by mistake fails on the lease instead of taking it back.
|
||||
if p.Kind == KindController && p.LeaseCut {
|
||||
deny = append(deny, LeaseCutDenied()...)
|
||||
}
|
||||
if p.Kind != KindController {
|
||||
for _, only := range ControllerOnly() {
|
||||
for _, a := range pub {
|
||||
@@ -1421,6 +1534,14 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// And the vault's enrolment verbs to every principal but mesh-enrolment's (novox/hq ADR 0587 §1).
|
||||
if p.VaultEnrols && !((p.Kind == KindModule || p.Kind == KindProcess) && p.Module == delivery.EnrolmentModule) {
|
||||
for _, only := range EnrolmentOnly() {
|
||||
if slices.ContainsFunc(pub, func(a string) bool { return SubjectsOverlap(a, only) }) {
|
||||
deny = append(deny, only)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Permissions{
|
||||
Publish: pub,
|
||||
PublishDeny: deny,
|
||||
|
||||
@@ -104,7 +104,12 @@ var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsA
|
||||
ControllerConditions: {health.KeeperModule}, ControllerHandActs: {health.HealersModule, handacts.Module}, HealersWould: {},
|
||||
// ADR 0577 clause 1: the node-engine's data items are read by disk-load alone, each node's its own node's keys
|
||||
// (NodeKeyedReads); the engine announces nothing of its own, so its readers are named here.
|
||||
delivery.EngineDataRead: {health.DiskLoadModule}}
|
||||
delivery.EngineDataRead: {health.DiskLoadModule},
|
||||
// ADR 0583 §1 insight: every node's login is read by the grants part alone, each engine its own node's key through its
|
||||
// machine principal (NodeLoginsReads), never through `reads`.
|
||||
delivery.NodeLoginsRead: {GrantsModule},
|
||||
// ADR 0587 §2: the open enrolments are read by their holder and, as a named reader, by the grants part.
|
||||
delivery.EnrolmentsRead: {GrantsModule}}
|
||||
|
||||
// ReaderMay says whether module may read the state read (`<owner>.<name>`) by the named readers: always, for a state
|
||||
// that names none.
|
||||
|
||||
+58
-8
@@ -2,10 +2,12 @@ package buscompose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Every user the composed file should contain, derived from what the mesh knows.
|
||||
@@ -129,7 +131,8 @@ type Records struct {
|
||||
Assigned map[string][]Declared
|
||||
// Enrolling is every node with a live token — one enrolment user each, because the inbox an
|
||||
// answer goes to is scoped to the token and a shared one is one machine reading another's
|
||||
// sealed credentials (design 25 §6).
|
||||
// sealed credentials (design 25 §6). The grants part fills it from the seat mesh-enrolment's keys at live or
|
||||
// redeeming (novox/hq ADR 0587 §4), the controller from its store.
|
||||
Enrolling []string
|
||||
// People is each person's name against the tools they may invoke, `*` for an administrator.
|
||||
People map[string][]string
|
||||
@@ -145,9 +148,22 @@ type Records struct {
|
||||
// grants part as a placed module's are; it adds no principal, since each node's engine speaks as the node's account.
|
||||
// Nil where its manifest was not read.
|
||||
Engine *Declared
|
||||
// LeaseHandedAway says the controller's lease names a holder that is no controller (ADR 0582 §3), as the grants part
|
||||
// reads it: an enrolment user then enrols through the seat mesh-enrolment alone (novox/hq ADR 0587 §6). The
|
||||
// controller's composer never sets it. It is not LeaseCut: the cut is composed before the holder is written, so the
|
||||
// grants part may write it, and outlasts the holder at a hand-back; the two are true together only in between.
|
||||
LeaseHandedAway bool
|
||||
// NodeLogins is each node's key of the vault's node-logins (novox/hq ADR 0583 §1): the login in use and the one
|
||||
// rotating, each composed as a user of the node's permissions. A node absent logs in as `node.<n>` alone.
|
||||
NodeLogins map[string]delivery.NodeLogin
|
||||
// Announced is each module's last announcement of the states it keeps (novox/hq ADR 0495, issue 1790), as the
|
||||
// controller keeps it; a module absent has announced nothing yet. Nil where none was read.
|
||||
Announced map[string]announce.States
|
||||
// LeaseCut composes the list of the lease write (novox/hq ADR 0582 clause 3, issue 774): the controller may neither
|
||||
// write the lease's key nor update, purge or delete its bucket, and the grants part may write the key `holder`.
|
||||
// Set by the grants part from the write's second step until a hand-back composes without it (clause 5); the
|
||||
// controller's composer never sets it.
|
||||
LeaseCut bool
|
||||
}
|
||||
|
||||
// Users is every user the composed file should contain, in the order it will be written.
|
||||
@@ -157,7 +173,7 @@ type Records struct {
|
||||
// correct.
|
||||
func Users(r Records) ([]Principal, error) {
|
||||
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r), AsksTheVault: vaultVerbsOf(r),
|
||||
AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig), FilesFor: sortedCopy(r.Nodes)}}
|
||||
AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig), FilesFor: sortedCopy(r.Nodes), LeaseCut: r.LeaseCut}}
|
||||
|
||||
served := servedReadsOf(r)
|
||||
logs := DeclaredLogs(r)
|
||||
@@ -175,10 +191,14 @@ func Users(r Records) ([]Principal, error) {
|
||||
}
|
||||
checks = append(checks, d.Checks...)
|
||||
}
|
||||
out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks,
|
||||
MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey),
|
||||
AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r),
|
||||
FollowsGrantsUserList: bus, ServedReads: servedOn(served, node)})
|
||||
// **One user per login the node has** (novox/hq ADR 0583 §1): the one in use, and while it rotates the other too,
|
||||
// each with the node's permissions.
|
||||
for _, login := range nodeLoginsOf(r, node) {
|
||||
out = append(out, Principal{Kind: KindNode, Node: node, Login: login, WitnessesController: witness, Checks: checks,
|
||||
MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey),
|
||||
AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r),
|
||||
FollowsGrantsUserList: bus, ServedReads: servedOn(served, node), RotatesItsLogin: servesVerb(r, VaultRotateNode)})
|
||||
}
|
||||
// **And the machine's provisioner client, on an account of its own** (novox/hq ADR 0349 clause 11): one
|
||||
// per machine that runs it, delivered to the module running it.
|
||||
for _, d := range r.Assigned[node] {
|
||||
@@ -246,6 +266,7 @@ func Users(r Records) ([]Principal, error) {
|
||||
}
|
||||
if d.Module == GrantsModule {
|
||||
out[len(out)-1].CreatesStreams = declaredStreamsOf(r)
|
||||
out[len(out)-1].LeaseCut = r.LeaseCut
|
||||
grants = len(out) - 1
|
||||
}
|
||||
// **The vault's holder asks the desks and the issuing providers itself** (issue 723), under the
|
||||
@@ -269,7 +290,8 @@ func Users(r Records) ([]Principal, error) {
|
||||
}
|
||||
}
|
||||
for _, node := range sortedCopy(r.Enrolling) {
|
||||
out = append(out, Principal{Kind: KindEnrolment, Node: node})
|
||||
out = append(out, Principal{Kind: KindEnrolment, Node: node, Redeems: servesVerb(r, EnrolmentRedeem),
|
||||
LeaseHandedAway: r.LeaseHandedAway})
|
||||
}
|
||||
for _, person := range sortedNames(r.People) {
|
||||
p := Principal{Kind: KindPerson, Module: person, Invokes: r.People[person]}
|
||||
@@ -280,6 +302,13 @@ func Users(r Records) ([]Principal, error) {
|
||||
out = append(out, p)
|
||||
}
|
||||
|
||||
// The vault's enrolment verbs are mesh-enrolment's alone, however wide another's grant (novox/hq ADR 0587 §1).
|
||||
if slices.ContainsFunc(VerbsTheEnrolmentAsksTheVault, func(v SeatVerb) bool { return servesVerb(r, v) }) {
|
||||
for i := range out {
|
||||
out[i].VaultEnrols = true
|
||||
}
|
||||
}
|
||||
|
||||
// The grants part creates the durable consumers the accounts need (novox/hq ADR 0561 §6), by stream and name.
|
||||
if grants >= 0 {
|
||||
out[grants].CreatesConsumers = AccountConsumers(out)
|
||||
@@ -379,6 +408,27 @@ func asksSeatOf(r Records) []Seat {
|
||||
return nil
|
||||
}
|
||||
|
||||
// nodeLoginsOf are the logins composed for node, as Principal.Login names them ("" for `node.<n>`): the one its
|
||||
// node-logins key says is in use and the one rotating, in that order; `node.<n>` alone when the key is absent or does
|
||||
// not hold (a key naming another node's login is no login of this node's).
|
||||
func nodeLoginsOf(r Records, node string) []string {
|
||||
l, ok := r.NodeLogins[node]
|
||||
if !ok || l.Node != node || (l.Login != delivery.FirstLogin(node) && l.Login != delivery.SecondLogin(node)) {
|
||||
return []string{""}
|
||||
}
|
||||
login := func(name string) string {
|
||||
if name == delivery.FirstLogin(node) {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
out := []string{login(l.Login)}
|
||||
if l.Rotates() && l.Rotating == delivery.OtherLogin(node, l.Login) {
|
||||
out = append(out, login(l.Rotating))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name.
|
||||
const VaultSeat = "mesh-vault"
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ func createsDeclared(p Principal, pattern string) bool {
|
||||
if isController(p, pattern) {
|
||||
return true
|
||||
}
|
||||
if removesAssignmentsStream(p, pattern) {
|
||||
if removesAssignmentsStream(p, pattern) || setsLeaseExpiry(p, pattern) {
|
||||
return true
|
||||
}
|
||||
name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.CREATE.")
|
||||
@@ -96,6 +96,19 @@ func ownModule(p Principal, pattern string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// moduleNamed is the writer that is module: its own principal, a process of it, or the node tools carrying it.
|
||||
func moduleNamed(module string) func(Principal, string) bool {
|
||||
return func(p Principal, _ string) bool {
|
||||
switch p.Kind {
|
||||
case KindModule, KindProcess:
|
||||
return p.Module == module
|
||||
case KindNodeTools:
|
||||
return slices.ContainsFunc(p.Carries, func(d Declared) bool { return d.Module == module })
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ownAnnouncer is a module, a process of it or the node tools carrying it saying what that module keeps, or the
|
||||
// controller saying what it keeps itself (novox/hq ADR 0495 clause 2): each under its own name alone.
|
||||
func ownAnnouncer(p Principal, pattern string) bool {
|
||||
@@ -158,7 +171,8 @@ var WritersTable = []WriterRow{
|
||||
// subject is this machine's engine's alone.
|
||||
Subjects: []string{"mesh.control.*.report", "mesh.control.*.health", "mesh.control.*.cli"}, Writes: ownMachine},
|
||||
{State: "the controller lease", Writer: "the controller instance holding it", KeptIn: "key-value " + LeaseBucket,
|
||||
Others: "a candidate waits", Subjects: kvOf(LeaseBucket), Writes: isController},
|
||||
Others: "a candidate waits", Subjects: kvOf(LeaseBucket), Writes: writesTheLease,
|
||||
Shared: "from the lease write's cut the controller writes it no more and the grants part writes its key holder alone (novox/hq ADR 0582 clause 3)"},
|
||||
// The merges mesh-delivery announced, for a moved module's registration (ADR 0420 §7), written by the lease holder.
|
||||
{State: "the merges mesh-delivery announced", Writer: "controller (lease holder)",
|
||||
KeptIn: "key-value " + DeliveryMergesBucket, Others: "read at a moved module's registration",
|
||||
@@ -188,7 +202,7 @@ var WritersTable = []WriterRow{
|
||||
Subjects: []string{"$JS.API.STREAM.CREATE.>", "$JS.API.STREAM.UPDATE.>", "$JS.API.STREAM.DELETE.>",
|
||||
"$JS.API.CONSUMER.DURABLE.CREATE.>"},
|
||||
Writes: createsDeclared,
|
||||
Shared: "the grants part creates a stream a manifest declares, by its name, and never updates or deletes one (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54); the bus module's tools account deletes the stream ASSIGNMENTS at the lease write, and no other (research 072 row 33)"},
|
||||
Shared: "the grants part creates a stream a manifest declares, by its name, and never updates or deletes one (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54); the bus module's tools account deletes the stream ASSIGNMENTS at the lease write, and no other (research 072 row 33), and updates the lease's bucket for its expiry alone (novox/hq ADR 0582 clause 3)"},
|
||||
{State: "builds and their outcomes", Writer: "the build seat's holder", KeptIn: "its own state",
|
||||
Others: "the controller asks",
|
||||
Subjects: []string{"mesh.seat.node-build-agent.event.built", "mesh.seat.mesh-build-machine.event.built"},
|
||||
@@ -217,6 +231,16 @@ var WritersTable = []WriterRow{
|
||||
Writes: ownModule},
|
||||
{State: "the operator-channel's open messages", Writer: "the seat's holder", KeptIn: "its own key-value state",
|
||||
Others: "—"},
|
||||
// The seat mesh-enrolment's open enrolments (novox/hq ADR 0587 §2, issue 774): its holder alone writes them, each a
|
||||
// compare-and-set at the revision read; the grants part reads them as a named reader.
|
||||
{State: "the open enrolments", Writer: "the seat mesh-enrolment's holder, compare-and-set",
|
||||
KeptIn: "key-value " + delivery.EnrolmentsBucket + ", with per-key expiry",
|
||||
Others: "the grants part reads them (a named reader) and composes each enrol.<node> user from them",
|
||||
Subjects: kvOf(delivery.EnrolmentsBucket), Writes: moduleNamed(delivery.EnrolmentModule)},
|
||||
// Which login each node uses (novox/hq ADR 0583 §1 insight): the vault alone writes it.
|
||||
{State: "a node's logins", Writer: "the vault", KeptIn: "key-value " + delivery.NodeLoginsBucket,
|
||||
Others: "the grants part reads every key, each node's engine its own node's key alone",
|
||||
Subjects: kvOf(delivery.NodeLoginsBucket), Writes: moduleNamed(delivery.VaultModule)},
|
||||
{State: "the facts snapshot", Writer: "controller", KeptIn: "the artifact store, facts/latest",
|
||||
Others: "the build seat reads"},
|
||||
// What a consumer gave up on (novox/hq issue 330): copied by the controller from the server's notice,
|
||||
@@ -288,7 +312,8 @@ var WritersTable = []WriterRow{
|
||||
KeptIn: "key-value " + PredefinedBucket, Others: "read through `reads` as mesh-controller." + MirrorPredefined,
|
||||
Subjects: kvOf(PredefinedBucket), Writes: isController},
|
||||
{State: "the declared-packages mirror", Writer: "controller (lease holder), from the composition of each node, only on a change",
|
||||
KeptIn: "key-value " + DeclaredPackagesBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredPackages,
|
||||
KeptIn: "key-value " + DeclaredPackagesBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredPackages +
|
||||
", and each node's engine its node's key, until the lease write (ADR 0565 clause 6)",
|
||||
Subjects: kvOf(DeclaredPackagesBucket), Writes: isController},
|
||||
// What a machine's provisioner client placed of a module's files (novox/hq ADR 0349 §4, design 57 P6): its own
|
||||
// machine's word alone, which a walk's end and a gate wait for (ADR 0362, ADR 0349 §15). Kept newest per module
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// **What still depends on the controller, and the lease write's check** (novox/hq ADR 0571 §13, ADR 0582 clause 2,
|
||||
// issue 774): every declaration of a manifest other than the controller's on the controller (a read of one of its
|
||||
// states, an invoke of one of its verbs or tools, a consume or a wait on one of its events, a use of its seat), judged
|
||||
// against the named, temporary list of what another accepted record keeps until the lease write. The grants part's
|
||||
// grants_lease_check runs it over every manifest the indexer holds; ManifestRefusals runs it over a repository's
|
||||
// manifests, in the test ADR 0571 §13 names in mesh-catalog, mesh-delivery and mesh-tools. A read of the lease key is no
|
||||
// dependency: it names the lease's holder, whoever that is, and parts read it to learn that the write happened.
|
||||
|
||||
// ControllerLease is the controller's lease as a reader names it (`<module>.<state>`): no dependency.
|
||||
const ControllerLease = ControllerSeatName + ".lease"
|
||||
|
||||
// The kinds of a dependency on the controller, as a manifest declares them.
|
||||
const (
|
||||
DependsRead = "read"
|
||||
DependsInvoke = "invoke"
|
||||
DependsConsume = "consume"
|
||||
DependsUses = "uses"
|
||||
DependsWaits = "waits"
|
||||
)
|
||||
|
||||
// The phases of the lease write's check (ADR 0571 §13): before the lease write's own delivery the whole named list is
|
||||
// allowed; that delivery drops every entry but the three kept to the write itself, and its check runs with those alone.
|
||||
const (
|
||||
PhaseBefore = "before-delivery"
|
||||
PhaseWrite = "write"
|
||||
)
|
||||
|
||||
// Dependency is one declaration of a manifest other than the controller's on the controller: a read of one of its
|
||||
// states, an invoke of one of its verbs or tools, a consume or a wait on one of its events, or a send to its seat.
|
||||
type Dependency struct {
|
||||
Module string `json:"module"`
|
||||
// By is the account declaring it when it is not the module's own: `process <id>` or `tools <binary>`.
|
||||
By string `json:"by,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
What string `json:"what"`
|
||||
// Kept is the record that keeps it until the lease write, from the named list; empty when none does.
|
||||
Kept string `json:"kept,omitempty"`
|
||||
// ToTheWrite says it is one of the three kept to the write itself, which the lease write's own delivery keeps.
|
||||
ToTheWrite bool `json:"to_the_write,omitempty"`
|
||||
}
|
||||
|
||||
// KeptDependency is one entry of the named, temporary list ADR 0571 §13 allows: a read, invoke or consume another
|
||||
// accepted record keeps until the lease write, with that record. ToTheWrite marks the three kept to the write itself.
|
||||
type KeptDependency struct {
|
||||
Module, Kind, What, Record string
|
||||
ToTheWrite bool
|
||||
}
|
||||
|
||||
// KeepList is the named list. Each entry names the clause that keeps it; a dependency not on it fails the check. It
|
||||
// holds the three kept to the write itself and nothing else: the lease write's own delivery dropped every other entry
|
||||
// (ADR 0571 §13; issue 774), so its check runs with these alone in either phase. The third, the bus node agent's
|
||||
// following of the controller's user list (ADR 0571 §8, ADR 0547 §8), is no manifest's declaration but the
|
||||
// node-engine's own, so no manifest matches it; it stays named here so the list reads as the record has it.
|
||||
var KeepList = []KeptDependency{
|
||||
{Module: "mesh-delivery-builder", Kind: DependsConsume, What: "mesh-controller.artifacts-gone", Record: "ADR 0571 §4, §13", ToTheWrite: true},
|
||||
{Module: "artifact-store-tools", Kind: DependsInvoke, What: "seat:mesh-controller.collect", Record: "ADR 0571 §4, §13", ToTheWrite: true},
|
||||
{Module: "node-engine", Kind: DependsRead, What: "the controller's user list of the bus module's composition", Record: "ADR 0571 §8, §13; ADR 0547 §8", ToTheWrite: true},
|
||||
}
|
||||
|
||||
// EndedBy names, for a declaration no entry keeps, the record that already ended it, so a refusal says where it moved.
|
||||
// The controller's `grants` is one: ADR 0571 §9 has no manifest read it from L7's delivery, which changes ADR 0567 §10
|
||||
// and ADR 0547 §5's shadow compare, so neither the assigner's nor the grants part's read of it is kept.
|
||||
var EndedBy = map[string]string{
|
||||
DependsRead + " mesh-controller.grants": "ADR 0571 §9: no manifest reads the controller's grants; the grants part's key is the one read",
|
||||
DependsInvoke + " seat:mesh-controller.hand-act": "ADR 0565 §11: every recorder calls mesh-hand-acts",
|
||||
DependsInvoke + " seat:mesh-controller.warranted": "ADR 0565 §11: every recorder calls mesh-hand-acts",
|
||||
DependsInvoke + " seat:mesh-controller.root-free": "ADR 0571 §11: each machine's engine answers root-free (seat:node-engine.root-free@machine)",
|
||||
DependsInvoke + " seat:mesh-controller.held": "ADR 0571 §2: held is the seat mesh-artifact-sweep's",
|
||||
}
|
||||
|
||||
// keptFor is the list's entry for one dependency, or nil.
|
||||
func keptFor(d Dependency) *KeptDependency {
|
||||
for i, k := range KeepList {
|
||||
if k.Module == d.Module && k.Kind == d.Kind && k.What == d.What {
|
||||
return &KeepList[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// onController says whether a name `<owner>.<rest>` is the controller's.
|
||||
func onController(name string) bool { return strings.HasPrefix(name, ControllerSeatName+".") }
|
||||
|
||||
// invokesController says whether an invoke names a verb of the controller's seat or a tool of its module. `*` names none:
|
||||
// it is the console's grant to every tool, judged by no record as a dependency.
|
||||
func invokesController(invoke string) bool {
|
||||
if seat, _, ok := SeatInvoke(invoke); ok {
|
||||
return seat == ControllerSeatName
|
||||
}
|
||||
return onController(invoke)
|
||||
}
|
||||
|
||||
// Dependencies are every declaration on the controller in the manifests given, by module, each with the list's entry
|
||||
// that keeps it. The controller's own manifest is not judged, nor a read of its lease key: it names the lease's holder,
|
||||
// whoever that is, and parts read it to learn that the write happened (ADR 0571 §13).
|
||||
func Dependencies(manifests map[string]Manifest) []Dependency {
|
||||
var out []Dependency
|
||||
add := func(module, by, kind, what string) {
|
||||
d := Dependency{Module: module, By: by, Kind: kind, What: what}
|
||||
if k := keptFor(d); k != nil {
|
||||
d.Kept, d.ToTheWrite = k.Record, k.ToTheWrite
|
||||
}
|
||||
if !slices.Contains(out, d) {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
for name, m := range manifests {
|
||||
if name == ControllerSeatName || m.Module == ControllerSeatName {
|
||||
continue
|
||||
}
|
||||
for _, r := range m.Reads {
|
||||
if onController(r) && r != ControllerLease {
|
||||
add(name, "", DependsRead, r)
|
||||
}
|
||||
}
|
||||
for _, i := range m.Invokes {
|
||||
if invokesController(i) {
|
||||
add(name, "", DependsInvoke, i)
|
||||
}
|
||||
}
|
||||
for _, c := range m.Consumes {
|
||||
if onController(c) {
|
||||
add(name, "", DependsConsume, c)
|
||||
}
|
||||
}
|
||||
for _, w := range m.Waits {
|
||||
if onController(w) {
|
||||
add(name, "", DependsWaits, w)
|
||||
}
|
||||
}
|
||||
for _, u := range m.Uses {
|
||||
if u == ControllerSeatName {
|
||||
add(name, "", DependsUses, u)
|
||||
}
|
||||
}
|
||||
for _, a := range m.BusAccounts() {
|
||||
by := "process " + a.ID
|
||||
if a.Tools {
|
||||
by = "tools " + a.ID
|
||||
}
|
||||
for _, i := range a.Bus.Invokes {
|
||||
if invokesController(i) {
|
||||
add(name, by, DependsInvoke, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
a, b := out[i], out[j]
|
||||
if a.Module != b.Module {
|
||||
return a.Module < b.Module
|
||||
}
|
||||
if a.Kind != b.Kind {
|
||||
return a.Kind < b.Kind
|
||||
}
|
||||
if a.What != b.What {
|
||||
return a.What < b.What
|
||||
}
|
||||
return a.By < b.By
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Verdict is the lease write's check in one phase.
|
||||
type Verdict struct {
|
||||
Phase string `json:"phase"`
|
||||
Passes bool `json:"passes"`
|
||||
Reasons []string `json:"reasons"`
|
||||
}
|
||||
|
||||
// Coverage is how much of the mesh's manifests the dependencies were derived from: the manifests read, the modules the
|
||||
// indexer holds (-1 when its keys could not be listed, which Unread then names), and each manifest that could not be
|
||||
// read.
|
||||
type Coverage struct {
|
||||
Read, Indexed int
|
||||
Unread []Unread
|
||||
}
|
||||
|
||||
// Unread is one manifest, or one listing of them, that could not be read, and why.
|
||||
type Unread struct {
|
||||
Input string `json:"input"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// Judge runs the lease write's check over the dependencies (ADR 0571 §13) in one phase: before the lease write's own
|
||||
// delivery, every dependency must be on the named list; at the write, only the three kept to the write itself may stay.
|
||||
// It passes only over every module the indexer holds: no manifest read, fewer read than the indexer holds, or one that
|
||||
// could not be read fails it, since what was not read could hide a dependency. A lease already handed away leaves
|
||||
// nothing to write.
|
||||
func Judge(deps []Dependency, phase string, cov Coverage, handedAway bool, leaseSays string) Verdict {
|
||||
v := Verdict{Phase: phase, Reasons: []string{}}
|
||||
if handedAway {
|
||||
v.Reasons = append(v.Reasons, "there is nothing to write: "+leaseSays)
|
||||
}
|
||||
switch {
|
||||
case cov.Read == 0:
|
||||
v.Reasons = append(v.Reasons, fmt.Sprintf("no manifest was read from the indexer (it holds %d modules): with nothing read, nothing on the controller can be judged absent", max(cov.Indexed, 0)))
|
||||
case cov.Indexed >= 0 && cov.Read < cov.Indexed:
|
||||
v.Reasons = append(v.Reasons, fmt.Sprintf("only %d of the %d modules the indexer holds had their manifest read, so what the others declare on the controller is not known", cov.Read, cov.Indexed))
|
||||
}
|
||||
for _, d := range deps {
|
||||
who := d.Module
|
||||
if d.By != "" {
|
||||
who += " (" + d.By + ")"
|
||||
}
|
||||
switch {
|
||||
case d.Kept == "":
|
||||
why := fmt.Sprintf("%s declares %s %s, which no record keeps until the lease write (ADR 0571 §13)", who, d.Kind, d.What)
|
||||
if ended := EndedBy[d.Kind+" "+d.What]; ended != "" {
|
||||
why += "; ended by " + ended
|
||||
}
|
||||
v.Reasons = append(v.Reasons, why)
|
||||
case phase == PhaseWrite && !d.ToTheWrite:
|
||||
v.Reasons = append(v.Reasons, fmt.Sprintf("%s declares %s %s, kept by %s only until the lease write's own delivery, which drops it", who, d.Kind, d.What, d.Kept))
|
||||
}
|
||||
}
|
||||
for _, g := range cov.Unread {
|
||||
v.Reasons = append(v.Reasons, fmt.Sprintf("%s could not be read (%s), so what it declares on the controller is not known", g.Input, g.Why))
|
||||
}
|
||||
v.Passes = len(v.Reasons) == 0
|
||||
return v
|
||||
}
|
||||
|
||||
// ManifestRefusals is the lease write's check over a repository's manifests, by module name, in one phase (PhaseBefore
|
||||
// or PhaseWrite): each reason the check gives, none when it passes. Every manifest given counts as read: the caller reads
|
||||
// them from its own tree, and none given fails it.
|
||||
func ManifestRefusals(manifests map[string]Manifest, phase string) []string {
|
||||
return Judge(Dependencies(manifests), phase, Coverage{Read: len(manifests), Indexed: -1}, false, "").Reasons
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Issue 774 (ADR 0571 §13, ADR 0582 clause 2): the lease write's check judges every manifest's dependency on the
|
||||
// controller against the named list, at the write (the three kept to it alone) and before its delivery. Moved with the
|
||||
// judge from mesh-grants' leasecheck_test.go (TestTheLeaseCheckJudgesEveryDependencyOnTheController's manifest rows and
|
||||
// TestTheKeepListNamesItsRecordsAndThreeToTheWrite), whose manifests were mesh-grants' fixtures; the conditions row is
|
||||
// mesh-conditions' manifest at mesh-catalog ce3bedd5 (module.json:31-32).
|
||||
func TestTheLeaseWritesCheckJudgesEveryDependencyOnTheController(t *testing.T) {
|
||||
toTheWrite := 0
|
||||
for _, k := range KeepList {
|
||||
if !strings.HasPrefix(k.Record, "ADR ") || k.Module == "" || k.What == "" {
|
||||
t.Errorf("entry %+v names no record, module or declaration", k)
|
||||
}
|
||||
if k.ToTheWrite {
|
||||
toTheWrite++
|
||||
}
|
||||
}
|
||||
if toTheWrite != 3 || len(KeepList) != 3 {
|
||||
t.Errorf("%d entries, %d kept to the write itself: ADR 0571 §13 keeps the three alone from the write's delivery", len(KeepList), toTheWrite)
|
||||
}
|
||||
|
||||
type want struct {
|
||||
deps []string // "<module> <kind> <what> <kept by>", kept by the record or "unkept"
|
||||
write string // a reason the check at the write names, or "" when it passes
|
||||
before string // a reason the check before the delivery names, or "" when it passes
|
||||
}
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
manifests map[string]string
|
||||
want want
|
||||
}{
|
||||
// 774/S4/AC2.
|
||||
{"a read of the lease alone is no dependency, and the controller's own manifest is not judged",
|
||||
map[string]string{"mod-l": `{"module":"mod-l","version":"1","reads":["mesh-controller.lease"]}`,
|
||||
"mesh-controller": `{"module":"mesh-controller","version":"1","invokes":["seat:mesh-controller.status"]}`},
|
||||
want{}},
|
||||
// 774/S4/AC1, 774/S30/AC1: the keeper's read of the controller's conditions put back is refused in either phase,
|
||||
// since the lease write's delivery dropped its entry (it was kept by ADR 0564 §9 before).
|
||||
{"the keeper's read of the controller's conditions, put back, is refused",
|
||||
map[string]string{"mesh-conditions": `{"module":"mesh-conditions","version":"1","reads":["mesh-controller.conditions","mesh-controller.lease"]}`},
|
||||
want{deps: []string{"mesh-conditions read mesh-controller.conditions unkept"},
|
||||
write: "mesh-conditions declares read mesh-controller.conditions, which no record keeps until the lease write",
|
||||
before: "mesh-conditions declares read mesh-controller.conditions, which no record keeps until the lease write"}},
|
||||
// 774/S30/AC1: the three kept to the write pass in either phase.
|
||||
{"the artifact sweep's collect is kept to the write itself",
|
||||
map[string]string{"artifact-store-tools": `{"module":"artifact-store-tools","version":"1","invokes":["seat:mesh-controller.collect"]}`},
|
||||
want{deps: []string{"artifact-store-tools invoke seat:mesh-controller.collect kept by ADR 0571 §4, §13"}}},
|
||||
{"the builder's consume of artifacts-gone is kept to the write itself",
|
||||
map[string]string{"mesh-delivery-builder": `{"module":"mesh-delivery-builder","version":"1","consumes":["mesh-controller.artifacts-gone"]}`},
|
||||
want{deps: []string{"mesh-delivery-builder consume mesh-controller.artifacts-gone kept by ADR 0571 §4, §13"}}},
|
||||
{"a read no record keeps fails both phases, named",
|
||||
map[string]string{"mod-u": `{"module":"mod-u","version":"1","reads":["mesh-controller.declared-packages"]}`},
|
||||
want{deps: []string{"mod-u read mesh-controller.declared-packages unkept"},
|
||||
write: "mod-u declares read mesh-controller.declared-packages, which no record keeps until the lease write",
|
||||
before: "mod-u declares read mesh-controller.declared-packages, which no record keeps until the lease write"}},
|
||||
{"a seat verb and a module tool of the controller's, a consume and a wait are each one; every tool (*) is none",
|
||||
map[string]string{"mod-i": `{"module":"mod-i","version":"1","invokes":["seat:mesh-controller.root-free","mesh-controller.status","*","nats.nats_users"],` +
|
||||
`"consumes":["mesh-controller.checked","mod-x.thing"],"uses":["mesh-controller"]}`,
|
||||
"node-tools": `{"module":"node-tools","version":"1","waits":["mesh-controller.checked"]}`},
|
||||
want{deps: []string{"mod-i consume mesh-controller.checked unkept", "mod-i invoke mesh-controller.status unkept",
|
||||
"mod-i invoke seat:mesh-controller.root-free unkept", "mod-i uses mesh-controller unkept", "node-tools waits mesh-controller.checked unkept"},
|
||||
write: "mod-i declares invoke seat:mesh-controller.root-free, which no record keeps until the lease write (ADR 0571 §13); ended by ADR 0571 §11",
|
||||
before: "node-tools declares waits mesh-controller.checked"}},
|
||||
{"a process's own invoke of the controller is its module's dependency, named by the process",
|
||||
map[string]string{"mod-p": `{"module":"mod-p","version":"1","resources":[{"id":"account","type":"user","name":"mod-p"},{"id":"worker","type":"process","run":["./w"],"user":"mod-p",` +
|
||||
`"bus":{"invokes":["seat:mesh-controller.held"]}}]}`},
|
||||
want{deps: []string{"mod-p invoke seat:mesh-controller.held unkept"},
|
||||
write: "mod-p (process worker) declares invoke seat:mesh-controller.held", before: "mod-p (process worker)"}},
|
||||
{"no manifest fails both phases", map[string]string{}, want{write: "no manifest was read", before: "no manifest was read"}},
|
||||
} {
|
||||
manifests := map[string]Manifest{}
|
||||
for name, raw := range row.manifests {
|
||||
m, err := Decode([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %s: %v", row.name, name, err)
|
||||
}
|
||||
manifests[name] = m
|
||||
}
|
||||
var deps []string
|
||||
for _, d := range Dependencies(manifests) {
|
||||
kept := "unkept"
|
||||
if d.Kept != "" {
|
||||
kept = "kept by " + d.Kept
|
||||
}
|
||||
deps = append(deps, d.Module+" "+d.Kind+" "+d.What+" "+kept)
|
||||
}
|
||||
if !slices.Equal(deps, row.want.deps) {
|
||||
t.Errorf("%s: dependencies %q, want %q", row.name, deps, row.want.deps)
|
||||
}
|
||||
for phase, reason := range map[string]string{PhaseWrite: row.want.write, PhaseBefore: row.want.before} {
|
||||
got := ManifestRefusals(manifests, phase)
|
||||
if (reason == "") != (len(got) == 0) || !slices.ContainsFunc(got, func(r string) bool { return strings.Contains(r, reason) }) && reason != "" {
|
||||
t.Errorf("%s: at %s refused %q, want %q", row.name, phase, got, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
// The delivery seat answers "what did the mesh's checks say of this pull request?" as a verb of its own,
|
||||
// `checks` (novox/hq ADR 0239): read by repository and a pull request's number or a commit.
|
||||
func TestTheDeliverySeatPromisesChecks(t *testing.T) {
|
||||
t.Run("the seat mesh-grants promises the lease write", grantsSeatPromisesTheLeaseWrite)
|
||||
seat, ok := SeatNamed(DeliverySeat)
|
||||
if !ok {
|
||||
t.Fatal("the delivery seat is not in the set")
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package catalogue
|
||||
|
||||
// **Joining a machine is the seat mesh-enrolment, held once** (novox/hq ADR 0587 §1, issue 774): held for the mesh by
|
||||
// the module mesh-enrolment, the only caller the vault's manifest names for its enrolment-token and node-account verbs
|
||||
// and a caller it names for `sign`. Six verbs, five events, none carrying a secret or anything derived from the token:
|
||||
// the one hash an event carries is the digest of the public keys a node presented. It mints and keeps no secret and
|
||||
// holds no signing key. Its state and its key layout are go/delivery's (enrolment.go).
|
||||
|
||||
// EnrolmentSeat is the seat mesh-enrolment.
|
||||
const EnrolmentSeat = "mesh-enrolment"
|
||||
|
||||
// The seat's six verbs (ADR 0587 §1, §4-§8).
|
||||
const (
|
||||
EnrolmentVerbIssue = "issue"
|
||||
EnrolmentVerbRedeem = "redeem"
|
||||
EnrolmentVerbConfirm = "confirm"
|
||||
EnrolmentVerbRevoke = "revoke"
|
||||
EnrolmentVerbTokens = "tokens"
|
||||
EnrolmentVerbAgain = "again"
|
||||
)
|
||||
|
||||
// The seat's five events (ADR 0587 §1, §2, §5): said by its holder on the seat, each carrying an enrolment key's whole
|
||||
// content except enrolment-expired, which names the node and the enrolment-issued event that carried it.
|
||||
const (
|
||||
EnrolmentIssuedEvent = "enrolment-issued"
|
||||
NodeEnrolledEvent = "node-enrolled"
|
||||
EnrolmentRefusedEvent = "enrolment-refused"
|
||||
EnrolmentRevokedEvent = "enrolment-revoked"
|
||||
EnrolmentExpiredEvent = "enrolment-expired"
|
||||
)
|
||||
|
||||
// EnrolmentEvents are the seat's events, in the order an enrolment says them; the last four each end one.
|
||||
var EnrolmentEvents = []string{EnrolmentIssuedEvent, NodeEnrolledEvent, EnrolmentRefusedEvent, EnrolmentRevokedEvent,
|
||||
EnrolmentExpiredEvent}
|
||||
|
||||
// enrolmentDecision is the record that made the seat.
|
||||
const enrolmentDecision = "novox/hq ADR 0587"
|
||||
|
||||
// enrolmentVerbs are the seat's verbs: what reads all it holds and does by hand all it does (ADR 0587 §8). Not optional:
|
||||
// the seat is new with its holder, so no holder of before serves fewer.
|
||||
func enrolmentVerbs() []Verb {
|
||||
str := func(description string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": description}
|
||||
}
|
||||
return []Verb{
|
||||
{Name: EnrolmentVerbIssue, Description: "Issue a one-time token for a new machine, on the operator's Approve: " +
|
||||
"refused at once, naming every finding, for a name that is not a node name or that a node record, a placement " +
|
||||
"key or an open enrolment holds, and for every call before the controller's lease is handed away. Otherwise " +
|
||||
"reserves the lowest free private-network address, asks the operator under the seat's own name and, on the " +
|
||||
"warrant, adds the node to placement with its tunnel key, asks the vault for the token and says enrolment-issued. " +
|
||||
"The token itself is read only through tokens.",
|
||||
Input: schema(map[string]string{
|
||||
"node": "the new machine's name in the mesh",
|
||||
"why": "why it joins, shown to the operator with the ask",
|
||||
"expires": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours",
|
||||
// The operator's decision of 2026-11-02 01:05: the tunnel key is given at issue, so on the Approve the node
|
||||
// enters placement with it and the hub peers it before redeem.
|
||||
"overlay_key": "the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it",
|
||||
}, []string{"node", "why", "overlay_key"})},
|
||||
{Name: EnrolmentVerbRedeem, Description: "Spend a token: asked by a joining node-engine dialled as enrol.<node> " +
|
||||
"alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the " +
|
||||
"vault for the node's account and certificate, writes the node into placement, and answers the account sealed " +
|
||||
"to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys " +
|
||||
"it finishes what is not done and answers the same; with other keys it is refused.",
|
||||
Input: map[string]any{"type": "object", "additionalProperties": false,
|
||||
"required": []string{"node", "public_key", "sealing_key", "proof"},
|
||||
"properties": map[string]any{
|
||||
"node": str("the node the token was issued for"),
|
||||
"public_key": str("the identity key's public half, base64"),
|
||||
"overlay_key": str("the private network key's public half"),
|
||||
"sealing_key": str("the sealing key's public half, which the account is sealed to"),
|
||||
"serving_key": str("the serving key's public half, which the certificate binds"),
|
||||
"profile": map[string]any{"type": "object", "description": "what the machine is, as its record says it"},
|
||||
"tunnel": map[string]any{"type": "object", "description": "a tunnel found on the machine: everything but its private key"},
|
||||
"proof": str("the identity key's signature over the redeem's proof bytes (delivery.RedeemProof), base64"),
|
||||
}}},
|
||||
{Name: EnrolmentVerbConfirm, Description: "Confirm an enrolment: asked by the node-engine dialled as its own " +
|
||||
"node login alone, the runtime's proof of that caller being the confirmation. Says node-enrolled and ends the " +
|
||||
"enrolment.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: EnrolmentVerbRevoke, Description: "End an enrolment at any stage, with why, without an Approve: withdraws " +
|
||||
"an open ask, deletes the node's key and its address, has the vault retire an account it already minted for " +
|
||||
"it, records the act as a hand act and says enrolment-revoked.",
|
||||
Input: schema(map[string]string{"node": "the node", "why": "why, recorded with the hand act"},
|
||||
[]string{"node", "why"})},
|
||||
{Name: EnrolmentVerbTokens, Description: "Read every open enrolment, or one: its stage, expiry, ask and revision, " +
|
||||
"the bucket's fill against its cap, and whether issuing is on and why (the lease). For a node at live, its " +
|
||||
"token sealed to the operator key, asked of the vault at that moment and relayed as the vault sealed it. With " +
|
||||
"history, the ended enrolments from the seat's events, as far back as the events are kept.",
|
||||
Input: schema(map[string]string{"node": "one node", "history": "\"true\": the ended enrolments too"},
|
||||
nil, "history")},
|
||||
{Name: EnrolmentVerbAgain, Description: "Raise an issue's ask again when the operator's channel does not hold it, " +
|
||||
"and nothing more.",
|
||||
Input: schema(map[string]string{"node": "the node whose ask is raised again"}, []string{"node"})},
|
||||
}
|
||||
}
|
||||
@@ -744,12 +744,9 @@ func TestSSHIsOpenFromTheMeshEvenWhenNothingIsAssigned(t *testing.T) {
|
||||
if strings.Contains(nft, "\t\ttcp dport 22 accept") {
|
||||
t.Fatalf("ssh is open to everywhere on a machine that faces inward only:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// And from outside as well, on a machine that faces outward — because that is the way in when the
|
||||
// private network is the thing that broke.
|
||||
func TestSSHIsOpenFromOutsideOnAMachineThatFacesIt(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, true, nil, nil, "mesh0")
|
||||
// And from outside as well, on a machine that faces outward — because that is the way in when the
|
||||
// private network is the thing that broke.
|
||||
nft = AsNftables(nil, []string{"198.51.100.2"}, true, nil, nil, "mesh0")
|
||||
if !strings.Contains(nft, "\t\ttcp dport 22 accept") {
|
||||
t.Fatalf("a machine reachable from outside does not answer ssh there:\n%s", nft)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package catalogue
|
||||
|
||||
// **The lease write, a verb of the seat `mesh-grants`** (novox/hq ADR 0582 clause 1, issue 774): the grants part
|
||||
// holds the seat once for the mesh and, on the operator's warrant, hands the controller's lease to itself and back.
|
||||
// It serves two verbs and says three events, none carrying a secret:
|
||||
//
|
||||
// - lease-handed: the lease names mesh-grants, written on the operator's warrant (clause 3);
|
||||
// - lease-altered: the lease key or its bucket's expiry differs from what mesh-grants wrote (clause 4);
|
||||
// - removal-failed: the day-later removal of the controller's consumer or stream failed (clause 6).
|
||||
|
||||
// GrantsSeat is the seat the grants part (mesh-grants) holds.
|
||||
const GrantsSeat = "mesh-grants"
|
||||
|
||||
// The seat's verbs.
|
||||
const (
|
||||
GrantsVerbLeaseWrite = "lease-write"
|
||||
GrantsVerbLeaseHandBack = "lease-hand-back"
|
||||
)
|
||||
|
||||
// The seat's events.
|
||||
const (
|
||||
GrantsEventLeaseHanded = "lease-handed"
|
||||
GrantsEventLeaseAltered = "lease-altered"
|
||||
GrantsEventRemovalFailed = "removal-failed"
|
||||
)
|
||||
|
||||
// grantsDecision is the record that made the seat.
|
||||
const grantsDecision = "novox/hq ADR 0582"
|
||||
|
||||
// grantsEvents are the seat's events, in the order the write meets them.
|
||||
func grantsEvents() []string {
|
||||
return []string{GrantsEventLeaseHanded, GrantsEventLeaseAltered, GrantsEventRemovalFailed}
|
||||
}
|
||||
|
||||
// grantsVerbs are the seat's verbs (ADR 0582 clauses 1, 2, 3, 5).
|
||||
func grantsVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: GrantsVerbLeaseWrite, Description: "Hand the controller's lease to mesh-grants, kept for good. Refused, " +
|
||||
"naming the check, until the lease check at phase write passes and every condition it does not judge reads " +
|
||||
"green. Otherwise the operator is asked once; on the warrant the checks are read again, the controller's " +
|
||||
"service is stopped, the bus's user list is composed without the controller's write on the lease, the lease's " +
|
||||
"expiry is removed and the key holder names mesh-grants, said as lease-handed and recorded as a hand act. A no " +
|
||||
"or an expiry of the ask acts on nothing.",
|
||||
Input: schema(map[string]string{"why": "why the lease is handed now, shown to the operator and kept"},
|
||||
[]string{"why"})},
|
||||
{Name: GrantsVerbLeaseHandBack, Description: "Give the lease back to the controller, for a day after the write: " +
|
||||
"asked and warranted the same way, it restores the lease's fifteen-second expiry, deletes the key holder, " +
|
||||
"gives the controller its rights back once the list is loaded, and starts the controller. Refused once the " +
|
||||
"day-later removal ran: the controller would start without its consumer and stream.",
|
||||
Input: schema(map[string]string{"why": "why the lease goes back, shown to the operator and kept"},
|
||||
[]string{"why"})},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The seat mesh-grants (novox/hq ADR 0582 clause 1, issue 774, 774/S1/AC2): its holder serves lease-write and
|
||||
// lease-hand-back, both a condition of holding, and nothing else; the seat says lease-handed, lease-altered and
|
||||
// removal-failed. A case of TestTheDeliverySeatPromisesChecks, the seat-verb test, within the test budget.
|
||||
func grantsSeatPromisesTheLeaseWrite(t *testing.T) {
|
||||
seat, ok := SeatNamed(GrantsSeat)
|
||||
if !ok {
|
||||
t.Fatal("the seat mesh-grants is not in the set")
|
||||
}
|
||||
if seat.Scope != ScopeMesh || seat.Delivers != "" {
|
||||
t.Errorf("mesh-grants is at scope %q delivering %q, want mesh and nothing", seat.Scope, seat.Delivers)
|
||||
}
|
||||
if got, want := VerbNames(seat.Serves), []string{"lease-write", "lease-hand-back"}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("mesh-grants serves %v, want %v", got, want)
|
||||
}
|
||||
if want := []string{"lease-handed", "lease-altered", "removal-failed"}; !reflect.DeepEqual(seat.Emits, want) {
|
||||
t.Errorf("mesh-grants says %v, want %v", seat.Emits, want)
|
||||
}
|
||||
for _, v := range seat.Serves {
|
||||
if v.Optional {
|
||||
t.Errorf("%s is optional: the holder ships with the seat, so every verb is a condition of holding", v.Name)
|
||||
}
|
||||
props, _ := v.Input["properties"].(map[string]any)
|
||||
if req, _ := v.Input["required"].([]string); len(props) != 1 || !reflect.DeepEqual(req, []string{"why"}) {
|
||||
t.Errorf("%s takes %v requiring %v, want only a why", v.Name, props, req)
|
||||
}
|
||||
}
|
||||
|
||||
claim := func(serves ...string) Manifest {
|
||||
return Manifest{Module: "mesh-grants", Claims: []Claim{{Name: GrantsSeat, Scope: ScopeMesh, Serves: serves}}}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
m Manifest
|
||||
refuse string // empty: held
|
||||
}{
|
||||
{"both verbs", claim("lease-write", "lease-hand-back"), ""},
|
||||
{"without the hand-back", claim("lease-write"), "lease-hand-back"},
|
||||
{"a verb the seat does not promise", claim("lease-write", "lease-hand-back", "lease-take"), "lease-take"},
|
||||
{"at node scope", Manifest{Module: "mesh-grants", Claims: []Claim{{Name: GrantsSeat, Scope: ScopeNode,
|
||||
Serves: []string{"lease-write", "lease-hand-back"}}}}, "scope"},
|
||||
} {
|
||||
err := CanHold(c.m, seat)
|
||||
switch {
|
||||
case c.refuse == "" && err != nil:
|
||||
t.Errorf("%s: refused: %v", c.name, err)
|
||||
case c.refuse != "" && (err == nil || !strings.Contains(err.Error(), c.refuse)):
|
||||
t.Errorf("%s: %v, want a refusal naming %q", c.name, err, c.refuse)
|
||||
}
|
||||
}
|
||||
|
||||
// No other seat changed: the set is the one before it with mesh-grants added beside mesh-hand-acts.
|
||||
var names []string
|
||||
for _, s := range DefaultSeats() {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
at := strings.Index(strings.Join(names, " "), HandActsSeat+" "+GrantsSeat+" ")
|
||||
if at < 0 {
|
||||
t.Errorf("mesh-grants does not follow mesh-hand-acts in the seat set: %v", names)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,10 @@ func placementVerbs() []Verb {
|
||||
"place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), " +
|
||||
"unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide " +
|
||||
"(node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, " +
|
||||
"layer, mesh-wide-values, values)",
|
||||
"layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the " +
|
||||
"proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), " +
|
||||
"remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, " +
|
||||
"on the same warrant)",
|
||||
"items": map[string]any{"type": "object", "required": []string{"op", "node"}}}
|
||||
return []Verb{
|
||||
{Name: PlacementVerbPlacement, Optional: true, Description: "Read placement: one node's key, the nodes one " +
|
||||
@@ -52,11 +55,17 @@ func placementVerbs() []Verb {
|
||||
"on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two " +
|
||||
"hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose " +
|
||||
"it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the " +
|
||||
"caller, as the runtime proves it.",
|
||||
"caller, as the runtime proves it. An add-node from mesh-enrolment, whose warrant names its node and address, " +
|
||||
"is written with no ask of its own: its Approve is the enrolment's (ADR 0587 §7).",
|
||||
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"why", "changes"},
|
||||
"properties": map[string]any{
|
||||
"why": map[string]any{"type": "string", "description": "why, shown to the operator with the proposal"},
|
||||
"changes": changes,
|
||||
// ADR 0587 §7: an add-node carries the warrant of the enrolment that issued the node's token, whose
|
||||
// Approve is the proposal's; the holder writes it with no ask of its own.
|
||||
"warrant": map[string]any{"type": "object", "description": "for an add-node alone: the enrolment's " +
|
||||
"warrant {node, address, ask, warrant}, naming the change's node and address",
|
||||
"required": []string{"node", "address", "ask", "warrant"}},
|
||||
}}},
|
||||
{Name: PlacementVerbWithdraw, Optional: true, Description: "Withdraw an open proposal: its key and its " +
|
||||
"candidates are deleted and placement-withdrawn carries it whole. With why.",
|
||||
|
||||
+57
-6
@@ -233,13 +233,59 @@ var defaultSeats = append([]Seat{
|
||||
"controller's, never anew for one the vault holds a value or an adopted hash for. Never the value.",
|
||||
Input: schema(map[string]string{"user": "the bus user, e.g. ace.mesh-build-go"}, []string{"user"})},
|
||||
{Name: "sign", Optional: true, Description: "Sign with the vault's key or authority (novox/hq ADR 0550 §2): " +
|
||||
"kind node-certificate signs the certificate of the calling machine's internal name for the serving key " +
|
||||
"its record states, built from bus state alone and asked by that machine's node-engine only; kind " +
|
||||
"enrolment signs an enrolment reply under its own context, for a caller the adoption record names. " +
|
||||
"Answers the certificate or the signature and the signer's fingerprint, and says signed; never a key.",
|
||||
"kind node-certificate signs the certificate of a machine's internal name, asked by that machine's " +
|
||||
"node-engine for the serving key its record states, built from bus state alone, or by mesh-enrolment for " +
|
||||
"a node it enrols, for the serving key the node presented at redeem (ADR 0587 §3); kind enrolment signs a " +
|
||||
"redeem answer under its own context, for mesh-enrolment alone. Answers the certificate or the signature " +
|
||||
"and the signer's fingerprint, and says signed; never a key.",
|
||||
Input: schema(map[string]string{"kind": "node-certificate or enrolment",
|
||||
"node": "the machine, for a node certificate: the caller's own",
|
||||
"reply": "the enrolment reply's bytes in base64, for an enrolment"}, []string{"kind"})},
|
||||
"node": "the machine, for a node certificate: the caller's own, or the node mesh-enrolment enrols",
|
||||
"serving_key": "from mesh-enrolment: the serving key's public half the enrolling node presented",
|
||||
"reply": "the redeem answer's bytes in base64, for an enrolment"}, []string{"kind"})},
|
||||
// Enrolment (novox/hq ADR 0561 §5, ADR 0587 §3, §5; issue 774): mesh-enrolment is the only caller the vault's
|
||||
// manifest names for these two.
|
||||
{Name: "enrolment-token", Optional: true, Description: "Make a one-time token for a node mesh-enrolment " +
|
||||
"issues on the operator's warrant: its secret kept with its copy sealed to the operator key and its hash " +
|
||||
"written as the bus user enrol.<node>. Answers only what the joining machine must trust (the bus's " +
|
||||
"address, the bus certificate's fingerprint, the vault's public signing key), never the token; with " +
|
||||
"sealed, the token sealed to the operator key, for tokens to relay; with drop, the token and its hash " +
|
||||
"dropped, at the enrolment's end. Asked by mesh-enrolment alone, only from the lease write. The token " +
|
||||
"carries the first tunnel mesh-enrolment built at issue (ADR 0587 §3, insight of 2026-11-02).",
|
||||
Input: withObject(schema(map[string]string{"node": "the node the token is for",
|
||||
"expires": "when the token expires (RFC 3339), as the enrolment's key says",
|
||||
"sealed": "\"true\": answer the token sealed to the operator key",
|
||||
"drop": "\"true\": drop the node's token and its hash"}, []string{"node"}, "sealed", "drop"),
|
||||
"tunnel", "the node's first tunnel, built by mesh-enrolment at issue and carried in the token: the hub's "+
|
||||
"public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range",
|
||||
map[string]string{"hub_key": "the hub's tunnel public key", "hub_endpoint": "the hub's endpoint, host:port",
|
||||
"address": "the joining node's tunnel address",
|
||||
"key": "the joining machine's own tunnel public key, as given at issue",
|
||||
"range": "the private network's range, a prefix"})},
|
||||
{Name: "node-account", Optional: true, Description: "Mint a joining node's bus account node.<node>, sealed " +
|
||||
"to the sealing key it presented, and write its hash; asked again for the same node and keys, answers the " +
|
||||
"account it already minted; with retire, the account it minted for a revoked enrolment retired. Writes the " +
|
||||
"node's node-logins key naming node.<node>. Asked by mesh-enrolment alone. Never the value in the clear.",
|
||||
Input: schema(map[string]string{"node": "the joining node",
|
||||
"sealing_key": "the sealing key's public half the node presented, which the account is sealed to",
|
||||
"keys_digest": "the digest of every public key the node presented (delivery.KeysDigest)",
|
||||
"retire": "\"true\": retire the account minted for this node"}, []string{"node"}, "retire")},
|
||||
// A node's account rotates over two logins (novox/hq ADR 0583 §2-§5; issue 774), one node at a time, the bus
|
||||
// node last; the state node-logins says where each node stands.
|
||||
{Name: "rotate-node", Optional: true, Description: "Begin a node's account rotation to its other login: " +
|
||||
"answered only for a call carrying a hand act recorded through mesh-hand-acts, which the vault reads back by " +
|
||||
"its id. Makes the other login's value sealed to that node, writes it rotating in node-logins and says " +
|
||||
"rotation-begun; says node-account-ready once the bus node's have shows a list holding both logins. One " +
|
||||
"node at a time across the mesh, the bus node last: a call while a rotation runs waits until it is done.",
|
||||
Input: schema(map[string]string{"node": "the node whose account rotates", "why": "why, in a sentence",
|
||||
"act": "the id of the hand act recorded for it in mesh-hand-acts"}, []string{"node", "why", "act"})},
|
||||
{Name: "node-account-confirm", Optional: true, Description: "Confirm a node's switch to its new login: " +
|
||||
"asked by the node-engine dialled as that login, the runtime's proof of the caller being the confirmation. " +
|
||||
"The old login's hash is then removed, and rotation-done said once the bus node's have shows the list " +
|
||||
"without it.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "abandon", Optional: true, Description: "Abandon a node's rotation before its old login is retired: " +
|
||||
"the new login and its value dropped, the old one kept; a node that switched switches back first.",
|
||||
Input: schema(map[string]string{"node": "the node whose rotation is abandoned"}, []string{"node"})},
|
||||
}},
|
||||
// The owner of every config value (novox/hq ADR 0348 §9, design 57 K3): its holder alone writes the buckets
|
||||
// mesh-config_values, _resolved, _predefined and _proposals, and serves the one verb `config` (§1, §10).
|
||||
@@ -302,6 +348,11 @@ var defaultSeats = append([]Seat{
|
||||
// policy and the bus step at the assigner, and the hand-act log.
|
||||
{Name: WantsSeat, Scope: ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"policy-changed"}, Serves: wantsVerbs()},
|
||||
{Name: HandActsSeat, Scope: ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"act-recorded"}, Serves: handActsVerbs()},
|
||||
// The lease write (novox/hq ADR 0582 clause 1, grants_seat.go): the grants part hands the controller's lease to
|
||||
// itself and back on the operator's warrant.
|
||||
{Name: GrantsSeat, Scope: ScopeMesh, Decision: grantsDecision, Emits: grantsEvents(), Serves: grantsVerbs()},
|
||||
// Joining a machine (novox/hq ADR 0587 §1, enrolment_seat.go): held once by the module mesh-enrolment.
|
||||
{Name: EnrolmentSeat, Scope: ScopeMesh, Decision: enrolmentDecision, Emits: EnrolmentEvents, Serves: enrolmentVerbs()},
|
||||
// The artifact sweep (novox/hq ADR 0571 clause 2, artifact_sweep_seat.go).
|
||||
{Name: ArtifactSweepSeat, Scope: ScopeMesh, Decision: "novox/hq ADR 0571", Emits: []string{"artifacts-gone"},
|
||||
Serves: artifactSweepVerbs()},
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package catalogue
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The vault's provision is one the controller itself dereferences — every minted credential is
|
||||
// sealed with it — so it is delivered by a seat of the mesh's own, and a second provider is a second
|
||||
@@ -65,6 +68,14 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
|
||||
"secret-ask": {"node", "module", "name", "desk", "asked_by", "outcome", "call", "sealed"},
|
||||
"issued": {"provision", "provider", "module", "call", "sealed", "holders"},
|
||||
"rotate": {"node", "module", "name", "provider", "provision", "by"},
|
||||
// 774/S37/AC1: the enrolment verbs mesh-enrolment asks (ADR 0587 §3, §5).
|
||||
"enrolment-token": {"node", "expires", "sealed", "drop", "tunnel"},
|
||||
"node-account": {"node", "sealing_key", "keys_digest", "retire"},
|
||||
"sign": {"kind", "node", "serving_key", "reply"},
|
||||
// 774/S38: the rotation verbs (ADR 0583 §2, §3, §5).
|
||||
"rotate-node": {"node", "why", "act"},
|
||||
"node-account-confirm": {},
|
||||
"abandon": {"node"},
|
||||
}
|
||||
for name, args := range want {
|
||||
var verb *Verb
|
||||
@@ -89,6 +100,14 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
|
||||
if props["value"] != nil {
|
||||
t.Errorf("%s takes a value in the clear", name)
|
||||
}
|
||||
// The first tunnel the token carries (ADR 0587 §3, insight of 2026-11-02): an object of the hub's key, its endpoint
|
||||
// and the node's address.
|
||||
if name == "enrolment-token" {
|
||||
tunnel, _ := props["tunnel"].(map[string]any)
|
||||
if tunnel["type"] != "object" || !slices.Equal(tunnel["required"].([]string), []string{"address", "hub_endpoint", "hub_key", "key", "range"}) {
|
||||
t.Errorf("enrolment-token's tunnel is %v", tunnel)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The holders `issued` names (issue 749) are a list of {node, module}.
|
||||
for _, verb := range seat.Serves {
|
||||
@@ -105,7 +124,8 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
|
||||
}
|
||||
serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
|
||||
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh,
|
||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate"}}}}
|
||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "sign", "enrolment-token", "node-account",
|
||||
"rotate-node", "node-account-confirm", "abandon"}}}}
|
||||
if err := CanHold(serving, seat); err != nil {
|
||||
t.Fatalf("the vault serving its verbs was refused: %v", err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// A Verb is one tool a role serves: its name, what it does, and the schema of its arguments and of
|
||||
@@ -596,6 +598,14 @@ var ControllerVerbs = []Verb{
|
||||
//
|
||||
// **The schema is the whole of what a verb takes** (novox/hq issue 244): an argument it does not
|
||||
// name is refused when the verb is called, never passed over.
|
||||
// withObject adds to a verb's input s the property name, an object of the string fields given, each required.
|
||||
func withObject(s map[string]any, name, description string, fields map[string]string) map[string]any {
|
||||
inner := schema(fields, slices.Sorted(maps.Keys(fields)))
|
||||
inner["description"], inner["additionalProperties"] = description, false
|
||||
s["properties"].(map[string]any)[name] = inner
|
||||
return s
|
||||
}
|
||||
|
||||
func schema(properties map[string]string, required []string, switches ...string) map[string]any {
|
||||
isSwitch := map[string]bool{}
|
||||
for _, s := range switches {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -25,6 +26,30 @@ func TestAServedVerbIsANameOrADefinition(t *testing.T) {
|
||||
|
||||
// Holding a mesh seat that serves verbs requires serving them, and the refusal names the verbs.
|
||||
func TestHoldingAMeshSeatRequiresServingItsVerbs(t *testing.T) {
|
||||
// 774/S34/AC1: the seat mesh-enrolment is held by a module serving its six verbs, and a seventh is refused.
|
||||
enrolment, ok := SeatNamed(EnrolmentSeat)
|
||||
if !ok || enrolment.Scope != ScopeMesh || len(enrolment.Emits) != 5 {
|
||||
t.Fatalf("mesh-enrolment: in the set %v, %+v", ok, enrolment)
|
||||
}
|
||||
six := []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}
|
||||
// The tunnel key is given at issue (the operator, 2026-11-02 01:05).
|
||||
if issue := enrolment.Serves[0]; issue.Name != "issue" || !slices.Contains(issue.Input["required"].([]string), "overlay_key") {
|
||||
t.Errorf("issue requires %v", issue.Input["required"])
|
||||
}
|
||||
holder := func(serves ...string) Manifest {
|
||||
return Manifest{Module: "mesh-enrolment", Tools: serves,
|
||||
Claims: []Claim{{Name: EnrolmentSeat, Scope: ScopeMesh, Serves: serves}}}
|
||||
}
|
||||
if err := CanHold(holder(six...), enrolment); err != nil {
|
||||
t.Errorf("the holder serving the six verbs was refused: %v", err)
|
||||
}
|
||||
if err := CanHold(holder(six[:5]...), enrolment); err == nil || !strings.Contains(err.Error(), "does not serve again") {
|
||||
t.Errorf("a holder missing again: %v", err)
|
||||
}
|
||||
if err := CanHold(holder(append(six, "mint")...), enrolment); err == nil || !strings.Contains(err.Error(), "mint") {
|
||||
t.Errorf("a holder saying it serves mint: %v", err)
|
||||
}
|
||||
|
||||
was := Seats()
|
||||
t.Cleanup(func() { UseSeats(was) })
|
||||
UseSeats([]Seat{{Name: "mesh-controller", Scope: ScopeMesh, Decision: "test",
|
||||
|
||||
+110
-39
@@ -223,9 +223,56 @@ func taken(name string, raw []byte, m delivery.Module) (man catalogue.Manifest,
|
||||
// manifest is refused is read for its offer alone (unusable). A manifest the bus does not answer for still ends the
|
||||
// read: that is the bus's, not the module's.
|
||||
func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) {
|
||||
b, err := readBase(ctx, r, mesh, s)
|
||||
b.refuseOwn(refuse)
|
||||
if err != nil {
|
||||
return compose.Input{}, err
|
||||
}
|
||||
return b.finish(ctx, r, r.Build, mesh, s, b.in, refuse, wanted)
|
||||
}
|
||||
|
||||
// base is the part of a node's input that the fingerprints it composes its modules at leave alone (issue 1954): read once
|
||||
// for its plans at the wanted builds and at the newest, whose builds alone differ (PlansHeldTwice).
|
||||
type base struct {
|
||||
in compose.Input
|
||||
placements map[string]delivery.Placement
|
||||
records map[string]delivery.NodeRecord
|
||||
modules map[string]delivery.Module
|
||||
// ownRefused is each module placed on the composing node whose manifest the indexer refused, with why, in the order
|
||||
// read: every scope holds it.
|
||||
ownRefused [][2]string
|
||||
}
|
||||
|
||||
// refuseOwn hands refuse each module placed on the composing node whose manifest is refused.
|
||||
func (b base) refuseOwn(refuse func(module string, err error)) {
|
||||
if refuse == nil {
|
||||
return
|
||||
}
|
||||
for _, r := range b.ownRefused {
|
||||
refuse(r[0], errors.New(r[1]))
|
||||
}
|
||||
}
|
||||
|
||||
// cloned is the input with its own catalogue, nodes and kept manifests, what finishing it sets: two scopes finished over
|
||||
// one base never see each other's builds.
|
||||
func (b base) cloned() compose.Input {
|
||||
in := b.in
|
||||
in.Catalogue = maps.Clone(b.in.Catalogue)
|
||||
in.Nodes = slices.Clone(b.in.Nodes)
|
||||
for i := range in.Nodes {
|
||||
in.Nodes[i].Kept = maps.Clone(in.Nodes[i].Kept)
|
||||
}
|
||||
in.KeptCoreBoxes = slices.Clone(b.in.KeptCoreBoxes)
|
||||
in.OwnSecrets = slices.Clone(b.in.OwnSecrets)
|
||||
return in
|
||||
}
|
||||
|
||||
// readBase is input up to the builds: placement, records, the indexer's modules and manifests, each node's values.
|
||||
func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
|
||||
var b base
|
||||
placements, err := r.Placements(ctx)
|
||||
if err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: placement: %w", err)
|
||||
return b, fmt.Errorf("frombus: placement: %w", err)
|
||||
}
|
||||
if len(s.Placements) > 0 {
|
||||
given := make(map[string]delivery.Placement, len(placements)+len(s.Placements))
|
||||
@@ -238,7 +285,7 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
continue
|
||||
}
|
||||
if p.Node != node {
|
||||
return compose.Input{}, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node)
|
||||
return b, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node)
|
||||
}
|
||||
given[node] = *p
|
||||
}
|
||||
@@ -246,14 +293,14 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
}
|
||||
records, err := r.Records(ctx)
|
||||
if err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: node records: %w", err)
|
||||
return b, fmt.Errorf("frombus: node records: %w", err)
|
||||
}
|
||||
modules, err := r.Modules(ctx)
|
||||
if err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: the indexer's modules: %w", err)
|
||||
return b, fmt.Errorf("frombus: the indexer's modules: %w", err)
|
||||
}
|
||||
if _, ok := placements[s.Node]; !ok && !s.Every {
|
||||
return compose.Input{}, fmt.Errorf("frombus: %s has no placement in the catalogue", s.Node)
|
||||
return b, fmt.Errorf("frombus: %s has no placement in the catalogue", s.Node)
|
||||
}
|
||||
|
||||
in := compose.Input{OverlayCIDR: mesh.OverlayCIDR, InternalSuffix: mesh.InternalSuffix, Authority: mesh.Authority,
|
||||
@@ -279,18 +326,6 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
in.Authority = delivery.AuthorityBundle(in.Authority, vault)
|
||||
}
|
||||
}
|
||||
parse := parser(mesh)
|
||||
read := func(module, digest string) (catalogue.Manifest, error) {
|
||||
raw, err := r.Manifest(ctx, digest)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", module, digest, err)
|
||||
}
|
||||
man, err := parse(raw)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s) does not decode: %w", module, digest, err)
|
||||
}
|
||||
return man, nil
|
||||
}
|
||||
named := func(field *map[string]string, module, why string) {
|
||||
if *field == nil {
|
||||
*field = map[string]string{}
|
||||
@@ -309,7 +344,7 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
raw, err := r.Manifest(ctx, m.Manifest)
|
||||
if err != nil {
|
||||
// Not read is no refusal of the module: a bus that does not answer is waited out by the caller.
|
||||
return compose.Input{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", name, m.Manifest, err)
|
||||
return b, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", name, m.Manifest, err)
|
||||
}
|
||||
// One module's manifest the indexer refused refuses that module alone, by the rule a build that cannot be read
|
||||
// follows (issue 774: power's bar item refused every node's composition, 2026-10-30; ADR 0578 §3: one refused
|
||||
@@ -318,8 +353,8 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
man, why := taken(name, raw, m)
|
||||
if why != "" {
|
||||
named(&in.Refused, name, why)
|
||||
if refuse != nil && ownPlaced[name] {
|
||||
refuse(name, errors.New(why))
|
||||
if ownPlaced[name] {
|
||||
b.ownRefused = append(b.ownRefused, [2]string{name, why})
|
||||
}
|
||||
} else if aside := man.SetAside(); aside != "" {
|
||||
named(&in.SetAside, name, aside)
|
||||
@@ -332,11 +367,11 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
for _, name := range delivery.ProvidedModules() {
|
||||
raw, err := json.Marshal(overlay.Manifest())
|
||||
if err != nil {
|
||||
return compose.Input{}, err
|
||||
return b, err
|
||||
}
|
||||
var private catalogue.Manifest
|
||||
if err := json.Unmarshal(raw, &private); err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: the manifest mesh-sdk provides for %s: %w", name, err)
|
||||
return b, fmt.Errorf("frombus: the manifest mesh-sdk provides for %s: %w", name, err)
|
||||
}
|
||||
in.Catalogue[name] = private
|
||||
}
|
||||
@@ -357,13 +392,13 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
var values map[string]any
|
||||
if withValues {
|
||||
if values, err = r.Values(ctx, node, m.Module); err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: %s's config values on %s: %w", m.Module, node, err)
|
||||
return b, fmt.Errorf("frombus: %s's config values on %s: %w", m.Module, node, err)
|
||||
}
|
||||
} else if keys := catalogue.ServedSettings(in.Catalogue[m.Module]); len(keys) > 0 {
|
||||
// What a provider elsewhere serves from its settings is told to every consumer: the composing node reads
|
||||
// those keys of it, and no other (issue 1126: mailu's smtp domain, a consumer's on shanks).
|
||||
if values, err = r.Served(ctx, node, m.Module, keys); err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: what %s serves on %s (%s): %w", m.Module, node,
|
||||
return b, fmt.Errorf("frombus: what %s serves on %s (%s): %w", m.Module, node,
|
||||
strings.Join(keys, ", "), err)
|
||||
}
|
||||
}
|
||||
@@ -436,14 +471,35 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
in.CarriedPeers = carriedPeers(in.Adopted.Tunnel, placements[in.Adopted.Hub].Decided.PeerNames, records)
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return compose.Input{}, fmt.Errorf("frombus: modules are placed on %s, whose agent has written no record yet",
|
||||
return b, fmt.Errorf("frombus: modules are placed on %s, whose agent has written no record yet",
|
||||
strings.Join(missing, ", "))
|
||||
}
|
||||
b.in, b.placements, b.records, b.modules = in, placements, records, modules
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// finish is input from its base on: the composing node's modules from their builds (build reads one), its own secrets.
|
||||
// in is the base's input, or a copy of it (base.cloned) where one base is finished more than once.
|
||||
func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error),
|
||||
mesh Mesh, s Scope, in compose.Input, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) {
|
||||
var err error
|
||||
parse := parser(mesh)
|
||||
read := func(module, digest string) (catalogue.Manifest, error) {
|
||||
raw, err := r.Manifest(ctx, digest)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", module, digest, err)
|
||||
}
|
||||
man, err := parse(raw)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s) does not decode: %w", module, digest, err)
|
||||
}
|
||||
return man, nil
|
||||
}
|
||||
if !s.Every {
|
||||
if _, ok := records[s.Node]; !ok {
|
||||
if _, ok := b.records[s.Node]; !ok {
|
||||
return compose.Input{}, fmt.Errorf("frombus: %s has written no record of itself yet", s.Node)
|
||||
}
|
||||
if err := fromBuilds(ctx, r, &in, s, placements[s.Node], modules, read, parse, refuse, wanted); err != nil {
|
||||
if err := fromBuilds(ctx, r, build, &in, s, b.placements[s.Node], b.modules, read, parse, refuse, wanted); err != nil {
|
||||
return compose.Input{}, err
|
||||
}
|
||||
// Its own modules' secrets, sealed to its own key by the vault: a composition carries them sealed (ADR 0550 §5).
|
||||
@@ -603,15 +659,8 @@ func PlansOf(in compose.Input, node string) (map[string]catalogue.Plan, error) {
|
||||
|
||||
// plansIn is the controller's handedPlans in a mode: Foreseeing is the controller's own.
|
||||
func plansIn(in compose.Input, node string, mode compose.Mode) (map[string]catalogue.Plan, error) {
|
||||
resolved, err := compose.Resolve(in, node)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
|
||||
}
|
||||
rendered, err := compose.Render(in, node, mode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
|
||||
}
|
||||
gens, err := compose.Generators(in)
|
||||
// One composition for the three, so each node of the mesh is resolved once (issue 1954).
|
||||
resolved, rendered, gens, err := compose.Composing(in, node, mode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
|
||||
}
|
||||
@@ -673,7 +722,8 @@ func ranOf(ctx context.Context, r Reader, mesh Mesh, running []delivery.Composed
|
||||
// module it keeps at another build (Scope.KeptAt, into its node's Kept), read from the builder's done key and resolved
|
||||
// by the one take-in rule (takein.Manifest), a templates archive read in the artifact store as the node reaches it
|
||||
// (issue 1126).
|
||||
func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p delivery.Placement, modules map[string]delivery.Module,
|
||||
func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error),
|
||||
in *compose.Input, s Scope, p delivery.Placement, modules map[string]delivery.Module,
|
||||
read func(module, digest string) (catalogue.Manifest, error), parse func([]byte) (catalogue.Manifest, error),
|
||||
refuse func(module string, err error), wanted map[string]string) error {
|
||||
// stub keeps a refused module in the composition as a stub for this node alone (compose.Stub): what it provides
|
||||
@@ -716,6 +766,24 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
|
||||
if b.Manifest == "" {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the build of %s at %s names no manifest", module, fp)
|
||||
}
|
||||
// A take-in kept by the reader is read back as below, never shared: each composition holds its own manifest.
|
||||
keeper, keeps := r.(takenInKeeper)
|
||||
if keeps && !addressed {
|
||||
address, addressErr = compose.ArtifactStoreAddress(*in, s.Node)
|
||||
addressed = true
|
||||
}
|
||||
// Kept by the store's address too: a node that reaches no store takes in as before, and fails where it did.
|
||||
key, keyed := takenInKey(module, fp, address, *b)
|
||||
keyed = keyed && addressErr == nil && address != ""
|
||||
if keeps && keyed {
|
||||
if raw, ok := keeper.takenIn(key); ok {
|
||||
resolved, err := parse(raw)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the built manifest of %s at %s: %w", module, fp, err)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
}
|
||||
src, err := read(module, b.Manifest)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, err
|
||||
@@ -733,6 +801,9 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
|
||||
if resolved, err = parse(raw); err != nil {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the built manifest of %s at %s: %w", module, fp, err)
|
||||
}
|
||||
if keeps && keyed {
|
||||
keeper.keepTakenIn(key, raw)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
// A module wanted at another build than the trunk's is composed from the build its want names (issue 774), as one
|
||||
@@ -766,7 +837,7 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
|
||||
if man, ok := in.Catalogue[pm.Module]; !ok || (man.Build == nil && !man.Templated()) {
|
||||
continue // nothing is built of it: its manifest is what it composes from
|
||||
}
|
||||
b, err := r.Build(ctx, pm.Module, m.FP2)
|
||||
b, err := build(ctx, pm.Module, m.FP2)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("frombus: the build of %s at %s: %w", pm.Module, m.FP2, err)
|
||||
if refuse == nil {
|
||||
@@ -801,7 +872,7 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
|
||||
}
|
||||
for _, module := range sortedKeys(keptAt) {
|
||||
fp := keptAt[module]
|
||||
b, err := r.Build(ctx, module, fp)
|
||||
b, err := build(ctx, module, fp)
|
||||
switch {
|
||||
case err != nil:
|
||||
err = fmt.Errorf("frombus: the build of %s at %s: %w", module, fp, err)
|
||||
|
||||
@@ -2,10 +2,12 @@ package frombustest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/fromruntime"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
@@ -117,6 +119,18 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
|
||||
restic.Provisions[0].Provider != Node || restic.Provisions[0].ProviderModule != "postgres" {
|
||||
t.Fatalf("%s: the plans are %+v (held %v)", row.name, plans, held)
|
||||
}
|
||||
// Issue 1954: the plans at the wanted builds and at the newest, over one read, are what PlansHeld answers for each,
|
||||
// and where the time went is said by phase.
|
||||
keptAt := FPs()
|
||||
at, newest, took := frombus.PlansHeldTwice(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt})
|
||||
wantAt, heldAt, errAt := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt})
|
||||
if !reflect.DeepEqual(at, frombus.HeldPlans{Plans: wantAt, Held: heldAt, Err: errAt}) ||
|
||||
!reflect.DeepEqual(newest, frombus.HeldPlans{Plans: plans, Held: held}) {
|
||||
t.Errorf("%s: twice composed %+v and %+v, want %v %v %v and %v %v", row.name, at, newest, wantAt, heldAt, errAt, plans, held)
|
||||
}
|
||||
if said := frombus.PhasesSaid(took); !strings.HasPrefix(said, "read ") || !strings.Contains(said, "plans at newest ") {
|
||||
t.Errorf("%s: the phases are said as %q", row.name, said)
|
||||
}
|
||||
if row.bad == "" || row.elsewhere {
|
||||
if len(held) > 0 {
|
||||
t.Errorf("%s: held %v", row.name, held)
|
||||
@@ -167,6 +181,85 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
|
||||
if fetched != 1 {
|
||||
t.Errorf("a templates archive was fetched %d times, want once", fetched)
|
||||
}
|
||||
|
||||
// Issue 1954: the states reader takes a build in once per its content: composed again, its archive is not read again
|
||||
// and the plans are the same.
|
||||
b, err := Provisioned()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader, archives := frombus.StatesReader(statesOf(t, b), func(_ context.Context, url string) ([]byte, error) {
|
||||
fetched++
|
||||
_, digest, _ := strings.Cut(url, "/blobs/")
|
||||
return b.Archives[digest], nil
|
||||
}), fetched
|
||||
var first map[string]catalogue.Plan
|
||||
for i := range 2 {
|
||||
plans, held, err := frombus.PlansHeld(context.Background(), reader, frombus.Mesh{}, frombus.Scope{Node: Node})
|
||||
if err != nil || len(held) > 0 || (i == 1 && !reflect.DeepEqual(plans, first)) || !planned(plans, "restic") {
|
||||
t.Fatalf("composed %v (held %v): %v", plans, held, err)
|
||||
}
|
||||
first = plans
|
||||
}
|
||||
if fetched-archives != 1 {
|
||||
t.Errorf("restic's archive was read %d times over two compositions, want once", fetched-archives)
|
||||
}
|
||||
}
|
||||
|
||||
// statesOf is the bus b as a module's read states hold it.
|
||||
func statesOf(t *testing.T, b *Bus) frombus.States {
|
||||
t.Helper()
|
||||
s := states{}
|
||||
put := func(state, key string, raw []byte, err error) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s[state] == nil {
|
||||
s[state] = map[string][]byte{}
|
||||
}
|
||||
s[state][key] = raw
|
||||
}
|
||||
for node, p := range b.Placement {
|
||||
raw, err := delivery.EncodePlacement(p)
|
||||
put(frombus.PlacementRead, "placement."+node, raw, err)
|
||||
}
|
||||
for node, rec := range b.Record {
|
||||
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec})
|
||||
put(frombus.HaveRead, node+".node", raw, err)
|
||||
}
|
||||
for module, m := range b.Module {
|
||||
key, _ := delivery.ModulesKey(module)
|
||||
raw, err := delivery.EncodeModule(m)
|
||||
put(frombus.ModulesRead, key, raw, err)
|
||||
}
|
||||
for digest, man := range b.Manifests {
|
||||
key, _ := delivery.ManifestsKey(digest)
|
||||
raw, err := delivery.EncodeManifest(delivery.Manifest{Module: "m", JSON: string(man)})
|
||||
put(frombus.ManifestsRead, key, raw, err)
|
||||
}
|
||||
for at, build := range b.Builds {
|
||||
module, fp, _ := strings.Cut(at, " ")
|
||||
key, _ := delivery.BuilderBuildKey(module, fp)
|
||||
raw, err := delivery.EncodeBuilderBuild(*build)
|
||||
put(frombus.BuildsRead, key, raw, err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// states are read states in memory.
|
||||
type states map[string]map[string][]byte
|
||||
|
||||
func (s states) Get(state, key string) ([]byte, bool, error) {
|
||||
v, ok := s[state][key]
|
||||
return v, ok, nil
|
||||
}
|
||||
|
||||
func (s states) Keys(state string) ([]string, error) {
|
||||
var keys []string
|
||||
for k := range s[state] {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func planned[P any](plans map[string]P, module string) bool { _, ok := plans[module]; return ok }
|
||||
|
||||
@@ -43,13 +43,22 @@ type States interface {
|
||||
type Fetch func(ctx context.Context, url string) ([]byte, error)
|
||||
|
||||
// StatesReader is the Reader over s; fetch reads a templates archive, nil for a holder that reads none.
|
||||
func StatesReader(s States, fetch Fetch) Reader { return statesReader{s: s, fetch: fetch} }
|
||||
// It keeps what each build's take-in made, by the build's content (issue 1954), so a holder composing again and again
|
||||
// takes in only a build it has not seen.
|
||||
func StatesReader(s States, fetch Fetch) Reader {
|
||||
return statesReader{s: s, fetch: fetch, kept: &keptTakeIns{}}
|
||||
}
|
||||
|
||||
type statesReader struct {
|
||||
s States
|
||||
fetch Fetch
|
||||
kept *keptTakeIns
|
||||
}
|
||||
|
||||
func (r statesReader) takenIn(key string) ([]byte, bool) { return r.kept.takenIn(key) }
|
||||
|
||||
func (r statesReader) keepTakenIn(key string, raw []byte) { r.kept.keepTakenIn(key, raw) }
|
||||
|
||||
func (r statesReader) each(read string, visit func(key string, value []byte)) error {
|
||||
keys, err := r.s.Keys(read)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package frombus
|
||||
|
||||
// **A node's plans at two sets of builds, over one read** (issue 1954; novox/hq ADR 0570 clause 2, ADR 0580 clause 2):
|
||||
// the seat mesh-provisioner's holder composes each node at the builds its wants name and at the newest build of each
|
||||
// placed module. Both are composed from one read of the bus (placement, records, the indexer's modules and manifests,
|
||||
// values: base); only the builds the node's modules are composed from differ, each build read once for both, and a
|
||||
// build taken in once per content by a reader that keeps it (StatesReader). Where the time went is said by phase.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// HeldPlans is one scope's plans, as PlansHeld answers them: err a refusal that is no single module's.
|
||||
type HeldPlans struct {
|
||||
Plans map[string]catalogue.Plan
|
||||
Held map[string]string
|
||||
Err error
|
||||
}
|
||||
|
||||
// Phase is one step of a composition and how long it took.
|
||||
type Phase struct {
|
||||
Name string `json:"phase"`
|
||||
Took time.Duration `json:"took"`
|
||||
}
|
||||
|
||||
// PlansHeldTwice is PlansHeld at s and at s with no kept builds (each placed module at its newest build), over one
|
||||
// read of the bus: what each answers is what PlansHeld answers for that scope. A read that fails is both scopes' err.
|
||||
// took names each phase in order: the read, then each scope's builds and its plans.
|
||||
func PlansHeldTwice(ctx context.Context, r Reader, mesh Mesh, s Scope) (at, newest HeldPlans, took []Phase) {
|
||||
start := time.Now()
|
||||
mark := func(name string) {
|
||||
now := time.Now()
|
||||
took = append(took, Phase{Name: name, Took: now.Sub(start)})
|
||||
start = now
|
||||
}
|
||||
b, err := readBase(ctx, r, mesh, s)
|
||||
mark("read")
|
||||
builds := onceBuilds(r)
|
||||
newestScope := s
|
||||
newestScope.KeptAt = nil
|
||||
for _, sc := range []struct {
|
||||
scope Scope
|
||||
into *HeldPlans
|
||||
name string
|
||||
}{{s, &at, "wanted"}, {newestScope, &newest, "newest"}} {
|
||||
held := map[string]string{}
|
||||
sc.into.Held = held
|
||||
refuse := func(module string, err error) { held[module] = err.Error() }
|
||||
b.refuseOwn(refuse)
|
||||
if err != nil {
|
||||
sc.into.Err = err
|
||||
continue
|
||||
}
|
||||
in, err := b.finish(ctx, r, builds, mesh, sc.scope, b.cloned(), refuse, nil)
|
||||
mark("builds at " + sc.name)
|
||||
if err != nil {
|
||||
sc.into.Err = err
|
||||
continue
|
||||
}
|
||||
sc.into.Plans, sc.into.Err = plansHeldOf(in, s.Node, held)
|
||||
mark("plans at " + sc.name)
|
||||
}
|
||||
return at, newest, took
|
||||
}
|
||||
|
||||
// onceBuilds is r's Build, each module's build at a fingerprint read once: both scopes of one composition read the
|
||||
// builds of the same moment.
|
||||
func onceBuilds(r Reader) func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) {
|
||||
type answer struct {
|
||||
b *delivery.BuilderBuild
|
||||
err error
|
||||
}
|
||||
read := map[string]answer{}
|
||||
return func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) {
|
||||
key := module + " " + fp
|
||||
if a, ok := read[key]; ok {
|
||||
return a.b, a.err
|
||||
}
|
||||
b, err := r.Build(ctx, module, fp)
|
||||
read[key] = answer{b, err}
|
||||
return b, err
|
||||
}
|
||||
}
|
||||
|
||||
// takenInKeeper is a Reader that keeps what a build's take-in made (StatesReader does): the resolved manifest's bytes,
|
||||
// by the content it was made from, so a composition made again on every change takes in only a build it has not seen.
|
||||
type takenInKeeper interface {
|
||||
takenIn(key string) ([]byte, bool)
|
||||
keepTakenIn(key string, raw []byte)
|
||||
}
|
||||
|
||||
// takenInKey is what a take-in is made from: the artifact store's address and the build's record (its manifest's digest, its artifacts by digest), read
|
||||
// whole; an archive is checked against its digest on every read, so the same record takes in to the same bytes.
|
||||
func takenInKey(module, fp, address string, b delivery.BuilderBuild) (string, bool) {
|
||||
raw, err := json.Marshal(b)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
sum := sha256.Sum256(append([]byte(module+" "+fp+" "+address+" "), raw...))
|
||||
return hex.EncodeToString(sum[:]), true
|
||||
}
|
||||
|
||||
// takenInAtMost bounds the take-ins a reader keeps; past it they are emptied, which costs only a take-in again.
|
||||
const takenInAtMost = 512
|
||||
|
||||
// keptTakeIns is a reader's kept take-ins.
|
||||
type keptTakeIns struct {
|
||||
mu sync.Mutex
|
||||
kept map[string][]byte
|
||||
}
|
||||
|
||||
func (k *keptTakeIns) takenIn(key string) ([]byte, bool) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
raw, ok := k.kept[key]
|
||||
return raw, ok
|
||||
}
|
||||
|
||||
func (k *keptTakeIns) keepTakenIn(key string, raw []byte) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.kept == nil || len(k.kept) >= takenInAtMost {
|
||||
k.kept = map[string][]byte{}
|
||||
}
|
||||
k.kept[key] = raw
|
||||
}
|
||||
|
||||
// PhasesSaid says the phases in one line, each in milliseconds.
|
||||
func PhasesSaid(took []Phase) string {
|
||||
out := ""
|
||||
for i, p := range took {
|
||||
if i > 0 {
|
||||
out += ", "
|
||||
}
|
||||
out += fmt.Sprintf("%s %d ms", p.Name, p.Took.Milliseconds())
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -27,6 +27,23 @@ type composer struct {
|
||||
// unhostable is, from the last look at the rest of the mesh, each module assigned to another node that this
|
||||
// composition finds that node cannot run: what it would have offered is missing here (issue 1126, probe #532).
|
||||
unhostable []string
|
||||
|
||||
// What one composition looks at again and again, each worked out once over its Input, which it never changes (issue
|
||||
// 1954: a node's plans resolved every machine of the mesh some two hundred times, 11.7 s of CPU for novox): who is
|
||||
// on the private network, and the rest of the mesh as each node sees it.
|
||||
onNetwork []Node
|
||||
onNetworkSet bool
|
||||
rest map[string]restOf
|
||||
plans map[string]planOf
|
||||
}
|
||||
|
||||
// restOf is theRestOfTheMesh's answer for one excluded node, kept: what it found unhostable beside it.
|
||||
type restOf struct {
|
||||
world catalogue.World
|
||||
passes Passes
|
||||
left []LeftOut
|
||||
unhostable []string
|
||||
err error
|
||||
}
|
||||
|
||||
func newComposer(in Input) *composer { return &composer{in: in, seen: map[string]bool{}} }
|
||||
@@ -249,6 +266,14 @@ func (c *composer) whereEveryoneIs() (map[string]string, error) {
|
||||
// same rule as network(), so its name and its address stay where the tunnel to it does (issue 1833); one without is not
|
||||
// counted, and said.
|
||||
func (c *composer) onTheNetwork() []Node {
|
||||
if !c.onNetworkSet {
|
||||
c.onNetwork, c.onNetworkSet = c.onTheNetworkNow(), true
|
||||
}
|
||||
return slices.Clone(c.onNetwork)
|
||||
}
|
||||
|
||||
// onTheNetworkNow is onTheNetwork worked out: every placed machine's set resolved.
|
||||
func (c *composer) onTheNetworkNow() []Node {
|
||||
var out []Node
|
||||
for _, n := range c.in.sorted() {
|
||||
if n.Place.Address == "" {
|
||||
|
||||
@@ -35,6 +35,26 @@ func Render(in Input, node string, mode Mode) (Rendered, error) {
|
||||
return newComposer(in).render(node, mode)
|
||||
}
|
||||
|
||||
// Composing is Resolve, Render and Generators of one node over one composition, which resolves each node of the mesh
|
||||
// once for all three (issue 1954: three compositions resolved the mesh again each, 11.7 s for one node's plans). Each
|
||||
// answers as it does alone; the first refusal ends it, in that order, with what follows it empty.
|
||||
func Composing(in Input, node string, mode Mode) (Resolved, Rendered, map[string]catalogue.Generator, error) {
|
||||
c := newComposer(in)
|
||||
resolved, err := c.planFor(node)
|
||||
if err != nil {
|
||||
return Resolved{}, Rendered{}, nil, err
|
||||
}
|
||||
rendered, err := c.render(node, mode)
|
||||
if err != nil {
|
||||
return resolved, Rendered{}, nil, err
|
||||
}
|
||||
gens, err := c.generators()
|
||||
if err != nil {
|
||||
return resolved, rendered, nil, err
|
||||
}
|
||||
return resolved, rendered, gens, nil
|
||||
}
|
||||
|
||||
func (c *composer) render(nodeName string, mode Mode) (Rendered, error) {
|
||||
resolved, err := c.planFor(nodeName)
|
||||
if err != nil {
|
||||
|
||||
+39
-1
@@ -36,8 +36,46 @@ func Resolve(in Input, node string) (Resolved, error) {
|
||||
// IsUnresolvable reports whether err is a node's own set failing to compose, rather than the mesh being unable to answer.
|
||||
func IsUnresolvable(err error) bool { return unresolvable(err) }
|
||||
|
||||
// planFor is the controller's planFor.
|
||||
// planFor is the controller's planFor, worked out once per node in one composition (issue 1954): each caller has its
|
||||
// own copy of what a caller may set (a need's fields, as Resolution.Compose sets its Serves), the rest only read.
|
||||
func (c *composer) planFor(nodeName string) (Resolved, error) {
|
||||
p, ok := c.plans[nodeName]
|
||||
if !ok {
|
||||
p.resolved, p.err = c.planForNow(nodeName)
|
||||
if c.plans == nil {
|
||||
c.plans = map[string]planOf{}
|
||||
}
|
||||
c.plans[nodeName] = p
|
||||
}
|
||||
if p.err != nil {
|
||||
return Resolved{}, p.err
|
||||
}
|
||||
return p.resolved.copied(), nil
|
||||
}
|
||||
|
||||
// planOf is planFor's answer for one node, kept.
|
||||
type planOf struct {
|
||||
resolved Resolved
|
||||
err error
|
||||
}
|
||||
|
||||
// copied is r with its own needs, words and settings lists: what a caller may change of it.
|
||||
func (r Resolved) copied() Resolved {
|
||||
out := r
|
||||
out.Plan.Needs = slices.Clone(r.Plan.Needs)
|
||||
out.Foreseen = slices.Clone(r.Foreseen)
|
||||
out.NotGiven = slices.Clone(r.NotGiven)
|
||||
if r.Settings != nil {
|
||||
out.Settings = make(catalogue.SettingsBy, len(r.Settings))
|
||||
for m, layers := range r.Settings {
|
||||
out.Settings[m] = slices.Clone(layers)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// planForNow is planFor worked out.
|
||||
func (c *composer) planForNow(nodeName string) (Resolved, error) {
|
||||
resolved, err := c.resolution(nodeName)
|
||||
if err != nil {
|
||||
return Resolved{}, err
|
||||
|
||||
@@ -3,6 +3,7 @@ package compose
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -59,6 +60,22 @@ func TheRestOfTheMesh(in Input, exclude string) (catalogue.World, Passes, []Left
|
||||
}
|
||||
|
||||
func (c *composer) theRestOfTheMesh(exclude string) (catalogue.World, Passes, []LeftOut, error) {
|
||||
r, ok := c.rest[exclude]
|
||||
if !ok {
|
||||
r.world, r.passes, r.left, r.err = c.theRestOfTheMeshNow(exclude)
|
||||
r.unhostable = c.unhostable
|
||||
if c.rest == nil {
|
||||
c.rest = map[string]restOf{}
|
||||
}
|
||||
c.rest[exclude] = r
|
||||
}
|
||||
// Each caller has its own copy of what it may set or add to; the maps and the slices within are only read.
|
||||
c.unhostable = slices.Clone(r.unhostable)
|
||||
return r.world, r.passes, slices.Clone(r.left), r.err
|
||||
}
|
||||
|
||||
// theRestOfTheMeshNow is theRestOfTheMesh worked out: every other machine's set resolved, twice.
|
||||
func (c *composer) theRestOfTheMeshNow(exclude string) (catalogue.World, Passes, []LeftOut, error) {
|
||||
shelf := c.in.Catalogue
|
||||
holdings := append([]catalogue.Held(nil), c.in.Holdings...)
|
||||
nodes := c.in.sorted()
|
||||
|
||||
@@ -91,8 +91,8 @@ func TestToolchainGate(t *testing.T) {
|
||||
gate, set, said := start(t, true)
|
||||
key, value := gateReport(t, "ace", "v26.12.0", old)
|
||||
gate.Toolchain(NodeBuildModule, key, value, false)
|
||||
if _, none := agreedOf(set); !strings.HasPrefix(none, "builders disagree") {
|
||||
t.Fatalf("before the placement is settled every report counts; got %q", none)
|
||||
if a := delivery.ToolchainAgreement(set.Entries("typescript")); len(a.SittingOut) != 1 || a.SittingOut[0].Node != "ace" { // ADR 0584: the majority is agreed, ace's report counts as the one that sits out
|
||||
t.Fatalf("before the placement is settled every report counts; got %+v", a)
|
||||
}
|
||||
k, v := placing(t, "ace", GoBuildModule) // another build module on ace: no matter
|
||||
gate.Placement(k, v, false, old)
|
||||
@@ -111,8 +111,8 @@ func TestToolchainGate(t *testing.T) {
|
||||
// Placed on ace with a report written since: it counts, and the builders really disagree.
|
||||
k, v = placing(t, "ace", GoBuildModule, NodeBuildModule)
|
||||
gate.Placement(k, v, false, old.Add(-time.Minute))
|
||||
if _, none := agreedOf(set); !strings.HasPrefix(none, "builders disagree: v26.12.0 recipe f59571cf4be75adf on ace") {
|
||||
t.Fatalf("builders that run different versions still disagree; got %q", none)
|
||||
if a := delivery.ToolchainAgreement(set.Entries("typescript")); a.Toolchain.Version != "v26.12.1" || len(a.SittingOut) != 1 || a.SittingOut[0].Version != "v26.12.0" {
|
||||
t.Fatalf("builders that run different versions: the majority is agreed and ace sits out; got %+v", a)
|
||||
}
|
||||
// Taken off ace again (its key deleted): its report goes again.
|
||||
if !gate.Placement(k, nil, true, placed) {
|
||||
|
||||
@@ -40,6 +40,10 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
|
||||
{"a node's health report, a part pointing at the hub", `{"v":1,"node":"anchor","network":{"state":"unhealthy",` +
|
||||
`"since":"2026-10-29T20:00:00Z","parts":[{"part":"tunnel","state":"unhealthy","reason":"the tunnel to the hub has not handshaken for over five minutes",` +
|
||||
`"toward":["hub"],"targets":["novox"],"since":"2026-10-29T20:00:00Z"}]},"at":"2026-10-29T20:00:00Z"}`, &NodeHealth{}},
|
||||
// A node fallen back to its old login (ADR 0583 §6), which the watchman raises as node-account-fallback.
|
||||
{"a node's health report, fallen back to its old login", `{"v":1,"node":"anchor","fallback":{"node":"anchor",` +
|
||||
`"from":"node.anchor--b","why":"the new login's test-dial was refused","at":"2026-11-02T01:00:00Z"},` +
|
||||
`"at":"2026-11-02T01:00:00Z"}`, &NodeHealth{}},
|
||||
{"the predefined list", `{"v":1,"commit":"abc","at":"2026-10-29T12:00:00Z","list":{"domain":{"type":"string"}}}`, &PredefinedKept{}},
|
||||
} {
|
||||
if err := json.Unmarshal([]byte(c.raw), c.into); err != nil {
|
||||
@@ -60,6 +64,10 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
|
||||
if _, err := EncodeNodeHealth(NodeHealth{}); err == nil {
|
||||
t.Error("a report naming no node was encoded")
|
||||
}
|
||||
if _, err := EncodeNodeHealth(NodeHealth{Node: "anchor", Fallback: &LoginFallback{Node: "ace", From: "node.anchor--b",
|
||||
Why: "refused", At: time.Now()}}); err == nil {
|
||||
t.Error("a fall back naming another node was encoded")
|
||||
}
|
||||
// ADR 0571 clause 4: who holds which seat, from placement; a node seat by its node.
|
||||
held := SeatHoldersOf(map[string]Placement{
|
||||
"ace": {Node: "ace", Holds: []Holding{{Claim: "mesh-dns-resolver", Scope: "mesh", Module: "dnsmasq"}, {Claim: "node-files", Scope: "node", Module: "mesh-files"}}},
|
||||
@@ -142,6 +150,40 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
|
||||
t.Errorf("%s keyed %q, %v", pull, got, err)
|
||||
}
|
||||
}
|
||||
// ADR 0565 §6: the engine works out its node's key of `packages` from the compositions it composed, the mirror's bytes
|
||||
// for the same resources (mesh-controller internal/inventory/declared_packages_test.go aNodesPackages at 0fbb5ef3):
|
||||
// a module's own composition, the node's own with a folded module's resource named by its id alone, and a file.
|
||||
res := func(s ...string) []json.RawMessage {
|
||||
var out []json.RawMessage
|
||||
for _, r := range s {
|
||||
out = append(out, json.RawMessage(r))
|
||||
}
|
||||
return out
|
||||
}
|
||||
comps := []Composed{
|
||||
{Node: "one", Module: "web", Resources: res(`{"id":"pkg-curl","type":"package","package":"curl"}`,
|
||||
`{"id":"pkg-nano","type":"package","package":"nano","absent":true}`, `{"id":"conf","type":"file","path":"/etc/web.conf"}`)},
|
||||
{Node: "one", Resources: res(`{"id":"backup.pkg-curl","type":"package","package":"curl"}`)},
|
||||
}
|
||||
want := `{"node":"one","packages":[{"package":"curl","modules":["backup","web"],"absent":false},` +
|
||||
`{"package":"nano","modules":["web"],"absent":true}]}`
|
||||
got, err := DeclaredPackagesOf("one", comps)
|
||||
if raw, _ := json.Marshal(got); err != nil || string(raw) != want {
|
||||
t.Errorf("the node's packages are %s (%v)", raw, err)
|
||||
}
|
||||
if none, _ := DeclaredPackagesOf("one", nil); string(jsonOf(none)) != `{"node":"one","packages":[]}` || EnginePackagesRead != "mesh-host.packages" {
|
||||
t.Errorf("a node declaring no package is %s", jsonOf(none))
|
||||
}
|
||||
// Their difference with the mirror: a package only one side holds, and one whose modules differ; none when they agree.
|
||||
mirror := DeclaredPackages{Node: "one", Packages: []PackageDeclared{{Package: "curl", Modules: []string{"web"}},
|
||||
{Package: "nano", Modules: []string{"web"}, Absent: true}, {Package: "vim", Modules: []string{"edit"}}}}
|
||||
if d := DiffDeclaredPackages(got, mirror); len(d) != 2 || d[0].Package != "curl" || strings.Join(d[0].Engine, ",") != "backup,web" ||
|
||||
d[1].Package != "vim" || len(d[1].Engine) != 0 || d[1].Mirror[0] != "edit" {
|
||||
t.Errorf("the difference is %+v", d)
|
||||
}
|
||||
if d := DiffDeclaredPackages(got, got); d == nil || len(d) != 0 {
|
||||
t.Errorf("one declaration differs from itself: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonOf(v any) []byte {
|
||||
|
||||
@@ -242,9 +242,22 @@ func TestRoundTrip(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := DecodeGrantsUserList(raw)
|
||||
if err != nil || back.Digest != digest || back.LeftOut == nil || back.Follow != FollowGrants {
|
||||
if err != nil || back.Digest != digest || back.LeftOut == nil || back.Follow != FollowGrants || back.LeaseHanded {
|
||||
t.Fatalf("decoded %+v, %v", back, err)
|
||||
}
|
||||
// ADR 0582 clause 3 (issue 774, 774/S3): a key written before the flag existed reads not handed; one written with
|
||||
// it reads handed.
|
||||
if old, err := DecodeGrantsUserList([]byte(`{"v":1,"follow":"grants","digest":"","object":"","sealed_to":"",` +
|
||||
`"node":"novox","revision":3,"users":40,"left_out":[],"at":"2026-10-30T10:00:00Z"}`)); err != nil || old.LeaseHanded {
|
||||
t.Errorf("a key from before the lease write decodes %+v, %v", old, err)
|
||||
}
|
||||
g.LeaseHanded = true
|
||||
if raw, err := EncodeGrantsUserList(g); err != nil || !bytes.Contains(raw, []byte(`"lease-handed":true`)) {
|
||||
t.Errorf("the handed flag is written as %s, %v", raw, err)
|
||||
} else if back, err := DecodeGrantsUserList(raw); err != nil || !back.LeaseHanded {
|
||||
t.Errorf("the handed flag reads back %+v, %v", back, err)
|
||||
}
|
||||
g.LeaseHanded = false
|
||||
for _, bad := range []GrantsUserList{{Follow: "both", Digest: digest, Object: g.Object},
|
||||
{Follow: FollowGrants, Digest: digest, Object: "other"}, {Follow: FollowGrants, Digest: "md5:x", Object: "md5:x"}} {
|
||||
if _, err := EncodeGrantsUserList(bad); err == nil {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
)
|
||||
|
||||
// **A node's declared packages, worked out by its engine** (novox/hq ADR 0565 §1, §6; issue 1953): the derivation and
|
||||
// the comparison of the node-engine's state `packages` (EnginePackagesState, layout DeclaredPackages), so the engine
|
||||
// that writes the key, the node seat's verb that compares it with the controller's mirror, and any reader work them out
|
||||
// one way. The controller's own copy of the derivation (mesh-controller internal/inventory DeclaredPackagesValue) is
|
||||
// not changed (ADR 0547 §1) and ends with its lease.
|
||||
|
||||
// EnginePackagesRead is the engine's state `packages` as a reader names it in `reads`: the node-engine's manifest is
|
||||
// mesh-host's, so its bucket is mesh-host_packages, read as disk-load reads mesh-host.data (EngineDataRead).
|
||||
const EnginePackagesRead = "mesh-host." + EnginePackagesState
|
||||
|
||||
// DeclaredPackagesOf is a node's key of `packages` from the compositions its engine composed for the node's wants:
|
||||
// each resource of type package, by its name and whether it is declared absent, with the modules that declare it so,
|
||||
// in the mirror's layout and order (names sorted, absent after present, modules sorted). A resource's module is the
|
||||
// one it names, else its composition's, else the guess from its id (declaration.ModuleGuess); a package of no module
|
||||
// lists none.
|
||||
func DeclaredPackagesOf(node string, compositions []Composed) (DeclaredPackages, error) {
|
||||
by := map[packageKey]map[string]bool{}
|
||||
for _, c := range compositions {
|
||||
for _, raw := range c.Resources {
|
||||
var r struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Package string `json:"package"`
|
||||
Absent bool `json:"absent"`
|
||||
Module string `json:"module"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return DeclaredPackages{}, fmt.Errorf("delivery: a resource of %s's composition %q is not readable: %w", node, c.Module, err)
|
||||
}
|
||||
if r.Type != "package" || r.Package == "" {
|
||||
continue
|
||||
}
|
||||
k := packageKey{r.Package, r.Absent}
|
||||
if by[k] == nil {
|
||||
by[k] = map[string]bool{}
|
||||
}
|
||||
module := r.Module
|
||||
if module == "" {
|
||||
module = c.Module
|
||||
}
|
||||
if module == "" {
|
||||
module, _ = declaration.ModuleGuess(r.ID)
|
||||
}
|
||||
if module != "" {
|
||||
by[k][module] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
v := DeclaredPackages{Node: node, Packages: []PackageDeclared{}}
|
||||
for _, k := range sortedPackageKeys(by) {
|
||||
modules := make([]string, 0, len(by[k]))
|
||||
for m := range by[k] {
|
||||
modules = append(modules, m)
|
||||
}
|
||||
sort.Strings(modules)
|
||||
v.Packages = append(v.Packages, PackageDeclared{Package: k.name, Modules: modules, Absent: k.absent})
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// PackageDifference is one package two declarations of a node hold differently: the modules each side declares it
|
||||
// with (present, or absent when Absent), none on a side that does not hold it.
|
||||
type PackageDifference struct {
|
||||
Package string `json:"package"`
|
||||
Absent bool `json:"absent"`
|
||||
Engine []string `json:"engine"`
|
||||
Mirror []string `json:"mirror"`
|
||||
}
|
||||
|
||||
// DiffDeclaredPackages is every package the engine's key and the controller's mirror hold differently for one node, in
|
||||
// the layout's order: what the node seat's verb packages answers until the lease write (ADR 0565 §6). Empty when they
|
||||
// agree.
|
||||
func DiffDeclaredPackages(engine, mirror DeclaredPackages) []PackageDifference {
|
||||
index := func(d DeclaredPackages) map[packageKey][]string {
|
||||
out := map[packageKey][]string{}
|
||||
for _, p := range d.Packages {
|
||||
out[packageKey{p.Package, p.Absent}] = append([]string{}, p.Modules...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
e, m := index(engine), index(mirror)
|
||||
union := map[packageKey]bool{}
|
||||
for k := range e {
|
||||
union[k] = true
|
||||
}
|
||||
for k := range m {
|
||||
union[k] = true
|
||||
}
|
||||
out := []PackageDifference{}
|
||||
for _, k := range sortedPackageKeys(union) {
|
||||
em, inE := e[k]
|
||||
mm, inM := m[k]
|
||||
if inE && inM && slices.Equal(em, mm) {
|
||||
continue
|
||||
}
|
||||
if em == nil {
|
||||
em = []string{}
|
||||
}
|
||||
if mm == nil {
|
||||
mm = []string{}
|
||||
}
|
||||
out = append(out, PackageDifference{Package: k.name, Absent: k.absent, Engine: em, Mirror: mm})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// packageKey is a package as the layout tells it apart: its name, and whether it is declared absent.
|
||||
type packageKey struct {
|
||||
name string
|
||||
absent bool
|
||||
}
|
||||
|
||||
// sortedPackageKeys is the keys of m by name, absent after present.
|
||||
func sortedPackageKeys[V any](m map[packageKey]V) []packageKey {
|
||||
keys := make([]packageKey, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].name != keys[j].name {
|
||||
return keys[i].name < keys[j].name
|
||||
}
|
||||
return !keys[i].absent && keys[j].absent
|
||||
})
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// **The state of the seat mesh-enrolment** (novox/hq ADR 0587 §2, issue 774): the holder's bucket `enrolments`, holding
|
||||
// no secret, with two kinds of key. `node.<node>`, one per open enrolment, holds an Enrolment; `address.<address>`
|
||||
// reserves one private-network address for one open enrolment and holds the node's name and, from the ask, the ask and the issue's time. Every write is a
|
||||
// compare-and-set at the revision read. The bucket keeps per-key expiry (`key-ttl`): a key at stage live carries the
|
||||
// token's expiry, at asked and redeeming none (§5). It is read by the holder and, as a named reader, by the grants part,
|
||||
// which composes the user `enrol.<node>` from a key at live and the vault's hash (§4). These constants are the seat's
|
||||
// contract: every reader takes the names from here.
|
||||
const (
|
||||
// EnrolmentModule is the module holding the seat mesh-enrolment.
|
||||
EnrolmentModule = catalogue.EnrolmentSeat
|
||||
// EnrolmentsState is its state, EnrolmentsBucket the bucket, EnrolmentsRead the name a reader's manifest declares.
|
||||
EnrolmentsState = "enrolments"
|
||||
EnrolmentsBucket = EnrolmentModule + "_" + EnrolmentsState
|
||||
EnrolmentsRead = EnrolmentModule + "." + EnrolmentsState
|
||||
|
||||
// The stages of an open enrolment (§2, §4, §5).
|
||||
EnrolmentAsked = "asked"
|
||||
EnrolmentLive = "live"
|
||||
EnrolmentRedeeming = "redeeming"
|
||||
|
||||
// DefaultTokenLife is a token's life when issue names none; MostTokenLife the longest it may name (§4).
|
||||
DefaultTokenLife = time.Hour
|
||||
MostTokenLife = 24 * time.Hour
|
||||
|
||||
// EnrolmentUserPrefix is the bus user a joining machine dials as, `enrol.<node>` (§4).
|
||||
EnrolmentUserPrefix = "enrol."
|
||||
|
||||
enrolmentNodePrefix = "node."
|
||||
enrolmentAddressPrefix = "address."
|
||||
)
|
||||
|
||||
// Enrolment is one key `node.<node>` of `enrolments`: the node's name, the private-network address chosen at issue, the
|
||||
// why, the caller as the runtime proved it, the ask's id, the stage, when it was issued and when the token expires, and
|
||||
// from redeem the digest of the public keys presented (KeysDigest). No secret and nothing derived from the token.
|
||||
type Enrolment struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
Address string `json:"address"`
|
||||
Why string `json:"why"`
|
||||
Caller string `json:"caller"`
|
||||
Ask string `json:"ask,omitempty"`
|
||||
Stage string `json:"stage"`
|
||||
Issued time.Time `json:"issued"`
|
||||
Expires time.Time `json:"expires"`
|
||||
KeysDigest string `json:"keys-digest,omitempty"`
|
||||
// OverlayKey is the machine's tunnel public key, given at issue (the operator's decision of 2026-11-02 01:05),
|
||||
// base64 of 32 bytes: at every stage, since the node enters placement with it on the operator's yes.
|
||||
OverlayKey string `json:"overlay-key"`
|
||||
}
|
||||
|
||||
// EnrolmentAddress is one key `address.<address>`: the node the address is reserved for and, from when the issue's ask
|
||||
// is raised, that ask's id and the issue's time (ADR 0589 §6), so the reservation names the enrolment it holds the
|
||||
// address for. The key is written when the address is reserved, before the ask: Ask and Issued are then both empty, and
|
||||
// from the ask both set; one without the other is refused.
|
||||
type EnrolmentAddress struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
Ask string `json:"ask,omitempty"`
|
||||
Issued time.Time `json:"issued,omitzero"`
|
||||
}
|
||||
|
||||
func (a EnrolmentAddress) check() error {
|
||||
if err := checkName("node", a.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
if (strings.TrimSpace(a.Ask) == "") != a.Issued.IsZero() {
|
||||
return fmt.Errorf("delivery: the address reserved for %s names its ask without the issue's time, or the reverse", a.Node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnrolmentIssuedID is the id of the enrolment-issued event of node's enrolment issued at issued:
|
||||
// `enrolment-issued.<node>.<issue time in Unix nanoseconds>`, what enrolment-expired names (EnrolmentExpiry).
|
||||
func EnrolmentIssuedID(node string, issued time.Time) string {
|
||||
return catalogue.EnrolmentIssuedEvent + "." + node + "." + strconv.FormatInt(issued.UnixNano(), 10)
|
||||
}
|
||||
|
||||
// EnrolmentNodeKey is a node's key in `enrolments`.
|
||||
func EnrolmentNodeKey(node string) string { return enrolmentNodePrefix + node }
|
||||
|
||||
// EnrolmentAddressKey is an address's key in `enrolments`: the address as it is, its dots kept (a key's tokens).
|
||||
func EnrolmentAddressKey(address string) string { return enrolmentAddressPrefix + address }
|
||||
|
||||
// EnrolmentKeyNode is the node a key `node.<node>` names, and whether it is one.
|
||||
func EnrolmentKeyNode(key string) (string, bool) {
|
||||
node, ok := strings.CutPrefix(key, enrolmentNodePrefix)
|
||||
return node, ok && checkName("node", node) == nil
|
||||
}
|
||||
|
||||
// EnrolmentKeyAddress is the address a key `address.<address>` names, and whether it is one.
|
||||
func EnrolmentKeyAddress(key string) (string, bool) {
|
||||
address, ok := strings.CutPrefix(key, enrolmentAddressPrefix)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
_, err := netip.ParseAddr(address)
|
||||
return address, err == nil
|
||||
}
|
||||
|
||||
// EnrolmentUser is the bus user a node enrols as.
|
||||
func EnrolmentUser(node string) string { return EnrolmentUserPrefix + node }
|
||||
|
||||
func (e Enrolment) check() error {
|
||||
if err := checkName("node", e.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := netip.ParseAddr(e.Address); err != nil {
|
||||
return fmt.Errorf("delivery: the enrolment of %s names the address %q, which is not an address", e.Node, e.Address)
|
||||
}
|
||||
if strings.TrimSpace(e.Why) == "" || strings.TrimSpace(e.Caller) == "" {
|
||||
return fmt.Errorf("delivery: the enrolment of %s names no why or no caller", e.Node)
|
||||
}
|
||||
if k, err := base64.StdEncoding.DecodeString(e.OverlayKey); err != nil || len(k) != 32 {
|
||||
return fmt.Errorf("delivery: the enrolment of %s names no tunnel key of 32 bytes in base64", e.Node)
|
||||
}
|
||||
switch e.Stage {
|
||||
case EnrolmentAsked, EnrolmentLive:
|
||||
if e.KeysDigest != "" {
|
||||
return fmt.Errorf("delivery: the enrolment of %s at %s names presented keys, which only a redeem gives", e.Node, e.Stage)
|
||||
}
|
||||
case EnrolmentRedeeming:
|
||||
if err := checkDigest(e.KeysDigest, "the enrolment's keys digest"); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("delivery: the enrolment of %s is at stage %q, not asked, live or redeeming", e.Node, e.Stage)
|
||||
}
|
||||
if e.Issued.IsZero() || !e.Expires.After(e.Issued) {
|
||||
return fmt.Errorf("delivery: the enrolment of %s names no issue or an expiry not after it", e.Node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeEnrolment is e as JSON at Version; it refuses what DecodeEnrolment refuses.
|
||||
func EncodeEnrolment(e Enrolment) ([]byte, error) {
|
||||
if err := e.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.V = Version
|
||||
return json.Marshal(e)
|
||||
}
|
||||
|
||||
// DecodeEnrolment reads a key `node.<node>`; it refuses an unknown version (ErrUnknownVersion), a node that is not a
|
||||
// name, an address that is not one, an unknown stage, presented keys before redeem or none at it, and an expiry not
|
||||
// after the issue.
|
||||
func DecodeEnrolment(data []byte) (Enrolment, error) {
|
||||
var e Enrolment
|
||||
if err := decode(data, &e); err != nil {
|
||||
return Enrolment{}, err
|
||||
}
|
||||
if err := e.check(); err != nil {
|
||||
return Enrolment{}, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// EncodeEnrolmentAddress is a key `address.<address>` reserving it for node: with no ask and a zero issued when it is
|
||||
// reserved, with the issue's ask and time once the ask is raised. It refuses what DecodeEnrolmentAddress refuses.
|
||||
func EncodeEnrolmentAddress(node, ask string, issued time.Time) ([]byte, error) {
|
||||
a := EnrolmentAddress{V: Version, Node: node, Ask: ask, Issued: issued}
|
||||
if err := a.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
// DecodeEnrolmentAddress reads a key `address.<address>`; it refuses no node, and an ask without the issue's time or
|
||||
// the reverse.
|
||||
func DecodeEnrolmentAddress(data []byte) (EnrolmentAddress, error) {
|
||||
var a EnrolmentAddress
|
||||
if err := decode(data, &a); err != nil {
|
||||
return EnrolmentAddress{}, err
|
||||
}
|
||||
if err := a.check(); err != nil {
|
||||
return EnrolmentAddress{}, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// TokenLife is the life issue's `expires` names: DefaultTokenLife when empty, refused when it is not a duration, not
|
||||
// positive or longer than MostTokenLife.
|
||||
func TokenLife(expires string) (time.Duration, error) {
|
||||
if strings.TrimSpace(expires) == "" {
|
||||
return DefaultTokenLife, nil
|
||||
}
|
||||
d, err := time.ParseDuration(strings.TrimSpace(expires))
|
||||
if err != nil || d <= 0 || d > MostTokenLife {
|
||||
return 0, fmt.Errorf("delivery: a token lasts a duration up to %s, such as 1h, not %q", MostTokenLife, expires)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// EnrolmentExpiry is what enrolment-expired carries (§2): the node, and the enrolment-issued event that carried the
|
||||
// key's content, named by its message id (the bundle's publish answers no stream sequence, and §2 asks only that the
|
||||
// event be named), since the bus deleted the key before the holder heard of it.
|
||||
type EnrolmentExpiry struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
IssuedID string `json:"issued-event"`
|
||||
}
|
||||
|
||||
func (x EnrolmentExpiry) check() error {
|
||||
if err := checkName("node", x.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(x.IssuedID) == "" {
|
||||
return fmt.Errorf("delivery: the expiry of %s's enrolment names no enrolment-issued event", x.Node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeEnrolmentExpiry is x as JSON at Version; it refuses what DecodeEnrolmentExpiry refuses.
|
||||
func EncodeEnrolmentExpiry(x EnrolmentExpiry) ([]byte, error) {
|
||||
if err := x.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x.V = Version
|
||||
return json.Marshal(x)
|
||||
}
|
||||
|
||||
// DecodeEnrolmentExpiry reads enrolment-expired's body; it refuses an unknown version, a node that is not a name and
|
||||
// no issued event named.
|
||||
func DecodeEnrolmentExpiry(data []byte) (EnrolmentExpiry, error) {
|
||||
var x EnrolmentExpiry
|
||||
if err := decode(data, &x); err != nil {
|
||||
return EnrolmentExpiry{}, err
|
||||
}
|
||||
if err := x.check(); err != nil {
|
||||
return EnrolmentExpiry{}, err
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
// **Redeem** (§6): what a joining node-engine sends, and what the holder answers. Shared by the holder and the engine,
|
||||
// which live in two repositories.
|
||||
|
||||
// Redeem is the redeem verb's call: the node, the public halves of its keys and its identity key's signature over
|
||||
// RedeemProof. No token: the bus's login check verified it.
|
||||
type Redeem struct {
|
||||
Node string `json:"node"`
|
||||
PublicKey string `json:"public_key"`
|
||||
OverlayKey string `json:"overlay_key,omitempty"`
|
||||
SealingKey string `json:"sealing_key"`
|
||||
ServingKey string `json:"serving_key,omitempty"`
|
||||
Profile map[string]any `json:"profile,omitempty"`
|
||||
Tunnel json.RawMessage `json:"tunnel,omitempty"`
|
||||
Proof string `json:"proof"`
|
||||
}
|
||||
|
||||
// RedeemProof is the bytes a node signs with its identity key when it redeems: its name and every public key it
|
||||
// presents, so a proof cannot be moved to another node or other keys. The holder builds the same bytes.
|
||||
func RedeemProof(node, public, overlay, sealing, serving string) []byte {
|
||||
return []byte("novox-mesh-redeem v1\x00" + node + "\x00" + public + "\x00" + overlay + "\x00" + sealing + "\x00" + serving)
|
||||
}
|
||||
|
||||
// KeysDigest is the digest of the public keys a redeem presents, `sha256:<hex>`: what the key at redeeming and the
|
||||
// events carry, the one hash an event carries (§1).
|
||||
func KeysDigest(public, overlay, sealing, serving string) string {
|
||||
sum := sha256.Sum256([]byte(strings.Join([]string{public, overlay, sealing, serving}, "\x00")))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// KeysDigest is r's keys' digest.
|
||||
func (r Redeem) KeysDigest() string {
|
||||
return KeysDigest(r.PublicKey, r.OverlayKey, r.SealingKey, r.ServingKey)
|
||||
}
|
||||
|
||||
// ProofBytes is r's proof decoded from base64.
|
||||
func (r Redeem) ProofBytes() ([]byte, error) { return base64.StdEncoding.DecodeString(r.Proof) }
|
||||
|
||||
// RedeemAnswer is what redeem answers (§6): the node, the login it dials as next, its account sealed to the sealing key it
|
||||
// presented (base64), its certificate as PEM, the vault's public signing half (base64), and the vault's signature over
|
||||
// SignedBytes under the context of KindEnrolment. The engine refuses an answer not signed by the key its token carries.
|
||||
type RedeemAnswer struct {
|
||||
Node string `json:"node"`
|
||||
Login string `json:"login"`
|
||||
Account string `json:"account"`
|
||||
Certificate string `json:"certificate"`
|
||||
Signer string `json:"signer"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
}
|
||||
|
||||
// SignedBytes is what the vault signs of a: a without its signature, as JSON.
|
||||
func (a RedeemAnswer) SignedBytes() []byte {
|
||||
a.Signature = ""
|
||||
raw, _ := json.Marshal(a)
|
||||
return raw
|
||||
}
|
||||
|
||||
// **The vault's enrolment verbs** (ADR 0561 §5, ADR 0587 §3, §5), asked by mesh-enrolment alone.
|
||||
const (
|
||||
EnrolmentTokenVerb = "enrolment-token"
|
||||
NodeAccountVerb = "node-account"
|
||||
)
|
||||
|
||||
// EnrolmentTokenAnswer is what enrolment-token answers: what the joining machine must trust (the bus's address, its
|
||||
// certificate's fingerprint, the vault's public signing key in base64), never the token; Sealed holds the token sealed
|
||||
// to the operator key when asked with sealed, relayed by tokens as it is; Dropped says a drop left neither the token
|
||||
// nor its hash.
|
||||
type EnrolmentTokenAnswer struct {
|
||||
Node string `json:"node"`
|
||||
Broker string `json:"broker,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Signer string `json:"signer,omitempty"`
|
||||
Sealed string `json:"sealed,omitempty"`
|
||||
Dropped bool `json:"dropped,omitempty"`
|
||||
}
|
||||
|
||||
// NodeAccountAnswer is what node-account answers: the login minted, the account sealed to the sealing key the node
|
||||
// presented (base64), never in the clear; Retired says a retire left no account.
|
||||
type NodeAccountAnswer struct {
|
||||
Node string `json:"node"`
|
||||
Login string `json:"login,omitempty"`
|
||||
Account string `json:"account,omitempty"`
|
||||
Retired bool `json:"retired,omitempty"`
|
||||
}
|
||||
@@ -356,17 +356,16 @@ func TestGateSame(t *testing.T) { // 1201/AC6
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateBuckets(t *testing.T) {
|
||||
// today's keys: names of letters, digits, - / and _ key exactly as before the escape.
|
||||
func TestKeysOfPlainNamesAreUnchanged(t *testing.T) { // 1323/AC2
|
||||
// And the buckets and their history are the decided layout (once TestGateBuckets; merged to pay for issue 774's
|
||||
// token test).
|
||||
if MergesBucket != "mesh-delivery-gate_merges" || AsksBucket != "mesh-delivery-gate_asks" ||
|
||||
OwedBucket != "mesh-delivery-gate_owed" || AnswersBucket != "mesh-delivery-teller_answers" ||
|
||||
MergesHistory != 10 || AsksHistory != 5 || OwedHistory != 5 || AnswersHistory != 5 ||
|
||||
TurnsBucket != "mesh-delivery-gate_turns" || TurnsHistory != 5 {
|
||||
t.Error("a bucket or history differs from the decided layout")
|
||||
}
|
||||
}
|
||||
|
||||
// today's keys: names of letters, digits, - / and _ key exactly as before the escape.
|
||||
func TestKeysOfPlainNamesAreUnchanged(t *testing.T) { // 1323/AC2
|
||||
hh, _ := HeadsHash(gateHeads())
|
||||
for _, name := range []string{"feat/x", "fix/1323-escape", "feat/delivery_source-keys", "main", "A/B_c-9"} {
|
||||
if k, err := MergesKey(name); err != nil || k != name {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A node's two logins** (novox/hq ADR 0583, issue 774). A node's bus account rotates between the login `node.<n>` and
|
||||
// `node.<n>--b`, each with the node's permissions and its inbox `_INBOX.node.<n>` unchanged (§1). Which one a node uses
|
||||
// is the vault's state `node-logins`, one key per node (§1, insight 2026-11-01), written by the vault alone: the login
|
||||
// in use, the login rotating if any, and the rotation's stage. Each node-engine reads its own node's key alone; the
|
||||
// grants part reads them all and composes both logins while a node rotates (§2). The key lives while the node does:
|
||||
// its rotating part is cleared at rotation-done or abandon, and the key deleted when the node leaves placement.
|
||||
const (
|
||||
NodeLoginsState = "node-logins"
|
||||
NodeLoginsBucket = VaultModule + "_" + NodeLoginsState
|
||||
NodeLoginsRead = VaultModule + "." + NodeLoginsState
|
||||
|
||||
// The stages of a rotation (§2-§4): begun when the vault made the other login's value and wrote it rotating; ready
|
||||
// when the bus node's have shows a list holding both, said node-account-ready; confirmed when the engine called
|
||||
// node-account-confirm as the new login. Done (rotation-done) is no stage: the rotating part is cleared.
|
||||
RotationBegun = "begun"
|
||||
RotationReady = "ready"
|
||||
RotationConfirmed = "confirmed"
|
||||
|
||||
// The vault's rotation verbs on its seat (§2, §3, §5).
|
||||
RotateNodeVerb = "rotate-node"
|
||||
NodeAccountConfirmVerb = "node-account-confirm"
|
||||
AbandonVerb = "abandon"
|
||||
|
||||
// The vault's events of a rotation, said as its own (§2, §4), each carrying the node's NodeLogin.
|
||||
RotationBegunEvent = "rotation-begun"
|
||||
NodeAccountReadyEvent = "node-account-ready"
|
||||
RotationDoneEvent = "rotation-done"
|
||||
|
||||
// NodeAccountFallbackFinding is what a node-engine says when it fell back to its old login after repeated
|
||||
// authentication failures with the new one, before rotation-done; the watchman raises it (§6).
|
||||
NodeAccountFallbackFinding = "node-account-fallback"
|
||||
|
||||
nodeLoginPrefix = "node."
|
||||
// SecondLoginSuffix marks a node's second login, as a module's account rotates over `--b` (ADR 0114).
|
||||
SecondLoginSuffix = "--b"
|
||||
)
|
||||
|
||||
// RotationEventSubject is where the vault says event of a rotation.
|
||||
func RotationEventSubject(event string) string { return "mesh.mod." + VaultModule + ".event." + event }
|
||||
|
||||
// NodeLogin is one key of `node-logins`, the node's name: the login in use, the login rotating to (empty when none, and
|
||||
// then no stage), the rotation's stage, since when the key says what it says, and the user list's revision that held
|
||||
// both logins, from ready on.
|
||||
type NodeLogin struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
Login string `json:"login"`
|
||||
Rotating string `json:"rotating,omitempty"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
Since time.Time `json:"since"`
|
||||
ListRevision uint64 `json:"list-revision,omitempty"`
|
||||
// Acts are the hand-act ids that warranted the node's rotations, oldest first (ADR 0583 §2): the vault records each.
|
||||
Acts []string `json:"acts,omitempty"`
|
||||
}
|
||||
|
||||
// FirstLogin is a node's first login, `node.<n>`, the one every node had before rotation; SecondLogin its other.
|
||||
func FirstLogin(node string) string { return nodeLoginPrefix + node }
|
||||
func SecondLogin(node string) string { return nodeLoginPrefix + node + SecondLoginSuffix }
|
||||
|
||||
// OtherLogin is the login a node rotates to from login: the second from the first, the first from the second.
|
||||
func OtherLogin(node, login string) string {
|
||||
if login == SecondLogin(node) {
|
||||
return FirstLogin(node)
|
||||
}
|
||||
return SecondLogin(node)
|
||||
}
|
||||
|
||||
// LoginNode is the node a node login names, either of its two, and whether login is one.
|
||||
func LoginNode(login string) (string, bool) {
|
||||
rest, ok := strings.CutPrefix(login, nodeLoginPrefix)
|
||||
rest = strings.TrimSuffix(rest, SecondLoginSuffix)
|
||||
return rest, ok && checkName("node", rest) == nil
|
||||
}
|
||||
|
||||
// Rotates says the node is rotating: a login rotating is named.
|
||||
func (l NodeLogin) Rotates() bool { return l.Rotating != "" }
|
||||
|
||||
func (l NodeLogin) check() error {
|
||||
if err := checkName("node", l.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
if l.Login != FirstLogin(l.Node) && l.Login != SecondLogin(l.Node) {
|
||||
return fmt.Errorf("delivery: the node %s logs in as %q, neither of its two logins", l.Node, l.Login)
|
||||
}
|
||||
for _, act := range l.Acts {
|
||||
if strings.TrimSpace(act) == "" {
|
||||
return fmt.Errorf("delivery: the node %s names an empty hand act among its rotations' warrants", l.Node)
|
||||
}
|
||||
}
|
||||
if !l.Rotates() {
|
||||
if l.Stage != "" || l.ListRevision != 0 {
|
||||
return fmt.Errorf("delivery: the node %s rotates to nothing and names stage %q", l.Node, l.Stage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if l.Rotating != OtherLogin(l.Node, l.Login) {
|
||||
return fmt.Errorf("delivery: the node %s rotates from %s to %q, not its other login", l.Node, l.Login, l.Rotating)
|
||||
}
|
||||
switch l.Stage {
|
||||
case RotationBegun, RotationReady, RotationConfirmed:
|
||||
default:
|
||||
return fmt.Errorf("delivery: the node %s's rotation is at stage %q, not begun, ready or confirmed", l.Node, l.Stage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeNodeLogin is l as JSON at Version; it refuses what DecodeNodeLogin refuses.
|
||||
func EncodeNodeLogin(l NodeLogin) ([]byte, error) {
|
||||
if err := l.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.V = Version
|
||||
return json.Marshal(l)
|
||||
}
|
||||
|
||||
// DecodeNodeLogin reads a key of `node-logins`; it refuses an unknown version (ErrUnknownVersion), a login that is not
|
||||
// one of the node's two, a rotation to anything but the other, and a stage without a rotation or an unknown one. An
|
||||
// empty rotating reads as none.
|
||||
func DecodeNodeLogin(data []byte) (NodeLogin, error) {
|
||||
var l NodeLogin
|
||||
if err := decode(data, &l); err != nil {
|
||||
return NodeLogin{}, err
|
||||
}
|
||||
if err := l.check(); err != nil {
|
||||
return NodeLogin{}, err
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
// what it says changed. The watchman (seat mesh-watchdogs) reads it for six alerts the controller raised from the
|
||||
// passing health message: machine-network (Network), network-rewritten (the resolver file's part, Writer and Owner),
|
||||
// network-unreachable (Peers, read across every node's report), machine-units (Units), relogin-needed (Relogin) and
|
||||
// directory-used-as-found (Found). Moved here from mesh-host internal/link (65fbc1e6) unchanged; the engine builds a
|
||||
// report from its own health statement there (NodeHealthOf).
|
||||
// directory-used-as-found (Found); and for node-account-fallback (Fallback, ADR 0583 §6). Moved here from mesh-host
|
||||
// internal/link (65fbc1e6); the engine builds a report from its own health statement there (NodeHealthOf).
|
||||
|
||||
// NodeHealthState and NodeHealthBucket are the node-engine's state health, as a reader names it and as the bus does.
|
||||
const (
|
||||
@@ -40,6 +40,10 @@ type NodeHealth struct {
|
||||
Relogin []NodeAccount `json:"relogin,omitempty"`
|
||||
// Found are the directories used as found.
|
||||
Found []NodeDirectory `json:"found,omitempty"`
|
||||
// Fallback is set when the engine fell back to its node's old login after the new one failed its test-dial or its
|
||||
// confirm (novox/hq ADR 0583 §6): the watchman raises node-account-fallback from it. Nil while the node logs in as
|
||||
// its login in force.
|
||||
Fallback *LoginFallback `json:"fallback,omitempty"`
|
||||
// Root are the engine's verdicts on each account a module declares never to become root without a person (novox/hq
|
||||
// ADR 0266), sorted: the agent account's among them where a module declares it. What the self-check's DA and D-root
|
||||
// judge an agent's confinement by, on the machine's own word.
|
||||
@@ -51,6 +55,15 @@ type NodeHealth struct {
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// LoginFallback is a node's fall back to its old login (ADR 0583 §6): the node, the login it fell back from (the one the
|
||||
// rotation moved to), why, in words with no credential, and when.
|
||||
type LoginFallback struct {
|
||||
Node string `json:"node"`
|
||||
From string `json:"from"`
|
||||
Why string `json:"why"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// NetworkHealth is the machine's networking (ADR 0241): the worst of its parts, since when, and each part.
|
||||
type NetworkHealth struct {
|
||||
State string `json:"state"`
|
||||
@@ -150,6 +163,9 @@ func EncodeNodeHealth(h NodeHealth) ([]byte, error) {
|
||||
if h.Node == "" {
|
||||
return nil, errors.New("a node's health report names its node")
|
||||
}
|
||||
if f := h.Fallback; f != nil && (f.Node != h.Node || f.From == "" || f.Why == "" || f.At.IsZero()) {
|
||||
return nil, fmt.Errorf("a node's fall back to its old login names its node %s, the login fallen back from, why and when", h.Node)
|
||||
}
|
||||
return json.Marshal(h)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
@@ -13,7 +16,7 @@ import (
|
||||
// check (ADR 0201 §13) refuses such a field in a state every node reads.
|
||||
var secretNamed = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|private[-_]?key|credential|bearer|cookie|salt|signing)`)
|
||||
|
||||
// Issue 1126 (ADR 0555): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and
|
||||
// Issue 1126 (ADR 0555), and issue 774 for the enrolment's keys (ADR 0587 §2): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and
|
||||
// neither it nor the placement file names a field like a secret.
|
||||
func TestNodeRecord(t *testing.T) {
|
||||
hub := NodeRecord{Node: "novox", TunnelKey: "k", Capabilities: []string{"docker"}, SealingKey: "age1x", ServingKey: "pub",
|
||||
@@ -68,6 +71,143 @@ func TestNodeRecord(t *testing.T) {
|
||||
}
|
||||
}
|
||||
walk(reflect.TypeOf(NodeRecord{}), "NodeRecord")
|
||||
// 774/S35/AC1: an enrolment key and an address key read back equal, and neither names a field like a secret: the
|
||||
// grants part reads them (ADR 0587 §2).
|
||||
issued := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC)
|
||||
// The tunnel key given at issue, at every stage.
|
||||
wg := base64.StdEncoding.EncodeToString(make([]byte, 32))
|
||||
for _, e := range []Enrolment{
|
||||
{Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentLive,
|
||||
OverlayKey: wg, Issued: issued, Expires: issued.Add(DefaultTokenLife)},
|
||||
{Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentRedeeming,
|
||||
OverlayKey: wg, Issued: issued, Expires: issued.Add(MostTokenLife), KeysDigest: KeysDigest("pub", "overlay", "sealing", "serving")},
|
||||
} {
|
||||
raw, err := EncodeEnrolment(e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := DecodeEnrolment(raw)
|
||||
e.V = Version
|
||||
if err != nil || !reflect.DeepEqual(back, e) {
|
||||
t.Errorf("enrolment round trip: %+v, %v", back, err)
|
||||
}
|
||||
}
|
||||
// ADR 0589 §6: reserved before the ask with neither ask nor issue time, and from the ask with both.
|
||||
if raw, err := EncodeEnrolmentAddress("lab", "", time.Time{}); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Node != "lab" || back.Ask != "" || strings.Contains(string(raw), "issued") {
|
||||
t.Errorf("address round trip: %+v, %v (%s)", back, err, raw)
|
||||
}
|
||||
if raw, err := EncodeEnrolmentAddress("lab", "a1", issued); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Ask != "a1" || !back.Issued.Equal(issued) {
|
||||
t.Errorf("address at the ask round trip: %+v, %v", back, err)
|
||||
}
|
||||
for name, raw := range map[string]string{
|
||||
"no node": `{"v":1}`,
|
||||
"an ask with no issue time": `{"v":1,"node":"lab","ask":"a1"}`,
|
||||
"an issue time with no ask": `{"v":1,"node":"lab","issued":"2026-11-02T01:00:00Z"}`,
|
||||
} {
|
||||
if _, err := DecodeEnrolmentAddress([]byte(raw)); err == nil {
|
||||
t.Errorf("an address with %s: taken", name)
|
||||
}
|
||||
}
|
||||
if id := EnrolmentIssuedID("lab", issued); id != "enrolment-issued.lab."+strconv.FormatInt(issued.UnixNano(), 10) {
|
||||
t.Errorf("the issued event's id: %q", id)
|
||||
}
|
||||
for name, e := range map[string]Enrolment{
|
||||
"keys before redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentLive,
|
||||
OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour), KeysDigest: KeysDigest("a", "b", "c", "d")},
|
||||
"no keys at redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentRedeeming,
|
||||
OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour)},
|
||||
"no address": {Node: "lab", Why: "w", Caller: "c", Stage: EnrolmentAsked, OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour)},
|
||||
"no tunnel key": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentAsked, Issued: issued,
|
||||
Expires: issued.Add(time.Hour)},
|
||||
"a tunnel key not of 32 bytes": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentAsked,
|
||||
OverlayKey: base64.StdEncoding.EncodeToString(make([]byte, 31)), Issued: issued, Expires: issued.Add(time.Hour)},
|
||||
"other stage": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: "spent", OverlayKey: wg, Issued: issued,
|
||||
Expires: issued.Add(time.Hour)},
|
||||
} {
|
||||
if _, err := EncodeEnrolment(e); err == nil {
|
||||
t.Errorf("enrolment with %s: taken", name)
|
||||
}
|
||||
}
|
||||
if key, ok := EnrolmentKeyAddress(EnrolmentAddressKey("10.0.0.7")); !ok || key != "10.0.0.7" {
|
||||
t.Errorf("address key: %q %v", key, ok)
|
||||
}
|
||||
if node, ok := EnrolmentKeyNode(EnrolmentNodeKey("lab")); !ok || node != "lab" {
|
||||
t.Errorf("node key: %q %v", node, ok)
|
||||
}
|
||||
for _, life := range []string{"", "1h", "24h"} {
|
||||
if _, err := TokenLife(life); err != nil {
|
||||
t.Errorf("a life of %q: %v", life, err)
|
||||
}
|
||||
}
|
||||
for _, life := range []string{"25h", "-1h", "a day"} {
|
||||
if _, err := TokenLife(life); err == nil {
|
||||
t.Errorf("a life of %q: taken", life)
|
||||
}
|
||||
}
|
||||
// 774/S38/AC1: a node's key of node-logins reads back equal, an empty rotating reads as none, and a stage without a
|
||||
// rotation, a rotation to anything but the other login, or a login not the node's is refused (ADR 0583 §1).
|
||||
for _, l := range []NodeLogin{
|
||||
{Node: "g14", Login: "node.g14", Since: issued},
|
||||
{Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: RotationReady, Since: issued, ListRevision: 41},
|
||||
{Node: "g14", Login: "node.g14--b", Rotating: "node.g14", Stage: RotationBegun, Since: issued},
|
||||
} {
|
||||
raw, err := EncodeNodeLogin(l)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := DecodeNodeLogin(raw)
|
||||
l.V = Version
|
||||
if err != nil || !reflect.DeepEqual(back, l) {
|
||||
t.Errorf("node login round trip: %+v, %v", back, err)
|
||||
}
|
||||
}
|
||||
// The hand acts that warranted its rotations, as the vault records them.
|
||||
if raw, err := EncodeNodeLogin(NodeLogin{Node: "g14", Login: "node.g14--b", Acts: []string{"act-1"}}); err != nil ||
|
||||
!strings.Contains(string(raw), `"acts":["act-1"]`) {
|
||||
t.Errorf("a login with its acts: %s, %v", raw, err)
|
||||
}
|
||||
if l, err := DecodeNodeLogin([]byte(`{"v":1,"node":"g14","login":"node.g14","rotating":"","since":"2026-11-02T01:00:00Z"}`)); err != nil || l.Rotates() {
|
||||
t.Errorf("an empty rotating: %+v, %v", l, err)
|
||||
}
|
||||
for name, l := range map[string]NodeLogin{
|
||||
"a stage, no rotation": {Node: "g14", Login: "node.g14", Stage: RotationBegun},
|
||||
"another node's login": {Node: "g14", Login: "node.ace"},
|
||||
"a rotation to itself": {Node: "g14", Login: "node.g14", Rotating: "node.g14", Stage: RotationBegun},
|
||||
"a rotation, no stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b"},
|
||||
"a rotation, done stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: "done"},
|
||||
"an empty act": {Node: "g14", Login: "node.g14", Acts: []string{""}},
|
||||
} {
|
||||
if _, err := EncodeNodeLogin(l); err == nil {
|
||||
t.Errorf("node login with %s: taken", name)
|
||||
}
|
||||
}
|
||||
if node, ok := LoginNode("node.g14--b"); !ok || node != "g14" || OtherLogin("g14", "node.g14--b") != "node.g14" {
|
||||
t.Errorf("the second login's node: %q %v", node, ok)
|
||||
}
|
||||
walk(reflect.TypeOf(NodeLogin{}), "NodeLogin")
|
||||
walk(reflect.TypeOf(Enrolment{}), "Enrolment")
|
||||
walk(reflect.TypeOf(EnrolmentAddress{}), "EnrolmentAddress")
|
||||
walk(reflect.TypeOf(EnrolmentExpiry{}), "EnrolmentExpiry")
|
||||
// enrolment-expired names the issued event by its message id (§2), never a stream sequence.
|
||||
if raw, err := EncodeEnrolmentExpiry(EnrolmentExpiry{Node: "lab", IssuedID: "enrolment-issued.lab.1"}); err != nil {
|
||||
t.Errorf("an expiry: %v", err)
|
||||
} else if x, err := DecodeEnrolmentExpiry(raw); err != nil || x.IssuedID != "enrolment-issued.lab.1" ||
|
||||
!strings.Contains(string(raw), `"issued-event":"enrolment-issued.lab.1"`) {
|
||||
t.Errorf("an expiry reads back as %+v, %v (%s)", x, err, raw)
|
||||
}
|
||||
for name, raw := range map[string]string{
|
||||
"no issued event": `{"v":1,"node":"lab"}`,
|
||||
"a sequence for it": `{"v":1,"node":"lab","issued-event":7}`,
|
||||
"a node not a name": `{"v":1,"node":"Lab Pi","issued-event":"e"}`,
|
||||
} {
|
||||
if _, err := DecodeEnrolmentExpiry([]byte(raw)); err == nil {
|
||||
t.Errorf("an expiry with %s: taken", name)
|
||||
}
|
||||
}
|
||||
// And the placement file, which holds what a person decides of a node (K2): accounts by name, never a credential.
|
||||
walk(reflect.TypeOf(Placement{}), "Placement")
|
||||
}
|
||||
|
||||
@@ -65,6 +65,13 @@ const (
|
||||
OpDecide = "decide" // set a decided field of the node (NodeDecided by its JSON name), its address among them
|
||||
OpConfig = "config" // set a module's config placement or values on the node
|
||||
OpSeed = "seed" // the node's whole placement: the take-over (adopt, §7), never given by a caller
|
||||
// OpAddNode adds a joining node with its decided fields and the public halves of its keys, on the warrant of the
|
||||
// enrolment that issued its token (novox/hq ADR 0587 §7): mesh-enrolment's alone, the proposal's one change.
|
||||
OpAddNode = "add-node"
|
||||
// OpRemoveNode takes a joining node off placement again when its enrolment ends unconfirmed, revoked or expired
|
||||
// (the operator's decision of 2026-11-02 01:05): mesh-enrolment's alone, on the same warrant, the proposal's one
|
||||
// change, of a node with no module and no holding. The holder deletes its key (RemovedNodes).
|
||||
OpRemoveNode = "remove-node"
|
||||
)
|
||||
|
||||
// The states of an open proposal.
|
||||
@@ -107,31 +114,34 @@ type PlacementProposalChange struct {
|
||||
Values map[string]any `json:"values,omitempty"`
|
||||
// Whole is the node's whole placement (OpSeed).
|
||||
Whole *Placement `json:"whole,omitempty"`
|
||||
// Decided and Keys are a joining node's (OpAddNode): its decided fields, the address the operator approved among
|
||||
// them, and the public halves of its keys: at the operator's yes its tunnel key alone, given at issue (the operator's
|
||||
// decision of 2026-11-02 01:05); the identity keys are bound at redeem, checked against the node account's digest.
|
||||
Decided *NodeDecided `json:"decided,omitempty"`
|
||||
Keys *JoiningKeys `json:"keys,omitempty"`
|
||||
}
|
||||
|
||||
// BeforeLease is why the change cannot be proposed before the lease write, or "" (ADR 0567 §8): until then the holder
|
||||
// keeps the controller's assignments in step through its verbs assign, unassign, pin and unpin, so only those are
|
||||
// proposable — a module placed or taken off with no host port or binding, a pin set or removed, a module's config values
|
||||
// and the seed. A host port, a binding, a holding, a decided field and a config layer's places or accesses wait for the
|
||||
// lease write, which the refusal names.
|
||||
func (c PlacementProposalChange) BeforeLease() string {
|
||||
const lease = "which the controller's verbs cannot take from a caller, so it waits for the lease write (ADR 0567 §8)"
|
||||
switch c.Op {
|
||||
case OpPlace:
|
||||
if len(c.Ports)+len(c.Bindings) > 0 {
|
||||
return fmt.Sprintf("the place of %s on %s names host ports or bindings, %s", c.Module, c.Node, lease)
|
||||
}
|
||||
case OpTakeOff, OpPin, OpUnpin, OpSeed:
|
||||
case OpConfig:
|
||||
// A module's config values are the state's alone (§13); its layers' places and accesses are the controller's
|
||||
// until the lease write.
|
||||
if c.MeshWide != nil || c.Layer != nil {
|
||||
return fmt.Sprintf("the config of %s on %s sets a layer's places or accesses, %s", c.Module, c.Node, lease)
|
||||
}
|
||||
default:
|
||||
return fmt.Sprintf("a change %s of %s is no assignment or pin, %s", c.Op, c.Node, lease)
|
||||
}
|
||||
return ""
|
||||
// JoiningKeys are the public halves of a joining node's keys (ADR 0587 §6, §7): its private network (tunnel) key, given
|
||||
// at issue, and its identity, sealing and serving keys, presented at redeem, as Redeem carries them. An add-node at the
|
||||
// operator's yes carries the tunnel key alone. Public halves alone, never a private key.
|
||||
type JoiningKeys struct {
|
||||
Identity string `json:"identity"`
|
||||
Overlay string `json:"overlay,omitempty"`
|
||||
Sealing string `json:"sealing"`
|
||||
Serving string `json:"serving,omitempty"`
|
||||
}
|
||||
|
||||
// Digest is the keys' digest, as the enrolment's key holds it (KeysDigest).
|
||||
func (k JoiningKeys) Digest() string { return KeysDigest(k.Identity, k.Overlay, k.Sealing, k.Serving) }
|
||||
|
||||
// AddNodeWarrant is the warrant an add-node carries (ADR 0587 §7): the enrolment's node and address as its issue's ask
|
||||
// was bound to them, the ask's id, and the warrant's id as the teller wrote it. mesh-placement checks it names the
|
||||
// change's node and address, and that the caller is mesh-enrolment as the runtime proves it (AddNodeRefusal).
|
||||
type AddNodeWarrant struct {
|
||||
Node string `json:"node"`
|
||||
Address string `json:"address"`
|
||||
Ask string `json:"ask"`
|
||||
Warrant string `json:"warrant"`
|
||||
}
|
||||
|
||||
func (c PlacementProposalChange) check() error {
|
||||
@@ -160,8 +170,28 @@ func (c PlacementProposalChange) check() error {
|
||||
return fmt.Errorf("delivery: the seed of %s names no placement of that node", c.Node)
|
||||
}
|
||||
return c.Whole.check()
|
||||
case OpAddNode:
|
||||
if c.Decided == nil || c.Decided.Address == "" || c.Keys == nil || c.Keys.Overlay == "" {
|
||||
return fmt.Errorf("delivery: the add-node of %s names no address or no tunnel key", c.Node)
|
||||
}
|
||||
// The identity keys come at redeem, whole or not at all: an identity with no sealing key, or the reverse.
|
||||
if (c.Keys.Identity == "") != (c.Keys.Sealing == "") || (c.Keys.Identity == "" && c.Keys.Serving != "") {
|
||||
return fmt.Errorf("delivery: the add-node of %s names part of its identity keys; they come whole at redeem", c.Node)
|
||||
}
|
||||
if c.Decided.Hub {
|
||||
return fmt.Errorf("delivery: the add-node of %s makes it the hub, which a joining node never is", c.Node)
|
||||
}
|
||||
if c.Module != "" || len(c.Ports)+len(c.Bindings) > 0 || c.Whole != nil {
|
||||
return fmt.Errorf("delivery: the add-node of %s places a module; a module is placed by a proposal of its own", c.Node)
|
||||
}
|
||||
return nil
|
||||
case OpRemoveNode:
|
||||
if c.Module != "" || c.Decided != nil || c.Keys != nil || c.Whole != nil {
|
||||
return fmt.Errorf("delivery: the remove-node of %s names more than its node", c.Node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("delivery: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide or config", c.Op)
|
||||
return fmt.Errorf("delivery: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide, config, add-node or remove-node", c.Op)
|
||||
}
|
||||
|
||||
// LayChanges is the placements of the nodes the changes name with the changes laid over current, in the order given;
|
||||
@@ -200,6 +230,23 @@ func LayChanges(current map[string]Placement, changes []PlacementProposalChange)
|
||||
switch c.Op {
|
||||
case OpSeed:
|
||||
p = clonePlacement(*c.Whole)
|
||||
case OpAddNode:
|
||||
// A joining node has no key yet (ADR 0587 §7): one that has is no joining node.
|
||||
if _, has := current[c.Node]; has {
|
||||
findings = append(findings, fmt.Sprintf("change %d: %s is placed already, so it does not join", i+1, c.Node))
|
||||
continue
|
||||
}
|
||||
p = Placement{V: Version, Node: c.Node, Decided: *c.Decided, Modules: []PlacedModule{}}
|
||||
case OpRemoveNode:
|
||||
// Only a joining node never confirmed goes this way: one with no module and no holding (a node that runs
|
||||
// anything is taken off module by module, by a proposal the operator approves).
|
||||
cur, has := current[c.Node]
|
||||
if !has {
|
||||
findings = append(findings, fmt.Sprintf("change %d: %s is not placed, so there is nothing to remove", i+1, c.Node))
|
||||
} else if len(cur.Modules)+len(cur.Holds) > 0 {
|
||||
findings = append(findings, fmt.Sprintf("change %d: %s runs modules or holds seats, so it is no joining node to remove", i+1, c.Node))
|
||||
}
|
||||
continue
|
||||
case OpPlace:
|
||||
m := PlacedModule{Module: c.Module, Ports: c.Ports, Bindings: c.Bindings}
|
||||
if at >= 0 {
|
||||
@@ -403,6 +450,9 @@ type PlacementProposal struct {
|
||||
// Again is when `again` asked the holder to judge it now and raise its ask again (§10); the holder does once per
|
||||
// value.
|
||||
Again time.Time `json:"again,omitempty"`
|
||||
// Warrant is the enrolment's warrant an add-node carries (ADR 0587 §7): its Approve is the issue's, so the holder
|
||||
// writes it with no ask of its own. Only an add-node carries one.
|
||||
Warrant *AddNodeWarrant `json:"warrant,omitempty"`
|
||||
// Withdrawn is who withdrew it and why (`withdraw`, §2): the holder then deletes it and its candidates and says
|
||||
// placement-withdrawn, which only the holder's process can say on its stream.
|
||||
Withdrawn string `json:"withdrawn,omitempty"`
|
||||
@@ -444,9 +494,69 @@ func (p PlacementProposal) check() error {
|
||||
if p.Seed && p.ExportDigest == "" {
|
||||
return fmt.Errorf("delivery: the seed %s names no export digest", p.ID)
|
||||
}
|
||||
return p.checkAddNode()
|
||||
}
|
||||
|
||||
// checkAddNode refuses an add-node or a remove-node that is not the proposal's one change, carries no warrant, or whose
|
||||
// warrant names another node (or, for an add-node, another address) than the change; and a warrant on any other
|
||||
// proposal (ADR 0587 §7).
|
||||
func (p PlacementProposal) checkAddNode() error {
|
||||
adds := 0
|
||||
for _, c := range p.Changes {
|
||||
if c.Op == OpAddNode || c.Op == OpRemoveNode {
|
||||
adds++
|
||||
}
|
||||
}
|
||||
if adds == 0 {
|
||||
if p.Warrant != nil {
|
||||
return fmt.Errorf("delivery: the proposal %s carries an enrolment's warrant and adds no node", p.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if adds != 1 || len(p.Changes) != 1 || p.HandBack || p.Seed {
|
||||
return fmt.Errorf("delivery: the proposal %s adds a node beside other changes; an add-node is a proposal's one change", p.ID)
|
||||
}
|
||||
c, w := p.Changes[0], p.Warrant
|
||||
if w == nil || w.Ask == "" || w.Warrant == "" {
|
||||
return fmt.Errorf("delivery: the add-node of %s carries no warrant of its enrolment", c.Node)
|
||||
}
|
||||
if c.Op == OpRemoveNode {
|
||||
if w.Node != c.Node {
|
||||
return fmt.Errorf("delivery: the remove-node of %s carries the warrant of %s", c.Node, w.Node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if w.Node != c.Node || w.Address != c.Decided.Address {
|
||||
return fmt.Errorf("delivery: the add-node of %s at %s carries the warrant of %s at %s", c.Node, c.Decided.Address,
|
||||
w.Node, w.Address)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemovedNodes are the nodes whose key a proposal deletes (its remove-node), which LayChanges leaves out of what it lays.
|
||||
func (p PlacementProposal) RemovedNodes() []string {
|
||||
var out []string
|
||||
for _, c := range p.Changes {
|
||||
if c.Op == OpRemoveNode {
|
||||
out = append(out, c.Node)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AddNodeRefusal is why the holder of mesh-placement refuses an add-node or a remove-node from caller, the module the runtime proved
|
||||
// called (ADR 0587 §7), or "": only mesh-enrolment adds a node; what the proposal itself must hold, DecodePlacementProposal
|
||||
// refuses.
|
||||
func AddNodeRefusal(p PlacementProposal, caller string) string {
|
||||
if err := p.checkAddNode(); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if p.Warrant != nil && caller != EnrolmentModule {
|
||||
return fmt.Sprintf("an add-node or a remove-node is mesh-enrolment's alone, and %s proposed it", caller)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// EncodePlacementProposal is p as JSON at Version; it refuses what DecodePlacementProposal refuses.
|
||||
func EncodePlacementProposal(p PlacementProposal) ([]byte, error) {
|
||||
if err := p.check(); err != nil {
|
||||
|
||||
@@ -651,19 +651,6 @@ func TestPlacement(t *testing.T) {
|
||||
t.Errorf("%s: %v, want a finding naming %q", name, f, c.want)
|
||||
}
|
||||
}
|
||||
for change, waits := range map[*PlacementProposalChange]bool{
|
||||
{Op: OpPlace, Node: "g14", Module: "x"}: false, {Op: OpTakeOff, Node: "g14", Module: "x"}: false,
|
||||
{Op: OpPin, Node: "g14"}: false, {Op: OpUnpin, Node: "g14"}: false, {Op: OpSeed, Node: "g14"}: false,
|
||||
{Op: OpPlace, Node: "g14", Module: "x", Ports: map[string]int{"80": 80}}: true,
|
||||
{Op: OpPlace, Node: "g14", Module: "x", Bindings: map[string]Provider{"p": {}}}: true,
|
||||
{Op: OpHold, Node: "g14"}: true, {Op: OpDecide, Node: "g14"}: true,
|
||||
{Op: OpConfig, Node: "g14", Values: map[string]any{"puid": 1}}: false, {Op: OpConfig, Node: "g14", MeshValues: map[string]any{"a": 1}}: false,
|
||||
{Op: OpConfig, Node: "g14", Layer: map[string]any{"places": 1}}: true, {Op: OpConfig, Node: "g14", MeshWide: map[string]any{"accesses": 1}}: true,
|
||||
} {
|
||||
if got := change.BeforeLease(); (got != "") != waits || (waits && !strings.Contains(got, "lease write")) {
|
||||
t.Errorf("%+v before the lease write: %q", *change, got)
|
||||
}
|
||||
}
|
||||
|
||||
const id = "p-20261028t183000-1a2b3c"
|
||||
if got := NewProposalID(time.Date(2026, 10, 28, 18, 30, 0, 0, time.UTC)); !strings.HasPrefix(got, "p-20261028t183000-") || CheckProposalID(got) != nil {
|
||||
@@ -702,6 +689,92 @@ func TestPlacement(t *testing.T) {
|
||||
if ChangesDigest(p) == ChangesDigest(seed) {
|
||||
t.Error("two proposals' changes digest alike")
|
||||
}
|
||||
// 774/S42/AC1 (ADR 0587 §7): an add-node carries its enrolment's warrant naming its node and address, as its one
|
||||
// change, from mesh-enrolment alone; a warrant naming another node or address is refused.
|
||||
// At the operator's yes it carries the tunnel key alone (the operator, 2026-11-02 01:05).
|
||||
joining := PlacementProposalChange{Op: OpAddNode, Node: "lab", Decided: &NodeDecided{Address: "10.0.0.7"},
|
||||
Keys: &JoiningKeys{Overlay: "wg"}}
|
||||
add := PlacementProposal{ID: id, By: "ace.mesh-enrolment", Why: "lab joins", At: time.Unix(1, 0).UTC(),
|
||||
Changes: []PlacementProposalChange{joining}, Base: map[string]uint64{"lab": 0}, State: ProposalAnswering,
|
||||
Warrant: &AddNodeWarrant{Node: "lab", Address: "10.0.0.7", Ask: "a-1", Warrant: "w-1"}}
|
||||
if raw, err := EncodePlacementProposal(add); err != nil {
|
||||
t.Errorf("an add-node: %v", err)
|
||||
} else if back, err := DecodePlacementProposal(raw); err != nil || back.Warrant == nil || back.Changes[0].Keys.Overlay != "wg" {
|
||||
t.Errorf("an add-node round trip: %+v, %v", back, err)
|
||||
}
|
||||
if AddNodeRefusal(add, EnrolmentModule) != "" || !strings.Contains(AddNodeRefusal(add, AssignerModule), "mesh-enrolment's alone") {
|
||||
t.Errorf("add-node by caller: %q, %q", AddNodeRefusal(add, EnrolmentModule), AddNodeRefusal(add, AssignerModule))
|
||||
}
|
||||
if laid, f := LayChanges(current, add.Changes); len(f) != 0 || laid["lab"].Decided.Address != "10.0.0.7" || len(laid["lab"].Modules) != 0 {
|
||||
t.Errorf("an add-node laid: %+v, %v", laid, f)
|
||||
}
|
||||
if _, f := LayChanges(current, []PlacementProposalChange{{Op: OpAddNode, Node: "g14", Decided: &NodeDecided{Address: "10.0.0.9"},
|
||||
Keys: &JoiningKeys{Overlay: "wg"}}}); len(f) != 1 || !strings.Contains(f[0], "placed already") {
|
||||
t.Errorf("an add-node of a placed node: %v", f)
|
||||
}
|
||||
for name, mut := range map[string]func(*PlacementProposal){
|
||||
"warrant of another node": func(p *PlacementProposal) {
|
||||
p.Warrant = &AddNodeWarrant{Node: "pi", Address: "10.0.0.7", Ask: "a", Warrant: "w"}
|
||||
},
|
||||
"warrant of another address": func(p *PlacementProposal) {
|
||||
p.Warrant = &AddNodeWarrant{Node: "lab", Address: "10.0.0.8", Ask: "a", Warrant: "w"}
|
||||
},
|
||||
"no warrant": func(p *PlacementProposal) { p.Warrant = nil },
|
||||
"no tunnel key": func(p *PlacementProposal) {
|
||||
c := joining
|
||||
c.Keys = &JoiningKeys{Identity: "id", Sealing: "seal"}
|
||||
p.Changes = []PlacementProposalChange{c}
|
||||
},
|
||||
"half its identity keys": func(p *PlacementProposal) {
|
||||
c := joining
|
||||
c.Keys = &JoiningKeys{Overlay: "wg", Identity: "id"}
|
||||
p.Changes = []PlacementProposalChange{c}
|
||||
},
|
||||
"beside another change": func(p *PlacementProposal) {
|
||||
p.Changes = append(p.Changes, PlacementProposalChange{Op: OpPlace, Node: "lab", Module: "zsh"})
|
||||
},
|
||||
"the hub": func(p *PlacementProposal) {
|
||||
c := joining
|
||||
c.Decided = &NodeDecided{Address: "10.0.0.7", Hub: true}
|
||||
p.Changes = []PlacementProposalChange{c}
|
||||
},
|
||||
"a warrant on a move": func(p *PlacementProposal) {
|
||||
p.Changes, p.Base = []PlacementProposalChange{{Op: OpTakeOff, Node: "g14", Module: "zsh"}}, map[string]uint64{"g14": 4}
|
||||
},
|
||||
} {
|
||||
q := add
|
||||
q.Changes = append([]PlacementProposalChange(nil), add.Changes...)
|
||||
mut(&q)
|
||||
if _, err := EncodePlacementProposal(q); err == nil {
|
||||
t.Errorf("an add-node with %s: taken", name)
|
||||
}
|
||||
}
|
||||
// With its identity keys whole, as bound at redeem, it is taken too.
|
||||
whole := add
|
||||
whole.Changes = []PlacementProposalChange{joining}
|
||||
whole.Changes[0].Keys = &JoiningKeys{Overlay: "wg", Identity: "id", Sealing: "seal"}
|
||||
if _, err := EncodePlacementProposal(whole); err != nil {
|
||||
t.Errorf("an add-node with its identity keys: %v", err)
|
||||
}
|
||||
// An enrolment's end takes its never-confirmed node off again: mesh-enrolment's alone, of a node running nothing.
|
||||
remove := add
|
||||
remove.Changes = []PlacementProposalChange{{Op: OpRemoveNode, Node: "lab"}}
|
||||
if _, err := EncodePlacementProposal(remove); err != nil || AddNodeRefusal(remove, AssignerModule) == "" ||
|
||||
AddNodeRefusal(remove, EnrolmentModule) != "" || !slices.Equal(remove.RemovedNodes(), []string{"lab"}) {
|
||||
t.Errorf("a remove-node: %v, %q", err, AddNodeRefusal(remove, AssignerModule))
|
||||
}
|
||||
joined := map[string]Placement{"lab": {V: Version, Node: "lab", Decided: NodeDecided{Address: "10.0.0.7"}, Modules: []PlacedModule{}}}
|
||||
if laid, f := LayChanges(joined, remove.Changes); len(f) != 0 || len(laid) != 0 {
|
||||
t.Errorf("a joining node removed: %v, %v", laid, f)
|
||||
}
|
||||
if _, f := LayChanges(current, []PlacementProposalChange{{Op: OpRemoveNode, Node: "g14"}}); len(f) != 1 ||
|
||||
!strings.Contains(f[0], "runs modules") {
|
||||
t.Errorf("a running node removed: %v", f)
|
||||
}
|
||||
remove.Warrant = &AddNodeWarrant{Node: "pi", Address: "10.0.0.7", Ask: "a", Warrant: "w"}
|
||||
if _, err := EncodePlacementProposal(remove); err == nil {
|
||||
t.Error("a remove-node on another node's warrant: taken")
|
||||
}
|
||||
for name, mut := range map[string]func(*PlacementProposal){
|
||||
"no id": func(p *PlacementProposal) { p.ID = "" },
|
||||
"no proposer": func(p *PlacementProposal) { p.By = "" },
|
||||
|
||||
@@ -48,8 +48,8 @@ const (
|
||||
|
||||
// KindNodeCertificate is a node's certificate for its internal name, asked by that node's node-engine alone.
|
||||
KindNodeCertificate = "node-certificate"
|
||||
// KindEnrolment is an enrolment reply, signed under its own context; no caller is named until adoption's record
|
||||
// (ADR 0561 §5).
|
||||
// KindEnrolment is a redeem answer (RedeemAnswer), signed under its own context for mesh-enrolment alone (ADR 0587
|
||||
// §3, §6).
|
||||
KindEnrolment = "enrolment"
|
||||
// KindAuthority is the vault's own authority certificate, which the vault signs for itself.
|
||||
KindAuthority = "authority"
|
||||
|
||||
@@ -369,6 +369,9 @@ type CheckJob struct {
|
||||
// has to list them. The build step hands them to its test tool as the argument NodeNamesArg, and the build module
|
||||
// gives them to the tests in NodeNamesEnv.
|
||||
NodeNames []string `json:"node-names,omitempty"`
|
||||
// Toolchain is the toolchain the checker agreed for the check's language (novox/hq ADR 0584): a node building with
|
||||
// another declines the ask, so a pass is never recorded as run on a version it did not run on. Nil names none.
|
||||
Toolchain *ToolchainUsed `json:"toolchain,omitempty"`
|
||||
}
|
||||
|
||||
// NodeNamesArg is the test tool's argument that carries a check's node names, and NodeNamesEnv the environment
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Token is what a person carries to a machine that is joining (novox/hq ADR 0004): where the bus is, what certificate
|
||||
// to expect there, whose signature to believe afterwards, and a one-time right to join; and, when it was issued for the
|
||||
// machine's tunnel key, the machine's first tunnel (ADR 0169).
|
||||
//
|
||||
// Moved here from the node-engine (mesh-host internal/identity, which keeps an alias) so the vault, which makes the
|
||||
// token from the lease write on (novox/hq ADR 0587 §3, issue 774), mesh-cli, which opens and prints it, and the
|
||||
// node-engine, which parses it, read one shape. The field names are the wire format the controller's `token issue`
|
||||
// writes, unchanged, so a token of either issuer reads the same; the tunnel stays in it (the lead's settlement of
|
||||
// ADR 0587 §3, 2026-11-02).
|
||||
type Token struct {
|
||||
Version int `json:"v"`
|
||||
|
||||
// Node is what the mesh calls this machine: the bus user it dials as is named after it, so it must be known before
|
||||
// the mesh can say anything.
|
||||
Node string `json:"node,omitempty"`
|
||||
|
||||
Broker string `json:"broker,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Signer []byte `json:"signer,omitempty"`
|
||||
Secret string `json:"secret"`
|
||||
|
||||
// Adopted says this node joins adopted (novox/hq ADR 0100); absent for a converged node.
|
||||
Adopted bool `json:"adopted,omitempty"`
|
||||
|
||||
// Tunnel is this machine's first tunnel, when the token was issued for the key it made (novox/hq ADR 0169): its own
|
||||
// address and the hub to reach, so it reaches the bus over the private network and the bus never faces the internet.
|
||||
Tunnel *TokenTunnel `json:"tunnel,omitempty"`
|
||||
}
|
||||
|
||||
// TokenTunnel is the joining machine's side of its first tunnel, in the field names the controller writes.
|
||||
type TokenTunnel struct {
|
||||
Key string `json:"key"`
|
||||
Address string `json:"address"`
|
||||
Range string `json:"range"`
|
||||
HubKey string `json:"hub_key"`
|
||||
HubEndpoint string `json:"hub_endpoint"`
|
||||
}
|
||||
|
||||
// ParseToken reads a token a person pasted. Every refusal says *this is not a token* rather than *this is the wrong
|
||||
// token*: telling "not from the mesh I joined" apart from "malformed" is a signature check later (ADR 0004). A token
|
||||
// missing a part is refused whole, naming every part, never used partially: without the fingerprint a node connects
|
||||
// to whatever answers, without the signing key it cannot tell a declaration from a forgery.
|
||||
func ParseToken(encoded string) (Token, error) {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(encoded))
|
||||
if err != nil {
|
||||
return Token{}, fmt.Errorf("this is not a token: %w", err)
|
||||
}
|
||||
var t Token
|
||||
if err := json.Unmarshal(raw, &t); err != nil {
|
||||
return Token{}, fmt.Errorf("this is not a token: %w", err)
|
||||
}
|
||||
if t.Version != 1 {
|
||||
return Token{}, fmt.Errorf(
|
||||
"this token says it is version %d, and this host understands version 1", t.Version)
|
||||
}
|
||||
if missing := t.Missing(); len(missing) > 0 {
|
||||
return Token{}, fmt.Errorf(
|
||||
"this token is missing %s, so it cannot be used to join anything",
|
||||
strings.Join(missing, ", "))
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Missing names every part of t a joining machine needs and t lacks.
|
||||
func (t Token) Missing() []string {
|
||||
var missing []string
|
||||
if strings.TrimSpace(t.Broker) == "" {
|
||||
missing = append(missing, "the broker's address")
|
||||
}
|
||||
if strings.TrimSpace(t.Fingerprint) == "" {
|
||||
missing = append(missing, "the broker certificate's fingerprint")
|
||||
}
|
||||
if len(t.Signer) != ed25519.PublicKeySize {
|
||||
missing = append(missing, "the vault's signing key")
|
||||
}
|
||||
if strings.TrimSpace(t.Secret) == "" {
|
||||
missing = append(missing, "the one-time secret")
|
||||
}
|
||||
if tt := t.Tunnel; tt != nil {
|
||||
for _, part := range []struct{ value, says string }{
|
||||
{tt.Key, "the tunnel key it was issued for"}, {tt.Address, "this machine's address"},
|
||||
{tt.Range, "the private network's range"}, {tt.HubKey, "the hub's tunnel key"},
|
||||
{tt.HubEndpoint, "where the hub's tunnel is dialled"},
|
||||
} {
|
||||
if strings.TrimSpace(part.value) == "" {
|
||||
missing = append(missing, part.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// Encode is t as one line a person carries: base64 (URL alphabet, no padding) of its JSON at version 1, as the
|
||||
// controller's `token issue` writes it. Refused when a part is missing.
|
||||
func (t Token) Encode() (string, error) {
|
||||
t.Version = 1
|
||||
if missing := t.Missing(); len(missing) > 0 {
|
||||
return "", fmt.Errorf("delivery: the token is missing %s", strings.Join(missing, ", "))
|
||||
}
|
||||
raw, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
// SignerKey is the signing key the token carries, as a key: the controller's before the lease write, the vault's after.
|
||||
func (t Token) SignerKey() ed25519.PublicKey { return ed25519.PublicKey(t.Signer) }
|
||||
@@ -0,0 +1,53 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// controllerToken is a token made by the controller's own `token issue` code: mesh-control origin/main 5c281ad9,
|
||||
// internal/token Token.Encode, run on 2026-11-02 for node anchor, adopted, with a first tunnel, its signer the ed25519
|
||||
// key of the seed 00 01 .. 1f. Copied, never written by hand.
|
||||
const controllerToken = "eyJ2IjoxLCJub2RlIjoiYW5jaG9yIiwiYnJva2VyIjoiMTkyLjAuMi4xMDo0MjIyIiwiZmluZ2VycHJpbnQiOiJzaGEyNTY6YWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYiIsInNpZ25lciI6IkE2RUh2L1BPRUw0ZGNOMFk1MHZBbVdmazFqQ2JwUTFmSGR5R1pCSlZNYmc9Iiwic2VjcmV0IjoiYS1vbmUtdGltZS1zZWNyZXQiLCJhZG9wdGVkIjp0cnVlLCJ0dW5uZWwiOnsia2V5IjoiZEdobExXMWhZMmhwYm1WekxXdGxlUT09IiwiYWRkcmVzcyI6IjEwLjAuMC43LzI0IiwicmFuZ2UiOiIxMC4wLjAuMC8yNCIsImh1Yl9rZXkiOiJkR2hsTFdoMVluTXRhMlY1IiwiaHViX2VuZHBvaW50IjoiMTk4LjUxLjEwMC4xOjUxODIwIn19"
|
||||
|
||||
// 774/S36/AC1: a token the controller made reads, with the moved code, field for field as it was written; encoding it
|
||||
// again gives the same line; a part missing is refused naming it; garbage and another version are not tokens.
|
||||
func TestATokenTheControllerMadeReads(t *testing.T) {
|
||||
signer, _ := hex.DecodeString("03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8")
|
||||
want := Token{Version: 1, Node: "anchor", Broker: "192.0.2.10:4222",
|
||||
Fingerprint: "sha256:" + strings.Repeat("ab", 32), Signer: signer, Secret: "a-one-time-secret", Adopted: true,
|
||||
Tunnel: &TokenTunnel{Key: "dGhlLW1hY2hpbmVzLWtleQ==", Address: "10.0.0.7/24", Range: "10.0.0.0/24",
|
||||
HubKey: "dGhlLWh1YnMta2V5", HubEndpoint: "198.51.100.1:51820"}}
|
||||
got, err := ParseToken(" " + controllerToken + "\n")
|
||||
if err != nil || !reflect.DeepEqual(got, want) || len(got.SignerKey()) != 32 {
|
||||
t.Fatalf("parsed %+v, %v", got, err)
|
||||
}
|
||||
if again, err := got.Encode(); err != nil || again != controllerToken {
|
||||
t.Errorf("encoded again: %q, %v", again, err)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
cut func(*Token)
|
||||
expect string
|
||||
}{
|
||||
{func(t *Token) { t.Broker = "" }, "broker's address"},
|
||||
{func(t *Token) { t.Fingerprint = "" }, "fingerprint"},
|
||||
{func(t *Token) { t.Signer = t.Signer[:5] }, "signing key"},
|
||||
{func(t *Token) { t.Secret = "" }, "one-time secret"},
|
||||
{func(t *Token) { t.Tunnel = &TokenTunnel{Key: "k"} }, "where the hub's tunnel is dialled"},
|
||||
} {
|
||||
cut := want
|
||||
cut.Tunnel = &TokenTunnel{}
|
||||
*cut.Tunnel = *want.Tunnel
|
||||
c.cut(&cut)
|
||||
if _, err := cut.Encode(); err == nil || !strings.Contains(err.Error(), c.expect) {
|
||||
t.Errorf("a token without %s: %v", c.expect, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "!!!not base64!!!", "aGVsbG8", "eyJ2Ijo5OX0"} {
|
||||
if _, err := ParseToken(bad); err == nil {
|
||||
t.Errorf("%q parsed as a token", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
-10
@@ -99,22 +99,47 @@ type NodeToolchain struct {
|
||||
Toolchain
|
||||
}
|
||||
|
||||
// AgreedToolchain is the one Toolchain2 every node's entry of a language agrees on, or none and why: "toolchain not
|
||||
// yet reported" with no entries, and "builders disagree" naming each differing version and recipe with the nodes
|
||||
// reporting it when two differ (novox/hq ADR 0405 clause 2). The answer does not depend on the entries' order. The
|
||||
// entries must all be of one language.
|
||||
func AgreedToolchain(entries []NodeToolchain) (tc Toolchain2, none string) {
|
||||
// SittingOut is a node whose toolchain of a language is not the agreed one: it builds nothing of that language until
|
||||
// it reports the agreed version (novox/hq ADR 0584 clause 2).
|
||||
type SittingOut struct {
|
||||
Node string
|
||||
Version string
|
||||
Recipe string
|
||||
}
|
||||
|
||||
// Agreement is what the nodes' entries of one language come to (novox/hq ADR 0584). Toolchain is the agreed one, set
|
||||
// when Wait is empty. Wait is why nothing is agreed: "toolchain not yet reported" with no entries, or "builders
|
||||
// disagree" naming each version and recipe with the nodes reporting it, when no version has a strict majority.
|
||||
// SittingOut lists, sorted by node, the nodes that report another version than the agreed one.
|
||||
type Agreement struct {
|
||||
Toolchain Toolchain2
|
||||
SittingOut []SittingOut
|
||||
Wait string
|
||||
}
|
||||
|
||||
// ToolchainAgreement is the agreed toolchain of a language: the version and recipe a strict majority of the
|
||||
// reporting nodes report (novox/hq ADR 0584 clause 1). With no strict majority (two against two) or no entry it
|
||||
// gives no toolchain and says why in Wait. The answer does not depend on the entries' order. The entries must all be
|
||||
// of one language.
|
||||
func ToolchainAgreement(entries []NodeToolchain) Agreement {
|
||||
if len(entries) == 0 {
|
||||
return Toolchain2{}, ToolchainNoReport
|
||||
return Agreement{Wait: ToolchainNoReport}
|
||||
}
|
||||
groups := map[Toolchain2][]string{}
|
||||
for _, e := range entries {
|
||||
k := Toolchain2{Language: e.Language, Version: e.Version, Recipe: e.Recipe}
|
||||
groups[k] = append(groups[k], e.Node)
|
||||
}
|
||||
if len(groups) == 1 {
|
||||
for k := range groups {
|
||||
return k, ""
|
||||
for k, nodes := range groups {
|
||||
if 2*len(nodes) > len(entries) {
|
||||
a := Agreement{Toolchain: k}
|
||||
for _, e := range entries {
|
||||
if e.Version != k.Version || e.Recipe != k.Recipe {
|
||||
a.SittingOut = append(a.SittingOut, SittingOut{Node: e.Node, Version: e.Version, Recipe: e.Recipe})
|
||||
}
|
||||
}
|
||||
sort.Slice(a.SittingOut, func(i, j int) bool { return a.SittingOut[i].Node < a.SittingOut[j].Node })
|
||||
return a
|
||||
}
|
||||
}
|
||||
parts := make([]string, 0, len(groups))
|
||||
@@ -123,5 +148,12 @@ func AgreedToolchain(entries []NodeToolchain) (tc Toolchain2, none string) {
|
||||
parts = append(parts, fmt.Sprintf("%s recipe %s on %s", k.Version, k.Recipe, strings.Join(nodes, ", ")))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return Toolchain2{}, "builders disagree: " + strings.Join(parts, "; ")
|
||||
return Agreement{Wait: "builders disagree: " + strings.Join(parts, "; ")}
|
||||
}
|
||||
|
||||
// AgreedToolchain is ToolchainAgreement's toolchain, or none and why (its Wait): the version a strict majority of the
|
||||
// nodes report (novox/hq ADR 0584, which changes ADR 0405 clause 2 and ADR 0477 clause 2).
|
||||
func AgreedToolchain(entries []NodeToolchain) (tc Toolchain2, none string) {
|
||||
a := ToolchainAgreement(entries)
|
||||
return a.Toolchain, a.Wait
|
||||
}
|
||||
|
||||
@@ -55,36 +55,50 @@ func TestToolchainKeyAndValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1443/AC2
|
||||
func TestAgreedToolchain(t *testing.T) {
|
||||
tc, none := AgreedToolchain([]NodeToolchain{tcEntry("ace", "go", "1.26.1", "3"), tcEntry("novox", "go", "1.26.1", "3")})
|
||||
if tc != (Toolchain2{Language: "go", Version: "1.26.1", Recipe: "3"}) || none != "" {
|
||||
t.Errorf("%+v %q", tc, none)
|
||||
}
|
||||
}
|
||||
|
||||
// 1443/AC3
|
||||
func TestAgreedToolchainNoReport(t *testing.T) {
|
||||
tc, none := AgreedToolchain(nil)
|
||||
if tc != (Toolchain2{}) || none != "toolchain not yet reported" {
|
||||
t.Errorf("%+v %q", tc, none)
|
||||
}
|
||||
}
|
||||
|
||||
// 1443/AC4
|
||||
func TestAgreedToolchainDisagree(t *testing.T) {
|
||||
a, b, c := tcEntry("ace", "go", "1.26.1", "3"), tcEntry("novox", "go", "1.26.2", "3"), tcEntry("g14", "go", "1.26.1", "4")
|
||||
_, first := AgreedToolchain([]NodeToolchain{a, b, c})
|
||||
_, second := AgreedToolchain([]NodeToolchain{c, b, a})
|
||||
if first == "" || first != second {
|
||||
t.Errorf("order changed the answer: %q vs %q", first, second)
|
||||
}
|
||||
for _, want := range []string{"builders disagree", "1.26.1", "1.26.2", "ace", "novox", "g14"} {
|
||||
if !strings.Contains(first, want) {
|
||||
t.Errorf("%q does not name %q", first, want)
|
||||
// 1443/AC2-4, ADR 0584: the agreed toolchain is the strict majority's; a minority node sits out; a tie waits.
|
||||
func TestToolchainAgreement(t *testing.T) {
|
||||
e := tcEntry
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
in []NodeToolchain
|
||||
version string
|
||||
out []SittingOut
|
||||
wait []string // "" wait means agreed
|
||||
}{
|
||||
{"all agree", []NodeToolchain{e("ace", "go", "1.26.1", "3"), e("novox", "go", "1.26.1", "3")}, "1.26.1", nil, nil},
|
||||
{"three against one", []NodeToolchain{e("ace", "typescript", "26.12.1", "3"), e("g14", "typescript", "26.13.0", "3"), e("novox", "typescript", "26.13.0", "3"), e("shanks", "typescript", "26.13.0", "3")},
|
||||
"26.13.0", []SittingOut{{"ace", "26.12.1", "3"}}, nil},
|
||||
{"two against two", []NodeToolchain{e("ace", "go", "1.26.1", "3"), e("novox", "go", "1.26.2", "3"), e("g14", "go", "1.26.1", "3"), e("shanks", "go", "1.26.2", "3")},
|
||||
"", nil, []string{"builders disagree", "1.26.1 recipe 3 on ace, g14", "1.26.2 recipe 3 on novox, shanks"}},
|
||||
{"one node alone", []NodeToolchain{e("ace", "go", "1.26.1", "3")}, "1.26.1", nil, nil},
|
||||
{"no report", nil, "", nil, []string{ToolchainNoReport}},
|
||||
} {
|
||||
a := ToolchainAgreement(c.in)
|
||||
rev := make([]NodeToolchain, len(c.in))
|
||||
for i, x := range c.in {
|
||||
rev[len(c.in)-1-i] = x
|
||||
}
|
||||
if b := ToolchainAgreement(rev); b.Wait != a.Wait || b.Toolchain != a.Toolchain {
|
||||
t.Errorf("%s: order changed the answer: %+v vs %+v", c.name, a, b)
|
||||
}
|
||||
if a.Toolchain.Version != c.version || len(a.SittingOut) != len(c.out) {
|
||||
t.Errorf("%s: %+v", c.name, a)
|
||||
}
|
||||
for i := range c.out {
|
||||
if a.SittingOut[i] != c.out[i] {
|
||||
t.Errorf("%s: sitting out %+v, want %+v", c.name, a.SittingOut, c.out)
|
||||
}
|
||||
}
|
||||
if (len(c.wait) == 0) != (a.Wait == "") {
|
||||
t.Errorf("%s: wait %q", c.name, a.Wait)
|
||||
}
|
||||
for _, w := range c.wait {
|
||||
if !strings.Contains(a.Wait, w) {
|
||||
t.Errorf("%s: %q does not name %q", c.name, a.Wait, w)
|
||||
}
|
||||
}
|
||||
if tc, none := AgreedToolchain(c.in); tc != a.Toolchain || none != a.Wait {
|
||||
t.Errorf("%s: AgreedToolchain %+v %q", c.name, tc, none)
|
||||
}
|
||||
}
|
||||
if tc, _ := AgreedToolchain([]NodeToolchain{a, b}); tc != (Toolchain2{}) {
|
||||
t.Errorf("a disagreement gave %+v", tc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,10 @@ type GrantsUserList struct {
|
||||
// LeftOut are the users the list leaves out for want of a hash in the vault.
|
||||
LeftOut []string `json:"left_out"`
|
||||
At time.Time `json:"at"`
|
||||
// LeaseHanded says the controller's lease is handed to the grants part (novox/hq ADR 0582 clause 3, ADR 0571 clause
|
||||
// 8): from then on the bus node follows this list alone and never falls back to the controller's. A key written
|
||||
// before it reads false.
|
||||
LeaseHanded bool `json:"lease-handed,omitempty"`
|
||||
}
|
||||
|
||||
// EncodeGrantsUserList is g as JSON at Version; it refuses a value the bus node could not follow.
|
||||
|
||||
+7
-15
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/membership"
|
||||
@@ -17,10 +16,10 @@ import (
|
||||
// not a membership is handed to onBad (nil to ignore it) and skipped.
|
||||
//
|
||||
// It reads the membership from the grants part's state memberships, its own key (novox/hq ADR 0571 clause 7),
|
||||
// watched so the value there now comes first and each change after it; and it hears the subject the grants part
|
||||
// publishes on until the lease write. The assignments stream is no longer read: the bus module's tools delete it
|
||||
// at the lease write (research 072 row 33). A state not there yet, or not readable, is told to onBad and the
|
||||
// subject is followed alone. The returned error is a refusal to follow at all.
|
||||
// watched so the value there now comes first and each change after it. Nothing else: the subject the grants part
|
||||
// published on is no longer heard (ADR 0571 clause 10, issue 774), since it stops publishing at the lease write, and
|
||||
// the assignments stream is no longer read (research 072 row 33). A state not there or not readable is a refusal to
|
||||
// follow, returned: there is nothing else to follow.
|
||||
func (c *Conn) FollowMembership(apply func(membership.Membership), onBad func(error)) error {
|
||||
if c.account.Node == "" || c.account.Module == "" {
|
||||
return fmt.Errorf("the account names no node or module, so it has no membership")
|
||||
@@ -38,27 +37,20 @@ func (c *Conn) FollowMembership(apply func(membership.Membership), onBad func(er
|
||||
}
|
||||
apply(issued)
|
||||
}
|
||||
subject := membership.Subject(c.account.Node, c.account.Module)
|
||||
if _, err := c.nc.Subscribe(subject, func(m *nats.Msg) { take(m.Data) }); err != nil {
|
||||
return fmt.Errorf("cannot follow %s: %w", subject, err)
|
||||
}
|
||||
key := membership.GrantsKey(c.account.Node, c.account.Module)
|
||||
js, err := jetstream.New(c.nc)
|
||||
if err != nil {
|
||||
bad(fmt.Errorf("cannot read %s of %s (%v); following %s alone", key, membership.GrantsBucket, err, subject))
|
||||
return nil
|
||||
return fmt.Errorf("cannot read %s of %s: %w", key, membership.GrantsBucket, err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
kv, err := js.KeyValue(ctx, membership.GrantsBucket)
|
||||
if err != nil {
|
||||
bad(fmt.Errorf("cannot read %s of %s yet (%v); following %s alone", key, membership.GrantsBucket, err, subject))
|
||||
return nil
|
||||
return fmt.Errorf("cannot read %s of %s: %w", key, membership.GrantsBucket, err)
|
||||
}
|
||||
w, err := kv.Watch(context.Background(), key)
|
||||
if err != nil {
|
||||
bad(fmt.Errorf("cannot watch %s of %s (%v); following %s alone", key, membership.GrantsBucket, err, subject))
|
||||
return nil
|
||||
return fmt.Errorf("cannot watch %s of %s: %w", key, membership.GrantsBucket, err)
|
||||
}
|
||||
go func() {
|
||||
for e := range w.Updates() {
|
||||
|
||||
@@ -3,14 +3,20 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/membership"
|
||||
)
|
||||
|
||||
// A process connected pinned to a test bus hears the membership published for its node and module, and
|
||||
// ignores what is not one (novox/hq issue 1633, AC2).
|
||||
// A process connected pinned to a test bus follows the membership the grants part writes for its node and module in
|
||||
// its state memberships, and reports what is not one (novox/hq issue 1633, AC2); it no longer hears mesh.assignment,
|
||||
// where the grants part stops publishing at the lease write (ADR 0571 clause 10; issue 774, 774/S5/AC1). Without the
|
||||
// state it refuses to follow, naming it.
|
||||
func TestFollowMembershipHearsWhatIsIssuedToTheAccount(t *testing.T) {
|
||||
url, fingerprint := selfSignedServer(t)
|
||||
account := mailSenderAccount(t, url, fingerprint)
|
||||
@@ -20,26 +26,52 @@ func TestFollowMembershipHearsWhatIsIssuedToTheAccount(t *testing.T) {
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
got := make(chan membership.Membership, 1)
|
||||
got := make(chan membership.Membership, 4)
|
||||
bad := make(chan error, 4)
|
||||
if err := conn.FollowMembership(func(m membership.Membership) { got <- m }, func(e error) { bad <- e }); err != nil {
|
||||
follow := func() error {
|
||||
return conn.FollowMembership(func(m membership.Membership) { got <- m }, func(e error) { bad <- e })
|
||||
}
|
||||
if err := follow(); err == nil || !strings.Contains(err.Error(), membership.GrantsBucket) {
|
||||
t.Fatalf("followed with no state memberships: %v", err)
|
||||
}
|
||||
js, err := jetstream.New(conn.nc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := conn.nc.Publish(membership.Subject("novox", "mesh-mail"), []byte("not json")); err != nil {
|
||||
ctx := context.Background()
|
||||
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: membership.GrantsBucket})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := conn.nc.Publish(membership.Subject("novox", "mesh-mail"), []byte(`{"node":"novox","module":"mesh-mail","mesh":["10.0.0.1"]}`)); err != nil {
|
||||
key := membership.GrantsKey("novox", "mesh-mail")
|
||||
if _, err := kv.Put(ctx, key, []byte("not json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := follow(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Published on the subject, as the grants part did until the write: not heard.
|
||||
if err := conn.nc.Publish(membership.Subject("novox", "mesh-mail"), []byte(`{"node":"novox","module":"mesh-mail","mesh":["10.0.0.9"]}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := kv.Put(ctx, key, membership.EncodeGrantsMembership(membership.Membership{Node: "novox", Module: "mesh-mail",
|
||||
Mesh: []string{"10.0.0.1"}})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case m := <-got:
|
||||
if m.Module != "mesh-mail" || len(m.Mesh) != 1 {
|
||||
if m.Module != "mesh-mail" || len(m.Mesh) != 1 || m.Mesh[0] != "10.0.0.1" {
|
||||
t.Fatalf("membership: %+v", m)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("no membership heard")
|
||||
t.Fatal("no membership followed")
|
||||
}
|
||||
select {
|
||||
case m := <-got:
|
||||
t.Errorf("a second membership was applied, from the subject: %+v", m)
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
if len(bad) == 0 {
|
||||
t.Error("the body that is not a membership was not reported")
|
||||
t.Error("the value that is not a membership was not reported")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,4 +283,24 @@ func TestABundleKeepsAndWatchesStateThroughTheRuntime(t *testing.T) {
|
||||
if gets != 3 {
|
||||
t.Fatalf("a kept value was read by key %d times, want once, and the missing one twice", gets)
|
||||
}
|
||||
// Issue 1954: a state the process alone writes reads its own put and delete back from the copy at once, and the
|
||||
// watch's changes of that key up to the write are not taken over it; a later change is.
|
||||
_ = handles["placement"](StateChange{Key: "own", Op: OpPut, Value: []byte("old"), Revision: 5})
|
||||
if err := w.Wrote("placement", "own", []byte("mine"), 7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = w.Wrote("placement", "gone", []byte("x"), 8)
|
||||
_ = w.Removed("placement", "gone")
|
||||
for _, c := range []StateChange{{Key: "own", Op: OpPut, Value: []byte("old"), Revision: 6},
|
||||
{Key: "own", Op: OpPut, Value: []byte("mine"), Revision: 7}, {Key: "gone", Op: OpPut, Value: []byte("x"), Revision: 8}} {
|
||||
_ = handles["placement"](c)
|
||||
}
|
||||
own, _, _ := w.Get("placement", "own")
|
||||
if _, found, _ := w.Get("placement", "gone"); found || string(own) != "mine" {
|
||||
t.Fatalf("after its own writes and the watch's older changes the copy holds own=%s, gone found %v", own, found)
|
||||
}
|
||||
_ = handles["placement"](StateChange{Key: "gone", Op: OpPut, Value: []byte("again"), Revision: 9})
|
||||
if v, _, _ := w.Get("placement", "gone"); string(v) != "again" {
|
||||
t.Fatalf("a change after its own delete is not taken: %s", v)
|
||||
}
|
||||
}
|
||||
|
||||
+54
-5
@@ -31,8 +31,12 @@ type watchedState struct {
|
||||
ready chan struct{} // closed once the watch's current values are all in the copy, or it was refused
|
||||
err error
|
||||
|
||||
mu sync.Mutex
|
||||
values map[string][]byte
|
||||
mu sync.Mutex
|
||||
values map[string][]byte
|
||||
// revs is, by key, the revision its value in the copy is at, and latest the highest revision the copy has seen: a
|
||||
// change the watch hands at a revision not newer than its key's is one the copy already took (Wrote, Removed).
|
||||
revs map[string]uint64
|
||||
latest uint64
|
||||
then []func(StateChange) error
|
||||
started bool // the current values are in, so a change runs then at once
|
||||
pending []StateChange // the current values, run through then once all are in
|
||||
@@ -77,7 +81,7 @@ func (w *WatchedStates) watched(state string, then func(StateChange) error) (*wa
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
s = &watchedState{ready: make(chan struct{}), values: map[string][]byte{}}
|
||||
s = &watchedState{ready: make(chan struct{}), values: map[string][]byte{}, revs: map[string]uint64{}}
|
||||
if then != nil {
|
||||
s.then = []func(StateChange) error{then}
|
||||
}
|
||||
@@ -85,11 +89,17 @@ func (w *WatchedStates) watched(state string, then func(StateChange) error) (*wa
|
||||
w.mu.Unlock()
|
||||
err := w.watch(state, func(c StateChange) error {
|
||||
s.mu.Lock()
|
||||
if c.Op == OpPut {
|
||||
switch {
|
||||
case c.Revision != 0 && c.Revision <= s.revs[c.Key]:
|
||||
// Already in the copy, or older than what is: this process wrote the key since (Wrote, Removed).
|
||||
case c.Op == OpPut:
|
||||
s.values[c.Key] = append([]byte(nil), c.Value...)
|
||||
} else {
|
||||
s.revs[c.Key] = c.Revision
|
||||
default:
|
||||
delete(s.values, c.Key)
|
||||
s.revs[c.Key] = c.Revision
|
||||
}
|
||||
s.latest = max(s.latest, c.Revision)
|
||||
if !s.started {
|
||||
s.pending = append(s.pending, c)
|
||||
s.mu.Unlock()
|
||||
@@ -240,5 +250,44 @@ func (w *WatchedStates) Once(state, key string) ([]byte, bool, error) {
|
||||
return v, true, nil
|
||||
}
|
||||
|
||||
// Wrote takes a put this process made into the copy at once, at the revision the bus answered (0 when not known): a
|
||||
// state the process alone writes (a holder's own) is read back from the copy right after its own write, before the
|
||||
// watch hands the change back, and the watch's older changes of the key are not taken over it (issue 1954). The state
|
||||
// is watched first when it is not yet.
|
||||
func (w *WatchedStates) Wrote(state, key string, value []byte, revision uint64) error {
|
||||
return w.took(state, key, value, revision, true)
|
||||
}
|
||||
|
||||
// Removed takes a delete this process made into the copy at once, as Wrote takes a put.
|
||||
func (w *WatchedStates) Removed(state, key string) error { return w.took(state, key, nil, 0, false) }
|
||||
|
||||
func (w *WatchedStates) took(state, key string, value []byte, revision uint64, put bool) error {
|
||||
s, err := w.watched(state, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
switch {
|
||||
case put && revision != 0:
|
||||
if revision <= s.revs[key] {
|
||||
return nil // the watch handed a newer change of the key already
|
||||
}
|
||||
s.values[key] = append([]byte(nil), value...)
|
||||
s.revs[key] = revision
|
||||
s.latest = max(s.latest, revision)
|
||||
default:
|
||||
// A delete's revision is not answered, nor a put's at times: it is newer than every revision the copy has seen,
|
||||
// so the watch's changes of the key up to that one are not taken over it.
|
||||
if put {
|
||||
s.values[key] = append([]byte(nil), value...)
|
||||
} else {
|
||||
delete(s.values, key)
|
||||
}
|
||||
s.revs[key] = s.latest
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Direct is a key read by key now, not from the copy: a value that changes and is read one at a time (a build).
|
||||
func (w *WatchedStates) Direct(state, key string) ([]byte, bool, error) { return w.get(state, key) }
|
||||
|
||||
Reference in New Issue
Block a user