Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63c8687166 | ||
|
|
b13f65674b | ||
|
|
e6ee939bd4 | ||
|
|
bde60130a8 | ||
|
|
795724f5ca | ||
|
|
44fee9eb0e | ||
|
|
cbcbb9644b | ||
|
|
d1a256b429 | ||
|
|
40bbc7e0d3 | ||
|
|
70f13a3775 | ||
|
|
189e64c486 | ||
|
|
1a1d3a1ecc | ||
|
|
288364f28d | ||
|
|
447bcf3a6b | ||
|
|
07d26adcd3 | ||
|
|
02214cf633 | ||
|
|
f904ff425e | ||
|
|
89370b6f5c | ||
|
|
4edd0e526c | ||
|
|
5d5ee69775 | ||
|
|
9125fcc712 | ||
|
|
17ef1d577d | ||
|
|
da27cd1593 | ||
|
|
aa6a4e7a59 | ||
|
|
8bf45fbafe | ||
|
|
e18be5cabe | ||
|
|
a78fa3db9d | ||
|
|
b81d7475e8 | ||
|
|
7ec6767418 | ||
|
|
30ec4329e0 | ||
|
|
3bae0d8fa2 | ||
|
|
7bd18306d4 | ||
|
|
1302eb9e1f | ||
|
|
f2ed0938ce | ||
|
|
4a84370b2f | ||
|
|
7d1b8864cc | ||
|
|
b18c62f671 | ||
|
|
cc38ca3f8d | ||
|
|
d739516056 | ||
|
|
a6c225eb45 | ||
|
|
5085b681e9 | ||
|
|
9e407f8f76 | ||
|
|
8545e7fc9f | ||
|
|
55a506975b | ||
|
|
b7dfe4c637 | ||
|
|
c4b05a3e93 | ||
|
|
38f2401f17 | ||
|
|
c8b041ab37 | ||
|
|
f52f47e1ff | ||
|
|
213ebe7cfd | ||
|
|
90e278cb97 | ||
|
|
58748c2be1 | ||
|
|
8e9cc58301 | ||
|
|
fb96216e46 | ||
|
|
5059fef3e6 | ||
|
|
564a04b45c |
@@ -1,7 +0,0 @@
|
||||
# The modules of this repository that still read the controller's assignments (mesh-controller.assignments), each with
|
||||
# why. mesh-grants' test (TestGrants, "assignments readers") refuses any other module.json that reads them, and an entry
|
||||
# here whose module no longer does: the list only shrinks, and it is empty in the first change after the controller's
|
||||
# lease is written to a holder that is no controller (novox/hq ADR 0549 §3, §6; issue 774). Each module named here
|
||||
# uses the assignments only while the controller holds the lease (buscompose.LeaseHandedAway), placement alone after it.
|
||||
mesh-delivery-assigner unioned with the placement until the lease write (assign.Placed with handedAway); placement alone after
|
||||
mesh-grants unioned with the placement until the lease write (ReadInputs); placement alone after
|
||||
+2
-3
@@ -14,7 +14,6 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/composer"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/provider"
|
||||
)
|
||||
|
||||
// holder is a test holder of the Go build seat: it takes every ask from the seat's check worker, as mesh-build-go does,
|
||||
@@ -103,9 +102,9 @@ func TestAGroupWithAnSDKMemberIsComposedByTheComposerAtItsHead(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.putJSON(t, delivery.ModulesBucket, "web", mod)
|
||||
b.putJSON(t, provider.AssignmentsBucket, "g14.web", []byte(`{"node":"g14","module":"web","build":"build-1"}`))
|
||||
b.place(t, "g14", "web")
|
||||
running := strings.Repeat("3", 40)
|
||||
b.putJSON(t, provider.AssignmentsBucket, "novox.mesh-controller", []byte(`{"node":"novox","module":"mesh-controller","build":"c-1"}`))
|
||||
b.place(t, "novox", "mesh-controller")
|
||||
b.built(t, "mesh-controller", "novox/mesh-controller", running)
|
||||
sdkHead := strings.Repeat("4", 40)
|
||||
heads := []delivery.Head{{Repository: "novox/mesh-sdk", Number: 80, Head: sdkHead}, {Repository: "novox/web", Number: 7, Head: strings.Repeat("2", 40)}}
|
||||
|
||||
@@ -27,7 +27,6 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/judge"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
"git.novox.be/novox/mesh-sdk/go/provider"
|
||||
|
||||
"mesh-delivery-assigner/internal/assign"
|
||||
)
|
||||
@@ -40,6 +39,8 @@ type bed struct {
|
||||
ctx context.Context
|
||||
dir string
|
||||
buckets map[string]jetstream.KeyValue
|
||||
// placed is, by node, the modules the bed's placement names on it (place).
|
||||
placed map[string][]string
|
||||
}
|
||||
|
||||
func startBed(t *testing.T, leaveOut string) *bed {
|
||||
@@ -235,8 +236,7 @@ func TestTheAssignerDoesNotStartWithoutABucketItReads(t *testing.T) {
|
||||
}
|
||||
|
||||
// Without the seat's placement state it starts all the same, and once the bucket is made (the controller makes
|
||||
// it from the manifest) it is read with no restart: a module only the placement names is
|
||||
// said as placed by one source only.
|
||||
// it from the manifest) it is read with no restart: a node it names gets its own want, the node composing its own.
|
||||
pb := startBed(t, delivery.PlacementBucket)
|
||||
pdone := pb.start(t)
|
||||
select {
|
||||
@@ -253,13 +253,8 @@ func TestTheAssignerDoesNotStartWithoutABucketItReads(t *testing.T) {
|
||||
if _, err := placement.Put(pb.ctx, plkey, pl); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for deadline := time.Now().Add(15 * time.Second); ; time.Sleep(200 * time.Millisecond) {
|
||||
if c, ok := pb.conditionsSaid(t)[delivery.AssignerModule+".placement.n1.web"]; ok && c.State == asks.StateRaised {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the placement state made after the start was not read")
|
||||
}
|
||||
if nw, err := delivery.DecodeNodeWant(pb.get(t, delivery.AssignerWantsBucket, "want.n1.node")); err != nil || !nw.Self {
|
||||
t.Fatalf("the placement state made after the start was not read: n1's own want = %+v, %v", nw, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +266,29 @@ func (b *bed) putJSON(t *testing.T, bucket, key string, value []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
// place adds module to the seat's placement of node, written as the seat writes it: one node key and a key per module
|
||||
// (research 072 row 37). The placement alone names where modules run (ADR 0567 §8).
|
||||
func (b *bed) place(t *testing.T, node, module string) {
|
||||
t.Helper()
|
||||
if b.placed == nil {
|
||||
b.placed = map[string][]string{}
|
||||
}
|
||||
if !slices.Contains(b.placed[node], module) {
|
||||
b.placed[node] = append(b.placed[node], module)
|
||||
}
|
||||
pl := delivery.Placement{Node: node}
|
||||
for _, m := range b.placed[node] {
|
||||
pl.Modules = append(pl.Modules, delivery.PlacedModule{Module: m})
|
||||
}
|
||||
values, err := delivery.PlacementValues(pl)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, raw := range values {
|
||||
b.putJSON(t, delivery.PlacementBucket, key, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// built appends the end of a done trunk build of module at commit to the builder's log, as the builder does.
|
||||
func (b *bed) built(t *testing.T, module, repository, commit string) {
|
||||
t.Helper()
|
||||
@@ -284,24 +302,20 @@ func (b *bed) built(t *testing.T, module, repository, commit string) {
|
||||
}
|
||||
}
|
||||
|
||||
// compose says the controller composed module for node at fp, with the digest d (a letter repeated) and the judgement of the
|
||||
// move to it, in mesh-controller_composed.
|
||||
func (b *bed) compose(t *testing.T, node, module, fp string, d byte, judgement delivery.Judgement) string {
|
||||
// compose says the node's own composition is the digest d (a letter repeated), as its have names it (`<node>.node`): a
|
||||
// placed node composes its own (ADR 0571 §6), and the controller's composition is read no more. It answers the digest.
|
||||
func (b *bed) compose(t *testing.T, node string, d byte) string {
|
||||
t.Helper()
|
||||
key, err := delivery.ComposedKey(node, module, fp)
|
||||
key, err := delivery.HaveNodeKey(node)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := "sha256:" + strings.Repeat(string(d), 64)
|
||||
cd := delivery.ComposedDigest{Digest: digest, Judgement: judgement}
|
||||
if judgement != delivery.JudgedNeither {
|
||||
cd.Against, cd.Words = "sha256:"+strings.Repeat("0", 64), "cannot be told"
|
||||
}
|
||||
v, err := delivery.EncodeComposedDigest(cd)
|
||||
v, err := delivery.EncodeHaveNode(delivery.HaveNode{Composed: digest})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.putJSON(t, delivery.ControllerComposedBucket, key, v)
|
||||
b.putJSON(t, delivery.HaveBucket, key, v)
|
||||
return digest
|
||||
}
|
||||
|
||||
@@ -329,8 +343,8 @@ func TestAChangeIsComposedOnTheBus(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.putJSON(t, delivery.ModulesBucket, "web", mod)
|
||||
b.putJSON(t, provider.AssignmentsBucket, "g14.web", []byte(`{"node":"g14","module":"web","build":"build-1"}`))
|
||||
b.putJSON(t, provider.AssignmentsBucket, "ace.other", []byte(`{"node":"ace","module":"other","build":"build-2"}`))
|
||||
b.place(t, "g14", "web")
|
||||
b.place(t, "ace", "other")
|
||||
heads := []delivery.Head{{Repository: "novox/web", Number: 7, Head: strings.Repeat("2", 40)}}
|
||||
hash, _ := delivery.HeadsHash(heads)
|
||||
// 1927/AC1: the candidate carries the fingerprint set the SDK computes, as the indexer writes it.
|
||||
@@ -407,18 +421,18 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
|
||||
for _, fp := range []string{fNew, fOld} {
|
||||
gkey, _ := delivery.ControllerGrantKey("web", fp)
|
||||
grant, _ := delivery.EncodeControllerGrant(delivery.ControllerGrant{})
|
||||
b.putJSON(t, delivery.ControllerGrantsBucket, gkey, grant)
|
||||
b.putJSON(t, delivery.PartGrantsBucket, gkey, grant)
|
||||
}
|
||||
runs := map[string]string{"ace": fOld, "g14": "build-9", "shanks": fOld}
|
||||
for _, node := range []string{"ace", "g14", "shanks"} {
|
||||
b.compose(t, node, "web", fNew, 'c', delivery.JudgedNeither)
|
||||
b.compose(t, node, 'c')
|
||||
on, _ := delivery.EncodePresence(delivery.Presence{Online: true})
|
||||
b.putJSON(t, delivery.PresenceBucket, node, on)
|
||||
have, _ := delivery.EncodeHave(delivery.Have{FP: runs[node], Phase: delivery.PhaseRunning, Health: delivery.HealthOK})
|
||||
b.putJSON(t, delivery.HaveBucket, node+".web", have)
|
||||
b.putJSON(t, provider.AssignmentsBucket, node+".web", []byte(`{"node":"`+node+`","module":"web","build":"b-`+node+`"}`))
|
||||
b.place(t, node, "web")
|
||||
}
|
||||
b.compose(t, "shanks", "web", fOld, 'a', delivery.JudgedNeither)
|
||||
b.compose(t, "shanks", 'a')
|
||||
b.start(t)
|
||||
|
||||
wkey, _ := delivery.WantKey("ace", "web")
|
||||
@@ -426,8 +440,8 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
|
||||
if err != nil || want.FP != fNew {
|
||||
t.Fatalf("the canary ace's want = %+v, %v", want, err)
|
||||
}
|
||||
if w := b.wantNow(t, "shanks", "web", "shanks' first want at what it runs", func(w delivery.Want) bool { return w.FP != "" }); w.FP != fOld || w.Composed != "sha256:"+strings.Repeat("a", 64) || w.Newest != fNew {
|
||||
t.Errorf("shanks' want = %+v, want the fingerprint it runs naming its composition, held back from F", w)
|
||||
if w := b.wantNow(t, "shanks", "web", "shanks' first want at what it runs", func(w delivery.Want) bool { return w.FP != "" }); w.FP != fOld || w.Composed != "" || !w.Self || w.Newest != fNew {
|
||||
t.Errorf("shanks' want = %+v, want the fingerprint it runs, the node composing it, held back from F", w)
|
||||
}
|
||||
var neither asks.Condition
|
||||
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline) && neither.Key == ""; time.Sleep(100 * time.Millisecond) {
|
||||
@@ -451,8 +465,8 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
|
||||
// there by itself: the condition is cleared.
|
||||
healthyF, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthOK})
|
||||
b.putJSON(t, delivery.HaveBucket, "ace.web", healthyF)
|
||||
if w := b.wantNow(t, "g14", "web", "g14's first want at F once the canary runs it healthy", func(w delivery.Want) bool { return w.FP != "" }); w.FP != fNew || w.Composed != "sha256:"+strings.Repeat("c", 64) || w.Newest != "" {
|
||||
t.Errorf("g14's want = %+v, want F with its composition", w)
|
||||
if w := b.wantNow(t, "g14", "web", "g14's first want at F once the canary runs it healthy", func(w delivery.Want) bool { return w.FP != "" }); w.FP != fNew || w.Composed != "" || !w.Self || w.Newest != "" {
|
||||
t.Errorf("g14's want = %+v, want F, the node composing it", w)
|
||||
}
|
||||
cleared := false
|
||||
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline) && !cleared; time.Sleep(100 * time.Millisecond) {
|
||||
@@ -503,7 +517,7 @@ func TestAHeldFingerprintIsAskedAbout(t *testing.T) {
|
||||
b.putJSON(t, delivery.PresenceBucket, "ace", on)
|
||||
failed, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed, Detail: "connection refused"})
|
||||
b.putJSON(t, delivery.HaveBucket, "ace.web", failed)
|
||||
b.putJSON(t, provider.AssignmentsBucket, "ace.web", []byte(`{"node":"ace","module":"web","build":"b-1"}`))
|
||||
b.place(t, "ace", "web")
|
||||
b.start(t)
|
||||
hkey, _ := delivery.HeldKey("web", fNew)
|
||||
// 1906/H3: the held record names the machine and keeps the health, when and the node's words.
|
||||
@@ -566,7 +580,7 @@ func TestAFailedDataModuleIsAskedToRestore(t *testing.T) {
|
||||
wantKey, _ := delivery.WantKey("ace", "mail")
|
||||
want, _ := delivery.EncodeWant(delivery.Want{FP: fNew, Composed: "sha256:" + strings.Repeat("c", 64)})
|
||||
b.putJSON(t, delivery.AssignerWantsBucket, wantKey, want)
|
||||
b.putJSON(t, provider.AssignmentsBucket, "ace.mail", []byte(`{"node":"ace","module":"mail","build":"b-1"}`))
|
||||
b.place(t, "ace", "mail")
|
||||
b.start(t)
|
||||
|
||||
akey, _ := delivery.RestoreAskKey("ace", "mail", fNew)
|
||||
@@ -617,9 +631,9 @@ func TestTheAssignerReplacedResumesWithNothingLostOrRepeated(t *testing.T) {
|
||||
b.putJSON(t, delivery.BuilderBuildsBucket, bkey, built)
|
||||
gkey, _ := delivery.ControllerGrantKey(name, fp)
|
||||
grant, _ := delivery.EncodeControllerGrant(delivery.ControllerGrant{})
|
||||
b.putJSON(t, delivery.ControllerGrantsBucket, gkey, grant)
|
||||
b.compose(t, "ace", name, fp, 'c', delivery.JudgedNeither)
|
||||
b.putJSON(t, provider.AssignmentsBucket, "ace."+name, []byte(fmt.Sprintf(`{"node":"ace","module":%q,"build":"b-%d"}`, name, i)))
|
||||
b.putJSON(t, delivery.PartGrantsBucket, gkey, grant)
|
||||
b.compose(t, "ace", 'c')
|
||||
b.place(t, "ace", name)
|
||||
}
|
||||
count := func() int {
|
||||
keys, err := b.buckets[delivery.AssignerWantsBucket].Keys(b.ctx)
|
||||
@@ -649,58 +663,6 @@ func TestTheAssignerReplacedResumesWithNothingLostOrRepeated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1906/D2 (ADR 0539 §6), whole on the bus: a want waits for the composition of its module for the node, names its digest once it
|
||||
// appears, and a new revision of the composition at the same fingerprint is written as a new want; the node's own want
|
||||
// `want.<node>.node` is written once the node's composition exists, and again when its digest changes.
|
||||
func TestAWantWaitsForItsComposition(t *testing.T) {
|
||||
b := startBed(t, "")
|
||||
fNew := delivery.SourcePrefix2 + strings.Repeat("e", 64)
|
||||
digest := b.manifest(t, "web", `{"module":"web","version":"1"}`)
|
||||
mod, _ := delivery.EncodeModule(delivery.Module{FP2: fNew, Manifest: digest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: delivery.RootPath})
|
||||
b.putJSON(t, delivery.ModulesBucket, "web", mod)
|
||||
built, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: digest})
|
||||
bkey, _ := delivery.BuilderBuildKey("web", fNew)
|
||||
b.putJSON(t, delivery.BuilderBuildsBucket, bkey, built)
|
||||
gkey, _ := delivery.ControllerGrantKey("web", fNew)
|
||||
grant, _ := delivery.EncodeControllerGrant(delivery.ControllerGrant{})
|
||||
b.putJSON(t, delivery.ControllerGrantsBucket, gkey, grant)
|
||||
on, _ := delivery.EncodePresence(delivery.Presence{Online: true})
|
||||
b.putJSON(t, delivery.PresenceBucket, "ace", on)
|
||||
b.putJSON(t, provider.AssignmentsBucket, "ace.web", []byte(`{"node":"ace","module":"web","build":"b-1"}`))
|
||||
b.start(t)
|
||||
wkey, _ := delivery.WantKey("ace", "web")
|
||||
time.Sleep(time.Second)
|
||||
if e, err := b.buckets[delivery.AssignerWantsBucket].Get(b.ctx, wkey); err == nil {
|
||||
t.Fatalf("a want was written before the composition existed: %s", e.Value())
|
||||
}
|
||||
first := b.compose(t, "ace", "web", fNew, 'c', delivery.JudgedNeither)
|
||||
if w, err := delivery.DecodeWant(b.get(t, delivery.AssignerWantsBucket, wkey)); err != nil || w.FP != fNew || w.Composed != first {
|
||||
t.Fatalf("the want = %+v, %v; want F naming %s", w, err, first)
|
||||
}
|
||||
second := b.compose(t, "ace", "web", fNew, 'd', delivery.JudgedNeither)
|
||||
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
|
||||
if e, err := b.buckets[delivery.AssignerWantsBucket].Get(b.ctx, wkey); err == nil {
|
||||
if w, err := delivery.DecodeWant(e.Value()); err == nil && w.Composed == second {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if w, err := delivery.DecodeWant(b.get(t, delivery.AssignerWantsBucket, wkey)); err != nil || w.Composed != second {
|
||||
t.Fatalf("after a new revision at the same fingerprint the want = %+v, %v; want it naming %s", w, err, second)
|
||||
}
|
||||
// the node's own want
|
||||
nkey, _ := delivery.WantNodeKey("ace")
|
||||
if e, err := b.buckets[delivery.AssignerWantsBucket].Get(b.ctx, nkey); err == nil {
|
||||
t.Fatalf("the node's own want was written before its composition: %s", e.Value())
|
||||
}
|
||||
ownKey, _ := delivery.ComposedNodeKey("ace")
|
||||
own, _ := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: "sha256:" + strings.Repeat("a", 64), Judgement: delivery.JudgedNeither})
|
||||
b.putJSON(t, delivery.ControllerComposedBucket, ownKey, own)
|
||||
if w, err := delivery.DecodeNodeWant(b.get(t, delivery.AssignerWantsBucket, nkey)); err != nil || w.Composed != "sha256:"+strings.Repeat("a", 64) {
|
||||
t.Fatalf("the node's own want = %+v, %v", w, err)
|
||||
}
|
||||
}
|
||||
|
||||
// fixture is a module web (or the manifest given) at fp on the node ace, which is online: built, granted and assigned.
|
||||
func (b *bed) fixture(t *testing.T, name, fp, manifest string) {
|
||||
t.Helper()
|
||||
@@ -712,10 +674,10 @@ func (b *bed) fixture(t *testing.T, name, fp, manifest string) {
|
||||
b.putJSON(t, delivery.BuilderBuildsBucket, bkey, built)
|
||||
gkey, _ := delivery.ControllerGrantKey(name, fp)
|
||||
grant, _ := delivery.EncodeControllerGrant(delivery.ControllerGrant{})
|
||||
b.putJSON(t, delivery.ControllerGrantsBucket, gkey, grant)
|
||||
b.putJSON(t, delivery.PartGrantsBucket, gkey, grant)
|
||||
on, _ := delivery.EncodePresence(delivery.Presence{Online: true})
|
||||
b.putJSON(t, delivery.PresenceBucket, "ace", on)
|
||||
b.putJSON(t, provider.AssignmentsBucket, "ace."+name, []byte(`{"node":"ace","module":"`+name+`","build":"b-1"}`))
|
||||
b.place(t, "ace", name)
|
||||
}
|
||||
|
||||
func (b *bed) putWant(t *testing.T, node, module string, w delivery.Want) {
|
||||
@@ -778,8 +740,8 @@ func TestAReleaseAnswerWritesTheWantAgain(t *testing.T) {
|
||||
b := startBed(t, "")
|
||||
fNew, fOld := delivery.SourcePrefix2+strings.Repeat("b", 64), delivery.SourcePrefix2+strings.Repeat("a", 64)
|
||||
b.fixture(t, "web", fNew, `{"module":"web","version":"1"}`)
|
||||
b.compose(t, "ace", "web", fNew, 'b', delivery.JudgedNeither)
|
||||
b.compose(t, "ace", "web", fOld, 'a', delivery.JudgedNeither)
|
||||
b.compose(t, "ace", 'b')
|
||||
b.compose(t, "ace", 'a')
|
||||
healthy, _ := delivery.EncodeHave(delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK})
|
||||
b.putJSON(t, delivery.HaveBucket, "ace.web", healthy) // it ran the older fingerprint healthy before
|
||||
failed, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed, Detail: "connection refused"})
|
||||
@@ -794,8 +756,8 @@ func TestAReleaseAnswerWritesTheWantAgain(t *testing.T) {
|
||||
b.get(t, delivery.AssignerAsksBucket, hold)
|
||||
b.answer(t, hold, "release")
|
||||
w = b.wantNow(t, "ace", "web", "the held fingerprint's want written again", func(w delivery.Want) bool { return w.FP == fNew })
|
||||
if w.Composed != "sha256:"+strings.Repeat("b", 64) || w.Rule != 0 {
|
||||
t.Errorf("the want = %+v", w)
|
||||
if w.Composed != "" || !w.Self || w.Rule != 0 {
|
||||
t.Errorf("the want = %+v, want F by the rules, the node composing it", w)
|
||||
}
|
||||
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) {
|
||||
cleared := false
|
||||
@@ -825,19 +787,19 @@ func (b *bed) askNow(t *testing.T, key string, asking int) delivery.AssignerAsk
|
||||
|
||||
// 1906/D4 (ADR 0539 §16), whole on the bus: a module held on a node because its backup failed is asked about, and the answer
|
||||
// is written into the node's want; an answer "again" followed by another failure is asked again, and its answer is another want
|
||||
// value, so the node retries; an old answer is never taken for a new revision at the same fingerprint.
|
||||
// value, so the node retries.
|
||||
func TestABackupAnswerIsWrittenIntoTheNodesWant(t *testing.T) {
|
||||
b := startBed(t, "")
|
||||
fNew := delivery.SourcePrefix2 + strings.Repeat("b", 64)
|
||||
b.fixture(t, "web", fNew, `{"module":"web","version":"1"}`)
|
||||
first := b.compose(t, "ace", "web", fNew, 'b', delivery.JudgedNeither)
|
||||
b.putWant(t, "ace", "web", delivery.Want{FP: fNew, Composed: first})
|
||||
b.compose(t, "ace", 'b')
|
||||
b.putWant(t, "ace", "web", delivery.Want{FP: fNew, Self: true}) // a placed node's want names the build alone
|
||||
held, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseHeld, Health: delivery.HealthWaits, Detail: "no space left"})
|
||||
b.putJSON(t, delivery.HaveBucket, "ace.web", held)
|
||||
b.start(t)
|
||||
akey, _ := delivery.BackupAskKey("ace", "web", fNew)
|
||||
a := b.askNow(t, akey, 0)
|
||||
if !strings.Contains(a.Ask.Explanation, "no space left") || !strings.Contains(a.Ask.Explanation, first) {
|
||||
if !strings.Contains(a.Ask.Explanation, "no space left") {
|
||||
t.Fatalf("the backup ask = %+v", a)
|
||||
}
|
||||
// an answer to another ask is no answer to this one
|
||||
@@ -848,7 +810,7 @@ func TestABackupAnswerIsWrittenIntoTheNodesWant(t *testing.T) {
|
||||
}
|
||||
b.answerTo(t, akey, delivery.BackupAgain, a.Ask.ID)
|
||||
w1 := b.wantNow(t, "ace", "web", "the answer in the want", func(w delivery.Want) bool { return w.Backup == delivery.BackupAgain })
|
||||
if w1.FP != fNew || w1.Composed != first {
|
||||
if w1.FP != fNew || w1.Composed != "" || !w1.Self {
|
||||
t.Errorf("the want = %+v", w1)
|
||||
}
|
||||
// the node retries and the backup fails again: a new have value, asked again under the next number
|
||||
@@ -863,91 +825,10 @@ func TestABackupAnswerIsWrittenIntoTheNodesWant(t *testing.T) {
|
||||
if w2.Words == w1.Words {
|
||||
t.Errorf("the second answer left the want as it was: %+v", w2)
|
||||
}
|
||||
// a new revision at the same fingerprint, whose backup fails as well, is asked, and the old answer is not taken for it
|
||||
second := b.compose(t, "ace", "web", fNew, 'c', delivery.JudgedNeither)
|
||||
b.putWant(t, "ace", "web", delivery.Want{FP: fNew, Composed: second})
|
||||
a3 := b.askNow(t, akey, 3)
|
||||
if !strings.Contains(a3.Ask.Explanation, second) {
|
||||
t.Errorf("the ask for the new revision = %+v", a3)
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
if w := b.wantNow(t, "ace", "web", "the want", func(delivery.Want) bool { return true }); w.Backup != "" {
|
||||
t.Errorf("the old answer was taken for the new revision: %+v", w)
|
||||
}
|
||||
}
|
||||
|
||||
const recorded = `{"module":"web","version":"1","upgrade":{"policy":"record","why":"a person takes each build"}}`
|
||||
|
||||
// 1906/D4 (ADR 0539 §14), whole on the bus: a move of a module of policy record waits for its ask's yes when judged to recreate
|
||||
// a container, to move data or not to be judgeable, at a new fingerprint and at a new revision of the same alike; one judged
|
||||
// neither is written at once.
|
||||
func TestARecordMoveWaitsForTheYesAndANeitherMoveDoesNot(t *testing.T) {
|
||||
b := startBed(t, "")
|
||||
fNew, fOld := delivery.SourcePrefix2+strings.Repeat("b", 64), delivery.SourcePrefix2+strings.Repeat("a", 64)
|
||||
b.fixture(t, "web", fNew, recorded)
|
||||
old := "sha256:" + strings.Repeat("a", 64)
|
||||
b.putWant(t, "ace", "web", delivery.Want{FP: fOld, Composed: old})
|
||||
nw := b.compose(t, "ace", "web", fNew, 'b', delivery.JudgedRecreates)
|
||||
b.start(t)
|
||||
akey, _ := delivery.RecordAskKey("ace", "web", nw)
|
||||
a, err := delivery.DecodeAssignerAsk(akey, b.get(t, delivery.AssignerAsksBucket, akey))
|
||||
if err != nil || !strings.Contains(a.Ask.Explanation, "recreates a container") {
|
||||
t.Fatalf("the record ask = %+v, %v", a, err)
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
if w := b.wantNow(t, "ace", "web", "the old want", func(delivery.Want) bool { return true }); w.FP != fOld {
|
||||
t.Fatalf("the move was written before the yes: %+v", w)
|
||||
}
|
||||
b.answer(t, akey, assign.OptionYes)
|
||||
b.wantNow(t, "ace", "web", "the move after the yes", func(w delivery.Want) bool { return w.FP == fNew && w.Composed == nw })
|
||||
// a new revision at the same fingerprint, judged to move data, waits as well
|
||||
rev := b.compose(t, "ace", "web", fNew, 'c', delivery.JudgedMovesData)
|
||||
rkey, _ := delivery.RecordAskKey("ace", "web", rev)
|
||||
b.get(t, delivery.AssignerAsksBucket, rkey)
|
||||
time.Sleep(time.Second)
|
||||
if w := b.wantNow(t, "ace", "web", "the want", func(delivery.Want) bool { return true }); w.Composed != nw {
|
||||
t.Fatalf("the revision was written before the yes: %+v", w)
|
||||
}
|
||||
// and a revision judged neither is written at once
|
||||
free := b.compose(t, "ace", "web", fNew, 'd', delivery.JudgedNeither)
|
||||
b.wantNow(t, "ace", "web", "a move judged neither written at once", func(w delivery.Want) bool { return w.Composed == free })
|
||||
}
|
||||
|
||||
// 1906/D4 (ADR 0539 §7, §15), whole on the bus: the bus's new fingerprint is not written, and a revision of its composition at the
|
||||
// fingerprint it runs that differs only in its user list is written at once, with the shape the controller gives it: judged
|
||||
// recreates (the file is mounted), policy record from the bus, user-list-only; no record ask is made.
|
||||
func TestAUserListRevisionOfTheBusIsWrittenAtOnce(t *testing.T) {
|
||||
b := startBed(t, "")
|
||||
fNew, fOld := delivery.SourcePrefix2+strings.Repeat("b", 64), delivery.SourcePrefix2+strings.Repeat("a", 64)
|
||||
b.fixture(t, "bus", fNew, `{"module":"bus","version":"1","provides":[{"name":"mesh-bus"}]}`)
|
||||
running, _ := delivery.EncodeHave(delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK})
|
||||
b.putJSON(t, delivery.HaveBucket, "ace.bus", running)
|
||||
b.putWant(t, "ace", "bus", delivery.Want{FP: fOld, Composed: "sha256:" + strings.Repeat("a", 64)})
|
||||
b.compose(t, "ace", "bus", fNew, 'b', delivery.JudgedNeither)
|
||||
b.start(t)
|
||||
time.Sleep(time.Second)
|
||||
if w := b.wantNow(t, "ace", "bus", "the want", func(delivery.Want) bool { return true }); w.FP != fOld {
|
||||
t.Fatalf("the assigner moved the bus to a new fingerprint: %+v", w)
|
||||
}
|
||||
// the controller composes the bus at the fingerprint it runs with a new user list
|
||||
key, _ := delivery.ComposedKey("ace", "bus", fOld)
|
||||
free := "sha256:" + strings.Repeat("e", 64)
|
||||
v, err := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: free, Judgement: delivery.JudgedRecreates, Against: "sha256:" + strings.Repeat("a", 64),
|
||||
Words: "the user list file is mounted", UserListOnly: true, Policy: delivery.PolicyRecord, PolicyFrom: "the bus"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.putJSON(t, delivery.ControllerComposedBucket, key, v)
|
||||
w := b.wantNow(t, "ace", "bus", "the user-list revision written at once", func(w delivery.Want) bool { return w.Composed == free })
|
||||
if w.FP != fOld {
|
||||
t.Errorf("the want = %+v, want it at the fingerprint the bus runs", w)
|
||||
}
|
||||
rkey, _ := delivery.RecordAskKey("ace", "bus", free)
|
||||
if b.hasKey(delivery.AssignerAsksBucket, rkey) {
|
||||
t.Errorf("the user-list revision was asked")
|
||||
}
|
||||
}
|
||||
|
||||
// 1906/H2, whole on the bus: given a fingerprint held after one failed check, when every node that runs the module then runs
|
||||
// it healthy, the assigner lifts the hold by itself: the held record and its ask are taken away, the held condition is
|
||||
// cleared saying why, and the want stays at the fingerprint.
|
||||
@@ -955,7 +836,7 @@ func TestAHoldIsLiftedOnceEveryNodeRunsItHealthy(t *testing.T) {
|
||||
b := startBed(t, "")
|
||||
fNew := delivery.SourcePrefix2 + strings.Repeat("b", 64)
|
||||
b.fixture(t, "web", fNew, `{"module":"web","version":"1"}`)
|
||||
b.compose(t, "ace", "web", fNew, 'b', delivery.JudgedNeither)
|
||||
b.compose(t, "ace", 'b')
|
||||
failed, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed, Detail: "connection refused"})
|
||||
b.putJSON(t, delivery.HaveBucket, "ace.web", failed)
|
||||
b.putWant(t, "ace", "web", delivery.Want{FP: fNew, Composed: "sha256:" + strings.Repeat("b", 64)})
|
||||
@@ -988,7 +869,7 @@ func TestAHoldWrittenAgainAfterALiftAndARestartIsAskedAgain(t *testing.T) {
|
||||
b := startBed(t, "")
|
||||
fNew := delivery.SourcePrefix2 + strings.Repeat("b", 64)
|
||||
b.fixture(t, "web", fNew, `{"module":"web","version":"1"}`)
|
||||
b.compose(t, "ace", "web", fNew, 'b', delivery.JudgedNeither)
|
||||
b.compose(t, "ace", 'b')
|
||||
failed, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed, Detail: "connection refused"})
|
||||
healthy, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthOK})
|
||||
b.putJSON(t, delivery.HaveBucket, "ace.web", failed)
|
||||
@@ -1252,4 +1133,50 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) {
|
||||
if failed.Verdict == nil || !strings.Contains(strings.Join(failed.Verdict.Fails, "; "), "laptop (offline) did not answer") {
|
||||
t.Errorf("the silent node's verdict = %+v", failed.Verdict)
|
||||
}
|
||||
|
||||
// 774/S59 (ADR 0587 §7): a joining node is written on the enrolment's warrant with no node to answer and no ask raised,
|
||||
// and placement-changed carries that warrant. The assigner reads no lease (ADR 0567 §8): the enrolment's own issue waits
|
||||
// for the lease write (ADR 0587 §4).
|
||||
for len(heard) > 0 {
|
||||
<-heard
|
||||
}
|
||||
joined, err := verbs.doPropose(map[string]any{"why": "enrolment of ace", "warrant": `{"node":"ace","address":"10.9.0.2","ask":"enrol-ace-1","warrant":"enrol-ace-1@2026-10-28T20:00:00Z"}`,
|
||||
"changes": `[{"op":"add-node","node":"ace","decided":{"address":"10.9.0.2"},"keys":{"overlay":"dHVubmVs"}}]`}, "novox.mesh-enrolment")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case e := <-heard:
|
||||
if e.Proposal.ID != joined.(proposed).Proposal.ID || e.Warrant == nil || e.Warrant.Warrant != "enrol-ace-1@2026-10-28T20:00:00Z" || len(e.Nodes) != 1 || e.Nodes[0].Node != "ace" {
|
||||
t.Errorf("placement-changed for the joining node = %+v", e)
|
||||
}
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("no placement-changed heard for the joining node")
|
||||
}
|
||||
if ace, err := b.buckets[delivery.PlacementBucket].Get(b.ctx, "ace.node"); err != nil || !strings.Contains(string(ace.Value()), "10.9.0.2") {
|
||||
t.Errorf("the joining node's key = %v, %v", ace, err)
|
||||
}
|
||||
if akey, _ := delivery.PlacementAskKey(joined.(proposed).Proposal.ID); b.hasKey(delivery.AssignerAsksBucket, akey) || b.hasKey(delivery.PlacementProposalsBucket, joined.(proposed).Proposal.ID) {
|
||||
t.Error("an ask was raised for the joining node, or its proposal stays open")
|
||||
}
|
||||
// Its enrolment ends before it confirms: a remove-node on the same warrant deletes its key at once, with no ask, and
|
||||
// placement-changed carries the warrant (the operator's decision of 2026-11-02 01:05).
|
||||
for len(heard) > 0 {
|
||||
<-heard
|
||||
}
|
||||
if _, err := verbs.doPropose(map[string]any{"why": "enrolment of ace revoked", "warrant": `{"node":"ace","address":"10.9.0.2","ask":"enrol-ace-1","warrant":"enrol-ace-1@2026-10-28T20:00:00Z"}`,
|
||||
"changes": `[{"op":"remove-node","node":"ace"}]`}, "novox.mesh-enrolment"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case e := <-heard:
|
||||
if e.Warrant == nil || len(e.Nodes) != 1 || e.Nodes[0].Node != "ace" {
|
||||
t.Errorf("placement-changed for the removed node = %+v", e)
|
||||
}
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("no placement-changed heard for the removed node")
|
||||
}
|
||||
if _, err := b.buckets[delivery.PlacementBucket].Get(b.ctx, "ace.node"); err == nil {
|
||||
t.Error("the removed node's key stays")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery/buildrun"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
"git.novox.be/novox/mesh-sdk/go/provider"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
|
||||
"mesh-delivery-assigner/internal/assign"
|
||||
@@ -58,11 +57,7 @@ func main() {
|
||||
// holds the two lists equal), so the controller's broker grants exactly these.
|
||||
var readBuckets = []string{
|
||||
buscompose.ResolvedBucket,
|
||||
provider.AssignmentsBucket,
|
||||
delivery.ControllerGrantsBucket,
|
||||
delivery.PartGrantsBucket,
|
||||
buscompose.LeaseBucket,
|
||||
delivery.ControllerComposedBucket,
|
||||
delivery.AnswersBucket,
|
||||
delivery.BuilderBuildsBucket,
|
||||
delivery.CandidatesBucket,
|
||||
@@ -128,40 +123,14 @@ type buckets map[string]jetstream.KeyValue
|
||||
// without it and takes no put further until it starts again (ADR 0546).
|
||||
func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any)) (buckets, error) {
|
||||
out := buckets{}
|
||||
// The controller's lease first (ADR 0549 §5, ADR 0561 §3): once it is handed away the seat's placement alone names
|
||||
// where modules run, and the controller's assignments are no bucket the assigner needs. Not granted or not there, the
|
||||
// controller holds it, as before.
|
||||
away, why := false, ""
|
||||
if kv, err := js.KeyValue(ctx, buscompose.LeaseBucket); err != nil {
|
||||
logf("the bucket %s cannot be opened (%v): the controller is taken to hold its lease, so the assignments are read beside the placement", buscompose.LeaseBucket, err)
|
||||
} else {
|
||||
out[buscompose.LeaseBucket] = kv
|
||||
e, gerr := kv.Get(ctx, buscompose.ControllerLeaseKey)
|
||||
var raw []byte
|
||||
if gerr == nil {
|
||||
raw = e.Value()
|
||||
}
|
||||
if errors.Is(gerr, jetstream.ErrKeyNotFound) {
|
||||
gerr = nil
|
||||
}
|
||||
away, why = buscompose.LeaseHandedAway(raw, raw != nil, gerr)
|
||||
}
|
||||
for _, name := range append(append([]string{}, readBuckets...), ownBuckets...) {
|
||||
if name == buscompose.LeaseBucket {
|
||||
continue
|
||||
}
|
||||
kv, err := js.KeyValue(ctx, name)
|
||||
if name == provider.AssignmentsBucket && err != nil && away {
|
||||
logf("the bucket %s cannot be opened (%v), and none is needed: %s, so the seat's placement alone is read", name, err, why)
|
||||
continue
|
||||
}
|
||||
if name == delivery.AssignerPutsBucket && err != nil {
|
||||
logf("the bucket %s cannot be opened (%v): no put is taken further until the assigner starts again", name, err)
|
||||
continue
|
||||
}
|
||||
if slices.Contains(seatBuckets, name) && err != nil {
|
||||
// The seat's states come in the same delivery as this assigner (ADR 0567 §1): without placement the controller's
|
||||
// assignments alone name where modules run, as before any placement was written (ADR 0549 §5).
|
||||
// The seat's states come in the same delivery as this assigner (ADR 0567 §1): without placement no module is placed.
|
||||
logf("the bucket %s cannot be opened (%v): it is bound once the bus says it was made", name, err)
|
||||
continue
|
||||
}
|
||||
@@ -194,10 +163,9 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any
|
||||
logf("the bucket %s cannot be opened (%v): the assigner's conditions are said straight to the router until it starts again", name, err)
|
||||
continue
|
||||
}
|
||||
if sourceOfGrants(name) && err != nil {
|
||||
// The grants part's grants and the controller's lease choose where grants are read from (issue 774): without
|
||||
// them the controller's grants are read, as before the lease is handed away.
|
||||
logf("the bucket %s cannot be opened (%v): the controller's grants are read until the assigner starts again", name, err)
|
||||
if name == delivery.PartGrantsBucket && err != nil {
|
||||
// The grants part's grants (ADR 0571 clause 9): without them no module counts as granted until it is bound.
|
||||
logf("the bucket %s cannot be opened (%v): no grant is read until it is bound once the bus says it was made", name, err)
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
@@ -216,13 +184,7 @@ func startsWithout(name string) bool {
|
||||
delivery.AssignerPutsBucket:
|
||||
return true
|
||||
}
|
||||
return slices.Contains(seatBuckets, name) || sourceOfGrants(name)
|
||||
}
|
||||
|
||||
// sourceOfGrants says the bucket is one of the two that choose the grants' source, the grants part's grants and the
|
||||
// controller's lease: neither stops the assigner when it is not there (delivery.GrantSource).
|
||||
func sourceOfGrants(name string) bool {
|
||||
return name == delivery.PartGrantsBucket || name == buscompose.LeaseBucket
|
||||
return slices.Contains(seatBuckets, name) || name == delivery.PartGrantsBucket
|
||||
}
|
||||
|
||||
// run is the program: it returns when ctx ends or the bus cannot be used.
|
||||
@@ -332,11 +294,7 @@ func (e emitting) Emit(eventType string, body any) error { return e.Conn.Emit(ev
|
||||
// watched only for keys, so a manifest that arrives wakes a composition that waits for it.
|
||||
var watched = []string{
|
||||
buscompose.ResolvedBucket,
|
||||
provider.AssignmentsBucket,
|
||||
delivery.ControllerGrantsBucket,
|
||||
delivery.PartGrantsBucket,
|
||||
buscompose.LeaseBucket,
|
||||
delivery.ControllerComposedBucket,
|
||||
delivery.AnswersBucket,
|
||||
delivery.BuilderBuildsBucket,
|
||||
delivery.CandidatesBucket,
|
||||
@@ -419,13 +377,7 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
|
||||
if e.Operation() == jetstream.KeyValuePut {
|
||||
value = e.Value()
|
||||
}
|
||||
before := w.grantsBucket()
|
||||
w.putEntry(name, e.Key(), value, e.Revision(), e.Created())
|
||||
if name == buscompose.LeaseBucket && w.grantsBucket() == before {
|
||||
// The controller renews its lease every few seconds: only a change of where grants are read from
|
||||
// makes a pass (delivery.GrantSource).
|
||||
continue
|
||||
}
|
||||
wake()
|
||||
}
|
||||
}
|
||||
@@ -461,13 +413,13 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
|
||||
return
|
||||
}
|
||||
if bus != nil {
|
||||
p.acts = newPutActs(bus, wake, logger.Printf, w.handedAway)
|
||||
p.acts = newPutActs(bus, wake, logger.Printf)
|
||||
}
|
||||
logger.Printf("the bucket %s is open: puts are taken further", delivery.AssignerPutsBucket)
|
||||
}
|
||||
started := 0
|
||||
for _, name := range watched {
|
||||
if kvs[name] == nil && (startsWithout(name) || name == provider.AssignmentsBucket) {
|
||||
if kvs[name] == nil && startsWithout(name) {
|
||||
continue // not on the bus at this start (open said so)
|
||||
}
|
||||
started++
|
||||
@@ -483,8 +435,8 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
|
||||
return err
|
||||
}
|
||||
// The seat's states arrive with the same delivery as this assigner (ADR 0567 §1): one not on the bus at this start is
|
||||
// bound once the bus says it was created, and watched from then on, with no restart (issue 1790's kind). Until then the
|
||||
// controller's assignments alone name where modules run (ADR 0549 §5).
|
||||
// bound once the bus says it was created, and watched from then on, with no restart (issue 1790's kind). Until then no
|
||||
// module is placed.
|
||||
type appeared struct {
|
||||
name string
|
||||
kv jetstream.KeyValue
|
||||
@@ -497,7 +449,7 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
|
||||
}
|
||||
}
|
||||
// And the two that choose where grants are read from, with the same delivery as the grants part that writes one.
|
||||
for _, name := range []string{delivery.PartGrantsBucket, buscompose.LeaseBucket, assign.PoliciesBucket, provisions.ProvisionsBucket, keptBucket, delivery.SeatHoldersBucket} {
|
||||
for _, name := range []string{delivery.PartGrantsBucket, assign.PoliciesBucket, provisions.ProvisionsBucket, keptBucket, delivery.SeatHoldersBucket} {
|
||||
if kvs[name] == nil && js != nil {
|
||||
name := name
|
||||
process.AwaitBucket(ctx, js, nc, name, "until then the bucket is not read",
|
||||
@@ -524,10 +476,10 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
|
||||
p := &passer{kvs: kvs, w: w, logf: logger.Printf, say: say, findings: findings, said: map[string]bool{}, answeredAt: map[string]uint64{}}
|
||||
p.asks = newComposeAsks(ctx, buildrun.Asker{Publisher: buildrun.JetStreamAsks{JS: js}, Waiter: buildrun.JetStreamAsks{JS: js}}, wake, logger.Printf)
|
||||
if bus != nil && kvs[delivery.AssignerPutsBucket] != nil {
|
||||
p.acts = newPutActs(bus, wake, logger.Printf, w.handedAway)
|
||||
p.acts = newPutActs(bus, wake, logger.Printf)
|
||||
}
|
||||
if bus != nil {
|
||||
p.seat = newSeatActs(bus, wake, logger.Printf, w.handedAway)
|
||||
p.seat = newSeatActs(bus, wake, logger.Printf)
|
||||
}
|
||||
if js != nil {
|
||||
p.publish = streamPublisher(ctx, js, thisNode)
|
||||
|
||||
@@ -19,10 +19,8 @@ import (
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/provider"
|
||||
)
|
||||
|
||||
// 1297/B2a: the buckets the program opens are exactly the manifest's reads and states, as the broker names them
|
||||
@@ -74,28 +72,21 @@ func TestOpensWhatItsManifestNames(t *testing.T) {
|
||||
}
|
||||
// ADR 0546: whatever keeps the puts' bucket from opening (not there, not granted, no answer in time), the assigner opens
|
||||
// the rest and starts, writing wants; any other bucket it cannot open stops the start, named.
|
||||
// Issue 774 (ADR 0549 §5): the controller's lease not granted, the assigner starts taking it as the controller's; the
|
||||
// assignments refused while the controller holds it stop the start, and once it is handed away they are not needed.
|
||||
handed := []byte(`{"handed-to":"the operator's hand-over","handed-by":"person.jochen"}`)
|
||||
for _, c := range []struct {
|
||||
missing string
|
||||
err error
|
||||
starts bool
|
||||
lease []byte
|
||||
said string
|
||||
}{
|
||||
{delivery.AssignerPutsBucket, jetstream.ErrBucketNotFound, true, nil, "no put is taken further"},
|
||||
{delivery.AssignerPutsBucket, errors.New("nats: permissions violation for subscription to \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_puts\""), true, nil, "no put is taken further"},
|
||||
{delivery.AssignerPutsBucket, context.DeadlineExceeded, true, nil, "no put is taken further"},
|
||||
{delivery.AssignerWantsBucket, errors.New("nats: permissions violation"), false, nil, ""},
|
||||
// Issue 774: the grants part's grants and the controller's lease choose the grants' source; neither stops the start.
|
||||
{delivery.PartGrantsBucket, jetstream.ErrBucketNotFound, true, nil, "the controller's grants are read"},
|
||||
{buscompose.LeaseBucket, errors.New("nats: permissions violation"), true, nil, "taken to hold its lease"},
|
||||
{provider.AssignmentsBucket, errors.New("nats: permissions violation"), false, []byte(`{"instance":"novox/1"}`), ""},
|
||||
{provider.AssignmentsBucket, errors.New("nats: permissions violation"), true, handed, "the seat's placement alone is read"},
|
||||
{delivery.AssignerPutsBucket, jetstream.ErrBucketNotFound, true, "no put is taken further"},
|
||||
{delivery.AssignerPutsBucket, errors.New("nats: permissions violation for subscription to \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_puts\""), true, "no put is taken further"},
|
||||
{delivery.AssignerPutsBucket, context.DeadlineExceeded, true, "no put is taken further"},
|
||||
{delivery.AssignerWantsBucket, errors.New("nats: permissions violation"), false, ""},
|
||||
// Issue 774: the grants part's grants not there do not stop the start.
|
||||
{delivery.PartGrantsBucket, jetstream.ErrBucketNotFound, true, "no grant is read"},
|
||||
} {
|
||||
var logged string
|
||||
kvs, err := open(context.Background(), refusingJS{missing: c.missing, err: c.err, lease: c.lease}, func(f string, a ...any) { logged += fmt.Sprintf(f, a...) })
|
||||
kvs, err := open(context.Background(), refusingJS{missing: c.missing, err: c.err}, func(f string, a ...any) { logged += fmt.Sprintf(f, a...) })
|
||||
switch {
|
||||
case c.starts && (err != nil || kvs[c.missing] != nil || len(kvs) != len(readBuckets)+len(ownBuckets)-1 || !strings.Contains(logged, c.said)):
|
||||
t.Errorf("%s refused with %v: the start answered %v, %d buckets, logged %q", c.missing, c.err, err, len(kvs), logged)
|
||||
@@ -112,12 +103,14 @@ func TestOpensWhatItsManifestNames(t *testing.T) {
|
||||
for _, name := range append(append([]string{}, readBuckets...), ownBuckets...) {
|
||||
kvs[name] = &watchKV{memKV: &memKV{values: map[string][]byte{}}}
|
||||
}
|
||||
ckey, _ := delivery.ComposedNodeKey("ace")
|
||||
cd, err := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: "sha256:" + strings.Repeat("d", 64), Judgement: delivery.JudgedUnchanged})
|
||||
placed, err := delivery.PlacementValues(delivery.Placement{Node: "ace"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kvs[delivery.ControllerComposedBucket].(*watchKV).entries = []kvEntry{{key: ckey, value: cd}}
|
||||
for key, raw := range placed {
|
||||
kv := kvs[delivery.PlacementBucket].(*watchKV)
|
||||
kv.entries = append(kv.entries, kvEntry{key: key, value: raw})
|
||||
}
|
||||
kvs[delivery.AssignerPutsBucket].(*watchKV).closed = true
|
||||
kvs[delivery.AssignerWantsBucket].(*watchKV).wrote = wrote
|
||||
go follow(ctx, nil, nil, kvs, func(asks.Condition) error { return nil }, assign.Findings{}, nil, nil, log.New(io.Discard, "", 0))
|
||||
@@ -136,18 +129,13 @@ type refusingJS struct {
|
||||
jetstream.JetStream
|
||||
missing string
|
||||
err error
|
||||
lease []byte // the controller's lease, when it holds one
|
||||
}
|
||||
|
||||
func (r refusingJS) KeyValue(_ context.Context, name string) (jetstream.KeyValue, error) {
|
||||
if name == r.missing {
|
||||
return nil, r.err
|
||||
}
|
||||
kv := &memKV{values: map[string][]byte{}}
|
||||
if name == buscompose.LeaseBucket && r.lease != nil {
|
||||
kv.values[buscompose.ControllerLeaseKey] = r.lease
|
||||
}
|
||||
return kv, nil
|
||||
return &memKV{values: map[string][]byte{}}, nil
|
||||
}
|
||||
|
||||
// watchKV is a memKV whose watch hands its entries and then the marker that all stands was handed, or ends at once when
|
||||
|
||||
@@ -59,12 +59,10 @@ type passer struct {
|
||||
// nodeFailed is, by node and module, the words of the condition last raised for a failure that is the node's own (ADR 0557
|
||||
// §1, issue 1496): raised once per words, cleared once when it ends.
|
||||
nodeFailed map[string]string
|
||||
// callSaid is, by the key of a refused controller call's condition, what is raised (issue 774): by this process, or by
|
||||
// a previous one and taken over from the findings.
|
||||
callSaid map[string]*callSaid
|
||||
// callsSwept says the first pass took over the refused calls' conditions a previous process raised (sweepCalls).
|
||||
callsSwept bool
|
||||
// The seat mesh-placement's holder (seat.go, ADR 0567): its calls of the controller (nil: none is made), how it says
|
||||
// callsCleared says the first pass cleared the conditions of refused controller calls a previous process raised
|
||||
// (clearStaleCalls): the assigner makes no such call now (issue 774).
|
||||
callsCleared bool
|
||||
// The seat mesh-placement's holder (seat.go, ADR 0567): its acts (nil: none is made), how it says
|
||||
// the seat's events (nil: none is said), the proposals it said, and what Intended computed by proposal and base.
|
||||
seat *seatActs
|
||||
publish publisher
|
||||
@@ -92,8 +90,7 @@ func (p *passer) pass(ctx context.Context) {
|
||||
p.backups(ctx)
|
||||
}
|
||||
|
||||
// nodeWants writes each node's own want, `want.<node>.node`, naming the digest of the node's own composition, once the
|
||||
// controller has composed it and again whenever that digest changes (ADR 0539 §6).
|
||||
// nodeWants writes each placed node's own want, `want.<node>.node`: the node composes its own (ADR 0547 §2, ADR 0571 §6).
|
||||
func (p *passer) nodeWants(ctx context.Context) {
|
||||
written := p.w.read(delivery.AssignerWantsBucket)
|
||||
placed := p.w.placedNodes()
|
||||
@@ -109,20 +106,6 @@ func (p *passer) nodeWants(ctx context.Context) {
|
||||
want := delivery.NodeWant{Self: true, Words: "the node composes its own composition (ADR 0547 §2)"}
|
||||
p.put(ctx, delivery.AssignerWantsBucket, key, func() ([]byte, error) { return delivery.EncodeNodeWant(want) })
|
||||
}
|
||||
for node, d := range p.w.nodeComposed() {
|
||||
if placed[node] {
|
||||
continue
|
||||
}
|
||||
key, err := delivery.WantNodeKey(node)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if before, err := delivery.DecodeNodeWant(written[key]); err == nil && before.Composed == d.Digest {
|
||||
continue
|
||||
}
|
||||
want := delivery.NodeWant{Composed: d.Digest, Words: "the node's own composition"}
|
||||
p.put(ctx, delivery.AssignerWantsBucket, key, func() ([]byte, error) { return delivery.EncodeNodeWant(want) })
|
||||
}
|
||||
}
|
||||
|
||||
// wants writes, for every module the trunk holds a fingerprint of, each node's want by the six rules (ADR 0429 §6), and
|
||||
@@ -130,7 +113,7 @@ func (p *passer) nodeWants(ctx context.Context) {
|
||||
// sends: the differences bucket is kept read-only with its history (ADR 0539 §8).
|
||||
func (p *passer) wants(ctx context.Context) {
|
||||
get := kvGetter(p.kvs[delivery.ManifestsBucket])
|
||||
assigned, _ := p.w.assigned()
|
||||
assigned := p.w.assigned()
|
||||
trunk := p.w.trunk()
|
||||
manifests := map[string]catalogue.Manifest{}
|
||||
for name, mod := range trunk {
|
||||
@@ -156,9 +139,7 @@ func (p *passer) wants(ctx context.Context) {
|
||||
defer p.unplacedFindings()
|
||||
defer p.seatHolders(ctx)
|
||||
defer p.policySplits(manifests, chosen)
|
||||
differ := p.withCalls(p.w.placementDiffers())
|
||||
p.sayCalls(differ)
|
||||
frozen := p.sayPlacement(differ)
|
||||
p.clearStaleCalls()
|
||||
placed := p.w.placedNodes()
|
||||
puts := p.putsByModule()
|
||||
p.staleAsks(ctx, trunk)
|
||||
@@ -186,15 +167,7 @@ func (p *passer) wants(ctx context.Context) {
|
||||
m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Chosen: chosenOf(chosen, name), Provisions: provisionsOf(name), Answering: answering(manifests, nodes), Nodes: nodesOf[name], Now: time.Now(), Puts: puts[name],
|
||||
Self: func(node string) bool { return placed[node] },
|
||||
Composed: func(node, fp string) (delivery.ComposedDigest, bool) {
|
||||
if placed[node] && p.w.handedAway() {
|
||||
return p.w.ownComposed(node) // from the lease write: the digest its own have names (ADR 0571 §6)
|
||||
}
|
||||
if placed[node] {
|
||||
// The node composes its own (ADR 0547 §2, §4): no composition of the controller's is waited for, the want
|
||||
// names the build alone, and the node judges the move itself.
|
||||
return delivery.ComposedDigest{Judgement: delivery.JudgedNeither, Words: "the node composes its own"}, true
|
||||
}
|
||||
return p.w.composed(node, name, fp)
|
||||
return p.w.composed(node, name, fp) // a placed node's own: the digest its own have names (ADR 0571 §6)
|
||||
},
|
||||
GrantedAt: func(fp string) bool {
|
||||
key, err := delivery.ControllerGrantKey(name, fp)
|
||||
@@ -267,9 +240,6 @@ func (p *passer) wants(ctx context.Context) {
|
||||
p.recordAsk(ctx, name, fp, need)
|
||||
}
|
||||
for node, want := range out.Wants {
|
||||
if frozen[node+"."+name] {
|
||||
continue // one source alone names it (ADR 0549 §5): no want, and the one written before stays
|
||||
}
|
||||
key, err := delivery.WantKey(node, name)
|
||||
if err != nil {
|
||||
p.logf("no want of %s on %s is written: %v", name, node, err)
|
||||
@@ -903,42 +873,6 @@ func (p *passer) backupAsk(ctx context.Context, key, node, module string, want d
|
||||
p.said[c.Key] = true
|
||||
}
|
||||
|
||||
// sayPlacement raises, once, the condition of every pair the controller's assignments and the catalogue's placement do not
|
||||
// both name, clears it once they agree, and answers those pairs by node and module (ADR 0549 §5): the pass writes no want
|
||||
// for them and keeps the one written before. A say that fails is made again at the next pass.
|
||||
func (p *passer) sayPlacement(differ []assign.Pair) map[string]bool {
|
||||
frozen := map[string]bool{}
|
||||
now := map[string]bool{}
|
||||
for _, d := range differ {
|
||||
frozen[d.Node+"."+d.Module] = true
|
||||
c := assign.PlacementCondition(d, true)
|
||||
now[c.Key] = true
|
||||
if p.said[c.Key+".raised"] {
|
||||
continue
|
||||
}
|
||||
p.logf("%s on %s is named by %s alone: no want is written for it there until both sources name it (ADR 0549 §5)", d.Module, d.Node, d.Only)
|
||||
if err := p.say(c); err != nil {
|
||||
p.logf("the placement condition of %s on %s could not be said, and is said at the next pass: %v", d.Module, d.Node, err)
|
||||
continue
|
||||
}
|
||||
p.said[c.Key+".raised"] = true
|
||||
}
|
||||
prefix := delivery.AssignerModule + ".placement."
|
||||
for key := range p.said {
|
||||
c, raised := strings.CutSuffix(key, ".raised")
|
||||
if !raised || !strings.HasPrefix(c, prefix) || now[c] {
|
||||
continue
|
||||
}
|
||||
node, module, _ := strings.Cut(strings.TrimPrefix(c, prefix), ".")
|
||||
if err := p.say(assign.PlacementCondition(assign.Pair{Node: node, Module: module}, false)); err != nil {
|
||||
p.logf("the placement condition of %s on %s could not be cleared, and is cleared at the next pass: %v", module, node, err)
|
||||
continue
|
||||
}
|
||||
delete(p.said, key)
|
||||
}
|
||||
return frozen
|
||||
}
|
||||
|
||||
// yeses writes a placed node's wants naming the build alone, and sees to the moves a node that composes its own judged a
|
||||
// person must allow (ADR 0547 §3): for a have of phase
|
||||
// waiting-yes it raises the record ask once, naming the composition waiting and the node's judgement, and on a yes writes
|
||||
@@ -1003,11 +937,8 @@ func (p *passer) policySplits(manifests map[string]catalogue.Manifest, chosen ma
|
||||
now = p.now
|
||||
}
|
||||
split := map[string]bool{}
|
||||
theirs := map[string][]string{}
|
||||
if !p.w.handedAway() { // the controller writes no policy from the lease write (ADR 0571 §6)
|
||||
theirs = assign.ControllerPolicies(p.w.read(delivery.ControllerComposedBucket))
|
||||
}
|
||||
for _, s := range assign.Splits(manifests, chosen, theirs) {
|
||||
// The controller writes no policy any more (ADR 0571 §6): nothing is compared with.
|
||||
for _, s := range assign.Splits(manifests, chosen, nil) {
|
||||
split[s.Module] = true
|
||||
if p.splits[s.Module] {
|
||||
continue
|
||||
|
||||
@@ -8,17 +8,14 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/provider"
|
||||
|
||||
"mesh-delivery-assigner/internal/assign"
|
||||
)
|
||||
@@ -52,6 +49,34 @@ func newPasser(kvs buckets, at time.Time) *passer {
|
||||
now: func() time.Time { return at }}
|
||||
}
|
||||
|
||||
// placeOn puts the seat's placement of node, naming the modules, into the world: one node key and a key per module
|
||||
// (research 072 row 37).
|
||||
func placeOn(t *testing.T, w *world, node string, modules ...string) {
|
||||
t.Helper()
|
||||
pl := delivery.Placement{Node: node}
|
||||
for _, m := range modules {
|
||||
pl.Modules = append(pl.Modules, delivery.PlacedModule{Module: m})
|
||||
}
|
||||
values, err := delivery.PlacementValues(pl)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, raw := range values {
|
||||
w.put(delivery.PlacementBucket, key, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// composes puts the digest the node's own have names as its composition (`<node>.node` of the haves).
|
||||
func composes(t *testing.T, w *world, node, digest string) {
|
||||
t.Helper()
|
||||
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Composed: digest})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, _ := delivery.HaveNodeKey(node)
|
||||
w.put(delivery.HaveBucket, key, raw)
|
||||
}
|
||||
|
||||
var fpTest = delivery.SourcePrefix2 + strings.Repeat("b", 64)
|
||||
|
||||
// 1906/H2: given a hold lifted by itself, when the assigner is started again and the same fingerprint is held again, then the
|
||||
@@ -196,7 +221,7 @@ func putOfTheBus(t *testing.T) {
|
||||
p := newPasser(kvs, now)
|
||||
bus := &fakeBus{calls: make(chan string, 8)}
|
||||
woke := make(chan struct{}, 8) // each act's goroutine wakes a pass once it is done
|
||||
p.acts = newPutActs(bus, func() { woke <- struct{}{} }, func(string, ...any) {}, nil)
|
||||
p.acts = newPutActs(bus, func() { woke <- struct{}{} }, func(string, ...any) {})
|
||||
p.acts.snapshot = func(context.Context, string, string) (string, error) {
|
||||
return "novox's restore point of nats (streams) taken 22:01", nil
|
||||
}
|
||||
@@ -211,7 +236,7 @@ func putOfTheBus(t *testing.T) {
|
||||
}
|
||||
p.w.put(bucket, key, data)
|
||||
}
|
||||
world(provider.AssignmentsBucket, "novox.nats", map[string]string{"node": "novox", "module": "nats"})
|
||||
placeOn(t, p.w, "novox", "nats")
|
||||
mdigest := strings.Repeat("e", 64)
|
||||
mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: mdigest, Repository: "novox/mesh-catalog", Commit: strings.Repeat("c", 40)})
|
||||
world(delivery.ModulesBucket, "nats", mod)
|
||||
@@ -267,12 +292,10 @@ func putOfTheBus(t *testing.T) {
|
||||
t.Errorf("a put asked %q: it reads the compositions and asks nobody for one", call)
|
||||
default:
|
||||
}
|
||||
ckey, _ := delivery.ComposedKey("novox", "nats", fp)
|
||||
cd, _ := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: digest, Judgement: delivery.JudgedCannot, Words: "the have names no composition"})
|
||||
world(delivery.ControllerComposedBucket, ckey, cd)
|
||||
composes(t, p.w, "novox", digest)
|
||||
gkey, _ := delivery.ControllerGrantKey("nats", fp)
|
||||
g, _ := delivery.EncodeControllerGrant(delivery.ControllerGrant{})
|
||||
world(delivery.ControllerGrantsBucket, gkey, g)
|
||||
world(delivery.PartGrantsBucket, gkey, g)
|
||||
got := step()
|
||||
akey, _ := delivery.RecordAskKey("novox", "nats", digest)
|
||||
if got.Step != delivery.PutPerson || got.Ask != akey {
|
||||
@@ -356,7 +379,7 @@ func putOfTheBus(t *testing.T) {
|
||||
// the journal, so it is loud with no operator channel.
|
||||
func TestAModuleOnNeitherListIsRaisedOnceAndClearedOnce(t *testing.T) {
|
||||
// A pair only one source names is a condition of the same kind (issue 1126): folded here to hold the count.
|
||||
aPairOneSourceNamesAloneGetsNoWantAndOneCondition(t)
|
||||
aPlacementAloneNamesWhereModulesRun(t)
|
||||
t.Run("gathers", testThePassGathersTheModulesOnNeitherList)
|
||||
p := newPasser(buckets{}, time.Date(2026, 10, 25, 15, 0, 0, 0, time.UTC))
|
||||
var said []asks.Condition
|
||||
@@ -530,7 +553,7 @@ func TestTheFirstPassAfterAStartClearsWhatAPreviousProcessRaised(t *testing.T) {
|
||||
p.say = func(c asks.Condition) error { said = append(said, c); return nil }
|
||||
mod, _ := delivery.EncodeModule(delivery.Module{FP2: fpTest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: delivery.RootPath})
|
||||
p.w.put(delivery.ModulesBucket, "web", mod)
|
||||
p.w.put(provider.AssignmentsBucket, "g14.web", []byte(`{"node":"g14","module":"web","build":"b-1"}`))
|
||||
placeOn(t, p.w, "g14", "web")
|
||||
on, _ := delivery.EncodePresence(delivery.Presence{Online: true})
|
||||
p.w.put(delivery.PresenceBucket, "g14", on)
|
||||
have, _ := delivery.EncodeHave(delivery.Have{FP: fpTest, Phase: delivery.PhaseRunning, Health: delivery.HealthOK})
|
||||
@@ -568,7 +591,7 @@ func testAHeldModuleRaisesNoNeitherAndClearsAStandingOne(t *testing.T) {
|
||||
p.say = func(c asks.Condition) error { *said = append(*said, c); return nil }
|
||||
mod, _ := delivery.EncodeModule(delivery.Module{FP2: fpTest, Repository: "novox/resolved", Commit: strings.Repeat("1", 40), Path: delivery.RootPath})
|
||||
p.w.put(delivery.ModulesBucket, "systemd-resolved", mod)
|
||||
p.w.put(provider.AssignmentsBucket, "shanks.systemd-resolved", []byte(`{"node":"shanks","module":"systemd-resolved","build":"b-1"}`))
|
||||
placeOn(t, p.w, "shanks", "systemd-resolved")
|
||||
on, _ := delivery.EncodePresence(delivery.Presence{Online: true})
|
||||
p.w.put(delivery.PresenceBucket, "shanks", on)
|
||||
have, _ := delivery.EncodeHave(delivery.Have{FP: fpTest, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed})
|
||||
@@ -632,15 +655,13 @@ func TestAsksOfAFingerprintTheTrunkLeftBehindAreTakenAway(t *testing.T) {
|
||||
name string
|
||||
trunk map[string]delivery.Module
|
||||
gone []string // the keys taken away
|
||||
noDigit bool // the controller's composition is not read
|
||||
heldAt string // the node's want is at this fingerprint, whose composition is digOld
|
||||
noDigit bool // the node's own composition is not read
|
||||
}{
|
||||
{"the trunk moved on", trunkOf(fpNew), []string{holdOld, holdGone, restOld, recOld}, false, ""},
|
||||
{"the trunk is at the old fingerprint", trunkOf(fpOld), []string{holdNew, holdGone, restNew, recOld}, false, ""},
|
||||
{"the trunk's fingerprint is not read yet", trunkOf(""), []string{holdGone}, false, ""},
|
||||
{"the composition is not read yet", trunkOf(fpNew), []string{holdOld, holdGone, restOld}, true, ""},
|
||||
{"a node held back at the old fingerprint is still asked about its composition", trunkOf(fpNew), []string{holdOld, holdGone, restOld}, false, fpOld},
|
||||
{"the trunk is not read at all", nil, nil, false, ""},
|
||||
{"the trunk moved on", trunkOf(fpNew), []string{holdOld, holdGone, restOld, recOld}, false},
|
||||
{"the trunk is at the old fingerprint", trunkOf(fpOld), []string{holdNew, holdGone, restNew, recOld}, false},
|
||||
{"the trunk's fingerprint is not read yet", trunkOf(""), []string{holdGone}, false},
|
||||
{"the composition is not read yet", trunkOf(fpNew), []string{holdOld, holdGone, restOld}, true},
|
||||
{"the trunk is not read at all", nil, nil, false},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
asksKV, wantsKV := &memKV{values: map[string][]byte{}}, &memKV{values: map[string][]byte{}}
|
||||
@@ -667,21 +688,9 @@ func TestAsksOfAFingerprintTheTrunkLeftBehindAreTakenAway(t *testing.T) {
|
||||
asksKV.values[key] = v
|
||||
p.w.put(delivery.AssignerAsksBucket, key, v)
|
||||
}
|
||||
placeOn(t, p.w, "novox", "web") // a placed node composes its own: the digest its have names
|
||||
if !c.noDigit {
|
||||
ckey, _ := delivery.ComposedKey("novox", "web", fpNew)
|
||||
v, _ := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: digNew, Judgement: delivery.JudgedUnchanged})
|
||||
p.w.put(delivery.ControllerComposedBucket, ckey, v)
|
||||
okey, _ := delivery.ComposedKey("novox", "web", fpOld)
|
||||
old, _ := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: digNew, Judgement: delivery.JudgedUnchanged})
|
||||
if c.heldAt != "" {
|
||||
old, _ = delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: digOld, Judgement: delivery.JudgedUnchanged})
|
||||
}
|
||||
p.w.put(delivery.ControllerComposedBucket, okey, old)
|
||||
}
|
||||
if c.heldAt != "" {
|
||||
wkey, _ := delivery.WantKey("novox", "web")
|
||||
w, _ := delivery.EncodeWant(delivery.Want{FP: c.heldAt, Composed: digOld})
|
||||
p.w.put(delivery.AssignerWantsBucket, wkey, w)
|
||||
composes(t, p.w, "novox", digNew)
|
||||
}
|
||||
heldOld, _ := delivery.HeldKey("web", fpOld)
|
||||
wantsKV.values[heldOld] = []byte("x")
|
||||
@@ -779,134 +788,38 @@ func mustKey(key string, err error) string {
|
||||
func TestARecordAskOfAHeldBackNodeSurvivesPassesAndItsYesMovesTheWant(t *testing.T) {
|
||||
// A placed node's yes comes from its have (issue 1126): folded here to hold the count.
|
||||
aPlacedNodesWantsNameTheBuildAloneAndItsYesComesFromItsHave(t)
|
||||
fpOld := delivery.SourcePrefix2 + strings.Repeat("a", 64)
|
||||
revised, was := "sha256:"+strings.Repeat("e", 64), "sha256:"+strings.Repeat("d", 64)
|
||||
asksKV, wantsKV := &memKV{values: map[string][]byte{}}, &memKV{values: map[string][]byte{}}
|
||||
p := newPasser(buckets{delivery.AssignerAsksBucket: asksKV, delivery.AssignerWantsBucket: wantsKV}, time.Now())
|
||||
p.logf = t.Logf
|
||||
put := func(bucket, key string, v []byte) { p.w.put(bucket, key, v) }
|
||||
mod, _ := delivery.EncodeModule(delivery.Module{FP2: fpTest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: delivery.RootPath})
|
||||
put(delivery.ModulesBucket, "web", mod) // the trunk's build is not done: rule 1 holds g14 back at fpOld
|
||||
put(provider.AssignmentsBucket, "g14.web", []byte(`{"node":"g14","module":"web","build":"b-1"}`))
|
||||
on, _ := delivery.EncodePresence(delivery.Presence{Online: true})
|
||||
put(delivery.PresenceBucket, "g14", on)
|
||||
have, _ := delivery.EncodeHave(delivery.Have{FP: fpOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK})
|
||||
put(delivery.HaveBucket, "g14.web", have)
|
||||
wkey, _ := delivery.WantKey("g14", "web")
|
||||
want, _ := delivery.EncodeWant(delivery.Want{FP: fpOld, Composed: was})
|
||||
wantsKV.values[wkey] = want
|
||||
put(delivery.AssignerWantsBucket, wkey, want)
|
||||
ckey, _ := delivery.ComposedKey("g14", "web", fpOld)
|
||||
cd, cerr := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: revised, Judgement: delivery.JudgedRecreates, Policy: delivery.PolicyRecord, PolicyFrom: "default", Against: was, Words: "recreates"})
|
||||
if cerr != nil {
|
||||
t.Fatal(cerr)
|
||||
}
|
||||
put(delivery.ControllerComposedBucket, ckey, cd)
|
||||
tkey, _ := delivery.ComposedKey("g14", "web", fpTest) // the trunk's own composition, another digest than the revised one
|
||||
tcd, _ := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: "sha256:" + strings.Repeat("f", 64), Judgement: delivery.JudgedUnchanged})
|
||||
put(delivery.ControllerComposedBucket, tkey, tcd)
|
||||
akey, _ := delivery.RecordAskKey("g14", "web", revised)
|
||||
for i := 0; i < 2; i++ {
|
||||
p.wants(context.Background())
|
||||
if _, there := asksKV.values[akey]; !there {
|
||||
t.Fatalf("pass %d: the record ask of the held-back node is not there", i+1)
|
||||
}
|
||||
}
|
||||
a, err := delivery.DecodeAssignerAsk(akey, asksKV.values[akey])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
yes, _ := delivery.EncodeAnswer(delivery.Answer{Option: assign.OptionYes, Warrant: a.Ask.ID + "@2026-10-26T01:00:00Z", Words: "the operator"})
|
||||
put(delivery.AnswersBucket, akey, yes)
|
||||
p.wants(context.Background())
|
||||
got, err := delivery.DecodeWant(wantsKV.values[wkey])
|
||||
if err != nil || got.FP != fpOld || got.Composed != revised {
|
||||
t.Errorf("after the yes the want is %+v (%v), want it at %s naming %s", got, err, fpOld, revised)
|
||||
}
|
||||
if _, there := asksKV.values[akey]; !there {
|
||||
t.Error("the answered record ask was taken away")
|
||||
}
|
||||
}
|
||||
|
||||
// 1126 (ADR 0549 §5): until the third milestone a pair the controller's assignments and the catalogue's placement do not
|
||||
// both name gets no want and one condition, raised once and cleared once both name it; before any placement was read the
|
||||
// assignments are the one source and nothing is raised.
|
||||
func aPairOneSourceNamesAloneGetsNoWantAndOneCondition(t *testing.T) {
|
||||
// 774/S26/AC1 (ADR 0549 §5, ADR 0567 §8): the seat's placement alone names where modules run: no controller state is read, so a
|
||||
// module in an assignment only is no module of its node; before any placement is read nothing is placed and the check composes
|
||||
// no input; placed, the check composes over the input the nodes compose from (frombus, 1126, ADR 0547 §2).
|
||||
func aPlacementAloneNamesWhereModulesRun(t *testing.T) {
|
||||
p := newPasser(buckets{}, time.Date(2026, 10, 26, 18, 0, 0, 0, time.UTC))
|
||||
var said []asks.Condition
|
||||
p.say = func(c asks.Condition) error { said = append(said, c); return nil }
|
||||
assigned := func(node, module string) {
|
||||
raw, _ := json.Marshal(map[string]string{"node": node, "module": module})
|
||||
p.w.put(provider.AssignmentsBucket, node+"."+module, raw)
|
||||
}
|
||||
place := func(node string, modules ...string) {
|
||||
pl := delivery.Placement{Node: node}
|
||||
for _, m := range modules {
|
||||
pl.Modules = append(pl.Modules, delivery.PlacedModule{Module: m})
|
||||
}
|
||||
// One node key and a key per module (research 072 row 37); a module taken off leaves its key, which the node
|
||||
// key no longer names.
|
||||
values, err := delivery.PlacementValues(pl)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, raw := range values {
|
||||
p.w.put(delivery.PlacementBucket, key, raw)
|
||||
}
|
||||
}
|
||||
assigned("g14", "a")
|
||||
assigned("g14", "b")
|
||||
if got, _ := p.w.assigned(); !slices.Equal(got["g14"], []string{"a", "b"}) || len(p.sayPlacement(p.w.placementDiffers())) != 0 || len(said) != 0 {
|
||||
t.Fatalf("before any placement: assigned %v, said %v", got, said)
|
||||
p.w.put("mesh-controller_assignments", "g14.b", []byte(`{"node":"g14","module":"b","build":"b-1"}`)) // read no more
|
||||
if got := p.w.assigned(); len(got) != 0 {
|
||||
t.Fatalf("before any placement: assigned %v", got)
|
||||
}
|
||||
none := func(context.Context, string) ([]byte, error) { return nil, errNotThere }
|
||||
if m := p.w.mesh(context.Background(), none); m.Input != nil || m.InputUnread != "" {
|
||||
t.Errorf("before any placement the check composes over %+v (%q)", m.Input, m.InputUnread)
|
||||
}
|
||||
place("g14", "a", "c")
|
||||
got, _ := p.w.assigned()
|
||||
frozen := p.sayPlacement(p.w.placementDiffers())
|
||||
p.sayPlacement(p.w.placementDiffers())
|
||||
if !slices.Equal(got["g14"], []string{"a"}) || !frozen["g14.b"] || !frozen["g14.c"] || frozen["g14.a"] || len(said) != 2 ||
|
||||
said[0].State != asks.StateRaised || !strings.Contains(said[0].Explanation, "the controller's assignments names b on g14") ||
|
||||
!strings.Contains(said[1].Explanation, "the seat mesh-placement's placement names c on g14") {
|
||||
t.Fatalf("one source alone: assigned %v, frozen %v, said %+v", got, frozen, said)
|
||||
placeOn(t, p.w, "g14", "c", "a")
|
||||
placeOn(t, p.w, "n2", "d")
|
||||
if got := p.w.assigned(); !slices.Equal(got["g14"], []string{"a", "c"}) || !slices.Equal(got["n2"], []string{"d"}) || len(got) != 2 {
|
||||
t.Errorf("the placement alone: assigned %v", got)
|
||||
}
|
||||
place("g14", "a", "b", "c")
|
||||
assigned("g14", "c")
|
||||
if frozen := p.sayPlacement(p.w.placementDiffers()); len(frozen) != 0 || len(said) != 4 || said[2].State != asks.StateCleared || said[3].State != asks.StateCleared {
|
||||
t.Errorf("both agree again: frozen %v, said %+v", frozen, said)
|
||||
}
|
||||
|
||||
// 1126 (ADR 0547 §2): placed, the check composes over the input the nodes compose from (frombus), once each placed node
|
||||
// wrote its record; until then it says why and composes the assignments alone.
|
||||
if m := p.w.mesh(context.Background(), none); m.Input != nil || !strings.Contains(m.InputUnread, "g14") {
|
||||
t.Errorf("a placed node with no record: input %+v, unread %q", m.Input, m.InputUnread)
|
||||
}
|
||||
placeOn(t, p.w, "g14", "a", "b", "c")
|
||||
rec, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "g14", TunnelKey: "k"}})
|
||||
p.w.put(delivery.HaveBucket, "g14.node", rec)
|
||||
rec2, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "n2", TunnelKey: "k2"}})
|
||||
p.w.put(delivery.HaveBucket, "n2.node", rec2)
|
||||
m := p.w.mesh(context.Background(), none)
|
||||
if m.Input == nil || len(m.Input.Nodes) != 1 || !slices.Equal(m.Input.Nodes[0].Assigned, []string{"a", "b", "c"}) || m.Input.Nodes[0].Place.Key != "k" {
|
||||
if m.Input == nil || len(m.Input.Nodes) != 2 || !slices.Equal(m.Input.Nodes[0].Assigned, []string{"a", "b", "c"}) || m.Input.Nodes[0].Place.Key != "k" {
|
||||
t.Errorf("the check's input from the bus: %+v (%q)", m.Input, m.InputUnread)
|
||||
}
|
||||
|
||||
// Issue 774 (ADR 0549 §5, §6): once the controller's lease is handed away the placement alone names where modules
|
||||
// run: a pair in the placement only gets its want and no condition, and one in the assignments only is no module of
|
||||
// the node. A lease that goes back to a controller brings the comparison back.
|
||||
place("n2", "d")
|
||||
assigned("g14", "e")
|
||||
p.w.put(buscompose.LeaseBucket, buscompose.ControllerLeaseKey, []byte(`{"handed-to":"the operator's hand-over","handed-by":"person.jochen"}`))
|
||||
got, all := p.w.assigned()
|
||||
if !slices.Equal(got["n2"], []string{"d"}) || !slices.Equal(got["g14"], []string{"a", "b", "c"}) || len(all) != 0 {
|
||||
t.Errorf("handed away: assigned %v, assignments %v; want the placement alone", got, all)
|
||||
}
|
||||
if frozen := p.sayPlacement(p.w.placementDiffers()); len(frozen) != 0 || len(said) != 4 {
|
||||
t.Errorf("handed away: frozen %v, said %+v; want no condition", frozen, said[4:])
|
||||
}
|
||||
p.w.put(buscompose.LeaseBucket, buscompose.ControllerLeaseKey, []byte(`{"instance":"novox/1","epoch":58}`))
|
||||
if frozen := p.sayPlacement(p.w.placementDiffers()); !frozen["n2.d"] || !frozen["g14.e"] {
|
||||
t.Errorf("a controller's lease again: frozen %v; want both pairs held", frozen)
|
||||
}
|
||||
}
|
||||
|
||||
// 1126 (ADR 0547 §2 to §4): a node the catalogue places composes its own: its wants name the build alone and its own want
|
||||
@@ -921,7 +834,7 @@ func aPlacedNodesWantsNameTheBuildAloneAndItsYesComesFromItsHave(t *testing.T) {
|
||||
put := func(bucket, key string, v []byte) { p.w.put(bucket, key, v) }
|
||||
mod, _ := delivery.EncodeModule(delivery.Module{FP2: fpTest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: delivery.RootPath})
|
||||
put(delivery.ModulesBucket, "web", mod) // the trunk's build is not done: rule 1 holds g14 at fpOld
|
||||
put(provider.AssignmentsBucket, "g14.web", []byte(`{"node":"g14","module":"web","build":"b-1"}`))
|
||||
placeOn(t, p.w, "g14", "web")
|
||||
pl, _ := delivery.EncodePlacement(delivery.Placement{Node: "g14", Modules: []delivery.PlacedModule{{Module: "web"}}})
|
||||
put(delivery.PlacementBucket, "placement.g14", pl)
|
||||
on, _ := delivery.EncodePresence(delivery.Presence{Online: true})
|
||||
@@ -958,15 +871,10 @@ func aPlacedNodesWantsNameTheBuildAloneAndItsYesComesFromItsHave(t *testing.T) {
|
||||
if _, there := asksKV.values[akey]; !there {
|
||||
t.Fatal("a have waiting for a yes raised no record ask")
|
||||
}
|
||||
// Issue 774, 2026-10-31: while the controller still composes the placed node's module (its lease not handed away), its
|
||||
// digests are not the one the node's have waits on; the ask stays, and a pass that changes nothing puts nothing: on novox
|
||||
// the asks of postgres, keycloak and mongodb were taken away and made again every 8 s, and a want put unchanged each
|
||||
// pass made the node compose again each time.
|
||||
for _, fp := range []string{fpTest, fpOld} {
|
||||
ck, _ := delivery.ComposedKey("g14", "web", fp)
|
||||
cv, _ := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: "sha256:" + strings.Repeat("c", 64), Judgement: delivery.JudgedUnchanged})
|
||||
put(delivery.ControllerComposedBucket, ck, cv)
|
||||
}
|
||||
// Issue 774, 2026-10-31: the node's own composition is not the digest its have waits on; the ask stays, and a pass that
|
||||
// changes nothing puts nothing: on novox the asks of postgres, keycloak and mongodb were taken away and made again every
|
||||
// 8 s, and a want put unchanged each pass made the node compose again each time.
|
||||
composes(t, p.w, "g14", "sha256:"+strings.Repeat("c", 64))
|
||||
asked := asksKV.values[akey]
|
||||
p.wants(context.Background())
|
||||
if !bytes.Equal(asksKV.values[akey], asked) {
|
||||
@@ -1003,178 +911,37 @@ func aPlacedNodesWantsNameTheBuildAloneAndItsYesComesFromItsHave(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// refusingBus is the assigner's process account against a controller that refuses the calls whose verb is in refuse.
|
||||
type refusingBus struct {
|
||||
fakeBus
|
||||
refuse map[string]string
|
||||
// pins are the controller's pins its placement-export answers, by node.
|
||||
mu sync.Mutex
|
||||
pins map[string]map[string]delivery.Provider
|
||||
}
|
||||
|
||||
func (b *refusingBus) setPins(node string, pins map[string]delivery.Provider) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.pins == nil {
|
||||
b.pins = map[string]map[string]delivery.Provider{}
|
||||
// 774/S25/AC1 (ADR 0567 §8): an approved placement change is written and followed with its warrant recorded and
|
||||
// placement-changed said, and no verb of the controller is called; the refused-call conditions a previous process raised are
|
||||
// cleared once, and said again at a later pass while the findings cannot be read.
|
||||
func TestAnApprovedPlacementCallsNoControllerVerb(t *testing.T) {
|
||||
p := newPasser(buckets{}, time.Date(2026, 11, 2, 9, 0, 0, 0, time.UTC))
|
||||
bus := &fakeBus{calls: make(chan string, 8)}
|
||||
p.seat = newSeatActs(bus, func() {}, func(string, ...any) {})
|
||||
var said []string
|
||||
p.publish = func(event string, _ any, _ string) error { said = append(said, event); return nil }
|
||||
prop := delivery.PlacementProposal{ID: "p-one", By: "operator@laptop", Why: "move web"}
|
||||
change := delivery.ChangeOf(delivery.Placement{Node: "anchor"}, delivery.Placement{Node: "anchor", Modules: []delivery.PlacedModule{{Module: "web"}}})
|
||||
p.followWrite(prop, delivery.Answer{}, []delivery.PlacementChange{change})
|
||||
if len(bus.calls) != 0 || !slices.Equal(said, []string{delivery.PlacementChangedEvent}) {
|
||||
t.Fatalf("calls = %d, said = %v", len(bus.calls), said)
|
||||
}
|
||||
b.pins[node] = pins
|
||||
}
|
||||
|
||||
func (b *refusingBus) Call(key string, body any) (json.RawMessage, error) {
|
||||
if key == exportCall {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
node, _ := body.(map[string]any)["node"].(string)
|
||||
answer, _ := json.Marshal(map[string]delivery.Placement{node: {V: delivery.Version, Node: node, Pins: b.pins[node]}})
|
||||
return json.Marshal(map[string]any{"ok": true, "answer": json.RawMessage(answer)})
|
||||
}
|
||||
for verb, words := range b.refuse {
|
||||
if strings.HasSuffix(key, "."+verb) {
|
||||
return nil, errors.New(words)
|
||||
}
|
||||
}
|
||||
return json.RawMessage(`{"ok":true}`), nil
|
||||
}
|
||||
|
||||
// Issue 774: a call of the controller it refuses is a condition, never only a journal line: raised once, urgent, naming the verb,
|
||||
// the node, the module and the refusal; cleared when a later call for the pair is answered, or, for a module's place, once the
|
||||
// pair differed and the two sources agree.
|
||||
func TestARefusedControllerCallIsACondition(t *testing.T) {
|
||||
p := newPasser(buckets{}, time.Date(2026, 10, 30, 21, 4, 0, 0, time.UTC))
|
||||
var conds []asks.Condition
|
||||
p.say = func(c asks.Condition) error { conds = append(conds, c); return nil }
|
||||
bus := &refusingBus{refuse: map[string]string{"assign": `assign takes no argument "why" — it takes "build", "module", "node"; nothing was done`}}
|
||||
woke := make(chan struct{}, 4)
|
||||
p.seat = newSeatActs(bus, func() { woke <- struct{}{} }, func(string, ...any) {}, func() bool { return false })
|
||||
assignWeb := assign.ControllerCall{Verb: "assign", Node: "anchor", Module: "web", Args: map[string]any{"node": "anchor", "module": "web"}}
|
||||
call := func(c assign.ControllerCall) {
|
||||
p.seat.callAll([]assign.ControllerCall{c})
|
||||
<-woke
|
||||
fails := 1
|
||||
p.findings.Keys = func() ([]string, error) {
|
||||
if fails > 0 {
|
||||
fails--
|
||||
return nil, errors.New("the bucket is not there yet")
|
||||
}
|
||||
return []string{"module.anchor.web.controller-call", "module.laptop.db.controller-pin", "module.anchor.web.neither"}, nil
|
||||
}
|
||||
differ := []assign.Pair{{Node: "anchor", Module: "web", Only: assign.OnlyPlacement}}
|
||||
|
||||
call(assignWeb)
|
||||
p.sayCalls(differ)
|
||||
p.sayCalls(differ)
|
||||
if len(conds) != 1 || conds[0].State != asks.StateRaised || conds[0].Key != "mesh-delivery-assigner.controller-call.anchor.web" ||
|
||||
!strings.Contains(conds[0].Explanation, `takes no argument "why"`) || !strings.Contains(conds[0].Headline, "assign web on anchor") {
|
||||
t.Fatalf("after a refused assign, said = %+v", conds)
|
||||
}
|
||||
|
||||
bus.refuse = nil
|
||||
call(assignWeb)
|
||||
p.sayCalls(nil)
|
||||
if len(conds) != 2 || conds[1].State != asks.StateCleared || conds[1].Key != conds[0].Key {
|
||||
t.Fatalf("after the assign is answered, said = %+v", conds)
|
||||
}
|
||||
|
||||
// Refused again, then assigned by hand: cleared once the pair, seen differing, agrees; not before it was seen differing.
|
||||
bus.refuse = map[string]string{"assign": "refused"}
|
||||
call(assignWeb)
|
||||
p.sayCalls(nil)
|
||||
if len(conds) != 3 || conds[2].State != asks.StateRaised {
|
||||
t.Fatalf("a second refusal is not raised: %+v", conds)
|
||||
}
|
||||
p.sayCalls(nil)
|
||||
if len(conds) != 3 {
|
||||
t.Fatalf("cleared before the pair was seen differing: %+v", conds)
|
||||
}
|
||||
p.sayCalls(differ)
|
||||
p.sayCalls(nil)
|
||||
if len(conds) != 4 || conds[3].State != asks.StateCleared || !strings.Contains(conds[3].Resolved, "agree") {
|
||||
t.Fatalf("the hand assign does not clear it: %+v", conds)
|
||||
}
|
||||
p.sayCalls(nil)
|
||||
if len(conds) != 4 {
|
||||
t.Fatalf("said again after the clear: %+v", conds)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 774: a refused pin's condition, and one a previous process raised, clear once the pair agrees after it was seen differing:
|
||||
// the controller's pins (its placement-export, read again when its compositions change) against the placement's, the controller's
|
||||
// assignments against the placement for a module's place. A start whose findings cannot be read takes them over at a later pass.
|
||||
func TestARefusedCallClearsWhenTheSourcesAgree(t *testing.T) {
|
||||
store := delivery.Provider{Node: "anchor", Module: "store"}
|
||||
pinDB := assign.ControllerCall{Verb: "pin", Node: "laptop", Module: "db",
|
||||
Args: map[string]any{"node": "laptop", "provision": "db", "from": "anchor", "module": "store"}}
|
||||
placeWeb := assign.ControllerCall{Verb: "assign", Node: "anchor", Module: "web", Args: map[string]any{"node": "anchor", "module": "web"}}
|
||||
webDiffers := []assign.Pair{{Node: "anchor", Module: "web", Only: assign.OnlyPlacement}}
|
||||
for name, c := range map[string]struct {
|
||||
call *assign.ControllerCall // refused in this process; nil: raised by a previous one
|
||||
findings []string // the findings a previous process left
|
||||
keysFail int // how many first reads of the findings fail
|
||||
differ []assign.Pair // the pairs the two sources name apart, while they differ
|
||||
agree func(*refusingBus) // the person's hand call
|
||||
key string
|
||||
}{
|
||||
"a pin refused, pinned by hand": {call: &pinDB, agree: func(b *refusingBus) { b.setPins("laptop", map[string]delivery.Provider{"db": store}) },
|
||||
key: "mesh-delivery-assigner.controller-pin.laptop.db"},
|
||||
"a place refused, assigned by hand": {call: &placeWeb, differ: webDiffers, agree: func(*refusingBus) {},
|
||||
key: "mesh-delivery-assigner.controller-call.anchor.web"},
|
||||
"a pin raised before a start": {findings: []string{"module.laptop.db.controller-pin"}, keysFail: 1,
|
||||
agree: func(b *refusingBus) { b.setPins("laptop", map[string]delivery.Provider{"db": store}) },
|
||||
key: "mesh-delivery-assigner.controller-pin.laptop.db"},
|
||||
"a place raised before a start": {findings: []string{"module.anchor.web.controller-call"}, differ: webDiffers, agree: func(*refusingBus) {},
|
||||
key: "mesh-delivery-assigner.controller-call.anchor.web"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
p := newPasser(buckets{}, time.Date(2026, 10, 30, 21, 4, 0, 0, time.UTC))
|
||||
var conds []asks.Condition
|
||||
p.say = func(c asks.Condition) error { conds = append(conds, c); return nil }
|
||||
fails := c.keysFail
|
||||
p.findings.Keys = func() ([]string, error) {
|
||||
if fails > 0 {
|
||||
fails--
|
||||
return nil, errors.New("the bucket is not there yet")
|
||||
}
|
||||
return c.findings, nil
|
||||
}
|
||||
bus := &refusingBus{refuse: map[string]string{"pin": "refused", "assign": "refused"}}
|
||||
woke := make(chan struct{}, 16)
|
||||
p.seat = newSeatActs(bus, func() { woke <- struct{}{} }, func(string, ...any) {}, func() bool { return false })
|
||||
// The placement pins db on laptop; the controller does not.
|
||||
raw, _ := delivery.EncodePlacement(delivery.Placement{V: delivery.Version, Node: "laptop", Pins: map[string]delivery.Provider{"db": store}})
|
||||
key, _ := delivery.LegacyPlacementKey("laptop")
|
||||
p.w.put(delivery.PlacementBucket, key, raw)
|
||||
// pass is a pass, then one more for each background read it started.
|
||||
pass := func(differ []assign.Pair) {
|
||||
p.sayCalls(differ)
|
||||
for {
|
||||
select {
|
||||
case <-woke:
|
||||
p.sayCalls(differ)
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.call != nil {
|
||||
p.seat.callAll([]assign.ControllerCall{*c.call})
|
||||
<-woke
|
||||
}
|
||||
pass(c.differ)
|
||||
pass(c.differ)
|
||||
for _, cond := range conds {
|
||||
if cond.State == asks.StateCleared {
|
||||
t.Fatalf("cleared while the sources differ: %+v", conds)
|
||||
}
|
||||
}
|
||||
c.agree(bus)
|
||||
p.w.put(delivery.ControllerComposedBucket, "laptop.x", []byte("moved")) // a pin changes a composition
|
||||
pass(nil)
|
||||
if len(conds) == 0 {
|
||||
t.Fatal("nothing was raised")
|
||||
}
|
||||
last := conds[len(conds)-1]
|
||||
if last.Key != c.key || last.State != asks.StateCleared || !strings.Contains(last.Resolved, "agree") {
|
||||
t.Fatalf("the hand call does not clear it: %+v", conds)
|
||||
}
|
||||
n := len(conds)
|
||||
pass(nil)
|
||||
if len(conds) != n {
|
||||
t.Errorf("said again after the clear: %+v", conds[n:])
|
||||
}
|
||||
})
|
||||
p.clearStaleCalls()
|
||||
p.clearStaleCalls()
|
||||
p.clearStaleCalls()
|
||||
if len(conds) != 2 || conds[0].State != asks.StateCleared || conds[0].Key != "mesh-delivery-assigner.controller-call.anchor.web" ||
|
||||
conds[1].Key != "mesh-delivery-assigner.controller-pin.laptop.db" {
|
||||
t.Fatalf("the old conditions cleared = %+v", conds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,8 +55,8 @@ type putActs struct {
|
||||
snapshot func(ctx context.Context, node, module string) (string, error)
|
||||
}
|
||||
|
||||
func newPutActs(bus caller, wake func(), logf func(string, ...any), leaseAway func() bool) *putActs {
|
||||
a := &putActs{rec: handacts.Recorder{Ask: bus.Call, LeaseAway: leaseAway}, bus: bus, wake: wake, logf: logf, snapping: map[string]bool{}, snapped: map[string]string{}, snapNo: map[string]string{}, warranted: map[string]bool{}, warrantNo: map[string]string{},
|
||||
func newPutActs(bus caller, wake func(), logf func(string, ...any)) *putActs {
|
||||
a := &putActs{rec: handacts.Recorder{Ask: bus.Call}, bus: bus, wake: wake, logf: logf, snapping: map[string]bool{}, snapped: map[string]string{}, snapNo: map[string]string{}, warranted: map[string]bool{}, warrantNo: map[string]string{},
|
||||
said: map[string]string{}}
|
||||
a.snapshot = a.snapshotTheBus
|
||||
return a
|
||||
@@ -97,7 +97,7 @@ func (p *passer) puts(ctx context.Context) {
|
||||
if len(all) == 0 {
|
||||
return
|
||||
}
|
||||
assigned, _ := p.w.assigned()
|
||||
assigned := p.w.assigned()
|
||||
trunk := p.w.trunk()
|
||||
get := kvGetter(p.kvs[delivery.ManifestsBucket])
|
||||
wanted := p.w.wantsOf()
|
||||
|
||||
@@ -10,14 +10,12 @@ import (
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/judge"
|
||||
@@ -59,80 +57,23 @@ func streamPublisher(ctx context.Context, js jetstream.JetStream, node string) p
|
||||
}
|
||||
}
|
||||
|
||||
// seatActs keeps what the seat's calls of the controller came to while this process runs: the export read again at a
|
||||
// seed's warrant, and each call that keeps the controller's assignments in step (§8).
|
||||
// seatActs keeps the seat's acts while this process runs: the warrant recorded in the hand-act log. The seat calls no verb of
|
||||
// the controller: placement is the one source (ADR 0567 §8, issue 774).
|
||||
type seatActs struct {
|
||||
mu sync.Mutex
|
||||
bus caller
|
||||
wake func()
|
||||
logf func(string, ...any)
|
||||
export map[string]exportCheck
|
||||
// calls is, by pair (node and module, or node and provision for a pin), what the last call for it came to.
|
||||
calls map[string]string
|
||||
// outcomes is, by the key of a call's condition, the last call made for its pair once answered or refused: refused
|
||||
// "" when the controller answered it (issue 774: a refused call is a condition, never only a journal line).
|
||||
outcomes map[string]callOutcome
|
||||
// pins is, by node, the controller's pins as its placement-export last answered them (pinsOf).
|
||||
pins map[string]pinRead
|
||||
bus caller
|
||||
wake func()
|
||||
logf func(string, ...any)
|
||||
// rec records the seat's acts in the hand-act log (ADR 0565 §11).
|
||||
rec handacts.Recorder
|
||||
}
|
||||
|
||||
// callOutcome is what one call of the controller came to: the call, and the controller's refusal ("" when it answered).
|
||||
type callOutcome struct {
|
||||
call assign.ControllerCall
|
||||
refused string
|
||||
}
|
||||
|
||||
// exportCheck is the export read again for one proposal: running, its answer, or why it was not read.
|
||||
type exportCheck struct {
|
||||
running bool
|
||||
export map[string]delivery.Placement
|
||||
err string
|
||||
}
|
||||
|
||||
func newSeatActs(bus caller, wake func(), logf func(string, ...any), leaseAway func() bool) *seatActs {
|
||||
return &seatActs{bus: bus, wake: wake, logf: logf, export: map[string]exportCheck{}, calls: map[string]string{},
|
||||
outcomes: map[string]callOutcome{}, pins: map[string]pinRead{}, rec: handacts.Recorder{Ask: bus.Call, LeaseAway: leaseAway}}
|
||||
}
|
||||
|
||||
// exportOf is the export read again for a proposal, asked once in the background.
|
||||
func (a *seatActs) exportOf(id string) exportCheck {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if c, ok := a.export[id]; ok {
|
||||
return c
|
||||
}
|
||||
a.export[id] = exportCheck{running: true}
|
||||
go func() {
|
||||
defer a.wake()
|
||||
raw, err := a.bus.Call(exportCall, map[string]any{})
|
||||
c := exportCheck{}
|
||||
if err != nil {
|
||||
c.err = "the controller's placement-export did not answer: " + err.Error()
|
||||
} else if c.export, err = assign.ExportOf(raw); err != nil {
|
||||
c.err = err.Error()
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.export[id] = c
|
||||
a.mu.Unlock()
|
||||
}()
|
||||
return exportCheck{running: true}
|
||||
}
|
||||
|
||||
// forget drops what is kept of a proposal's export, so a later warrant reads it again.
|
||||
func (a *seatActs) forget(id string) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
delete(a.export, id)
|
||||
a.mu.Unlock()
|
||||
func newSeatActs(bus caller, wake func(), logf func(string, ...any)) *seatActs {
|
||||
return &seatActs{bus: bus, wake: wake, logf: logf, rec: handacts.Recorder{Ask: bus.Call}}
|
||||
}
|
||||
|
||||
// record records once, in the hand-act log, that placement was written on the operator's warrant (§5): the router's
|
||||
// record of the teller's ask says who chose and how (ADR 0274). The log is the seat mesh-hand-acts' from its delivery, and
|
||||
// the controller's while that seat has no holder and the controller holds its lease (ADR 0565 §11, handacts.Recorder).
|
||||
// the controller's while that seat has no holder (ADR 0565 §11, handacts.Recorder).
|
||||
func (a *seatActs) record(warrant string) {
|
||||
if a == nil || warrant == "" {
|
||||
return
|
||||
@@ -147,257 +88,35 @@ func (a *seatActs) record(warrant string) {
|
||||
}()
|
||||
}
|
||||
|
||||
// handAct records a hand-back in the hand-act log (§7), as record does.
|
||||
func (a *seatActs) handAct(prop delivery.PlacementProposal) {
|
||||
go func() {
|
||||
if taken, err := a.rec.Record(handacts.Request{What: "mesh-placement hand-back of " + strings.Join(prop.Nodes(), ", "),
|
||||
Why: prop.Why, Cause: "asked by " + prop.By + ", approved by the operator"}); err != nil {
|
||||
a.logf("the hand-back %s is not recorded in the hand-act log: %v", prop.ID, err)
|
||||
} else {
|
||||
a.logf("the hand-back %s is recorded in the log of %s", prop.ID, taken.Log)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// callAll makes the controller's calls in order in the background, each said in the journal and kept by its pair.
|
||||
func (a *seatActs) callAll(calls []assign.ControllerCall) {
|
||||
if len(calls) == 0 {
|
||||
// clearStaleCalls clears, once, the conditions of the refused controller calls a previous process raised (findings of the
|
||||
// kinds controller-call and controller-pin): the assigner calls no verb of the controller any more (ADR 0567 §8, issue 774),
|
||||
// so nothing would clear them. It is tried again at the next pass while the findings cannot be read.
|
||||
func (p *passer) clearStaleCalls() {
|
||||
if p.callsCleared || p.say == nil || p.findings.Keys == nil {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
for _, c := range calls {
|
||||
a.calls[c.Node+"."+c.Module] = c.Key() + " is asked"
|
||||
}
|
||||
a.mu.Unlock()
|
||||
go func() {
|
||||
defer a.wake()
|
||||
for _, c := range calls {
|
||||
words, refused := c.Key()+" answered", ""
|
||||
if _, err := a.bus.Call("seat:"+catalogue.ControllerSeatName+"."+c.Verb, c.Args); err != nil {
|
||||
refused = err.Error()
|
||||
words = c.Key() + " refused: " + refused
|
||||
}
|
||||
a.logf("keeping the controller in step with placement: %s", words)
|
||||
a.mu.Lock()
|
||||
a.calls[c.Node+"."+c.Module] = words
|
||||
a.outcomes[assign.CallConditionKey(c)] = callOutcome{call: c, refused: refused}
|
||||
a.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// outcomesNow is a copy of every call's last outcome, by the key of its condition.
|
||||
func (a *seatActs) outcomesNow() map[string]callOutcome {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return maps.Clone(a.outcomes)
|
||||
}
|
||||
|
||||
// pinRead is the controller's pins of one node as its placement-export answered them, for the generation of the
|
||||
// controller's compositions it was asked at: a pin made at the controller changes a composition, which the assigner
|
||||
// watches, so a read is asked again only after such a change, never on a timer.
|
||||
type pinRead struct {
|
||||
gen uint64
|
||||
running bool
|
||||
known bool
|
||||
pins map[string]delivery.Provider
|
||||
}
|
||||
|
||||
// pinsOf is the controller's pins of node as last read, and whether a read answered; a read is asked in the background
|
||||
// when none was made at gen, and wakes a pass when it answers.
|
||||
func (a *seatActs) pinsOf(node string, gen uint64) (map[string]delivery.Provider, bool) {
|
||||
if a == nil {
|
||||
return nil, false
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
r, ok := a.pins[node]
|
||||
if !ok || (r.gen != gen && !r.running) {
|
||||
r.gen, r.running = gen, true
|
||||
a.pins[node] = r
|
||||
go func() {
|
||||
defer a.wake()
|
||||
raw, err := a.bus.Call(exportCall, map[string]any{"node": node})
|
||||
var pins map[string]delivery.Provider
|
||||
var known bool
|
||||
if err == nil {
|
||||
var export map[string]delivery.Placement
|
||||
if export, err = assign.ExportOf(raw); err == nil {
|
||||
pins, known = export[node].Pins, true
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
a.logf("the controller's pins of %s were not read, so a refused pin's condition there waits: %v", node, err)
|
||||
}
|
||||
a.mu.Lock()
|
||||
now := a.pins[node]
|
||||
now.running = false
|
||||
if known {
|
||||
now.pins, now.known = pins, true
|
||||
}
|
||||
a.pins[node] = now
|
||||
a.mu.Unlock()
|
||||
}()
|
||||
}
|
||||
return r.pins, r.known
|
||||
}
|
||||
|
||||
// sayCalls makes a refused call of the controller loud (issue 774): the condition of each pair whose last call was refused
|
||||
// is raised once per refusal, naming the verb, the node, the module and the controller's words. It is cleared when a later
|
||||
// call for the pair is answered, or once the pair, seen differing since the refusal, agrees between the two sources (a
|
||||
// person made the call by hand): for a module's place the controller's assignments and the placement, for a pin the
|
||||
// controller's pins (its placement-export, read again after its compositions change) and the placement's. The first pass
|
||||
// after a start takes over what a previous process raised (its findings) and clears each whose pair agrees; until the
|
||||
// findings are read it is tried again at every pass. A say that fails is made again at the next pass.
|
||||
func (p *passer) sayCalls(differ []assign.Pair) {
|
||||
if p.say == nil {
|
||||
return
|
||||
}
|
||||
if p.callSaid == nil {
|
||||
p.callSaid = map[string]*callSaid{}
|
||||
}
|
||||
differs := map[string]bool{}
|
||||
for _, d := range differ {
|
||||
differs[d.Node+"."+d.Module] = true
|
||||
}
|
||||
outcomes := p.seat.outcomesNow()
|
||||
if !p.callsSwept {
|
||||
p.callsSwept = p.sweepCalls(outcomes)
|
||||
}
|
||||
for key, o := range outcomes {
|
||||
said := p.callSaid[key]
|
||||
switch {
|
||||
case o.refused != "" && (said == nil || said.adopted || said.refused != o.refused):
|
||||
if err := p.say(assign.CallRefusedCondition(o.call, o.refused, true, "")); err != nil {
|
||||
p.logf("that %s was refused could not be said, and is said at the next pass: %v", o.call.Key(), err)
|
||||
continue
|
||||
}
|
||||
p.callSaid[key] = &callSaid{call: o.call, refused: o.refused}
|
||||
case o.refused == "" && said != nil:
|
||||
if err := p.say(assign.CallRefusedCondition(o.call, "", false, o.call.Key()+" was answered.")); err != nil {
|
||||
p.logf("the condition of %s could not be cleared, and is cleared at the next pass: %v", o.call.Key(), err)
|
||||
continue
|
||||
}
|
||||
delete(p.callSaid, key)
|
||||
}
|
||||
}
|
||||
var placements map[string]delivery.Placement
|
||||
for key, said := range p.callSaid {
|
||||
c := said.call
|
||||
var apart bool
|
||||
if c.CallKind() == assign.KindControllerCall {
|
||||
apart = differs[c.Node+"."+c.Module]
|
||||
} else {
|
||||
theirs, known := p.seat.pinsOf(c.Node, p.w.bucketGen(delivery.ControllerComposedBucket))
|
||||
if !known {
|
||||
continue
|
||||
}
|
||||
if placements == nil {
|
||||
placements, _ = p.seatPlacements()
|
||||
}
|
||||
ours, has := placements[c.Node].Pins[c.Module]
|
||||
to, had := theirs[c.Module]
|
||||
apart = has != had || ours != to
|
||||
}
|
||||
if apart {
|
||||
said.differed = true
|
||||
continue
|
||||
}
|
||||
if !said.differed {
|
||||
continue // not seen differing since the refusal: the world may not hold the write yet
|
||||
}
|
||||
if err := p.say(assign.CallRefusedCondition(c, "", false, agreeWords(c))); err != nil {
|
||||
p.logf("the condition of %s could not be cleared, and is cleared at the next pass: %v", c.Key(), err)
|
||||
continue
|
||||
}
|
||||
if o, ok := outcomes[key]; ok {
|
||||
p.seat.forgetOutcome(key, o)
|
||||
}
|
||||
delete(p.callSaid, key)
|
||||
}
|
||||
}
|
||||
|
||||
// sweepCalls takes over the refused calls' conditions a previous process raised, from the findings: each is followed as
|
||||
// one seen differing, so it clears at the first pass its pair agrees. It answers whether the findings were read.
|
||||
func (p *passer) sweepCalls(outcomes map[string]callOutcome) bool {
|
||||
if p.findings.Keys == nil {
|
||||
return true
|
||||
}
|
||||
keys, err := p.findings.Keys()
|
||||
if err != nil {
|
||||
p.logf("the findings were not read, so the refused calls a previous process raised are taken over at the next pass: %v", err)
|
||||
return false
|
||||
p.logf("the findings were not read, so the refused controller calls a previous process raised are cleared at the next pass: %v", err)
|
||||
return
|
||||
}
|
||||
p.callsCleared = true
|
||||
for _, k := range keys {
|
||||
rest, ok := strings.CutPrefix(k, "module.")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, kind := range []string{assign.KindControllerCall, assign.KindControllerPin} {
|
||||
pair, ok := strings.CutSuffix(rest, "."+kind)
|
||||
node, module, two := strings.Cut(pair, ".")
|
||||
if !ok || !two {
|
||||
continue
|
||||
if pair, ok := strings.CutSuffix(rest, "."+kind); ok {
|
||||
if node, module, two := strings.Cut(pair, "."); two {
|
||||
if err := p.say(assign.CallClearedCondition(kind, node, module)); err != nil {
|
||||
p.callsCleared = false
|
||||
p.logf("the condition of the old %s call for %s on %s could not be cleared, and is cleared at the next pass: %v", kind, module, node, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
verb := "assign"
|
||||
if kind == assign.KindControllerPin {
|
||||
verb = "pin"
|
||||
}
|
||||
c := assign.ControllerCall{Verb: verb, Node: node, Module: module}
|
||||
key := assign.CallConditionKey(c)
|
||||
if _, made := outcomes[key]; made || p.callSaid[key] != nil {
|
||||
continue
|
||||
}
|
||||
p.callSaid[key] = &callSaid{call: c, differed: true, adopted: true}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func agreeWords(c assign.ControllerCall) string {
|
||||
if c.CallKind() == assign.KindControllerPin {
|
||||
return "the controller's pins and the placement agree on " + c.Module + " for " + c.Node + "."
|
||||
}
|
||||
return "the controller's assignments and the placement agree on " + c.Module + " on " + c.Node + "."
|
||||
}
|
||||
|
||||
// callSaid is a raised call's condition: its call, the refusal it said, whether its pair was seen differing since, and
|
||||
// whether it was taken over from a previous process.
|
||||
type callSaid struct {
|
||||
call assign.ControllerCall
|
||||
refused string
|
||||
differed bool
|
||||
adopted bool
|
||||
}
|
||||
|
||||
// forgetOutcome drops a refused call's outcome once its condition is cleared, unless a later call replaced it.
|
||||
func (a *seatActs) forgetOutcome(key string, o callOutcome) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if now, ok := a.outcomes[key]; ok && now.refused == o.refused && now.call.Verb == o.call.Verb {
|
||||
delete(a.outcomes, key)
|
||||
}
|
||||
}
|
||||
|
||||
// callOf is what the last call for a pair came to, "" when none was made.
|
||||
func (a *seatActs) callOf(node, module string) string {
|
||||
if a == nil {
|
||||
return ""
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.calls[node+"."+module]
|
||||
}
|
||||
|
||||
// withCalls names, on each pair the two sources do not both name, the call the assigner made for it (§8).
|
||||
func (p *passer) withCalls(pairs []assign.Pair) []assign.Pair {
|
||||
for i := range pairs {
|
||||
pairs[i].Call = p.seat.callOf(pairs[i].Node, pairs[i].Module)
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// sayWaits raises, once, that the seat waits for the states not yet bound, and clears it once, when all are.
|
||||
@@ -544,17 +263,22 @@ func (p *passer) stepProposal(ctx context.Context, prop delivery.PlacementPropos
|
||||
p.endProposal(ctx, prop, prop.Withdrawn)
|
||||
return
|
||||
}
|
||||
if prop.Seed || prop.HandBack {
|
||||
// Adopt and hand-back are gone since the lease write (ADR 0547 §11, ADR 0567 §11): a proposal one left open ends.
|
||||
p.endProposal(ctx, prop, "adopt and hand-back are gone since the lease write: placement is the mesh's alone")
|
||||
return
|
||||
}
|
||||
if prop.Warrant != nil {
|
||||
p.addNode(ctx, prop, revs)
|
||||
return
|
||||
}
|
||||
akey, _ := delivery.PlacementAskKey(prop.ID)
|
||||
again := !prop.Again.IsZero() && (prop.Verdict == nil || prop.Again.After(prop.Verdict.At))
|
||||
if prop.State == delivery.ProposalAsked {
|
||||
askPulse, _ := delivery.PulseAskKey(prop.ID)
|
||||
switch {
|
||||
case p.answeredWith(akey, assign.OptionYes):
|
||||
if prop.HandBack {
|
||||
p.handBack(ctx, prop, akey, revs)
|
||||
} else {
|
||||
p.warranted(ctx, prop, rev, akey, current, revs)
|
||||
}
|
||||
p.warranted(ctx, prop, rev, akey, current, revs)
|
||||
return
|
||||
case p.answeredWith(akey, assign.OptionNo):
|
||||
p.endProposal(ctx, prop, "declined by the operator")
|
||||
@@ -564,19 +288,6 @@ func (p *passer) stepProposal(ctx context.Context, prop delivery.PlacementPropos
|
||||
return
|
||||
}
|
||||
}
|
||||
if prop.HandBack {
|
||||
// The operator's alone (§7): no node answers it; its ask is raised at once, and again on again.
|
||||
if prop.State != delivery.ProposalAsked || again {
|
||||
brief := fmt.Sprintf("%s (asked by %s): every node's placement key is deleted, %s, and each node returns to the "+
|
||||
"controller's compositions; the history is kept", prop.Why, prop.By, strings.Join(prop.Nodes(), ", "))
|
||||
now := p.clock().UTC()
|
||||
prop.Verdict = &delivery.PlacementVerdict{State: delivery.VerdictPass, At: now}
|
||||
if prop, ok := p.ask(ctx, prop, akey, brief); ok {
|
||||
p.writeProposal(ctx, prop, rev)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if moved(prop, revs) {
|
||||
p.layAgain(ctx, prop, rev, current, revs, "a key it was laid over moved since")
|
||||
return
|
||||
@@ -747,8 +458,7 @@ func (p *passer) mustAnswer(ctx context.Context, prop delivery.PlacementProposal
|
||||
func (p *passer) layAgain(ctx context.Context, prop delivery.PlacementProposal, rev uint64, current map[string]delivery.Placement, revs map[string]uint64, why string) {
|
||||
held, recorded := p.lays(ctx)
|
||||
again, laid, findings := assign.Propose(assign.ProposeIn{ID: prop.ID, By: prop.By, Why: prop.Why, At: prop.At, Changes: prop.Changes,
|
||||
Current: current, Revisions: revs, Held: held, Recorded: recorded,
|
||||
Seed: prop.Seed, ExportDigest: prop.ExportDigest, Leased: p.w.handedAway()})
|
||||
Current: current, Revisions: revs, Held: held, Recorded: recorded})
|
||||
if len(findings) > 0 {
|
||||
p.endProposal(ctx, prop, why+", and laid over the keys now it is refused: "+strings.Join(findings, "; "))
|
||||
return
|
||||
@@ -761,7 +471,6 @@ func (p *passer) layAgain(ctx context.Context, prop delivery.PlacementProposal,
|
||||
p.put(ctx, delivery.PlacementCandidatesBucket, key, func() ([]byte, error) { return delivery.EncodePlacementCandidate(c) })
|
||||
}
|
||||
again.Written, again.Words = p.clock().UTC(), why+": laid again over the keys as they stand, to be judged and asked again"
|
||||
p.seat.forget(prop.ID)
|
||||
p.writeProposal(ctx, again, rev)
|
||||
}
|
||||
|
||||
@@ -778,35 +487,14 @@ func (p *passer) lays(ctx context.Context) (func(string) bool, func(string) bool
|
||||
return func(m string) bool { return held[m] }, func(n string) bool { _, ok := records[n]; return ok }
|
||||
}
|
||||
|
||||
// warranted writes an approved proposal (§5): for the seed, after the export is read again and found unchanged (§7);
|
||||
// each changed key by compare-and-set at the revision it was laid over; then the warrant recorded, the candidates and the
|
||||
// proposal deleted, placement-changed said, and the controller kept in step (§8).
|
||||
// warranted writes an approved proposal (§5): each changed key by compare-and-set at the revision it was laid over; then the warrant recorded, the candidates and the
|
||||
// proposal deleted, and placement-changed said.
|
||||
func (p *passer) warranted(ctx context.Context, prop delivery.PlacementProposal, rev uint64, akey string, current map[string]delivery.Placement, revs map[string]uint64) {
|
||||
if err := p.performs(akey, assign.PlacementAct(prop)); err != nil {
|
||||
p.endProposal(ctx, prop, "its warrant does not bind these changes, so nothing is written: "+err.Error())
|
||||
return
|
||||
}
|
||||
answer, _ := p.w.answer(akey)
|
||||
if prop.Seed {
|
||||
if p.seat == nil {
|
||||
return
|
||||
}
|
||||
c := p.seat.exportOf(prop.ID)
|
||||
switch {
|
||||
case c.running:
|
||||
return // its answer wakes the next pass
|
||||
case c.err != "":
|
||||
if words := "the export cannot be read again at the warrant: " + c.err; prop.Words != words {
|
||||
prop.Words = words
|
||||
p.writeProposal(ctx, prop, rev)
|
||||
}
|
||||
return
|
||||
case delivery.ExportDigest(c.export) != prop.ExportDigest:
|
||||
p.endProposal(ctx, prop, "the controller's export changed since the seed was proposed: the new export is proposed as the seed")
|
||||
p.proposeSeed(ctx, prop, c.export)
|
||||
return
|
||||
}
|
||||
}
|
||||
if moved(prop, revs) {
|
||||
p.layAgain(ctx, prop, rev, current, revs, "a key moved since it was approved, so its warrant is spent")
|
||||
return
|
||||
@@ -831,7 +519,7 @@ func (p *passer) warranted(ctx context.Context, prop delivery.PlacementProposal,
|
||||
// Another writer came first (a hand-back): what is written stands and is followed through; the rest is laid
|
||||
// again over the keys as this write left them.
|
||||
p.logf("placement %s is not written at revision %d (%v): the rest of the proposal %s is laid again", w.Key, w.Base, err, prop.ID)
|
||||
p.followWrite(prop, answer, changes, current, writtenLaid)
|
||||
p.followWrite(prop, answer, changes)
|
||||
if rest, ok := assign.Rest(prop, writtenLaid); ok {
|
||||
p.layAgain(ctx, rest, rev, after, afterRevs, "a key moved while it was written, after "+strings.Join(sortedNodes(writtenLaid), ", ")+" were")
|
||||
} else {
|
||||
@@ -857,22 +545,82 @@ func (p *passer) warranted(ctx context.Context, prop delivery.PlacementProposal,
|
||||
p.deletePulses(ctx, prop.ID)
|
||||
_ = p.kvs[delivery.PlacementProposalsBucket].Delete(ctx, prop.ID)
|
||||
p.w.put(delivery.PlacementProposalsBucket, prop.ID, nil)
|
||||
p.followWrite(prop, answer, changes, current, writtenLaid)
|
||||
p.followWrite(prop, answer, changes)
|
||||
}
|
||||
|
||||
// followWrite follows the keys a warrant wrote: the warrant recorded, placement-changed said, and, until the lease
|
||||
// write, the controller's assignments kept in step for them (§8). The seed calls no controller verb: it is what the
|
||||
// controller holds already (§9).
|
||||
func (p *passer) followWrite(prop delivery.PlacementProposal, answer delivery.Answer, changes []delivery.PlacementChange,
|
||||
before map[string]delivery.Placement, written map[string]*delivery.Placement) {
|
||||
// addNode writes a joining node on the warrant of the enrolment that issued its token (ADR 0587 §7): the operator's Approve
|
||||
// was the issue's, so no node is waited on and no ask is raised. The key is created at the revision it was laid over (none: a
|
||||
// node with a key is no joining node); the enrolment recorded the warrant in the hand-act log when it was given, so this does
|
||||
// not record it again; placement-changed carries the warrant. A key written meanwhile ends the proposal, saying so.
|
||||
func (p *passer) addNode(ctx context.Context, prop delivery.PlacementProposal, revs map[string]uint64) {
|
||||
current, _ := p.seatPlacements()
|
||||
if moved(prop, revs) {
|
||||
p.endProposal(ctx, prop, "a key it was laid over was written since: the enrolment proposes the node again")
|
||||
return
|
||||
}
|
||||
laid, findings := delivery.LayChanges(current, prop.Changes)
|
||||
node := prop.Changes[0].Node
|
||||
if prop.Changes[0].Op == delivery.OpRemoveNode {
|
||||
p.removeNode(ctx, prop, current, findings)
|
||||
return
|
||||
}
|
||||
value, ok := laid[node]
|
||||
if len(findings) > 0 || !ok {
|
||||
p.endProposal(ctx, prop, "the node cannot be added: "+strings.Join(findings, "; "))
|
||||
return
|
||||
}
|
||||
rev, err := p.writeNode(ctx, node, &value)
|
||||
if err != nil {
|
||||
p.endProposal(ctx, prop, "the node's key was not written: "+err.Error())
|
||||
return
|
||||
}
|
||||
c := delivery.ChangeOf(delivery.Placement{Node: node}, value)
|
||||
c.Before, c.After = prop.Base[node], rev
|
||||
p.deleteCandidates(ctx, prop.ID)
|
||||
p.deletePulses(ctx, prop.ID)
|
||||
_ = p.kvs[delivery.PlacementProposalsBucket].Delete(ctx, prop.ID)
|
||||
p.w.put(delivery.PlacementProposalsBucket, prop.ID, nil)
|
||||
w := prop.Warrant
|
||||
p.sayChanged(prop, delivery.Answer{Option: assign.OptionYes, Warrant: w.Warrant,
|
||||
Words: fmt.Sprintf("the operator approved the machine %s at %s through the enrolment's ask %s", w.Node, w.Address, w.Ask)},
|
||||
[]delivery.PlacementChange{c})
|
||||
}
|
||||
|
||||
// removeNode deletes the key of a joining node whose enrolment ended unconfirmed, on that enrolment's warrant (the operator's
|
||||
// decision of 2026-11-02 01:05): no node answers and no ask is raised, as for its add-node; placement-changed carries the warrant.
|
||||
// A node that runs modules or holds seats is no joining node and stays (LayChanges' finding ends the proposal).
|
||||
func (p *passer) removeNode(ctx context.Context, prop delivery.PlacementProposal, current map[string]delivery.Placement, findings []string) {
|
||||
node := prop.Changes[0].Node
|
||||
before, ok := current[node]
|
||||
if len(findings) > 0 || !ok {
|
||||
p.endProposal(ctx, prop, "the node cannot be taken off: "+strings.Join(findings, "; "))
|
||||
return
|
||||
}
|
||||
rev, err := p.writeNode(ctx, node, nil)
|
||||
if err != nil {
|
||||
p.endProposal(ctx, prop, "the node's key was not deleted: "+err.Error())
|
||||
return
|
||||
}
|
||||
c := delivery.ChangeOf(before, delivery.Placement{Node: node})
|
||||
c.Before, c.After = prop.Base[node], rev
|
||||
p.deleteCandidates(ctx, prop.ID)
|
||||
p.deletePulses(ctx, prop.ID)
|
||||
_ = p.kvs[delivery.PlacementProposalsBucket].Delete(ctx, prop.ID)
|
||||
p.w.put(delivery.PlacementProposalsBucket, prop.ID, nil)
|
||||
w := prop.Warrant
|
||||
p.sayChanged(prop, delivery.Answer{Option: assign.OptionYes, Warrant: w.Warrant,
|
||||
Words: fmt.Sprintf("the machine %s left placement: its enrolment (ask %s) ended before it confirmed", w.Node, w.Ask)},
|
||||
[]delivery.PlacementChange{c})
|
||||
}
|
||||
|
||||
// followWrite follows the keys a warrant wrote: the warrant recorded and placement-changed said. No verb of the
|
||||
// controller is called: the placement is the one source of where modules run (ADR 0567 §8, issue 774).
|
||||
func (p *passer) followWrite(prop delivery.PlacementProposal, answer delivery.Answer, changes []delivery.PlacementChange) {
|
||||
if len(changes) == 0 {
|
||||
return
|
||||
}
|
||||
p.seat.record(answer.Warrant)
|
||||
p.sayChanged(prop, answer, changes)
|
||||
if p.seat != nil && !p.w.handedAway() && !prop.Seed {
|
||||
p.seat.callAll(assign.ControllerCalls(before, written))
|
||||
}
|
||||
}
|
||||
|
||||
func sortedNodes(m map[string]*delivery.Placement) []string {
|
||||
@@ -884,44 +632,6 @@ func sortedNodes(m map[string]*delivery.Placement) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// handBack deletes every key on the operator's warrant (§7) at the revision the hand-back read, records it in the
|
||||
// controller's hand-act log and says placement-changed; every node returns to the controller's compositions. A key moved
|
||||
// since ends it, and the operator hands back again.
|
||||
func (p *passer) handBack(ctx context.Context, prop delivery.PlacementProposal, akey string, revs map[string]uint64) {
|
||||
if err := p.performs(akey, assign.PlacementAct(prop)); err != nil {
|
||||
p.endProposal(ctx, prop, "its warrant does not bind this hand-back, so nothing is deleted: "+err.Error())
|
||||
return
|
||||
}
|
||||
answer, _ := p.w.answer(akey)
|
||||
current, held := p.seatPlacements()
|
||||
var changes []delivery.PlacementChange
|
||||
for _, node := range prop.Nodes() {
|
||||
key, _ := delivery.PlacementNodeKey(node)
|
||||
err := error(nil)
|
||||
if held[node] != prop.Base[node] {
|
||||
err = fmt.Errorf("its placement is at revision %d, the hand-back read %d", held[node], prop.Base[node])
|
||||
} else {
|
||||
_, err = p.writeNode(ctx, node, nil)
|
||||
}
|
||||
if err != nil {
|
||||
p.sayChanged(prop, answer, changes)
|
||||
p.endProposal(ctx, prop, fmt.Sprintf("%s moved since the hand-back was asked (%v): hand back again", key, err))
|
||||
return
|
||||
}
|
||||
c := delivery.ChangeOf(current[node], delivery.Placement{Node: node})
|
||||
c.Before = prop.Base[node]
|
||||
changes = append(changes, c)
|
||||
}
|
||||
if p.seat != nil {
|
||||
p.seat.handAct(prop)
|
||||
}
|
||||
p.seat.record(answer.Warrant)
|
||||
_ = p.kvs[delivery.PlacementProposalsBucket].Delete(ctx, prop.ID)
|
||||
p.w.put(delivery.PlacementProposalsBucket, prop.ID, nil)
|
||||
p.deletePulses(ctx, prop.ID)
|
||||
p.sayChanged(prop, answer, changes)
|
||||
}
|
||||
|
||||
// sayChanged says placement-changed for the keys written.
|
||||
func (p *passer) sayChanged(prop delivery.PlacementProposal, answer delivery.Answer, changes []delivery.PlacementChange) {
|
||||
if len(changes) == 0 {
|
||||
@@ -938,26 +648,6 @@ func (p *passer) sayChanged(prop delivery.PlacementProposal, answer delivery.Ans
|
||||
Warrant: &delivery.PlacementWarrant{Warrant: answer.Warrant, Words: answer.Words, At: at}}, prop.ID+".changed")
|
||||
}
|
||||
|
||||
// proposeSeed proposes an export as the seed again, by the same proposer and why.
|
||||
func (p *passer) proposeSeed(ctx context.Context, was delivery.PlacementProposal, export map[string]delivery.Placement) {
|
||||
held, recorded := p.lays(ctx)
|
||||
now := p.clock()
|
||||
seed, laid, findings := assign.Propose(assign.ProposeIn{ID: delivery.NewProposalID(now), By: was.By, Why: was.Why, At: now,
|
||||
Changes: assign.SeedChanges(export), Current: map[string]delivery.Placement{}, Revisions: map[string]uint64{},
|
||||
Held: held, Recorded: recorded, Seed: true, ExportDigest: delivery.ExportDigest(export), Leased: p.w.handedAway()})
|
||||
if len(findings) > 0 {
|
||||
p.logf("the new export is not proposed as the seed: %s", strings.Join(findings, "; "))
|
||||
return
|
||||
}
|
||||
for _, node := range seed.Nodes() {
|
||||
key, _ := delivery.PlacementCandidateKey(seed.ID, node)
|
||||
c := delivery.PlacementCandidate{Proposal: seed.ID, Placement: laid[node]}
|
||||
p.put(ctx, delivery.PlacementCandidatesBucket, key, func() ([]byte, error) { return delivery.EncodePlacementCandidate(c) })
|
||||
}
|
||||
seed.Written = now.UTC()
|
||||
p.writeProposal(ctx, seed, 0)
|
||||
}
|
||||
|
||||
// endProposal ends a proposal: its ask, its candidates and its key deleted, and placement-withdrawn said carrying it
|
||||
// whole with why it ended.
|
||||
func (p *passer) endProposal(ctx context.Context, prop delivery.PlacementProposal, why string) {
|
||||
@@ -973,7 +663,6 @@ func (p *passer) endProposal(ctx context.Context, prop delivery.PlacementProposa
|
||||
return
|
||||
}
|
||||
p.w.put(delivery.PlacementProposalsBucket, prop.ID, nil)
|
||||
p.seat.forget(prop.ID)
|
||||
p.logf("the proposal %s ends: %s", prop.ID, why)
|
||||
p.sayEvent(delivery.PlacementWithdrawnEvent, delivery.PlacementEvent{Proposal: prop, Ended: why}, prop.ID+".withdrawn")
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
@@ -207,7 +206,7 @@ func (s *seatTools) doPropose(args map[string]any, by string) (any, error) {
|
||||
}
|
||||
for _, c := range changes {
|
||||
if c.Op == delivery.OpSeed {
|
||||
return nil, errors.New("the seed is adopt's alone: propose takes place, take-off, pin, unpin, hold, unhold, decide and config")
|
||||
return nil, errors.New("the seed is gone with adopt: propose takes place, take-off, pin, unpin, hold, unhold, decide and config")
|
||||
}
|
||||
}
|
||||
current, revs, err := s.placements()
|
||||
@@ -218,9 +217,16 @@ func (s *seatTools) doPropose(args map[string]any, by string) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
warrant, err := warrantOf(args["warrant"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := s.now()
|
||||
p, laid, findings := assign.Propose(assign.ProposeIn{ID: delivery.NewProposalID(now), By: by, Why: arg(args, "why"), At: now,
|
||||
Changes: changes, Current: current, Revisions: revs, Held: held, Recorded: recorded, Leased: s.leased()})
|
||||
Changes: changes, Current: current, Revisions: revs, Held: held, Recorded: recorded, Warrant: warrant})
|
||||
if refusal := delivery.AddNodeRefusal(p, moduleOfCaller(by)); refusal != "" {
|
||||
findings = append(findings, refusal)
|
||||
}
|
||||
if len(findings) > 0 {
|
||||
return nil, fmt.Errorf("the proposal is refused: %s", strings.Join(findings, "; "))
|
||||
}
|
||||
@@ -230,6 +236,42 @@ func (s *seatTools) doPropose(args map[string]any, by string) (any, error) {
|
||||
return proposed{Proposal: p, Follow: followProposal(p.ID)}, nil
|
||||
}
|
||||
|
||||
// warrantOf reads propose's warrant, an enrolment's for an add-node: an object, given as one or as its text; none is none.
|
||||
func warrantOf(v any) (*delivery.AddNodeWarrant, error) {
|
||||
var raw []byte
|
||||
switch w := v.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case string:
|
||||
if strings.TrimSpace(w) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
raw = []byte(w)
|
||||
default:
|
||||
var err error
|
||||
if raw, err = json.Marshal(w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var out delivery.AddNodeWarrant
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("the warrant is no enrolment's warrant {node, address, ask, warrant}: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// moduleOfCaller is the module a caller is, as the runtime proves it, for AddNodeRefusal: mesh-enrolment when the proven
|
||||
// caller is that module's (its bus user `<node>.mesh-enrolment`, never a person's `person.mesh-enrolment`), else the caller
|
||||
// as proven, which the refusal names.
|
||||
func moduleOfCaller(by string) string {
|
||||
if (stdio.Callers{delivery.EnrolmentModule}).Allows(by) {
|
||||
return delivery.EnrolmentModule
|
||||
}
|
||||
return by
|
||||
}
|
||||
|
||||
// doWithdraw is the verb withdraw (§2): the proposal is marked, and the assigner deletes it and its candidates and says
|
||||
// placement-withdrawn carrying it whole.
|
||||
func (s *seatTools) doWithdraw(args map[string]any, by string) (any, error) {
|
||||
@@ -270,92 +312,15 @@ func (s *seatTools) doAgain(args map[string]any) (any, error) {
|
||||
return map[string]any{"proposal": p.ID, "again": p.Again, "follow": followProposal(p.ID)}, nil
|
||||
}
|
||||
|
||||
// exportCall is the controller's verb the seed is read from, by request and reply on its seat (§7).
|
||||
const exportCall = "seat:" + catalogue.ControllerSeatName + ".placement-export"
|
||||
|
||||
// doAdopt is the verb adopt (§7): the controller's export proposed whole as the seed.
|
||||
// doAdopt is the verb adopt (§7): gone since the lease write. The seed was the controller's export proposed whole, and the
|
||||
// mesh has no controller export to take over (ADR 0547 §11, ADR 0567 §11); nothing is written.
|
||||
func (s *seatTools) doAdopt(args map[string]any, by string) (any, error) {
|
||||
current, _, err := s.placements()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(current) > 0 {
|
||||
return nil, fmt.Errorf("placement is taken over already (%d keys): propose a change instead, or hand it back first", len(current))
|
||||
}
|
||||
if ids, err := s.st.keys(delivery.PlacementProposalsState); err == nil {
|
||||
for _, id := range ids {
|
||||
if p, err := s.proposal(id); err == nil && p.Seed {
|
||||
return nil, fmt.Errorf("the seed %s is open: withdraw it first, or answer its ask", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
raw, err := s.ask(exportCall, map[string]any{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the controller's placement-export did not answer: %w", err)
|
||||
}
|
||||
export, err := assign.ExportOf(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
held, recorded, err := s.lays()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := s.now()
|
||||
p, laid, findings := assign.Propose(assign.ProposeIn{ID: delivery.NewProposalID(now), By: by, Why: arg(args, "why"), At: now,
|
||||
Changes: assign.SeedChanges(export), Current: map[string]delivery.Placement{}, Revisions: map[string]uint64{},
|
||||
Held: held, Recorded: recorded, Seed: true, ExportDigest: delivery.ExportDigest(export), Leased: s.leased()})
|
||||
if len(findings) > 0 {
|
||||
return nil, fmt.Errorf("the seed is refused: %s", strings.Join(findings, "; "))
|
||||
}
|
||||
if err := s.write(p, laid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return proposed{Proposal: p, Follow: followProposal(p.ID)}, nil
|
||||
return nil, errors.New("adopt is gone: placement is the mesh's alone since the lease write, and there is no controller to take it over from (ADR 0547 §11, ADR 0567 §11)")
|
||||
}
|
||||
|
||||
// leased says the controller's lease is handed away (mesh-sdk buscompose.LeaseHandedAway, ADR 0561 §3; issue 774): the lease
|
||||
// write of ADR 0547 that ends keeping the controller in step (ADR 0567 §8). A lease not read, a controller's, expired or
|
||||
// unreadable, is not handed away.
|
||||
func (s *seatTools) leased() bool {
|
||||
raw, _, found, err := s.st.get(buscompose.ControllerLease, buscompose.ControllerLeaseKey)
|
||||
away, _ := buscompose.LeaseHandedAway(raw, found && raw != nil, err)
|
||||
return away
|
||||
}
|
||||
|
||||
// doHandBack is the verb hand-back (§7): the operator's alone, so it is asked as a proposal is. It writes a hand-back
|
||||
// proposal naming every key at its revision; the assigner raises its authorising ask and, on its warrant, deletes the keys,
|
||||
// records the hand act and says placement-changed; every node returns to the controller's compositions.
|
||||
// doHandBack is the verb hand-back (§7): gone since the lease write, as adopt is; nothing is deleted.
|
||||
func (s *seatTools) doHandBack(args map[string]any, by string) (any, error) {
|
||||
if s.leased() {
|
||||
return nil, errors.New("placement is the mesh's alone since the lease write: there is no controller to hand it back to")
|
||||
}
|
||||
why := arg(args, "why")
|
||||
if why == "" {
|
||||
return nil, errors.New("hand-back says why: it is recorded with the hand act")
|
||||
}
|
||||
if by == "" {
|
||||
return nil, errors.New("the runtime names no caller: a hand-back is made by someone")
|
||||
}
|
||||
_, revs, err := s.placements()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(revs) == 0 {
|
||||
return nil, errors.New("there is no placement key to hand back")
|
||||
}
|
||||
now := s.now()
|
||||
p := delivery.PlacementProposal{ID: delivery.NewProposalID(now), By: by, Why: why, At: now.UTC(), Written: now.UTC(),
|
||||
HandBack: true, Base: revs, State: delivery.ProposalAnswering}
|
||||
raw, err := delivery.EncodePlacementProposal(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.st.put(delivery.PlacementProposalsState, p.ID, raw); err != nil {
|
||||
return nil, fmt.Errorf("the hand-back is not written: %w", err)
|
||||
}
|
||||
return proposed{Proposal: p, Follow: "the operator is asked to approve it; on the warrant every key is deleted and each " +
|
||||
"node returns to the controller's compositions"}, nil
|
||||
return nil, errors.New("hand-back is gone: placement is the mesh's alone since the lease write, and there is no controller to hand it back to (ADR 0547 §11, ADR 0567 §11)")
|
||||
}
|
||||
|
||||
// placementAnswer is what the verb placement answers.
|
||||
|
||||
@@ -6,15 +6,16 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
@@ -35,8 +36,8 @@ func (s memStore) history(state, key string) ([]stdio.StateRevision, error) {
|
||||
}
|
||||
|
||||
// ADR 0567 §2, §7, §10: the seat's seven verbs over the states. propose lays a proposal and writes it with its candidates,
|
||||
// or refuses naming every finding and writes nothing; withdraw and again mark it for the holder; adopt proposes the
|
||||
// controller's export whole as the seed, once; hand-back records the hand act and deletes every key; placement and
|
||||
// or refuses naming every finding and writes nothing; withdraw and again mark it for the holder; adopt and hand-back are
|
||||
// answered as gone and change nothing (ADR 0547 §11, ADR 0567 §11); placement and
|
||||
// proposals read every state the seat keeps, and history names the stream for what the state no longer keeps.
|
||||
func TestTheSeatsVerbs(t *testing.T) {
|
||||
anchor := delivery.Placement{V: delivery.Version, Node: "anchor", Modules: []delivery.PlacedModule{{Module: "web"}}}
|
||||
@@ -51,32 +52,35 @@ func TestTheSeatsVerbs(t *testing.T) {
|
||||
raw, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: n}})
|
||||
st[haveState].values[n+".node"] = raw
|
||||
}
|
||||
export, _ := json.Marshal(map[string]any{"anchor": anchor})
|
||||
var asked []string
|
||||
s := newSeatTools(st, func(key string, body any) (json.RawMessage, error) {
|
||||
asked = append(asked, key)
|
||||
if key == exportCall {
|
||||
out, _ := json.Marshal(map[string]any{"ok": true, "output": string(export)})
|
||||
return out, nil
|
||||
}
|
||||
return json.RawMessage(`{"ok":true}`), nil
|
||||
})
|
||||
s.now = func() time.Time { return time.Date(2026, 10, 28, 20, 0, 0, 0, time.UTC) }
|
||||
|
||||
// adopt: the export proposed whole as the seed, once.
|
||||
got, err := s.doAdopt(map[string]any{"why": "take over"}, "operator@laptop")
|
||||
// 774/S28/AC1: adopt and hand-back are answered as gone, naming their records, and change nothing and ask nobody.
|
||||
raw, _ := delivery.EncodePlacement(anchor)
|
||||
st[delivery.PlacementState].values["placement.anchor"] = raw
|
||||
for verb, call := range map[string]func() (any, error){
|
||||
"adopt": func() (any, error) { return s.doAdopt(map[string]any{"why": "take over"}, "operator@laptop") },
|
||||
"hand-back": func() (any, error) { return s.doHandBack(map[string]any{"why": "back"}, "op") },
|
||||
} {
|
||||
if _, err := call(); err == nil || !strings.Contains(err.Error(), verb+" is gone") || !strings.Contains(err.Error(), "ADR 0567 §11") {
|
||||
t.Errorf("%s: %v", verb, err)
|
||||
}
|
||||
}
|
||||
if len(asked) != 0 || len(st[delivery.PlacementProposalsState].values) != 0 || len(st[delivery.PlacementCandidatesState].values) != 0 ||
|
||||
len(st[delivery.PlacementState].values) != 1 {
|
||||
t.Fatalf("adopt or hand-back changed something: asked %v", asked)
|
||||
}
|
||||
|
||||
// withdraw and again mark the proposal; the holder ends or judges it.
|
||||
got, err := s.doPropose(map[string]any{"why": "hold", "changes": `[{"op":"hold","node":"anchor","module":"web","claim":"node-shell","scope":"node"}]`}, "op")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed := got.(proposed).Proposal
|
||||
if !seed.Seed || seed.ExportDigest != delivery.ExportDigest(map[string]delivery.Placement{"anchor": anchor}) || !slices.Equal(asked, []string{exportCall}) ||
|
||||
len(st[delivery.PlacementCandidatesState].values) != 1 || st[delivery.PlacementProposalsState].values[seed.ID] == nil {
|
||||
t.Fatalf("adopt = %+v, asked %v", seed, asked)
|
||||
}
|
||||
if _, err := s.doAdopt(map[string]any{"why": "again"}, "op"); err == nil || !strings.Contains(err.Error(), "is open") {
|
||||
t.Errorf("a second adopt with a seed open: %v", err)
|
||||
}
|
||||
// withdraw and again mark the proposal; the holder ends or judges it.
|
||||
if _, err := s.doWithdraw(map[string]any{"id": seed.ID, "why": "not yet"}, "op"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -93,10 +97,8 @@ func TestTheSeatsVerbs(t *testing.T) {
|
||||
st[delivery.PlacementCandidatesState].values = map[string][]byte{}
|
||||
|
||||
// propose: refused whole, naming every finding; a valid move laid over the keys at their revisions.
|
||||
raw, _ := delivery.EncodePlacement(anchor)
|
||||
st[delivery.PlacementState].values["placement.anchor"] = raw
|
||||
if _, err := s.doPropose(map[string]any{"why": "x", "changes": `[{"op":"place","node":"anchor","module":"ghost"},{"op":"hold","node":"anchor","module":"web","claim":"x"}]`}, "op"); err == nil ||
|
||||
!strings.Contains(err.Error(), "ghost") || !strings.Contains(err.Error(), "lease write") || len(st[delivery.PlacementProposalsState].values) != 0 {
|
||||
!strings.Contains(err.Error(), "ghost") || len(st[delivery.PlacementProposalsState].values) != 0 {
|
||||
t.Errorf("a refused proposal: %v", err)
|
||||
}
|
||||
if _, err := s.doPropose(map[string]any{"why": "x", "changes": `[{"op":"swap","node":"anchor"}]`}, "op"); err == nil {
|
||||
@@ -144,32 +146,64 @@ func TestTheSeatsVerbs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// hand-back: the operator's alone, written as a hand-back proposal naming every key at its revision, for the assigner
|
||||
// to ask; nothing is deleted by the verb.
|
||||
got, err = s.doHandBack(map[string]any{"why": "back"}, "op")
|
||||
if hb := got.(proposed).Proposal; err != nil || !hb.HandBack || hb.Base["anchor"] != uint64(len(raw)) || len(st[delivery.PlacementState].values) != 1 {
|
||||
t.Errorf("hand-back = %+v, %v", got, err)
|
||||
}
|
||||
// Issue 774 (ADR 0567 §8): once the controller's lease is handed away a change its verbs cannot take is proposed, and
|
||||
// there is no controller to hand placement back to.
|
||||
st[buscompose.ControllerLease] = &memKV{values: map[string][]byte{buscompose.ControllerLeaseKey: []byte(`{"handed-to":"the operator's hand-over","handed-by":"person.jochen"}`)}}
|
||||
// Issue 774 (ADR 0567 §8): every kind of change is proposed, and there is no controller to hand placement back to.
|
||||
if _, err := s.doPropose(map[string]any{"why": "hold", "changes": `[{"op":"hold","node":"anchor","module":"web","claim":"node-shell","scope":"node"}]`}, "op"); err != nil {
|
||||
t.Errorf("a hold after the lease write: %v", err)
|
||||
t.Errorf("a hold: %v", err)
|
||||
}
|
||||
// 774/S59/AC1 (ADR 0587 §7): an add-node from mesh-enrolment whose warrant names this node and this address is proposed, with
|
||||
// the node a joining one with no record; from another caller, naming another node or address, or with no warrant it is refused.
|
||||
addNode := func(by, warrant string) error {
|
||||
_, err := s.doPropose(map[string]any{"why": "enrolment of ace", "warrant": warrant, "changes": []any{map[string]any{"op": "add-node", "node": "ace",
|
||||
"decided": map[string]any{"address": "10.9.0.2"}, "keys": map[string]any{"overlay": "dHVubmVs"}}}}, by)
|
||||
return err
|
||||
}
|
||||
good := `{"node":"ace","address":"10.9.0.2","ask":"enrol-ace-1","warrant":"enrol-ace-1@2026-10-28T20:00:00Z"}`
|
||||
for _, c := range []struct{ by, warrant, want string }{
|
||||
{"novox.mesh-grants", good, "mesh-enrolment's alone"},
|
||||
{"person.mesh-enrolment", good, "mesh-enrolment's alone"},
|
||||
{"novox.mesh-enrolment", `{"node":"g14","address":"10.9.0.2","ask":"a","warrant":"a@t"}`, "carries the warrant of g14"},
|
||||
{"novox.mesh-enrolment", `{"node":"ace","address":"10.9.0.9","ask":"a","warrant":"a@t"}`, "carries the warrant of ace at 10.9.0.9"},
|
||||
{"novox.mesh-enrolment", "", "carries no warrant"},
|
||||
} {
|
||||
if err := addNode(c.by, c.warrant); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("an add-node by %s with warrant %q: %v, want a refusal saying %q", c.by, c.warrant, err, c.want)
|
||||
}
|
||||
}
|
||||
if err := addNode("novox.mesh-enrolment", good); err != nil {
|
||||
t.Errorf("an add-node from mesh-enrolment: %v", err)
|
||||
}
|
||||
// A remove-node (the operator's decision of 2026-11-02 01:05) takes a joining node that never confirmed off placement: from
|
||||
// mesh-enrolment, on the warrant naming that node, of a node running nothing, it is proposed with the node's key to delete;
|
||||
// of a node running modules, or with another node's warrant, it is refused.
|
||||
joiner, _ := delivery.EncodePlacement(delivery.Placement{V: delivery.Version, Node: "joiner", Decided: delivery.NodeDecided{Address: "10.9.0.7"}, Modules: []delivery.PlacedModule{}})
|
||||
st[delivery.PlacementState].values["placement.joiner"] = joiner
|
||||
removeNode := func(node, warrant string) (any, error) {
|
||||
return s.doPropose(map[string]any{"why": "enrolment ended", "warrant": warrant,
|
||||
"changes": []any{map[string]any{"op": "remove-node", "node": node}}}, "novox.mesh-enrolment")
|
||||
}
|
||||
for _, c := range []struct{ node, warrant, want string }{
|
||||
{"anchor", `{"node":"anchor","address":"10.9.0.1","ask":"a","warrant":"a@t"}`, "runs modules or holds seats"},
|
||||
{"joiner", `{"node":"g14","address":"10.9.0.7","ask":"a","warrant":"a@t"}`, "carries the warrant of g14"},
|
||||
} {
|
||||
if _, err := removeNode(c.node, c.warrant); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("a remove-node of %s: %v, want a refusal saying %q", c.node, err, c.want)
|
||||
}
|
||||
}
|
||||
if got, err := removeNode("joiner", `{"node":"joiner","address":"10.9.0.7","ask":"a","warrant":"a@t"}`); err != nil {
|
||||
t.Errorf("a remove-node from mesh-enrolment: %v", err)
|
||||
} else if rm := got.(proposed).Proposal; !slices.Equal(rm.Nodes(), []string{"joiner"}) || !slices.Equal(rm.RemovedNodes(), []string{"joiner"}) {
|
||||
t.Errorf("the remove-node proposed %+v", rm)
|
||||
}
|
||||
if _, err := s.doHandBack(map[string]any{"why": "back"}, "op"); err == nil || !strings.Contains(err.Error(), "since the lease write") {
|
||||
t.Errorf("a hand-back after the lease write: %v", err)
|
||||
t.Errorf("a hand-back: %v", err)
|
||||
}
|
||||
if tools := s.tools(); len(tools) != 7 || tools[2].Name != "mesh-placement.propose" || tools[2].Run == nil || tools[2].Input == nil {
|
||||
t.Errorf("the seat's verbs as served: %+v", tools)
|
||||
}
|
||||
t.Run("1953/D4-D6: the seat mesh-wants' upgrade and bus", wantsVerbs)
|
||||
t.Run("1955/L7 (ADR 0571 §6): rule 7 reads the controller's composition until the lease write, the node's own after; an unplaced node is said", func(t *testing.T) {
|
||||
t.Run("1955/L7 (ADR 0571 §6): rule 7 reads the node's own composition; an unplaced node is said", func(t *testing.T) {
|
||||
w := newWorld()
|
||||
fp := delivery.SourcePrefix2 + strings.Repeat("2", 64)
|
||||
ckey, _ := delivery.ComposedKey("ace", "web", fp)
|
||||
cv, _ := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: "sha256:" + strings.Repeat("c", 64), Judgement: delivery.JudgedNeither,
|
||||
Policy: catalogue.PolicyRoll, PolicyFrom: catalogue.FromDefault})
|
||||
w.putEntry(delivery.ControllerComposedBucket, ckey, cv, 1, time.Now())
|
||||
pl, _ := delivery.EncodePlacement(delivery.Placement{V: delivery.Version, Node: "ace", Modules: []delivery.PlacedModule{{Module: "web"}}})
|
||||
pkey, _ := delivery.LegacyPlacementKey("ace")
|
||||
w.putEntry(delivery.PlacementBucket, pkey, pl, 1, time.Now())
|
||||
@@ -178,15 +212,11 @@ func TestTheSeatsVerbs(t *testing.T) {
|
||||
hk, _ := delivery.HaveNodeKey(node)
|
||||
w.putEntry(delivery.HaveBucket, hk, hn, 1, time.Now())
|
||||
}
|
||||
if d, ok := w.composed("ace", "web", fp); !ok || d.Digest != "sha256:"+strings.Repeat("c", 64) || len(w.unplacedNodes()) != 0 {
|
||||
t.Fatalf("before the lease write: %+v %v, unplaced %v", d, ok, w.unplacedNodes())
|
||||
}
|
||||
w.putEntry(buscompose.LeaseBucket, buscompose.ControllerLeaseKey, []byte(`{"handed-to":"mesh-vault","handed-by":"operator"}`), 2, time.Now())
|
||||
if d, ok := w.composed("ace", "web", fp); !ok || d.Digest != "sha256:"+strings.Repeat("a", 64) {
|
||||
t.Errorf("after the lease write, ace: %+v %v", d, ok)
|
||||
t.Errorf("ace: %+v %v", d, ok)
|
||||
}
|
||||
if _, ok := w.composed("g14", "web", fp); ok || !slices.Equal(w.unplacedNodes(), []string{"g14"}) {
|
||||
t.Errorf("after the lease write, g14 unplaced: composed %v, unplaced %v", ok, w.unplacedNodes())
|
||||
t.Errorf("g14 unplaced: composed %v, unplaced %v", ok, w.unplacedNodes())
|
||||
}
|
||||
findings := map[string][]byte{}
|
||||
p := &passer{w: w, logf: func(string, ...any) {}, findings: assign.Findings{
|
||||
@@ -354,7 +384,7 @@ func TestTheSeatsVerbs(t *testing.T) {
|
||||
func wantsVerbs(t *testing.T) {
|
||||
fpOld, fpNew := delivery.SourcePrefix2+strings.Repeat("1", 64), delivery.SourcePrefix2+strings.Repeat("2", 64)
|
||||
st := memStore{}
|
||||
for _, state := range []string{modulesState, manifestsState, composedState, policiesState, haveState, putsState, "findings"} {
|
||||
for _, state := range []string{modulesState, manifestsState, policiesState, haveState, putsState, "findings"} {
|
||||
st[state] = &memKV{values: map[string][]byte{}}
|
||||
}
|
||||
manifest := func(name, extra string) {
|
||||
@@ -369,13 +399,7 @@ func wantsVerbs(t *testing.T) {
|
||||
for node, fp := range map[string]string{"anchor": fpOld, "laptop": fpNew} {
|
||||
st[haveState].values[node+".nats"], _ = delivery.EncodeHave(delivery.Have{FP: fp, Phase: delivery.PhaseRunning, Health: delivery.HealthOK})
|
||||
}
|
||||
composed := func(module, policy string) {
|
||||
key, _ := delivery.ComposedKey("anchor", module, fpNew)
|
||||
st[composedState].values[key], _ = json.Marshal(delivery.ComposedDigest{V: delivery.Version, Digest: "sha256:" + strings.Repeat("c", 64),
|
||||
Judgement: delivery.JudgedNeither, Policy: policy, PolicyFrom: catalogue.FromPerson, PolicyWhy: "chosen at the controller"})
|
||||
}
|
||||
composed("web", catalogue.PolicyRecord)
|
||||
composed("photos", catalogue.PolicyRecord)
|
||||
st[haveState].values["anchor.node"], _ = delivery.EncodeHaveNode(delivery.HaveNode{Composed: "sha256:" + strings.Repeat("c", 64)})
|
||||
var asked []string
|
||||
var said []string
|
||||
w := newWantsTools(st, func(key string, body any) (json.RawMessage, error) {
|
||||
@@ -395,10 +419,52 @@ func wantsVerbs(t *testing.T) {
|
||||
if p := policyOf("nats"); p.Policy != catalogue.PolicyRecord || p.From != catalogue.FromBus {
|
||||
t.Errorf("nats = %+v", p)
|
||||
}
|
||||
// compare: web rolls at the assigner and records at the controller.
|
||||
// compare: the controller writes no policy, so no module splits; each node's own composition is the digest its have names.
|
||||
out, _ := w.doUpgrade(map[string]any{"compare": "true"}, "op")
|
||||
if splits := out.(map[string]any)["splits"].([]assign.Split); len(splits) != 1 || splits[0].Module != "web" || out.(map[string]any)["findings"] == nil {
|
||||
t.Errorf("splits = %+v", splits)
|
||||
if got := out.(map[string]any); len(got["splits"].([]assign.Split)) != 0 || got["empty"] != true || got["findings"] == nil ||
|
||||
got["composed"].(map[string]string)["anchor"] != "sha256:"+strings.Repeat("c", 64) || len(got["composed"].(map[string]string)) != 1 {
|
||||
t.Errorf("compare = %+v", got)
|
||||
}
|
||||
// Read by watch (issue 1953 L5f): the same answer, each watched state started once, and no get per key of the
|
||||
// large states (the modules, their manifests, the composed record).
|
||||
watches := map[string]int{}
|
||||
byWatch := newWantsTools(st, nil, nil)
|
||||
byWatch.watched = stdio.WatchedStatesOver(func(state string, handle func(stdio.StateChange) error) error {
|
||||
watches[state]++
|
||||
for k, v := range st[state].values {
|
||||
_ = handle(stdio.StateChange{Key: k, Op: stdio.OpPut, Value: v, Current: true})
|
||||
}
|
||||
return nil
|
||||
}, func(state, key string) ([]byte, bool, error) {
|
||||
watches[state+" by key"]++
|
||||
v, ok := st[state].values[key]
|
||||
return v, ok, nil
|
||||
})
|
||||
for range 2 {
|
||||
wout, werr := byWatch.doUpgrade(map[string]any{"compare": "true"}, "op")
|
||||
if werr != nil {
|
||||
t.Fatal(werr)
|
||||
}
|
||||
if got := wout.(map[string]any); got["composed"].(map[string]string)["anchor"] != "sha256:"+strings.Repeat("c", 64) {
|
||||
t.Errorf("compare by watch = %+v", got)
|
||||
}
|
||||
}
|
||||
if n := watches[manifestsState+" by key"]; n == 0 || n > len(st[manifestsState].values) {
|
||||
t.Errorf("the manifests were read by key %d times, want each once", n)
|
||||
}
|
||||
for _, state := range []string{modulesState, haveState, health.FindingsState} {
|
||||
if watches[state] != 1 {
|
||||
t.Errorf("%s was watched %d times, want once", state, watches[state])
|
||||
}
|
||||
}
|
||||
// ADR 0580 clause 3: the compare reads through mesh-sdk's watched reader and the seat keeps no mirror of its own.
|
||||
for f := range reflect.TypeFor[wantsTools]().Fields() {
|
||||
if f.Type.Kind() == reflect.Map || (f.Type.Kind() == reflect.Pointer && f.Type.Elem().Kind() == reflect.Map) {
|
||||
t.Errorf("wantsTools keeps %s, a copy of its own beside the watched reader", f.Name)
|
||||
}
|
||||
}
|
||||
if watches[policiesState] != 0 {
|
||||
t.Errorf("the seat's own small policies state was watched: %v", watches)
|
||||
}
|
||||
// The process says the split as its finding, and deletes it once they agree.
|
||||
findings := map[string][]byte{}
|
||||
@@ -406,9 +472,6 @@ func wantsVerbs(t *testing.T) {
|
||||
Put: func(k string, v []byte) error { findings[k] = v; return nil },
|
||||
Delete: func(k string) error { delete(findings, k); return nil },
|
||||
Keys: func() ([]string, error) { return []string{"module.gone.policy-split"}, nil }}}
|
||||
for k, v := range st[composedState].values {
|
||||
p.w.putEntry(delivery.ControllerComposedBucket, k, v, 1, time.Now())
|
||||
}
|
||||
man := map[string]catalogue.Manifest{}
|
||||
for _, name := range []string{"web", "photos"} {
|
||||
raw, _, _, _ := st.get(modulesState, name)
|
||||
@@ -418,9 +481,10 @@ func wantsVerbs(t *testing.T) {
|
||||
man[name], _ = catalogue.Decode([]byte(kept.JSON))
|
||||
}
|
||||
findings["module.gone.policy-split"] = []byte("{}")
|
||||
// The controller writes no policy now (ADR 0571 §6): the process compares with nothing, and deletes a finding left.
|
||||
p.policySplits(man, nil)
|
||||
if _, ok := findings[assign.PolicySplitKey("web")]; !ok || len(findings) != 1 {
|
||||
t.Errorf("findings = %v, want web's split alone (a stale one deleted)", findings)
|
||||
if len(findings) != 0 {
|
||||
t.Errorf("findings = %v, want a stale one deleted", findings)
|
||||
}
|
||||
// a person's record for web: written, recorded, said; the split is gone.
|
||||
if _, err := w.doUpgrade(map[string]any{"module": "web", "policy": "record", "why": "the operator checks it"}, "person.jochen"); err != nil {
|
||||
|
||||
@@ -352,10 +352,12 @@ func serveTools() error {
|
||||
}
|
||||
}()
|
||||
tools := append(s.tools(), newSeatTools(runtimeStore{}, stdio.Ask).tools()...)
|
||||
tools = append(tools, newWantsTools(runtimeStore{}, stdio.Ask, func(e, id string, b any) error {
|
||||
wants := newWantsTools(runtimeStore{}, stdio.Ask, func(e, id string, b any) error {
|
||||
// The seat's event, said on the seat: a claimed seat's event is never the module's own.
|
||||
return stdio.SeatPublish(wantsEventSubject(e), b, id)
|
||||
}).tools()...)
|
||||
})
|
||||
wants.watched = stdio.NewWatchedStates() // the large states read whole from one watched copy (ADR 0580 clause 3)
|
||||
tools = append(tools, wants.tools()...)
|
||||
return stdio.Serve("", tools)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/handacts"
|
||||
@@ -20,15 +19,14 @@ import (
|
||||
|
||||
// The seat mesh-wants' verbs (novox/hq ADR 0565 §7, §8, §9, §15; issue 1953 D4-D6), served through the tool runner beside the
|
||||
// put tools and mesh-placement's verbs. `upgrade` reads and sets a person's upgrade policy, kept in the state `policies`
|
||||
// (each set recorded as a hand act and said as policy-changed), and with compare lists every module whose policy differs from
|
||||
// the one the controller writes beside its compositions; `bus` says what a bus upgrade would do. The assigner's process reads
|
||||
// (each set recorded as a hand act and said as policy-changed), and with compare lists the digest of the composition each node's
|
||||
// own have names (the controller writes no policy any more, so no module's policy differs); `bus` says what a bus upgrade would do. The assigner's process reads
|
||||
// `policies` when it judges a move and writes the finding policy-split while a split stands.
|
||||
|
||||
// The states the verbs read, as the runtime names them.
|
||||
const (
|
||||
policiesState = delivery.AssignerPoliciesState
|
||||
manifestsState = delivery.IndexerModule + ".manifests"
|
||||
composedState = "mesh-controller.composed"
|
||||
// PolicyChangedEvent is the seat's event of a person's policy set or taken back.
|
||||
PolicyChangedEvent = "policy-changed"
|
||||
// busStepCause is the cause the bus's step is recorded under in the hand-act log.
|
||||
@@ -41,6 +39,10 @@ type wantsTools struct {
|
||||
ask func(key string, body any) (json.RawMessage, error)
|
||||
emit func(event, id string, body any) error
|
||||
now func() time.Time
|
||||
// watched is the copy of the large states it reads whole, each by one watch started on its first read and kept by
|
||||
// its changes after (mesh-sdk's WatchedStates, novox/hq ADR 0580 clause 3), never a get per key, which timed out on
|
||||
// a large state (issue 1953 L5f). Nil reads key by key.
|
||||
watched *stdio.WatchedStates
|
||||
}
|
||||
|
||||
// wantsEventSubject is the subject of one of the seat's events.
|
||||
@@ -52,15 +54,16 @@ func newWantsTools(st seatStore, ask func(string, any) (json.RawMessage, error),
|
||||
return &wantsTools{st: st, ask: ask, emit: emit, now: time.Now}
|
||||
}
|
||||
|
||||
// leased says the controller's lease is handed away.
|
||||
func (s *wantsTools) leased() bool {
|
||||
raw, _, found, err := s.st.get(buscompose.ControllerLease, buscompose.ControllerLeaseKey)
|
||||
away, _ := buscompose.LeaseHandedAway(raw, found && raw != nil, err)
|
||||
return away
|
||||
// values reads every key of a state: from the watched copy when there is one, else key by key.
|
||||
func (s *wantsTools) values(state string) (map[string][]byte, error) {
|
||||
if s.watched != nil {
|
||||
return s.watched.Values(state)
|
||||
}
|
||||
return s.direct(state)
|
||||
}
|
||||
|
||||
// values reads every key of a state.
|
||||
func (s *wantsTools) values(state string) (map[string][]byte, error) {
|
||||
// direct reads a state key by key, for a small state of the seat's own.
|
||||
func (s *wantsTools) direct(state string) (map[string][]byte, error) {
|
||||
keys, err := s.st.keys(state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s cannot be listed: %w", state, err)
|
||||
@@ -78,6 +81,16 @@ func (s *wantsTools) values(state string) (map[string][]byte, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// manifest reads a manifest by its digest: kept once read when there is a watched copy, a manifest never changing
|
||||
// under its digest.
|
||||
func (s *wantsTools) manifest(digest string) ([]byte, bool, error) {
|
||||
if s.watched != nil {
|
||||
return s.watched.Once(manifestsState, digest)
|
||||
}
|
||||
raw, _, found, err := s.st.get(manifestsState, digest)
|
||||
return raw, found, err
|
||||
}
|
||||
|
||||
// manifests is every module the indexer holds, with its trunk manifest and fingerprint.
|
||||
func (s *wantsTools) manifests() (map[string]catalogue.Manifest, map[string]delivery.Module, error) {
|
||||
mods, err := s.values(modulesState)
|
||||
@@ -94,7 +107,7 @@ func (s *wantsTools) manifests() (map[string]catalogue.Manifest, map[string]deli
|
||||
if m.Manifest == "" {
|
||||
continue
|
||||
}
|
||||
mraw, _, found, err := s.st.get(manifestsState, m.Manifest)
|
||||
mraw, found, err := s.manifest(m.Manifest)
|
||||
if err != nil || !found {
|
||||
continue
|
||||
}
|
||||
@@ -126,21 +139,29 @@ func (s *wantsTools) doUpgrade(args map[string]any, by string) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := s.values(policiesState)
|
||||
raw, err := s.direct(policiesState) // the seat's own small state, read whole after its own writes
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chosen, unreadable := assign.DecodePolicies(raw)
|
||||
if arg(args, "compare") == "true" {
|
||||
composed, err := s.values(composedState)
|
||||
haves, err := s.values(haveState)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
splits := assign.Splits(manifests, chosen, assign.ControllerPolicies(composed))
|
||||
composed := map[string]string{}
|
||||
for key, raw := range haves {
|
||||
if node, _, isNode, err := delivery.ParseHaveKey(key); err == nil && isNode {
|
||||
if h, err := delivery.DecodeHaveNode(raw); err == nil && h.Composed != "" {
|
||||
composed[node] = h.Composed
|
||||
}
|
||||
}
|
||||
}
|
||||
splits := assign.Splits(manifests, chosen, nil) // the controller writes no policy to differ from (ADR 0571 §6)
|
||||
if splits == nil {
|
||||
splits = []assign.Split{}
|
||||
}
|
||||
out := map[string]any{"splits": splits, "empty": len(splits) == 0}
|
||||
out := map[string]any{"splits": splits, "empty": len(splits) == 0, "composed": composed}
|
||||
// The assigner's findings, its said conditions and its policy splits as the condition keeper reads them (§8, §15).
|
||||
if found, err := s.values(health.FindingsState); err != nil {
|
||||
out["findings-unread"] = err.Error()
|
||||
@@ -207,7 +228,7 @@ func (s *wantsTools) setPolicy(module, policy, why, by string, manifests map[str
|
||||
return nil, fmt.Errorf("the choice for %s cannot be written: %w", module, err)
|
||||
}
|
||||
}
|
||||
rec := handacts.Recorder{Ask: s.ask, LeaseAway: s.leased}
|
||||
rec := handacts.Recorder{Ask: s.ask}
|
||||
taken, rerr := rec.Record(handacts.Request{What: what, Why: why, Cause: "upgrade-policy", Verb: "mesh-wants.upgrade"})
|
||||
recorded := taken.Log
|
||||
if rerr != nil {
|
||||
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
@@ -19,7 +18,6 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/provider"
|
||||
|
||||
"mesh-delivery-assigner/internal/assign"
|
||||
)
|
||||
@@ -28,18 +26,6 @@ import (
|
||||
// for (mesh-sdk delivery.CandidateNoneNoModule, which the indexer writes): the change removes the module.
|
||||
const noModuleWords = delivery.CandidateNoneNoModule
|
||||
|
||||
// assignment holds only the fields of the controller's Assignment the assigner reads (the view's own, mesh-delivery-view
|
||||
// internal/view/view.go): an assignment stands while it has not ended and has no state.
|
||||
type assignment struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Build string `json:"build"`
|
||||
Ended time.Time `json:"ended"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
func (a assignment) open() bool { return a.Ended.IsZero() && a.State == "" }
|
||||
|
||||
// world is what the assigner watched, as it stands now: every value by bucket and key, and the manifests read by
|
||||
// digest. The watches write it; a pass reads it.
|
||||
type world struct {
|
||||
@@ -210,38 +196,15 @@ func (w *world) manifest(ctx context.Context, get getter, digest string) parsed
|
||||
return p
|
||||
}
|
||||
|
||||
// assigned is each node's modules both the controller's assignments and the seat mesh-placement's placement name, in name order
|
||||
// (ADR 0549 §5; the assignments alone before any placement was read), with every open assignment by node and module.
|
||||
// Once the controller's lease is handed away it is the seat's placement alone, with no assignment (ADR 0549 §5, §6).
|
||||
func (w *world) assigned() (map[string][]string, map[string]assignment) {
|
||||
// A lease handed away before any placement was read keeps the assignments as they last stood, never an empty mesh:
|
||||
// the lease write's own check refuses that order (ADR 0547 §11).
|
||||
placed, seeded := w.placed()
|
||||
byAssignment, all := w.assignedByController()
|
||||
if away := w.handedAway(); away && seeded {
|
||||
both, _ := assign.Placed(byAssignment, placed, seeded, away)
|
||||
return both, map[string]assignment{}
|
||||
// assigned is each node's modules the seat mesh-placement's placement names, in name order (ADR 0549 §5, ADR 0567 §8): the
|
||||
// placement is the one source of where modules run. The controller's assignments are read no more (issue 774).
|
||||
func (w *world) assigned() map[string][]string {
|
||||
placed, _ := w.placed()
|
||||
out := make(map[string][]string, len(placed))
|
||||
for node, modules := range placed {
|
||||
out[node] = slices.Sorted(slices.Values(modules))
|
||||
}
|
||||
both, _ := assign.Placed(byAssignment, placed, seeded, false)
|
||||
return both, all
|
||||
}
|
||||
|
||||
// placementDiffers is every pair the controller's assignments and the seat's placement do not both name: none once
|
||||
// the controller's lease is handed away, since the placement is then the one source.
|
||||
func (w *world) placementDiffers() []assign.Pair {
|
||||
placed, seeded := w.placed()
|
||||
byAssignment, _ := w.assignedByController()
|
||||
_, differ := assign.Placed(byAssignment, placed, seeded, w.handedAway())
|
||||
return differ
|
||||
}
|
||||
|
||||
// handedAway says the controller's lease is handed to a holder that is no controller (mesh-sdk buscompose.LeaseHandedAway,
|
||||
// ADR 0561 §3): from then the seat mesh-placement's placement alone names where modules run (ADR 0549 §5). A lease not read (its
|
||||
// bucket not granted or not there), a controller's, expired or unreadable, is not handed away.
|
||||
func (w *world) handedAway() bool {
|
||||
raw, found := w.read(buscompose.LeaseBucket)[buscompose.ControllerLeaseKey]
|
||||
away, _ := buscompose.LeaseHandedAway(raw, found && raw != nil, nil)
|
||||
return away
|
||||
return out
|
||||
}
|
||||
|
||||
// placed is each node's modules in the seat's placement (ADR 0567 §1), and whether any placement key was read: a key
|
||||
@@ -271,24 +234,6 @@ func (w *world) placedNodes() map[string]bool {
|
||||
return out
|
||||
}
|
||||
|
||||
// assignedByController is each node's modules with an open assignment, read from the controller's assignments, in
|
||||
// name order.
|
||||
func (w *world) assignedByController() (map[string][]string, map[string]assignment) {
|
||||
out, all := map[string][]string{}, map[string]assignment{}
|
||||
for _, value := range w.read(provider.AssignmentsBucket) {
|
||||
var a assignment
|
||||
if json.Unmarshal(value, &a) != nil || a.Node == "" || a.Module == "" || !a.open() {
|
||||
continue
|
||||
}
|
||||
out[a.Node] = append(out[a.Node], a.Module)
|
||||
all[a.Node+"."+a.Module] = a
|
||||
}
|
||||
for _, mods := range out {
|
||||
sort.Strings(mods)
|
||||
}
|
||||
return out, all
|
||||
}
|
||||
|
||||
// trunk is every module the indexer holds at the trunk, with its value.
|
||||
func (w *world) trunk() map[string]delivery.Module {
|
||||
out := map[string]delivery.Module{}
|
||||
@@ -307,7 +252,7 @@ func (w *world) trunk() map[string]delivery.Module {
|
||||
// mesh is the mesh as the trunk holds it, for the composition check.
|
||||
func (w *world) mesh(ctx context.Context, manifests getter) assign.Mesh {
|
||||
m := assign.Mesh{Manifests: map[string]catalogue.Manifest{}, Unreadable: map[string]string{}, Digests: map[string]string{}}
|
||||
m.Assigned, _ = w.assigned()
|
||||
m.Assigned = w.assigned()
|
||||
for name, mod := range w.trunk() {
|
||||
if mod.Manifest == "" {
|
||||
continue
|
||||
@@ -584,15 +529,9 @@ func (w *world) value(bucket, key string) []byte {
|
||||
return w.values[bucket][key]
|
||||
}
|
||||
|
||||
// grantsBucket is the bucket the grants are read from now (delivery.GrantSource): the grants part's while the controller's
|
||||
// lease is handed away, as the lease bucket's key says it (buscompose.LeaseHandedAway), the controller's in every other
|
||||
// case, a lease not held here included.
|
||||
func (w *world) grantsBucket() string {
|
||||
raw := w.value(buscompose.LeaseBucket, buscompose.ControllerLeaseKey)
|
||||
away, _ := buscompose.LeaseHandedAway(raw, raw != nil, nil)
|
||||
bucket, _ := delivery.GrantSource(away)
|
||||
return bucket
|
||||
}
|
||||
// grantsBucket is the bucket the grants are read from: the grants part's state `grants`, always (ADR 0571 clause 9); no
|
||||
// manifest but the controller's reads the controller's grants.
|
||||
func (w *world) grantsBucket() string { return delivery.PartGrantsBucket }
|
||||
|
||||
// has says whether a bucket holds a key now.
|
||||
func (w *world) has(bucket, key string) bool {
|
||||
@@ -618,49 +557,14 @@ func (w *world) built(module, fp string) bool {
|
||||
return err == nil && b.State == delivery.BuildDone
|
||||
}
|
||||
|
||||
// composed is the controller's composition of module for node at fp: its digest and the judgement of the move to it
|
||||
// (mesh-controller_composed `<node>.<module>.<hex>`), and whether it exists and can be read.
|
||||
//
|
||||
// From the lease write the controller's copy is no longer read (ADR 0571 §6, a named exception of clause 10 until then): a
|
||||
// placed node composes its own, and its composition is the digest its own have names; an unplaced node has none, so it gets
|
||||
// no want (and the pass says so, unplacedFindings).
|
||||
// composed is the composition of module for node: a placed node composes its own, and its composition is the digest its own
|
||||
// have names (ADR 0571 §6); an unplaced node has none, so it gets no want (and the pass says so, unplacedFindings). The
|
||||
// controller's copy (mesh-controller_composed) is read no more (issue 774).
|
||||
func (w *world) composed(node, module, fp string) (delivery.ComposedDigest, bool) {
|
||||
if w.handedAway() {
|
||||
if !w.placedNodes()[node] {
|
||||
return delivery.ComposedDigest{}, false
|
||||
}
|
||||
return w.ownComposed(node)
|
||||
}
|
||||
key, err := delivery.ComposedKey(node, module, fp)
|
||||
if err != nil {
|
||||
if !w.placedNodes()[node] {
|
||||
return delivery.ComposedDigest{}, false
|
||||
}
|
||||
w.mu.Lock()
|
||||
value, ok := w.values[delivery.ControllerComposedBucket][key]
|
||||
w.mu.Unlock()
|
||||
if !ok {
|
||||
return delivery.ComposedDigest{}, false
|
||||
}
|
||||
d, err := delivery.DecodeComposedDigest(value)
|
||||
return d, err == nil
|
||||
}
|
||||
|
||||
// nodeComposed is, by node, the digest of the node's own composition (mesh-controller_composed `<node>.node`).
|
||||
func (w *world) nodeComposed() map[string]delivery.ComposedDigest {
|
||||
out := map[string]delivery.ComposedDigest{}
|
||||
if w.handedAway() {
|
||||
return out // the controller composes no node from the lease write (ADR 0571 §6)
|
||||
}
|
||||
for key, value := range w.read(delivery.ControllerComposedBucket) {
|
||||
node, _, _, isNode, err := delivery.ParseComposedKey(key)
|
||||
if err != nil || !isNode {
|
||||
continue
|
||||
}
|
||||
if d, err := delivery.DecodeComposedDigest(value); err == nil {
|
||||
out[node] = d
|
||||
}
|
||||
}
|
||||
return out
|
||||
return w.ownComposed(node)
|
||||
}
|
||||
|
||||
// answer is the teller's answer to the ask at key (mesh-delivery-teller_answers, the same key), and whether there is one.
|
||||
@@ -725,7 +629,7 @@ func (w *world) runningCommits(module string) []string {
|
||||
var keptBucket = "mesh-config_" + delivery.MeshConfigKeptState
|
||||
|
||||
// ownComposed is a placed node's own composition, as the digest its own have names (`<node>.node` of the haves, Composed):
|
||||
// what rule 7 reads from the lease write. A node whose have names none is not composed yet.
|
||||
// what rule 7 reads. A node whose have names none is not composed yet.
|
||||
func (w *world) ownComposed(node string) (delivery.ComposedDigest, bool) {
|
||||
key, err := delivery.HaveNodeKey(node)
|
||||
if err != nil {
|
||||
@@ -743,12 +647,9 @@ func (w *world) ownComposed(node string) (delivery.ComposedDigest, bool) {
|
||||
Words: "the node composes its own"}, true
|
||||
}
|
||||
|
||||
// unplacedNodes are, from the lease write, the nodes whose engine has a have and that no placement names: they get no
|
||||
// unplacedNodes are the nodes whose engine has a have and that no placement names: they get no
|
||||
// want, since nothing composes them.
|
||||
func (w *world) unplacedNodes() []string {
|
||||
if !w.handedAway() {
|
||||
return nil
|
||||
}
|
||||
placed := w.placedNodes()
|
||||
var out []string
|
||||
for key := range w.read(delivery.HaveBucket) {
|
||||
|
||||
@@ -128,24 +128,22 @@ func theWorldCountsTheValuesAKeyTakes(t *testing.T) {
|
||||
if got := w.changed(delivery.HaveBucket, "gone"); got != 0 {
|
||||
t.Errorf("changed = %d for a key never seen", got)
|
||||
}
|
||||
// Issue 774: grants are read from the grants part's bucket only while the controller's lease is handed away: a grant
|
||||
// only the part holds lets a node's put through then, and with a controller's lease (or none, or an unreadable one) it
|
||||
// holds it, a grant only the controller holds being the one that counts instead.
|
||||
// ADR 0571 clause 9: grants are read from the grants part's bucket in every case, a lease held, handed away, absent or
|
||||
// unreadable alike, and the controller's own grants count for nothing.
|
||||
fp := delivery.SourcePrefix2 + strings.Repeat("d", 64)
|
||||
gkey, _ := delivery.ControllerGrantKey("web", fp)
|
||||
grant, _ := delivery.EncodeControllerGrant(delivery.ControllerGrant{Accounts: []string{"ace.web"}})
|
||||
for _, row := range []struct {
|
||||
name, lease string
|
||||
inPart, inController bool
|
||||
bucket string
|
||||
granted bool
|
||||
}{
|
||||
{"handed away, the part's grant", `{"handed-to":"the operator's hand-over"}`, true, false, delivery.PartGrantsBucket, true},
|
||||
{"handed away, only the controller's grant", `{"handed-to":"x"}`, false, true, delivery.PartGrantsBucket, false},
|
||||
{"a controller holds it, only the part's grant", `{"instance":"novox/1","epoch":3}`, true, false, delivery.ControllerGrantsBucket, false},
|
||||
{"a controller holds it, its own grant", `{"instance":"novox/1"}`, false, true, delivery.ControllerGrantsBucket, true},
|
||||
{"no lease read", ``, true, false, delivery.ControllerGrantsBucket, false},
|
||||
{"an unreadable lease", `[1]`, true, false, delivery.ControllerGrantsBucket, false},
|
||||
{"handed away, the part's grant", `{"handed-to":"the operator's hand-over"}`, true, false, true},
|
||||
{"handed away, only the controller's grant", `{"handed-to":"x"}`, false, true, false},
|
||||
{"a controller holds it, the part's grant", `{"instance":"novox/1","epoch":3}`, true, false, true},
|
||||
{"a controller holds it, only its own grant", `{"instance":"novox/1"}`, false, true, false},
|
||||
{"no lease read", ``, true, false, true},
|
||||
{"an unreadable lease", `[1]`, true, false, true},
|
||||
} {
|
||||
p := newPasser(buckets{}, time.Now())
|
||||
if row.lease != "" {
|
||||
@@ -157,8 +155,8 @@ func theWorldCountsTheValuesAKeyTakes(t *testing.T) {
|
||||
if row.inController {
|
||||
p.w.put(delivery.ControllerGrantsBucket, gkey, grant)
|
||||
}
|
||||
if got := p.w.grantsBucket(); got != row.bucket {
|
||||
t.Errorf("%s: grants are read from %s, want %s", row.name, got, row.bucket)
|
||||
if got := p.w.grantsBucket(); got != delivery.PartGrantsBucket {
|
||||
t.Errorf("%s: grants are read from %s, want the part's", row.name, got)
|
||||
}
|
||||
if _, granted := p.composedFor("ace", "web", fp); granted != row.granted {
|
||||
t.Errorf("%s: granted = %v, want %v", row.name, granted, row.granted)
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-delivery-assigner
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005736-7b09de1e5502 h1:XndvkzyAFNde/821lEfYObqMTURzmve/w9Ks+QrBBJ8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005736-7b09de1e5502/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3 h1:an2f0MLx9OhaDLu5i51OMPBbuGwX+aoTzqOkFLMpVfE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c h1:rlUCZ5ifLojqDUTkcCCcPrugTzV2RT6F6JbIVTJkaC8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014012-a20bdf795236 h1:JEkSnrmHs5QIRJGhtrghLC1+C0AxqMpef8a32d/BLPg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014012-a20bdf795236/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014310-19c204273bdc h1:kVq7sS4I0EnL8DcAA2a3YstnXS6WmYYzJz7eK7IZ9RY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014310-19c204273bdc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62 h1:R5C8ICjiaDITHRFR/uIATwARPPXKxssUC70m0svstb0=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 h1:a33dsavoaqllh40eVICbhxi6Kero1EGSjt1V+2fa2Hk=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21 h1:4I4ayo0fZYP4PBfEX1sGDDd6arnw5IQJySJ3jcFobPM=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -117,7 +117,6 @@ func saidConditionsAreFindings(t *testing.T) {
|
||||
{HeldCondition("mail", fNew, "ace", "refused", true, true), LiftedCondition("mail", fNew), "module.mail." + strings.TrimPrefix(fNew, delivery.SourcePrefix2) + ".held"},
|
||||
{NodeFailedCondition("ace", "mail", fNew, NodeFailure{Words: "x", Others: []string{"g14"}}, true), NodeFailedCondition("ace", "mail", fNew, NodeFailure{}, false), "module.ace.mail.failed-alone"},
|
||||
{NeitherCondition("ace", "mail", Holding{Rule: 3, Words: "w"}, true), NeitherCondition("ace", "mail", Holding{}, false), "module.ace.mail.neither"},
|
||||
{PlacementCondition(Pair{Node: "ace", Module: "mail", Only: OnlyAssignments}, true), PlacementCondition(Pair{Node: "ace", Module: "mail"}, false), "module.ace.mail.placement"},
|
||||
{SeatWaitsCondition([]string{"placement"}), SeatWaitsCondition(nil), "module.mesh-delivery-assigner.seat-states"},
|
||||
{BackupCondition("ace", "mail", fNew, "disk full", true), BackupCondition("ace", "mail", fNew, "", false), "module.ace.mail." + strings.TrimPrefix(fNew, delivery.SourcePrefix2) + ".backup"},
|
||||
} {
|
||||
|
||||
@@ -68,33 +68,33 @@ func TestAChangeOfOneModuleComposesItsMachine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1297/AC2: a change that removes a module a machine runs fails, naming the machine and the module.
|
||||
func TestRemovingAModuleAMachineRunsFails(t *testing.T) {
|
||||
c := Change{Group: "g", Heads: "0123456789ab", Removed: []string{"web"}}
|
||||
v := Compose(meshOf(), c, "sdk", 0)
|
||||
if v.State != delivery.CompositionFail || v.Machine != "g14" || !strings.Contains(v.Words, "g14 runs web") {
|
||||
t.Fatalf("composition = %+v, want a fail naming g14 and web", v)
|
||||
}
|
||||
}
|
||||
|
||||
// 1297/B3c: a machine that composes on the trunk and not with the change fails it, naming the machine and why; the
|
||||
// consumer's machine is touched when its provider changes.
|
||||
func TestAMachineTheChangeBreaksFails(t *testing.T) {
|
||||
c := Change{Group: "g", Heads: "0123456789ab", Manifests: map[string]catalogue.Manifest{"store": {Module: "store", Version: "2"}}}
|
||||
v := Compose(meshOf(), c, "sdk", 0)
|
||||
if v.State != delivery.CompositionFail || v.Machine != "ace" || !strings.Contains(v.Words, "database") {
|
||||
t.Fatalf("composition = %+v, want a fail on ace naming the provision nothing provides", v)
|
||||
}
|
||||
}
|
||||
|
||||
// 1297/B3c: what was already broken on the trunk is said and fails nothing.
|
||||
func TestWhatWasBrokenBeforeFailsNothing(t *testing.T) {
|
||||
m := meshOf()
|
||||
m.Manifests["store"] = catalogue.Manifest{Module: "store", Version: "1"}
|
||||
c := Change{Group: "g", Heads: "0123456789ab", Manifests: map[string]catalogue.Manifest{"mail": {Module: "mail", Version: "2", Requires: []string{"database"}}}}
|
||||
v := Compose(m, c, "sdk", 0)
|
||||
if v.State != delivery.CompositionPass || !hasWarning(v, "on the trunk either") {
|
||||
t.Fatalf("composition = %+v, want a pass saying ace was already broken", v)
|
||||
// 1297/AC2 and B3c: a change that removes a module a machine runs fails, naming the machine and the module; a machine that
|
||||
// composes on the trunk and not with the change fails it, naming the machine and why (the consumer's machine is touched when
|
||||
// its provider changes); what was already broken on the trunk is said and fails nothing. One table (three tests merged).
|
||||
func TestAChangeThatBreaksAMachineFailsAndWhatWasBrokenFailsNothing(t *testing.T) {
|
||||
broken := meshOf()
|
||||
broken.Manifests["store"] = catalogue.Manifest{Module: "store", Version: "1"}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
mesh Mesh
|
||||
c Change
|
||||
state string
|
||||
must func(delivery.CompositionVerdict) bool
|
||||
}{
|
||||
{"removing a module a machine runs", meshOf(), Change{Group: "g", Heads: "0123456789ab", Removed: []string{"web"}}, delivery.CompositionFail,
|
||||
func(v delivery.CompositionVerdict) bool {
|
||||
return v.Machine == "g14" && strings.Contains(v.Words, "g14 runs web")
|
||||
}},
|
||||
{"a machine the change breaks", meshOf(), Change{Group: "g", Heads: "0123456789ab", Manifests: map[string]catalogue.Manifest{"store": {Module: "store", Version: "2"}}},
|
||||
delivery.CompositionFail, func(v delivery.CompositionVerdict) bool {
|
||||
return v.Machine == "ace" && strings.Contains(v.Words, "database")
|
||||
}},
|
||||
{"what was broken before", broken, Change{Group: "g", Heads: "0123456789ab", Manifests: map[string]catalogue.Manifest{"mail": {Module: "mail", Version: "2", Requires: []string{"database"}}}},
|
||||
delivery.CompositionPass, func(v delivery.CompositionVerdict) bool { return hasWarning(v, "on the trunk either") }},
|
||||
} {
|
||||
if v := Compose(c.mesh, c.c, "sdk", 0); v.State != c.state || !c.must(v) {
|
||||
t.Errorf("%s: composition = %+v", c.name, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,122 +1,12 @@
|
||||
package assign
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Until ADR 0547's third milestone two sources name which module runs on which node: the controller's assignments and
|
||||
// the seat mesh-placement's placement (ADR 0549 §5, ADR 0567 §8). The assigner writes wants only for a pair both name:
|
||||
// for a pair one source names alone it writes no want and touches none it wrote before, so nothing moves and nothing is
|
||||
// removed on one source's word, and it raises a condition naming the pair until both agree. After an approved proposal
|
||||
// the assigner keeps the controller in step itself, calling its assign, unassign, pin and unpin; a call refused or not
|
||||
// yet answered leaves the pair differing, and the condition names the call (Pair.Call). Before the first placement key
|
||||
// is written the controller's assignments are the one source, as before. From the lease write (the controller's lease
|
||||
// handed away) the placement is the one source.
|
||||
|
||||
// Pair is a module on a node that one source names and the other does not; Only says which: OnlyAssignments or
|
||||
// OnlyPlacement.
|
||||
type Pair struct {
|
||||
Node, Module, Only string
|
||||
// Call is what became of the assigner's call of the controller's verb for the pair, in words, when it made one.
|
||||
Call string
|
||||
}
|
||||
|
||||
// The source that names a pair alone.
|
||||
const (
|
||||
OnlyAssignments = "the controller's assignments"
|
||||
OnlyPlacement = "the seat mesh-placement's placement"
|
||||
)
|
||||
|
||||
// Placed is, by node, the modules both sources name, in name order, and every pair one names alone, by node then
|
||||
// module. With no placement read yet (seeded false) it is the assignments, and no pair differs. Once the controller's
|
||||
// lease is handed away (handedAway, ADR 0549 §5, §6) it is the placement alone, in name order, and no pair differs: the
|
||||
// assignments are then a source of nothing.
|
||||
func Placed(assigned, placed map[string][]string, seeded, handedAway bool) (map[string][]string, []Pair) {
|
||||
if handedAway && seeded {
|
||||
out := make(map[string][]string, len(placed))
|
||||
for node, modules := range placed {
|
||||
out[node] = slices.Sorted(slices.Values(modules))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if !seeded {
|
||||
return assigned, nil
|
||||
}
|
||||
set := func(m map[string][]string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for node, modules := range m {
|
||||
for _, module := range modules {
|
||||
out[node+"\x00"+module] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
inAssigned, inPlaced := set(assigned), set(placed)
|
||||
both := map[string][]string{}
|
||||
var differ []Pair
|
||||
for node, modules := range assigned {
|
||||
for _, module := range modules {
|
||||
if inPlaced[node+"\x00"+module] {
|
||||
both[node] = append(both[node], module)
|
||||
} else {
|
||||
differ = append(differ, Pair{Node: node, Module: module, Only: OnlyAssignments})
|
||||
}
|
||||
}
|
||||
}
|
||||
for node, modules := range placed {
|
||||
for _, module := range modules {
|
||||
if !inAssigned[node+"\x00"+module] {
|
||||
differ = append(differ, Pair{Node: node, Module: module, Only: OnlyPlacement})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, modules := range both {
|
||||
sort.Strings(modules)
|
||||
}
|
||||
sort.Slice(differ, func(i, j int) bool {
|
||||
if differ[i].Node != differ[j].Node {
|
||||
return differ[i].Node < differ[j].Node
|
||||
}
|
||||
return differ[i].Module < differ[j].Module
|
||||
})
|
||||
return both, differ
|
||||
}
|
||||
|
||||
// PlacementCondition is the condition of a pair the two sources do not both name: raised while they differ, cleared once
|
||||
// both name it or neither does.
|
||||
func PlacementCondition(p Pair, raised bool) asks.Condition {
|
||||
key := delivery.AssignerModule + ".placement." + p.Node + "." + p.Module
|
||||
subject := asks.Scope{Scope: "module", ID: p.Module, Machine: p.Node}
|
||||
if !raised {
|
||||
return asks.Condition{Key: key, Subject: subject, State: asks.StateCleared,
|
||||
Resolved: fmt.Sprintf("the controller's assignments and the seat's placement agree on %s on %s.", p.Module, p.Node)}
|
||||
}
|
||||
headline := fmt.Sprintf("%s on %s is placed by one source only", p.Module, p.Node)
|
||||
if n := len([]rune(headline)); n > asks.HeadlineLength {
|
||||
headline = string([]rune(headline)[:asks.HeadlineLength])
|
||||
}
|
||||
var needs string
|
||||
if p.Only == OnlyAssignments {
|
||||
needs = fmt.Sprintf("propose placing %s on %s (mesh-placement.propose), or unassign it at the controller", p.Module, p.Node)
|
||||
} else {
|
||||
needs = fmt.Sprintf("assign %s to %s at the controller, as the approved placement says, or propose taking it off", p.Module, p.Node)
|
||||
}
|
||||
if p.Call != "" {
|
||||
needs += "; the assigner's call: " + p.Call
|
||||
}
|
||||
return asks.Condition{Key: key, Severity: asks.SeverityUrgent, Subject: subject, State: asks.StateRaised,
|
||||
Summary: headline, Headline: headline, Needs: needs,
|
||||
Explanation: fmt.Sprintf("%s%s. %s names %s on %s and the other source does not. Until ADR 0547's third milestone both must "+
|
||||
"name a module on a node (ADR 0549 §5): the assigner writes no want for it there and leaves the one it wrote before, so "+
|
||||
"nothing moves or is removed. This clears once both name it or neither does.", asks.NeedsYou, needs, p.Only, p.Module, p.Node)}
|
||||
}
|
||||
|
||||
// SeatWaitsCondition is the assigner's word on the seat mesh-placement while a state of the seat is not bound (ADR 0567
|
||||
// §1): raised naming each, cleared when none is left.
|
||||
func SeatWaitsCondition(unbound []string) asks.Condition {
|
||||
|
||||
@@ -48,26 +48,6 @@ func PolicyOf(m catalogue.Manifest, chosen map[string]catalogue.PersonPolicy) (p
|
||||
return catalogue.Upgrade(m, c)
|
||||
}
|
||||
|
||||
// ControllerPolicies is, by module, every policy the controller writes beside a composition's digest of it.
|
||||
func ControllerPolicies(composed map[string][]byte) map[string][]string {
|
||||
out := map[string][]string{}
|
||||
for key, raw := range composed {
|
||||
_, module, _, isNode, err := delivery.ParseComposedKey(key)
|
||||
if err != nil || isNode {
|
||||
continue
|
||||
}
|
||||
d, err := delivery.DecodeComposedDigest(raw)
|
||||
if err != nil || d.Policy == "" {
|
||||
continue
|
||||
}
|
||||
if !contains(out[module], d.Policy) {
|
||||
out[module] = append(out[module], d.Policy)
|
||||
sort.Strings(out[module])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, v := range list {
|
||||
if v == s {
|
||||
|
||||
@@ -68,7 +68,7 @@ func StepPut(w PutWorld) PutNext {
|
||||
}
|
||||
fp := short(p.FP)
|
||||
if !w.Assigned {
|
||||
return say(delivery.PutRefused, "%s is not assigned to %s: a put moves a module where it stands assigned (mesh-controller.assign first)", w.Module, w.Node)
|
||||
return say(delivery.PutRefused, "%s is not assigned to %s: a put moves a module where it stands assigned (propose its placement first)", w.Module, w.Node)
|
||||
}
|
||||
switch w.Build {
|
||||
case delivery.BuildDone:
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package assign
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -19,30 +17,25 @@ import (
|
||||
// revision of its key; which modules the indexer holds and which nodes have a record; and, for the seed, the export's
|
||||
// digest.
|
||||
type ProposeIn struct {
|
||||
ID, By, Why string
|
||||
At time.Time
|
||||
Changes []delivery.PlacementProposalChange
|
||||
Current map[string]delivery.Placement
|
||||
Revisions map[string]uint64
|
||||
Held func(module string) bool
|
||||
Recorded func(node string) bool
|
||||
Seed bool
|
||||
ExportDigest string
|
||||
// Leased says the lease write of ADR 0547 has happened (the controller's lease handed away, mesh-sdk
|
||||
// buscompose.LeaseHandedAway; issue 774), after which the controller's assignments are read no more (§8): until then
|
||||
// the holder keeps the controller in step and refuses what its verbs cannot take.
|
||||
Leased bool
|
||||
ID, By, Why string
|
||||
At time.Time
|
||||
Changes []delivery.PlacementProposalChange
|
||||
Current map[string]delivery.Placement
|
||||
Revisions map[string]uint64
|
||||
Held func(module string) bool
|
||||
Recorded func(node string) bool
|
||||
// Warrant is the enrolment's warrant an add-node carries (ADR 0587 §7); only that change has one.
|
||||
Warrant *delivery.AddNodeWarrant
|
||||
}
|
||||
|
||||
// Propose lays a proposal over the current keys (§2): the proposal (state answering, each changed node's base revision),
|
||||
// the laid placement of every node whose key it changes, and every finding, which refuses it whole: a change the
|
||||
// controller's verbs cannot take before the lease write (§8), a change that does not fit its node, and what
|
||||
// the laid placement of every node whose key it changes, and every finding, which refuses it whole: a change that does not fit its node, and what
|
||||
// CheckPlacements refuses of the result laid over every placement (two modules 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). A key whose value the proposal leaves as it is changes nothing and is not laid.
|
||||
func Propose(in ProposeIn) (delivery.PlacementProposal, map[string]*delivery.Placement, []string) {
|
||||
p := delivery.PlacementProposal{ID: in.ID, By: strings.TrimSpace(in.By), Why: strings.TrimSpace(in.Why), At: in.At.UTC(),
|
||||
Changes: in.Changes, Seed: in.Seed, ExportDigest: in.ExportDigest, Base: map[string]uint64{}, State: delivery.ProposalAnswering}
|
||||
Changes: in.Changes, Warrant: in.Warrant, Base: map[string]uint64{}, State: delivery.ProposalAnswering}
|
||||
var findings []string
|
||||
if p.By == "" {
|
||||
findings = append(findings, "the runtime names no caller: a proposal is made by someone")
|
||||
@@ -54,14 +47,8 @@ func Propose(in ProposeIn) (delivery.PlacementProposal, map[string]*delivery.Pla
|
||||
findings = append(findings, "a proposal changes something: it names no change")
|
||||
}
|
||||
for i, c := range in.Changes {
|
||||
if (c.Op == delivery.OpSeed) != in.Seed {
|
||||
findings = append(findings, fmt.Sprintf("change %d: the seed is adopt's alone, and never mixed with other changes", i+1))
|
||||
continue
|
||||
}
|
||||
if !in.Leased {
|
||||
if why := c.BeforeLease(); why != "" {
|
||||
findings = append(findings, fmt.Sprintf("change %d: %s", i+1, why))
|
||||
}
|
||||
if c.Op == delivery.OpSeed {
|
||||
findings = append(findings, fmt.Sprintf("change %d: the seed is gone with adopt, the controller's export being gone", i+1))
|
||||
}
|
||||
}
|
||||
laid, lay := delivery.LayChanges(in.Current, in.Changes)
|
||||
@@ -80,6 +67,16 @@ func Propose(in ProposeIn) (delivery.PlacementProposal, map[string]*delivery.Pla
|
||||
out[node] = &pl
|
||||
p.Base[node] = in.Revisions[node]
|
||||
}
|
||||
// A remove-node deletes the key of a joining node that never confirmed (the operator's decision of 2026-11-02 01:05): its
|
||||
// candidate is no placement, and the checks across nodes read the mesh without it. LayChanges said why one cannot go.
|
||||
for _, node := range p.RemovedNodes() {
|
||||
if _, ok := in.Current[node]; !ok {
|
||||
continue
|
||||
}
|
||||
delete(all, node)
|
||||
out[node] = nil
|
||||
p.Base[node] = in.Revisions[node]
|
||||
}
|
||||
if len(findings) == 0 && len(out) == 0 {
|
||||
findings = append(findings, "the changes leave every placement as it is")
|
||||
}
|
||||
@@ -90,6 +87,18 @@ func Propose(in ProposeIn) (delivery.PlacementProposal, map[string]*delivery.Pla
|
||||
if recorded == nil {
|
||||
recorded = func(string) bool { return false }
|
||||
}
|
||||
// A joining node writes its record only once it holds its account, which the add-node's write gives it: this one change by
|
||||
// this one caller is the exception to a node with no record (ADR 0587 §7, changing ADR 0567 §2).
|
||||
joining := map[string]bool{}
|
||||
for _, c := range in.Changes {
|
||||
if c.Op == delivery.OpAddNode {
|
||||
joining[c.Node] = true
|
||||
}
|
||||
}
|
||||
if len(joining) > 0 {
|
||||
was := recorded
|
||||
recorded = func(node string) bool { return joining[node] || was(node) }
|
||||
}
|
||||
for _, r := range delivery.CheckPlacements(all, held, recorded) {
|
||||
findings = append(findings, r)
|
||||
}
|
||||
@@ -118,112 +127,19 @@ func Writes(p delivery.PlacementProposal, laid map[string]*delivery.Placement) (
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ControllerCall is one call of the controller's seat verbs the holder makes after writing an approved proposal, until
|
||||
// the lease write (§8): assign, unassign, pin or unpin, with its arguments.
|
||||
type ControllerCall struct {
|
||||
Verb string
|
||||
Args map[string]any
|
||||
// Node and Module name the pair the call keeps in step (a pin names no module).
|
||||
Node, Module string
|
||||
}
|
||||
|
||||
// Key names the call, as the condition of its pair says it.
|
||||
func (c ControllerCall) Key() string {
|
||||
return fmt.Sprintf("mesh-controller.%s %s %s", c.Verb, c.Node, c.Module)
|
||||
}
|
||||
|
||||
// ControllerCalls is every call that brings the controller's assignments and pins to the placement after: assign for a
|
||||
// module placed on a node before not, unassign for one taken off, pin for a pin set or changed, unpin for one removed. In
|
||||
// node order, unassigns before assigns, so a module moved between nodes is first taken off.
|
||||
//
|
||||
// Each call carries only the arguments the controller's seat contract declares for its verb (catalogue.ControllerVerbs):
|
||||
// the controller refuses any other argument and does nothing (issue 774: a "why" added to every call had every approved
|
||||
// placement refused since 2026-10-28). The proposal and its warrant are in the assigner's own record instead: the
|
||||
// placement-changed event and the hand-act log.
|
||||
func ControllerCalls(before map[string]delivery.Placement, after map[string]*delivery.Placement) []ControllerCall {
|
||||
nodes := make([]string, 0, len(after))
|
||||
for node := range after {
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
sort.Strings(nodes)
|
||||
var off, on, pins []ControllerCall
|
||||
for _, node := range nodes {
|
||||
was, now := before[node], delivery.Placement{}
|
||||
if a := after[node]; a != nil {
|
||||
now = *a
|
||||
}
|
||||
had, has := map[string]bool{}, map[string]bool{}
|
||||
for _, m := range was.Modules {
|
||||
had[m.Module] = true
|
||||
}
|
||||
for _, m := range now.Modules {
|
||||
has[m.Module] = true
|
||||
if !had[m.Module] {
|
||||
on = append(on, ControllerCall{Verb: "assign", Node: node, Module: m.Module, Args: map[string]any{"node": node, "module": m.Module}})
|
||||
}
|
||||
}
|
||||
for _, m := range was.Modules {
|
||||
if !has[m.Module] {
|
||||
off = append(off, ControllerCall{Verb: "unassign", Node: node, Module: m.Module, Args: map[string]any{"node": node, "module": m.Module}})
|
||||
}
|
||||
}
|
||||
for _, provision := range sortedKeys(now.Pins) {
|
||||
to := now.Pins[provision]
|
||||
if old, ok := was.Pins[provision]; !ok || old != to {
|
||||
pins = append(pins, ControllerCall{Verb: "pin", Node: node, Module: provision,
|
||||
Args: map[string]any{"node": node, "provision": provision, "from": to.Node, "module": to.Module}})
|
||||
}
|
||||
}
|
||||
for _, provision := range sortedKeys(was.Pins) {
|
||||
if _, ok := now.Pins[provision]; !ok {
|
||||
pins = append(pins, ControllerCall{Verb: "unpin", Node: node, Module: provision, Args: map[string]any{"node": node, "provision": provision}})
|
||||
}
|
||||
}
|
||||
}
|
||||
return append(append(off, on...), pins...)
|
||||
}
|
||||
|
||||
// CallKind is the kind of the condition of a refused call: one for a module's place (assign, unassign), one for a pin.
|
||||
func (c ControllerCall) CallKind() string {
|
||||
if c.Verb == "pin" || c.Verb == "unpin" {
|
||||
return KindControllerPin
|
||||
}
|
||||
return KindControllerCall
|
||||
}
|
||||
|
||||
// The kinds of a refused controller call's condition (and finding).
|
||||
// The kinds of the condition (and finding) of a refused controller call, which the assigner raised until it stopped calling
|
||||
// the controller's verbs (ADR 0567 §8, issue 774).
|
||||
const (
|
||||
KindControllerCall = "controller-call"
|
||||
KindControllerPin = "controller-pin"
|
||||
)
|
||||
|
||||
// CallConditionKey is the key of the condition of a call refused for its pair (node and module, or node and provision).
|
||||
func CallConditionKey(c ControllerCall) string {
|
||||
return delivery.AssignerModule + "." + c.CallKind() + "." + c.Node + "." + c.Module
|
||||
}
|
||||
|
||||
// CallRefusedCondition is the condition the assigner raises when the controller refused (or did not answer) a call that
|
||||
// keeps its assignments in step with an approved placement (ADR 0567 §8, issue 774): the placement is written, the
|
||||
// controller's assignments are not, so nothing new reaches the node (no bus account, no composition). It names the verb,
|
||||
// the node, the module (or provision) and the controller's words, keyed by the pair, and is cleared (raised false) when a
|
||||
// later call for the pair is answered, or both sources agree on it, with resolved saying which.
|
||||
func CallRefusedCondition(c ControllerCall, refusal string, raised bool, resolved string) asks.Condition {
|
||||
key := CallConditionKey(c)
|
||||
subject := asks.Scope{Scope: "module", ID: c.Module, Machine: c.Node}
|
||||
if !raised {
|
||||
return asks.Condition{Key: key, Subject: subject, State: asks.StateCleared, Resolved: resolved}
|
||||
}
|
||||
headline := fmt.Sprintf("the controller refused %s %s on %s", c.Verb, c.Module, c.Node)
|
||||
if n := len([]rune(headline)); n > asks.HeadlineLength {
|
||||
headline = string([]rune(headline)[:asks.HeadlineLength])
|
||||
}
|
||||
needs := fmt.Sprintf("call mesh-controller.%s for %s on %s by hand once the refusal's cause is fixed", c.Verb, c.Module, c.Node)
|
||||
return asks.Condition{Key: key, Severity: asks.SeverityUrgent, Subject: subject, State: asks.StateRaised,
|
||||
Summary: headline, Headline: headline, Needs: needs,
|
||||
Explanation: fmt.Sprintf("%s%s. An approved placement was written, and the assigner's call %s to keep the controller's "+
|
||||
"assignments in step was refused: %s. Until the controller holds it, the placement and the assignments differ, so "+
|
||||
"the assigner writes no want for the pair and nothing new reaches %s. This clears when a call for it is answered "+
|
||||
"or both sources agree.", asks.NeedsYou, needs, c.Key(), refusal, c.Node)}
|
||||
// CallClearedCondition clears the condition of a refused controller call a previous process raised, for its pair (node and
|
||||
// module, or node and provision for a pin).
|
||||
func CallClearedCondition(kind, node, module string) asks.Condition {
|
||||
return asks.Condition{Key: delivery.AssignerModule + "." + kind + "." + node + "." + module,
|
||||
Subject: asks.Scope{Scope: "module", ID: module, Machine: node}, State: asks.StateCleared,
|
||||
Resolved: "the assigner calls no verb of the controller any more: placement is the one source."}
|
||||
}
|
||||
|
||||
// PlacementAct is the act an approved proposal's yes binds: writing exactly its changes at its base revisions.
|
||||
@@ -246,9 +162,6 @@ func PlacementAsk(key string, p delivery.PlacementProposal, brief string, asking
|
||||
}
|
||||
nodes := p.Nodes()
|
||||
headline := fmt.Sprintf("Change placement on %s?", strings.Join(nodes, ", "))
|
||||
if p.Seed {
|
||||
headline = "Take placement over from the controller?"
|
||||
}
|
||||
if n := len([]rune(headline)); n > asks.HeadlineLength {
|
||||
headline = fmt.Sprintf("Change placement on %d machines?", len(nodes))
|
||||
}
|
||||
@@ -268,57 +181,6 @@ func PlacementAsk(key string, p delivery.PlacementProposal, brief string, asking
|
||||
}, Asking: asking}, nil
|
||||
}
|
||||
|
||||
// ExportOf reads the controller's answer to placement-export (§7): its verb answers what the command printed, the
|
||||
// JSON of every node's placement, in `output` (the controller's verbAnswer; `answer` when it carries it as data). A
|
||||
// refused or unreadable answer is an error naming it.
|
||||
func ExportOf(raw []byte) (map[string]delivery.Placement, error) {
|
||||
var a struct {
|
||||
Output string `json:"output"`
|
||||
OK *bool `json:"ok"`
|
||||
Answer json.RawMessage `json:"answer"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return nil, fmt.Errorf("the controller's placement-export answered no verb answer: %w", err)
|
||||
}
|
||||
if a.OK != nil && !*a.OK {
|
||||
return nil, fmt.Errorf("the controller's placement-export refused: %s", strings.TrimSpace(a.Output))
|
||||
}
|
||||
var byNode map[string]json.RawMessage
|
||||
if len(a.Answer) > 0 && string(a.Answer) != "null" {
|
||||
if err := json.Unmarshal(a.Answer, &byNode); err != nil {
|
||||
return nil, fmt.Errorf("the controller's placement-export is no placement by node: %w", err)
|
||||
}
|
||||
} else if err := json.NewDecoder(strings.NewReader(a.Output)).Decode(&byNode); err != nil {
|
||||
// The command prints its JSON first; what its standard error said after it is left.
|
||||
return nil, fmt.Errorf("the controller's placement-export printed no placement by node: %w", err)
|
||||
}
|
||||
out := map[string]delivery.Placement{}
|
||||
for node, rawP := range byNode {
|
||||
p, err := delivery.DecodePlacement(rawP)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the controller's placement-export of %s cannot be read: %w", node, err)
|
||||
}
|
||||
if p.Node != node {
|
||||
return nil, fmt.Errorf("the controller's placement-export names %s under %s", p.Node, node)
|
||||
}
|
||||
out[node] = p
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("the controller's placement-export names no node")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SeedChanges is the seed's changes: every node's placement of the export, whole, in node order.
|
||||
func SeedChanges(export map[string]delivery.Placement) []delivery.PlacementProposalChange {
|
||||
var out []delivery.PlacementProposalChange
|
||||
for _, node := range sortedKeys(export) {
|
||||
p := export[node]
|
||||
out = append(out, delivery.PlacementProposalChange{Op: delivery.OpSeed, Node: node, Whole: &p})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Rest is what a proposal leaves to write once the keys of written are: its changes of the other nodes, to be laid again
|
||||
// over the keys as the write left them (§5: a key moved while it was written). False when nothing is left.
|
||||
func Rest(p delivery.PlacementProposal, written map[string]*delivery.Placement) (delivery.PlacementProposal, bool) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -38,39 +37,30 @@ func TestThePlacementSeatDecides(t *testing.T) {
|
||||
in ProposeIn
|
||||
want string
|
||||
}{
|
||||
"a port before the lease": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "laptop", Module: "web", Ports: map[string]int{"80": 8080}}), "lease write"},
|
||||
"a holding": {in(delivery.PlacementProposalChange{Op: delivery.OpHold, Node: "anchor", Module: "store", Claim: "mesh-store", Scope: "mesh"}), "lease write"},
|
||||
"a binding": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "laptop", Module: "web", Bindings: map[string]delivery.Provider{"db": {Node: "anchor", Module: "store"}}}), "lease write"},
|
||||
"a config place": {in(delivery.PlacementProposalChange{Op: delivery.OpConfig, Node: "anchor", Module: "web", Layer: map[string]any{"places": []any{}}}), "lease write"},
|
||||
"a config access": {in(delivery.PlacementProposalChange{Op: delivery.OpConfig, Node: "anchor", Module: "web", MeshWide: map[string]any{"accesses": []any{}}}), "lease write"},
|
||||
"a module not held": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "laptop", Module: "ghost"}), "the indexer holds no module ghost"},
|
||||
"a node with no record": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "stranger", Module: "web"}), "stranger is a node with no record"},
|
||||
"nothing changes": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "anchor", Module: "web"}), "leave every placement as it is"},
|
||||
"no why": {func() ProposeIn { i := in(move...); i.Why = " "; return i }(), "says why"},
|
||||
"no caller": {func() ProposeIn { i := in(move...); i.By = ""; return i }(), "names no caller"},
|
||||
"a seed by hand": {in(delivery.PlacementProposalChange{Op: delivery.OpSeed, Node: "laptop", Whole: &laptop}), "adopt's alone"},
|
||||
"a pin of nobody placed": {in(delivery.PlacementProposalChange{Op: delivery.OpPin, Node: "laptop", Provision: "db", Provider: &delivery.Provider{Node: "laptop", Module: "store"}}), "not placed there"},
|
||||
"a module not held": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "laptop", Module: "ghost"}), "the indexer holds no module ghost"},
|
||||
"a node with no record": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "stranger", Module: "web"}), "stranger is a node with no record"},
|
||||
"nothing changes": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "anchor", Module: "web"}), "leave every placement as it is"},
|
||||
"no why": {func() ProposeIn { i := in(move...); i.Why = " "; return i }(), "says why"},
|
||||
"no caller": {func() ProposeIn { i := in(move...); i.By = ""; return i }(), "names no caller"},
|
||||
"a seed by hand": {in(delivery.PlacementProposalChange{Op: delivery.OpSeed, Node: "laptop", Whole: &laptop}), "gone with adopt"},
|
||||
"a pin of nobody placed": {in(delivery.PlacementProposalChange{Op: delivery.OpPin, Node: "laptop", Provision: "db", Provider: &delivery.Provider{Node: "laptop", Module: "store"}}), "not placed there"},
|
||||
} {
|
||||
if _, _, f := Propose(c.in); !slices.ContainsFunc(f, func(s string) bool { return strings.Contains(s, c.want) }) {
|
||||
t.Errorf("%s: findings %v, want one naming %q", name, f, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// A module's config values may be proposed before the lease write (§13); once the lease is written every change is
|
||||
// the state's alone, and none waits.
|
||||
if _, _, f := Propose(in(delivery.PlacementProposalChange{Op: delivery.OpConfig, Node: "anchor", Module: "web", Values: map[string]any{"puid": 1}})); len(f) != 0 {
|
||||
t.Errorf("config values before the lease write: %v", f)
|
||||
}
|
||||
// Every kind of change is taken: the placement is the one source, and the controller's verbs limit nothing (ADR 0567 §8).
|
||||
for _, c := range []delivery.PlacementProposalChange{
|
||||
{Op: delivery.OpConfig, Node: "anchor", Module: "web", Values: map[string]any{"puid": 1}},
|
||||
{Op: delivery.OpPlace, Node: "laptop", Module: "web", Ports: map[string]int{"80": 8080}, Bindings: map[string]delivery.Provider{"db": {Node: "anchor", Module: "store"}}},
|
||||
{Op: delivery.OpConfig, Node: "anchor", Module: "web", Layer: map[string]any{"places": []any{}}},
|
||||
{Op: delivery.OpConfig, Node: "anchor", Module: "web", MeshWide: map[string]any{"accesses": []any{}}},
|
||||
{Op: delivery.OpDecide, Node: "laptop", Field: "site", Value: json.RawMessage(`"attic"`)},
|
||||
{Op: delivery.OpHold, Node: "anchor", Module: "store", Claim: "node-shell", Scope: "node"},
|
||||
} {
|
||||
leased := in(c)
|
||||
leased.Leased = true
|
||||
if _, _, f := Propose(leased); len(f) != 0 {
|
||||
t.Errorf("after the lease write %s is refused: %v", c.Op, f)
|
||||
if _, _, f := Propose(in(c)); len(f) != 0 {
|
||||
t.Errorf("%s is refused: %v", c.Op, f)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,32 +82,10 @@ func TestThePlacementSeatDecides(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1126: the seed places the private network, which no repository holds; it is the mesh's own, and passes.
|
||||
wired := laptop
|
||||
wired.Modules = append(slices.Clone(laptop.Modules), delivery.PlacedModule{Module: "mesh-wireguard"})
|
||||
seed := in(SeedChanges(map[string]delivery.Placement{"laptop": wired})...)
|
||||
seed.Seed, seed.ExportDigest, seed.Current, seed.Revisions = true, "sha256:x", map[string]delivery.Placement{}, map[string]uint64{}
|
||||
seed.Held = func(m string) bool { return m != "mesh-wireguard" } // the indexer holds every module but it
|
||||
if _, _, f := Propose(seed); len(f) != 0 {
|
||||
t.Errorf("a seed placing the private network is refused: %v", f)
|
||||
}
|
||||
|
||||
writes, err := Writes(p, laid)
|
||||
if err != nil || len(writes) != 2 || writes[0].Key != "anchor.node" || writes[0].Base != 4 || writes[1].Base != 9 {
|
||||
t.Fatalf("writes = %+v, %v", writes, err)
|
||||
}
|
||||
pinned := *laid["laptop"]
|
||||
pinned.Pins = map[string]delivery.Provider{"db": {Node: "anchor", Module: "store"}}
|
||||
calls := ControllerCalls(map[string]delivery.Placement{"anchor": anchor, "laptop": laptop},
|
||||
map[string]*delivery.Placement{"anchor": laid["anchor"], "laptop": &pinned})
|
||||
var said []string
|
||||
for _, c := range calls {
|
||||
said = append(said, c.Key())
|
||||
}
|
||||
if !slices.Equal(said, []string{"mesh-controller.unassign anchor web", "mesh-controller.assign laptop web", "mesh-controller.pin laptop db"}) {
|
||||
t.Errorf("the controller's calls = %v", said)
|
||||
}
|
||||
|
||||
a, err := PlacementAsk("placement.p-one", p, "move web (proposed by operator@laptop)", 7, time.Unix(100, 0))
|
||||
if err != nil || a.Ask.Check(time.Unix(100, 0)) != nil || a.Ask.Options[0].Level != asks.Approve || a.Ask.Options[0].Performs(PlacementAct(p)) != nil {
|
||||
t.Fatalf("the ask = %+v, %v", a, err)
|
||||
@@ -127,112 +95,4 @@ func TestThePlacementSeatDecides(t *testing.T) {
|
||||
if a.Ask.Options[0].Performs(PlacementAct(q)) == nil {
|
||||
t.Error("the ask's yes binds changes it was not asked about")
|
||||
}
|
||||
|
||||
export, _ := json.Marshal(map[string]any{"anchor": anchor, "laptop": laptop})
|
||||
for name, c := range map[string]struct {
|
||||
raw string
|
||||
want int
|
||||
}{
|
||||
"in output": {`{"ok":true,"output":` + quote(string(export)+"\n") + `}`, 2},
|
||||
"as answer": {`{"ok":true,"output":"","answer":` + string(export) + `}`, 2},
|
||||
"refused": {`{"ok":false,"output":"no store"}`, 0},
|
||||
"no node": {`{"ok":true,"output":"{}"}`, 0},
|
||||
"no version": {`{"ok":true,"output":"{\"anchor\":{\"node\":\"anchor\",\"modules\":[]}}"}`, 0},
|
||||
} {
|
||||
got, err := ExportOf([]byte(c.raw))
|
||||
if len(got) != c.want || (c.want == 0) != (err != nil) {
|
||||
t.Errorf("%s: %d nodes, %v", name, len(got), err)
|
||||
}
|
||||
}
|
||||
if seed := SeedChanges(map[string]delivery.Placement{"laptop": laptop, "anchor": anchor}); len(seed) != 2 || seed[0].Node != "anchor" || seed[0].Op != delivery.OpSeed {
|
||||
t.Errorf("the seed's changes = %+v", seed)
|
||||
}
|
||||
}
|
||||
|
||||
func quote(s string) string {
|
||||
raw, _ := json.Marshal(s)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// Issue 774: every call the assigner makes of the controller carries only the arguments the controller's seat contract
|
||||
// declares for its verb, and every one it requires; the controller refuses any other argument and does nothing. A "why"
|
||||
// added to every call (2026-10-28) had each approved placement refused.
|
||||
func TestControllerCallsFollowTheContract(t *testing.T) {
|
||||
declared := map[string]map[string]any{}
|
||||
required := map[string][]string{}
|
||||
for _, v := range catalogue.ControllerVerbs {
|
||||
props, _ := v.Input["properties"].(map[string]any)
|
||||
declared[v.Name] = props
|
||||
switch r := v.Input["required"].(type) {
|
||||
case []string:
|
||||
required[v.Name] = r
|
||||
}
|
||||
}
|
||||
db := map[string]delivery.Provider{"db": {Node: "anchor", Module: "store"}}
|
||||
moved := map[string]delivery.Provider{"db": {Node: "laptop", Module: "store"}}
|
||||
placed := func(node string, pins map[string]delivery.Provider, modules ...string) delivery.Placement {
|
||||
p := delivery.Placement{Node: node, Pins: pins}
|
||||
for _, m := range modules {
|
||||
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
|
||||
}
|
||||
return p
|
||||
}
|
||||
ptr := func(p delivery.Placement) *delivery.Placement { return &p }
|
||||
for name, c := range map[string]struct {
|
||||
before map[string]delivery.Placement
|
||||
after map[string]*delivery.Placement
|
||||
verbs []string
|
||||
}{
|
||||
"assign": {map[string]delivery.Placement{}, map[string]*delivery.Placement{"anchor": ptr(placed("anchor", nil, "web"))}, []string{"assign"}},
|
||||
"unassign": {map[string]delivery.Placement{"anchor": placed("anchor", nil, "web")}, map[string]*delivery.Placement{"anchor": ptr(placed("anchor", nil))}, []string{"unassign"}},
|
||||
"key deleted": {map[string]delivery.Placement{"anchor": placed("anchor", db, "web")}, map[string]*delivery.Placement{"anchor": nil}, []string{"unassign", "unpin"}},
|
||||
"pin set": {map[string]delivery.Placement{"anchor": placed("anchor", nil, "web")}, map[string]*delivery.Placement{"anchor": ptr(placed("anchor", db, "web"))}, []string{"pin"}},
|
||||
"pin changed": {map[string]delivery.Placement{"anchor": placed("anchor", db, "web")}, map[string]*delivery.Placement{"anchor": ptr(placed("anchor", moved, "web"))}, []string{"pin"}},
|
||||
"moved and pinned": {map[string]delivery.Placement{"anchor": placed("anchor", nil, "web")}, map[string]*delivery.Placement{"anchor": ptr(placed("anchor", nil)), "laptop": ptr(placed("laptop", db, "web"))}, []string{"unassign", "assign", "pin"}},
|
||||
} {
|
||||
calls := ControllerCalls(c.before, c.after)
|
||||
var verbs []string
|
||||
for _, call := range calls {
|
||||
verbs = append(verbs, call.Verb)
|
||||
props, ok := declared[call.Verb]
|
||||
if !ok {
|
||||
t.Errorf("%s: the controller's seat has no verb %q", name, call.Verb)
|
||||
continue
|
||||
}
|
||||
for arg := range call.Args {
|
||||
if _, ok := props[arg]; !ok {
|
||||
t.Errorf("%s: %s gives %q, which mesh-controller.%s does not take, so the controller refuses it", name, call.Key(), arg, call.Verb)
|
||||
}
|
||||
}
|
||||
for _, arg := range required[call.Verb] {
|
||||
if _, ok := call.Args[arg]; !ok {
|
||||
t.Errorf("%s: %s lacks %q, which mesh-controller.%s requires", name, call.Key(), arg, call.Verb)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !slices.Equal(verbs, c.verbs) {
|
||||
t.Errorf("%s: the calls = %v, want %v", name, verbs, c.verbs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 774: a refused call is a condition keyed by its pair, a module's place apart from a pin, naming the verb, the node, the
|
||||
// module and the refusal; its clear says why.
|
||||
func TestCallRefusedCondition(t *testing.T) {
|
||||
assign := ControllerCall{Verb: "assign", Node: "anchor", Module: "web"}
|
||||
pin := ControllerCall{Verb: "pin", Node: "anchor", Module: "web"}
|
||||
c := CallRefusedCondition(assign, `assign takes no argument "why"`, true, "")
|
||||
if c.State != asks.StateRaised || c.Severity != asks.SeverityUrgent || c.Key != "mesh-delivery-assigner.controller-call.anchor.web" ||
|
||||
!strings.Contains(c.Headline, "assign web on anchor") || !strings.Contains(c.Explanation, `takes no argument "why"`) {
|
||||
t.Errorf("the raised condition = %+v", c)
|
||||
}
|
||||
if CallConditionKey(pin) == c.Key {
|
||||
t.Error("a pin's condition shares the key of a module's place")
|
||||
}
|
||||
if cl := CallRefusedCondition(assign, "", false, "answered"); cl.State != asks.StateCleared || cl.Key != c.Key || cl.Resolved != "answered" {
|
||||
t.Errorf("the clear = %+v", cl)
|
||||
}
|
||||
if _, _, raised, err := FindingOf(c, time.Unix(0, 0)); err != nil || !raised {
|
||||
t.Errorf("the condition is no finding: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,8 @@ type Module struct {
|
||||
// Chosen is a person's choice of the module's upgrade policy (the assigner's `policies`, ADR 0565 §7); nil: none.
|
||||
Chosen *catalogue.PersonPolicy
|
||||
Nodes []string
|
||||
// Composed answers the controller's composition of the module for a node at a fingerprint (mesh-controller_composed): its
|
||||
// digest and the judgement of the move to it, and whether it exists. Nil: none exists (ADR 0539 §1, §6).
|
||||
// Composed answers the composition of the module for a node at a fingerprint: the digest the node's own have names (a
|
||||
// placed node composes its own, ADR 0571 §6), and whether it names one. Nil: none exists (ADR 0539 §1, §6).
|
||||
Composed func(node, fp string) (delivery.ComposedDigest, bool)
|
||||
// Allowed says whether the operator answered yes to the record ask about the move of this module on node to the composition
|
||||
// with this digest (ADR 0539 §14). Nil: none did.
|
||||
@@ -131,7 +131,7 @@ func holds(m Module, node string, n Nodes, ordering bool) (bool, int, string) {
|
||||
short(m.FP))
|
||||
}
|
||||
if _, ok := m.composition(node, m.FP); !ok {
|
||||
return false, delivery.RuleComposed, fmt.Sprintf("the controller has not composed %s for %s at %s", m.Name, node, short(m.FP))
|
||||
return false, delivery.RuleComposed, fmt.Sprintf("%s has named no composition of its own for %s at %s yet", node, m.Name, short(m.FP))
|
||||
}
|
||||
if words, waiting := m.provisionWaiting(node); waiting {
|
||||
return false, delivery.RuleProvisions, words
|
||||
@@ -412,7 +412,8 @@ func Wants(m Module, n Nodes, wants map[string]delivery.Want, lastHealthy map[st
|
||||
next.Newest, next.Rule, next.Words = m.FP, rule, words
|
||||
at.FP = target
|
||||
}
|
||||
if had && !before.Removed && !before.Put && before.FP == target && before.Composed == d.Digest {
|
||||
// A placed node's want names the build alone: it is the same target while the build is.
|
||||
if had && !before.Removed && !before.Put && before.FP == target && (before.Composed == d.Digest || (before.Self && m.selfOn(node))) {
|
||||
next.Backup, next.Words = before.Backup, before.Words // the answer to a failed backup stays while its target stays (ADR 0539 §16)
|
||||
}
|
||||
running, ran := n.Haves[node][m.Name]
|
||||
@@ -534,7 +535,7 @@ func (m Module) selfDerived(node string, before delivery.Want, had bool, next de
|
||||
d, there := m.composition(node, next.FP)
|
||||
if !there || d.Digest == "" {
|
||||
out.Rules[node] = Holding{Rule: delivery.RuleComposed, Words: fmt.Sprintf("%s on %s was composed by the node while it was placed, "+
|
||||
"and the controller has not composed it at %s, so its want stays", m.Name, node, short(next.FP))}
|
||||
"and no composition of its own is named at %s now, so its want stays", m.Name, node, short(next.FP))}
|
||||
return next, false
|
||||
}
|
||||
at := m
|
||||
|
||||
@@ -815,6 +815,13 @@ func TestTheBackupAnswerStaysInTheWantWhileItsTargetStays(t *testing.T) {
|
||||
if w, ok := Wants(m, n, running, nil).Wants["ace"]; ok {
|
||||
t.Errorf("the answer was erased: %+v", w)
|
||||
}
|
||||
// A placed node composes its own: its want names the build alone, and the answer stays in it all the same (the digest its
|
||||
// have names is not in the want, so a want kept for its digest would erase the answer at every pass).
|
||||
m.Self = func(string) bool { return true }
|
||||
placed := map[string]delivery.Want{"ace": {FP: fNew, Self: true, Backup: delivery.BackupSkip, Words: BackupAnswerWords("skip", 0)}}
|
||||
if w, ok := Wants(m, n, placed, nil).Wants["ace"]; ok {
|
||||
t.Errorf("the answer in a placed node's want was erased: %+v", w)
|
||||
}
|
||||
}
|
||||
|
||||
// 1906/D4 (ADR 0539 §19): a hold released by the operator is not held again by a health check that failed before, and the
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestManifest(t *testing.T) {
|
||||
}
|
||||
reads := slices.Clone(m.Reads)
|
||||
slices.Sort(reads)
|
||||
want := []string{"mesh-build-go.steps", "mesh-conditions.takeover", "mesh-config.kept", "mesh-config.resolved", "mesh-controller.assignments", "mesh-controller.composed", "mesh-controller.grants", "mesh-controller.lease",
|
||||
want := []string{"mesh-build-go.steps", "mesh-conditions.takeover", "mesh-config.kept", "mesh-config.resolved",
|
||||
"mesh-delivery-builder.build-log", "mesh-delivery-builder.builds", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.manifests",
|
||||
"mesh-delivery-indexer.modules", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-grants.grants", "mesh-provisioner.provisions", "node-engine.have"}
|
||||
if !reflect.DeepEqual(reads, want) {
|
||||
@@ -59,7 +59,7 @@ func TestManifest(t *testing.T) {
|
||||
t.Errorf("uses = %v, want exactly node-build-go and operator-channel", m.Uses)
|
||||
}
|
||||
// ADR 0546: it serves its own tools (put, release, puts) from its bundle through the tool runner (the artifact loads the
|
||||
// module), says each put's step as the event put, and calls the controller's warranted and the bus node's backup holder.
|
||||
// module), says each put's step as the event put, and calls the hand-act seat and the bus node's backup holder.
|
||||
// ADR 0567 §1, §6-§10: it holds the seat mesh-placement, serving its seven verbs; keeps the seat's events on its stream
|
||||
// placement for 30 days; calls the controller's export, its four verbs that keep its assignments in step and its
|
||||
// hand-act log; and reads mesh-config's resolved values to compose every node a proposal touches.
|
||||
@@ -82,9 +82,8 @@ func TestManifest(t *testing.T) {
|
||||
!slices.Equal(slices.Sorted(slices.Values(m.Streams[0].Events)), slices.Sorted(slices.Values(delivery.PlacementEvents))) {
|
||||
t.Errorf("streams = %+v, want placement carrying the seat's four events for %d days", m.Streams, delivery.PlacementStreamDays)
|
||||
}
|
||||
if want := []string{"seat:mesh-controller.assign", "seat:mesh-controller.hand-act", "seat:mesh-controller.pin",
|
||||
"seat:mesh-controller.placement-export", "seat:mesh-controller.unassign", "seat:mesh-controller.unpin",
|
||||
"seat:mesh-controller.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-hand-acts.hand-acts", "seat:mesh-hand-acts.warranted", // ADR 0565 §9, §11: the seat, the controller's only while it has no holder
|
||||
if want := []string{
|
||||
"seat:mesh-hand-acts.hand-act", "seat:mesh-hand-acts.hand-acts", "seat:mesh-hand-acts.warranted", // ADR 0565 §9, §11: the seat alone
|
||||
"seat:node-backup.backed-up", "seat:node-backup.now"}; !slices.Equal(slices.Sorted(slices.Values(m.Invokes)), want) {
|
||||
t.Errorf("invokes = %v, want %v", m.Invokes, want)
|
||||
}
|
||||
|
||||
@@ -30,10 +30,6 @@
|
||||
"mesh-conditions.takeover",
|
||||
"mesh-config.kept",
|
||||
"mesh-config.resolved",
|
||||
"mesh-controller.assignments",
|
||||
"mesh-controller.composed",
|
||||
"mesh-controller.grants",
|
||||
"mesh-controller.lease",
|
||||
"mesh-delivery-builder.build-log",
|
||||
"mesh-delivery-builder.builds",
|
||||
"mesh-delivery-indexer.candidates",
|
||||
@@ -50,13 +46,6 @@
|
||||
"operator-channel"
|
||||
],
|
||||
"invokes": [
|
||||
"seat:mesh-controller.assign",
|
||||
"seat:mesh-controller.hand-act",
|
||||
"seat:mesh-controller.pin",
|
||||
"seat:mesh-controller.placement-export",
|
||||
"seat:mesh-controller.unassign",
|
||||
"seat:mesh-controller.unpin",
|
||||
"seat:mesh-controller.warranted",
|
||||
"seat:mesh-hand-acts.hand-act",
|
||||
"seat:mesh-hand-acts.hand-acts",
|
||||
"seat:mesh-hand-acts.warranted",
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -19,7 +20,7 @@ import (
|
||||
// (novox/hq issue 1937): on 2026-10-24 the store's 03:30 collection deleted the bare blobs of three done keys the
|
||||
// controller had never taken in, and the builder kept them done.
|
||||
|
||||
// heldVerb answers the controller seat's verb held on the bus, as the serving controller does: {result: {output, ok,
|
||||
// heldVerb answers the seat mesh-artifact-sweep's verb held on the bus, as its holder does: {result: {output, ok,
|
||||
// answer}}; a reference is gone while gone says so.
|
||||
type heldVerb struct {
|
||||
mu sync.Mutex
|
||||
@@ -29,7 +30,7 @@ type heldVerb struct {
|
||||
|
||||
func (h *heldVerb) serve(t *testing.T, b *bed) {
|
||||
t.Helper()
|
||||
sub, err := b.js.Conn().Subscribe("mesh.seat.mesh-controller.call.held.>", func(m *nats.Msg) {
|
||||
sub, err := b.js.Conn().Subscribe("mesh.seat."+catalogue.ArtifactSweepSeat+".call.held.>", func(m *nats.Msg) {
|
||||
var args struct {
|
||||
References []string `json:"references"`
|
||||
}
|
||||
|
||||
@@ -467,15 +467,20 @@ func TestTwoBuilderInstancesStartingTogetherEndWithOneDoneKey(t *testing.T) { //
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnArchiveOnlyModuleNamingNoToolchainIsBuilt: the indexer gives an archive no language, so modules.ca-trust has no
|
||||
// toolchains; the builder builds it on the build module's nodes all the same (review round 4).
|
||||
func TestAnArchiveOnlyModuleNamingNoToolchainIsBuilt(t *testing.T) {
|
||||
b := startBed(t)
|
||||
b.archive(t, "ca-trust", "modules/ca-trust", commit)
|
||||
b.start(t, "n1", nil)
|
||||
v := b.await(t, "ca-trust", isState(delivery.BuildDone))
|
||||
if len(v.Toolchains) != 0 || len(v.Artifacts) != 1 || strings.HasPrefix(v.Step, "one node (") {
|
||||
t.Errorf("build = %+v, want done naming no toolchain", v)
|
||||
// TestAnArchiveOnlyModuleAndARootModuleAreBuilt: the indexer gives an archive no language, so modules.ca-trust has no
|
||||
// toolchains; the builder builds it on the build module's nodes all the same (review round 4). A module whose path is "."
|
||||
// is given to the build tools as it is (1615/AC4). One table: the two tests were merged to pay for the enrolment's.
|
||||
func TestAnArchiveOnlyModuleAndARootModuleAreBuilt(t *testing.T) {
|
||||
for _, c := range []struct{ name, path string }{{"no toolchain", "modules/ca-trust"}, {"root", delivery.RootPath}} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
b := startBed(t)
|
||||
b.archive(t, "ca-trust", c.path, commit)
|
||||
b.start(t, "n1", nil)
|
||||
v := b.await(t, "ca-trust", isState(delivery.BuildDone))
|
||||
if c.name == "no toolchain" && (len(v.Toolchains) != 0 || len(v.Artifacts) != 1 || strings.HasPrefix(v.Step, "one node (")) {
|
||||
t.Errorf("build = %+v, want done naming no toolchain", v)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,14 +535,6 @@ func TestAToolchainChangeDoesNotBuildTheOldFingerprint(t *testing.T) { // 1615/A
|
||||
}
|
||||
}
|
||||
|
||||
// TestARootModuleIsBuilt: a module whose path is "." is given to the build tools as it is.
|
||||
func TestARootModuleIsBuilt(t *testing.T) { // 1615/AC4
|
||||
b := startBed(t)
|
||||
b.archive(t, "ca-trust", delivery.RootPath, commit)
|
||||
b.start(t, "n1", nil)
|
||||
b.await(t, "ca-trust", isState(delivery.BuildDone))
|
||||
}
|
||||
|
||||
func mustKey(t *testing.T, module string) string {
|
||||
k, err := delivery.BuilderBuildKey(module, fp)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/handacts"
|
||||
|
||||
"mesh-delivery-builder/internal/build"
|
||||
)
|
||||
@@ -26,19 +25,14 @@ const SeatHeld = "seat:" + catalogue.ArtifactSweepSeat + ".held"
|
||||
// toolCaller calls a tool or a seat verb as the builder's own account (mesh-sdk process.Conn.Call).
|
||||
type toolCaller func(key string, body any) (json.RawMessage, error)
|
||||
|
||||
// storedVia is build.Builder's Stored: it asks the controller's verb held (mesh-sdk delivery.HeldVerb; novox/hq issue
|
||||
// 1937) and answers the references the store no longer holds. One it could not ask about, with none gone, is an error:
|
||||
// the key is then left done, and the controller's take-in still refuses a gone blob.
|
||||
//
|
||||
// It asks the seat mesh-artifact-sweep's `held` (novox/hq ADR 0571 §2), and the controller's while that seat has no holder that
|
||||
// answers (a named exception of clause 10, until the lease write).
|
||||
// storedVia is build.Builder's Stored: it asks the seat mesh-artifact-sweep's `held` (novox/hq ADR 0571 §2, issue 1937)
|
||||
// and answers the references the store no longer holds. One it could not ask about, with none gone, is an error: the key
|
||||
// is then left done, and the controller's take-in still refuses a gone blob. The controller's `held` is not asked: it
|
||||
// is no longer a fallback (issue 1955).
|
||||
func storedVia(call toolCaller) func(ctx context.Context, references []string) ([]string, error) {
|
||||
return func(_ context.Context, references []string) ([]string, error) {
|
||||
body := map[string]any{"references": references}
|
||||
raw, err := call(SeatHeld, body)
|
||||
if handacts.NoResponder(err) {
|
||||
raw, err = call(delivery.HeldVerb, body)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("asking held: %w", err)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-delivery-builder
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101143343-67354f8e019a
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005736-7b09de1e5502 h1:XndvkzyAFNde/821lEfYObqMTURzmve/w9Ks+QrBBJ8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005736-7b09de1e5502/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3 h1:an2f0MLx9OhaDLu5i51OMPBbuGwX+aoTzqOkFLMpVfE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c h1:rlUCZ5ifLojqDUTkcCCcPrugTzV2RT6F6JbIVTJkaC8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014012-a20bdf795236 h1:JEkSnrmHs5QIRJGhtrghLC1+C0AxqMpef8a32d/BLPg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014012-a20bdf795236/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014310-19c204273bdc h1:kVq7sS4I0EnL8DcAA2a3YstnXS6WmYYzJz7eK7IZ9RY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014310-19c204273bdc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62 h1:R5C8ICjiaDITHRFR/uIATwARPPXKxssUC70m0svstb0=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101143343-67354f8e019a h1:AChSJUmHyOZ5smiuTrtDZNjetYNTFb+anRFjXIQHbSA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101143343-67354f8e019a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -177,6 +178,42 @@ func (b *Builder) drop(ctx context.Context, ask delivery.StepAsk, old string) {
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultDeclineBound is how long one ask may go on being declined, from its first decline, before its key fails (the
|
||||
// checker's DefaultDeclineBound, issue 890); DefaultDeclinePause is how long the builder waits before it puts a declined
|
||||
// ask again, so a lone node off the agreed toolchain cannot spin on it.
|
||||
const (
|
||||
DefaultDeclineBound = 30 * time.Minute
|
||||
DefaultDeclinePause = 5 * time.Second
|
||||
)
|
||||
|
||||
func (b *Builder) declineBound() time.Duration {
|
||||
if b.DeclineBound > 0 {
|
||||
return b.DeclineBound
|
||||
}
|
||||
return DefaultDeclineBound
|
||||
}
|
||||
|
||||
func (b *Builder) declinePause() time.Duration {
|
||||
if b.DeclinePause != 0 {
|
||||
return max(b.DeclinePause, 0)
|
||||
}
|
||||
return DefaultDeclinePause
|
||||
}
|
||||
|
||||
// declinedWords names the nodes that declined an ask and why, in the order of their names.
|
||||
func declinedWords(by map[string]string) string {
|
||||
nodes := make([]string, 0, len(by))
|
||||
for n := range by {
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
sort.Strings(nodes)
|
||||
var parts []string
|
||||
for _, n := range nodes {
|
||||
parts = append(parts, n+": "+by[n])
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func (b *Builder) askings() int {
|
||||
if b.Askings > 0 {
|
||||
return b.Askings
|
||||
@@ -255,6 +292,8 @@ func (b *Builder) unlisten(key, language string, c chan string) {
|
||||
var (
|
||||
errCheck = errors.New("the check bound passed")
|
||||
errAgain = errors.New("asked again")
|
||||
// errDeclined: a build node on another toolchain than the agreed one sat out of the ask (ADR 0584 clause 2).
|
||||
errDeclined = errors.New("declined")
|
||||
)
|
||||
|
||||
type byHand struct{ why string }
|
||||
@@ -268,11 +307,42 @@ func (b *Builder) answer(ctx context.Context, l *lane, ask delivery.StepAsk, ask
|
||||
again := b.listen(key, ask.Language)
|
||||
defer b.unlisten(key, ask.Language, again)
|
||||
asking := askingOf(asked.step, l.module)
|
||||
var firstDecline time.Time
|
||||
declines := map[string]string{} // node -> why it declined, the last
|
||||
for {
|
||||
step, why, err := b.waitOnce(ctx, ask, again)
|
||||
switch {
|
||||
case errors.Is(err, errCheck):
|
||||
continue
|
||||
case errors.Is(err, errDeclined):
|
||||
// Not an asking: a node that sits out would use them all while it idles and pulls the ask again at once.
|
||||
// Bounded by time from the first decline, then failed loudly naming the nodes and their reasons (as the
|
||||
// checker's decline loop is, issue 890).
|
||||
now := time.Now()
|
||||
if firstDecline.IsZero() {
|
||||
firstDecline = now
|
||||
}
|
||||
declines[step.Node] = why
|
||||
if bound := b.declineBound(); now.Sub(firstDecline) > bound {
|
||||
return step, ask, fmt.Errorf("the ask %s of %s was declined for %s and no build node took it: %s", ask.ID, l.module, bound.Round(time.Second), declinedWords(declines))
|
||||
}
|
||||
// The pause is a context's bound, not a timer (ADR 0437 §8).
|
||||
pause, stop := context.WithTimeout(ctx, b.declinePause())
|
||||
<-pause.Done()
|
||||
stop()
|
||||
if ctx.Err() != nil {
|
||||
return step, ask, ctx.Err()
|
||||
}
|
||||
old := ask.ID
|
||||
ask.ID, ask.At = buildstep.NewID(), time.Now()
|
||||
if err := asked.note(ctx, ask.Language, ask.ID, fmt.Sprintf("put %s again after %s declined it", l.module, step.Node)); err != nil {
|
||||
return step, ask, fmt.Errorf("the ask of %s put again after a decline could not be written to the key: %w", l.module, err)
|
||||
}
|
||||
if err := b.Asks.Put(ctx, ask); err != nil {
|
||||
return step, ask, err
|
||||
}
|
||||
b.drop(ctx, ask, old)
|
||||
b.logf("%s: the ask %s of %s was declined on %s and is put again as %s: %s", key, old, l.module, step.Node, ask.ID, why)
|
||||
case errors.Is(err, errAgain):
|
||||
if asking >= b.askings() {
|
||||
return step, ask, fmt.Errorf("the ask %s could not be answered and was asked %d times, the most it is: %s", ask.ID, asking, why)
|
||||
@@ -317,6 +387,11 @@ func (b *Builder) waitOnce(ctx context.Context, ask delivery.StepAsk, again <-ch
|
||||
defer stop()
|
||||
}
|
||||
step, err := b.Asks.Wait(wait, ask)
|
||||
if _, declined := delivery.DeclinedOf(step); err == nil && declined {
|
||||
// A build node off the agreed toolchain sat out (ADR 0584 clause 2). It is no asking: answer() puts the ask again
|
||||
// after a pause, within DeclineBound.
|
||||
return step, step.Reason, errDeclined
|
||||
}
|
||||
if err == nil && buildrun.IsLost(step) {
|
||||
// The build seat gave the ask up (issue 734): it can no longer be answered, so it is asked again now, with the
|
||||
// words of why, not at the check bound.
|
||||
|
||||
@@ -61,8 +61,8 @@ type ClaimOptions struct {
|
||||
// a package's reference. A key queued waiting for a version (BuilderBuild.Waits) is taken only once it is (ADR 0500
|
||||
// clause 6). Nil means none is.
|
||||
Published func(ref string) bool
|
||||
// Stored answers which of the references given the artifact store no longer holds (the controller's verb held,
|
||||
// mesh-sdk delivery.HeldVerb; novox/hq issue 1937). A done key of the trunk's fingerprint is asked before it is left
|
||||
// Stored answers which of the references given the artifact store no longer holds (the seat mesh-artifact-sweep's verb held,
|
||||
// novox/hq ADR 0571 §2, issue 1937). A done key of the trunk's fingerprint is asked before it is left
|
||||
// done: the store's nightly collector deletes a blob nothing holds, and a key the controller never took in holds
|
||||
// nothing, so a key whose blob is gone is claimed building as a new revision and built again. Nil asks nothing.
|
||||
Stored func(ctx context.Context, references []string) (gone []string, err error)
|
||||
|
||||
@@ -51,7 +51,7 @@ type Builder struct {
|
||||
// Answered is told the build module whose step answered a reference (issue 1886): what the builder says it writes
|
||||
// rests on it. Nil tells nobody.
|
||||
Answered func(buildModule string)
|
||||
// Stored answers which references the artifact store no longer holds (the controller's verb held; issue 1937): a done
|
||||
// Stored answers which references the artifact store no longer holds (the seat mesh-artifact-sweep's verb held; issue 1937): a done
|
||||
// key of the trunk's fingerprint is asked before it is left done (ClaimOptions.Stored). Nil asks nothing.
|
||||
Stored func(ctx context.Context, references []string) (gone []string, err error)
|
||||
// Trunk answers the head of a repository in the gate's source revision rev (source.main at that revision): the trunk a
|
||||
@@ -67,6 +67,9 @@ type Builder struct {
|
||||
// Askings is how many times one build module's part of a build is asked, the first included, before its key is failed
|
||||
// with why; DefaultAskings when 0.
|
||||
Askings int
|
||||
// DeclineBound and DeclinePause bound and pace the asks build nodes off the agreed toolchain declined (ADR 0584 clause 2):
|
||||
// they are no askings. DefaultDeclineBound and DefaultDeclinePause when 0; a negative DeclinePause puts it again at once.
|
||||
DeclineBound, DeclinePause time.Duration
|
||||
// Said is told each ask asked again (the builder's event build.asked-again). Nil tells nobody.
|
||||
Said func(AskedAgain)
|
||||
// Log is told each build's start and end, the entries of the builder's log (novox/hq ADR 0571 §1). Nil tells nobody.
|
||||
@@ -415,7 +418,7 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) {
|
||||
if planErr == nil && !nothing {
|
||||
if why := b.stale(m, plan); why != "" {
|
||||
// The fingerprint names a toolchain the build nodes no longer agree on: a new fingerprint is coming, and a build
|
||||
// now would be of this source with another toolchain (ADR 0477 clause 2). Nothing is written.
|
||||
// now would be of this source with another toolchain (ADR 0584 clause 1, which changes ADR 0477 clause 2). Nothing is written.
|
||||
b.logf("%s at %s is not built: %s", module, m.FP2, why)
|
||||
return
|
||||
}
|
||||
@@ -506,10 +509,11 @@ func (b *Builder) stale(m delivery.Module, plan Plan) string {
|
||||
continue // an archive is packed with no toolchain
|
||||
}
|
||||
language := buildrun.LanguageOf[bm]
|
||||
tc, none := delivery.AgreedToolchain(b.Toolchains.Entries(language))
|
||||
if none != "" {
|
||||
return fmt.Sprintf("the %s toolchain is not agreed: %s", language, none)
|
||||
ag := delivery.ToolchainAgreement(b.Toolchains.Entries(language))
|
||||
if ag.Wait != "" {
|
||||
return fmt.Sprintf("the %s toolchain is not agreed: %s", language, ag.Wait)
|
||||
}
|
||||
tc := ag.Toolchain
|
||||
want, ok := m.Toolchains[language]
|
||||
if !ok || want.Version != tc.Version || want.Recipe != tc.Recipe {
|
||||
return fmt.Sprintf("the fingerprint names %s %s (recipe %s) and the nodes agree on %s (recipe %s)", language, want.Version, want.Recipe, tc.Version, tc.Recipe)
|
||||
@@ -543,7 +547,8 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
|
||||
repository := delivery.RepositoryName(m.Repository)
|
||||
var lanes []*lane
|
||||
used := map[string]delivery.ToolchainUsed{}
|
||||
var kept []string // build modules whose artifacts stay as the prior done key has them
|
||||
var sitting []string // the nodes that sit out of a language's build, said in the key's step (ADR 0584 clause 2)
|
||||
var kept []string // build modules whose artifacts stay as the prior done key has them
|
||||
for _, bm := range plan.Modules() {
|
||||
if prior != nil && len(only) > 0 && !only[bm] {
|
||||
kept = append(kept, bm)
|
||||
@@ -553,10 +558,12 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
|
||||
if bm != ImageBuildModule && plan.NeedsToolchain(bm) {
|
||||
// The ask names the toolchain the fingerprint was computed with; a node building with another refuses it.
|
||||
language := buildrun.LanguageOf[bm]
|
||||
tc, none := delivery.AgreedToolchain(b.Toolchains.Entries(language))
|
||||
if none != "" {
|
||||
return failed("the %s toolchain is not agreed: %s", language, none)
|
||||
ag := delivery.ToolchainAgreement(b.Toolchains.Entries(language))
|
||||
if ag.Wait != "" {
|
||||
return failed("the %s toolchain is not agreed: %s", language, ag.Wait)
|
||||
}
|
||||
tc := ag.Toolchain
|
||||
sitting = append(sitting, SittingOutWords(language, ag)...)
|
||||
if want := m.Toolchains[language]; want.Version != tc.Version || want.Recipe != tc.Recipe {
|
||||
return delivery.BuilderBuild{} // the agreed toolchain moved since try looked: nothing is written
|
||||
}
|
||||
@@ -653,6 +660,9 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
|
||||
}
|
||||
}
|
||||
step := "built " + strings.Join(steps, "; ")
|
||||
if len(sitting) > 0 {
|
||||
step += " (" + strings.Join(sitting, "; ") + ")"
|
||||
}
|
||||
if len(used) == 0 {
|
||||
used = nil
|
||||
}
|
||||
@@ -942,3 +952,13 @@ func LogEnded(module string, m delivery.Module, ref, by string, at time.Time, re
|
||||
e.Made = append([]delivery.BuiltArtifact(nil), result.Artifacts...)
|
||||
return e
|
||||
}
|
||||
|
||||
// SittingOutWords says, per node that is not on the agreed toolchain of a language, that it sits out and which version
|
||||
// it has against the agreed one: "ace sits out: typescript 26.12.1, agreed 26.13.0" (novox/hq ADR 0584 clause 2).
|
||||
func SittingOutWords(language string, a delivery.Agreement) []string {
|
||||
var out []string
|
||||
for _, s := range a.SittingOut {
|
||||
out = append(out, fmt.Sprintf("%s sits out: %s %s, agreed %s", s.Node, language, s.Version, a.Toolchain.Version))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -223,6 +223,14 @@ var (
|
||||
|
||||
func TestABundleAndAnArchiveAreBuiltOnOneNodeAndDone(t *testing.T) { // 1607/AC7, ADR 0478 clauses 1, 4
|
||||
r := newRig(t, goBundle, archiveA, tsBundle)
|
||||
// ADR 0584: a third node agrees with novox and ace's typescript is the odd one; the build is done and its step says ace sits out.
|
||||
r.tools.Put(buildrun.NodeBuildModule, "g14.typescript", tcValue(t, "typescript", "tsc5"), false)
|
||||
r.tools.Put(buildrun.NodeBuildModule, "ace.typescript", tcValue(t, "typescript", "tsc4"), false)
|
||||
defer func() {
|
||||
if st := r.st.vals[r.key]; !strings.Contains(string(st), "ace sits out: typescript tsc4, agreed tsc5") {
|
||||
t.Errorf("the key does not say ace sits out: %s", st)
|
||||
}
|
||||
}()
|
||||
var logged []delivery.BuildLogEntry
|
||||
var lmu sync.Mutex
|
||||
r.b.Node, r.b.Log = "novox", func(e delivery.BuildLogEntry) { lmu.Lock(); logged = append(logged, e); lmu.Unlock() }
|
||||
@@ -730,6 +738,25 @@ func TestABuildAskWaitingInItsStreamIsNotBounded(t *testing.T) {
|
||||
t.Fatalf("the key = %+v after %d asks, want done from the one ask", v, len(r.asks.Asks()))
|
||||
}
|
||||
})
|
||||
t.Run("declined by a node off the agreed toolchain: put again, no asking used, the bound ends it loudly", func(t *testing.T) { // ADR 0584 clause 2
|
||||
r := newRig(t, goBundle)
|
||||
r.asks.Answer = nil
|
||||
r.b.DeclinePause, r.b.DeclineBound = -1, 150*time.Millisecond
|
||||
r.b.Module(context.Background(), "m", moduleValue(t, "modules/m"))
|
||||
declined, _ := json.Marshal(delivery.StepDeclined{Declined: true, Lacks: []string{"the agreed toolchain go1.27.1 (recipe 1)"}})
|
||||
waitForAsks(t, r, 1)
|
||||
for ended, deadline := 0, time.Now().Add(5*time.Second); valueOf(t, r.st, r.key).State == delivery.BuildBuilding && time.Now().Before(deadline); time.Sleep(5 * time.Millisecond) {
|
||||
if asks := r.asks.Asks(); len(asks) > ended { // each ask put again is declined in its turn
|
||||
r.asks.End(delivery.Step{ID: asks[ended].ID, Node: "ace", State: delivery.StepRefused, Reason: "this node builds with go1.26.0", Result: declined})
|
||||
ended++
|
||||
}
|
||||
}
|
||||
r.b.Wait()
|
||||
v := valueOf(t, r.st, r.key)
|
||||
if n := len(r.asks.Asks()); v.State != delivery.BuildFailed || n <= DefaultAskings || !strings.Contains(v.Error, "declined for") || !strings.Contains(v.Error, "ace: this node builds with go1.26.0") {
|
||||
t.Fatalf("the key = %+v after %d asks, want it failed after more than %d asks, naming ace and its reason", v, n, DefaultAskings)
|
||||
}
|
||||
})
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
askings int
|
||||
|
||||
@@ -107,8 +107,8 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
|
||||
}
|
||||
// 1937/AC3: the builder asks the controller whether the artifact store still holds a done key's blobs (held), and no
|
||||
// other tool; it holds no capacity.
|
||||
// 1955/L7 (ADR 0571 §2): the seat mesh-artifact-sweep's held, the controller's only while the seat has no holder (clause 10).
|
||||
if want := []string{"seat:mesh-artifact-sweep.held", "seat:mesh-controller.held"}; !reflect.DeepEqual(sorted(m.Invokes), want) || len(m.Config) != 0 {
|
||||
// 1955/L7 (ADR 0571 §2): the seat mesh-artifact-sweep's held, the controller's `held` is no longer asked (issue 1955).
|
||||
if want := []string{"seat:mesh-artifact-sweep.held"}; !reflect.DeepEqual(sorted(m.Invokes), want) || len(m.Config) != 0 {
|
||||
t.Errorf("invokes = %v, config = %v, want %v and no config", m.Invokes, m.Config, want)
|
||||
}
|
||||
// The builder is woken by a build module's report of a toolchain, as the indexer is: the bucket may not exist when it
|
||||
|
||||
@@ -20,8 +20,7 @@
|
||||
"mesh-controller.artifacts-gone"
|
||||
],
|
||||
"invokes": [
|
||||
"seat:mesh-artifact-sweep.held",
|
||||
"seat:mesh-controller.held"
|
||||
"seat:mesh-artifact-sweep.held"
|
||||
],
|
||||
"state": [
|
||||
{ "name": "builds", "history": 5 },
|
||||
|
||||
+1
@@ -36,6 +36,7 @@ func (b *bed) assignerWrote(t *testing.T, key string, v delivery.CompositionVerd
|
||||
// 1297/AC3: started whole with the assigner's verdict on the bus, the checker's line says the composition's verdict, not
|
||||
// "not yet run": a pass names its machine, a fail fails the check with the assigner's words.
|
||||
func TestTheCheckersLineSaysTheAssignersComposition(t *testing.T) {
|
||||
t.Run("the read awaits the assigner's composition", checkerAwaitsTheAssignersComposition)
|
||||
for _, c := range []struct {
|
||||
state, owed, words string
|
||||
}{
|
||||
|
||||
+2
-1
@@ -21,7 +21,8 @@ import (
|
||||
|
||||
// 1297/AC3 (its bus half): on the real bus, the checker's read of the assigner's composition waits while the key is
|
||||
// absent and while the assigner says it waits, and answers the verdict the assigner then writes under the check's key.
|
||||
func TestTheCheckerAwaitsTheAssignersComposition(t *testing.T) {
|
||||
// A case of TestTheCheckersLineSaysTheAssignersComposition (merged into it for the test budget, issue 774).
|
||||
func checkerAwaitsTheAssignersComposition(t *testing.T) {
|
||||
dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "comp")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-delivery-checker
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101143343-67354f8e019a
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005736-7b09de1e5502 h1:XndvkzyAFNde/821lEfYObqMTURzmve/w9Ks+QrBBJ8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005736-7b09de1e5502/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3 h1:an2f0MLx9OhaDLu5i51OMPBbuGwX+aoTzqOkFLMpVfE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c h1:rlUCZ5ifLojqDUTkcCCcPrugTzV2RT6F6JbIVTJkaC8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014012-a20bdf795236 h1:JEkSnrmHs5QIRJGhtrghLC1+C0AxqMpef8a32d/BLPg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014012-a20bdf795236/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014310-19c204273bdc h1:kVq7sS4I0EnL8DcAA2a3YstnXS6WmYYzJz7eK7IZ9RY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014310-19c204273bdc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62 h1:R5C8ICjiaDITHRFR/uIATwARPPXKxssUC70m0svstb0=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101143343-67354f8e019a h1:AChSJUmHyOZ5smiuTrtDZNjetYNTFb+anRFjXIQHbSA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101143343-67354f8e019a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -342,6 +342,11 @@ func TestOnlyThePackageWithoutAKeptVerdictRuns(t *testing.T) { // 1288/AC1
|
||||
if out.Verdict.State != delivery.VerdictPass || len(asks) != 3 || len(asks[0].Needs) != 0 {
|
||||
t.Fatalf("verdict %+v, asks %+v: want a pass of three asks, the first naming no need", out.Verdict, asks)
|
||||
}
|
||||
for _, a := range asks { // ADR 0584: every check ask names the agreed toolchain, so a node off it declines
|
||||
if a.Check.Toolchain == nil || a.Check.Toolchain.Version != "go1.27.1" || a.Check.Toolchain.Recipe != "7" {
|
||||
t.Errorf("a check ask names toolchain %+v, want go1.27.1 recipe 7", a.Check.Toolchain)
|
||||
}
|
||||
}
|
||||
for _, a := range asks[1:] {
|
||||
if strings.Join(a.Needs, ",") != delivery.NeedNodeBuildNode || strings.Join(a.Check.Levels, ",") != delivery.LevelIntegration || strings.Join(a.Check.Packages, ",") != "fixture/node" {
|
||||
t.Errorf("a placed ask %+v, want the integration level needing node-build-node", a)
|
||||
|
||||
@@ -166,7 +166,9 @@ type target struct {
|
||||
module string
|
||||
node string
|
||||
toolchain string
|
||||
fetched Fetched
|
||||
// agreed is the toolchain the ask names, so a node off it declines the check (ADR 0584); nil when none is agreed.
|
||||
agreed *delivery.ToolchainUsed
|
||||
fetched Fetched
|
||||
// fetchArgs are the arguments of the fetch its check ask runs.
|
||||
fetchArgs map[string]any
|
||||
// priority are the Go packages (import paths) or the Node module directories whose test files the merge changes.
|
||||
@@ -374,6 +376,7 @@ func (st *state) judgeAll(ctx context.Context) *delivery.Verdict {
|
||||
t := &target{pull: p, module: bm}
|
||||
if tc, none := delivery.AgreedToolchain(st.j.Toolchains.Entries(buildrun.LanguageOf[bm])); none == "" {
|
||||
t.toolchain = tc.Version
|
||||
t.agreed = &delivery.ToolchainUsed{Version: tc.Version, Recipe: tc.Recipe}
|
||||
}
|
||||
args := map[string]any{"repository": p.Repository, "commit": p.Head, "ref": refOf(p.Number), "base": p.Trunk}
|
||||
if bm == buildrun.NodeBuildModule && groupPackage != nil && p.Repository != SDKRepository {
|
||||
@@ -428,7 +431,7 @@ func (st *state) ask(ctx context.Context, t *target, merged *[]Fetched) *deliver
|
||||
func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, levels, needs, packages []string) *delivery.Verdict {
|
||||
p := t.pull
|
||||
placed := len(needs) > 0
|
||||
job := &delivery.CheckJob{Fetch: t.fetchArgs, Levels: levels, Kept: true, Packages: packages}
|
||||
job := &delivery.CheckJob{Fetch: t.fetchArgs, Levels: levels, Kept: true, Packages: packages, Toolchain: t.agreed}
|
||||
if st.j.Nodes != nil {
|
||||
names, err := st.j.Nodes.Names(ctx)
|
||||
if err == nil && len(names) == 0 {
|
||||
@@ -495,8 +498,12 @@ func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, l
|
||||
bound = DefaultDeclineBound
|
||||
}
|
||||
if now.Sub(firstDecline) > bound {
|
||||
lacks := strings.Join(needs, ", ")
|
||||
if lacks == "" {
|
||||
lacks = strings.Join(d.Lacks, ", ") // a node off the agreed toolchain declined it (ADR 0584)
|
||||
}
|
||||
return fail(step.Node, fmt.Sprintf("the check of %s#%d needs a node with %s, and none took it in %s: every node that pulled it lacks it (last %s)",
|
||||
p.Repository, p.Number, strings.Join(needs, ", "), bound, step.Node))
|
||||
p.Repository, p.Number, lacks, bound, step.Node))
|
||||
}
|
||||
st.j.logf("%s#%d: the ask %s was declined on %s, which lacks %s; asked again", p.Repository, p.Number, a.ID, step.Node, strings.Join(d.Lacks, ", "))
|
||||
a.ID, a.At = buildstep.NewID(), now.UTC()
|
||||
|
||||
+3
-1
@@ -423,7 +423,9 @@ func TestBridgeWritesMergedMessage(t *testing.T) { // 1236/AC4, 1448/AC5
|
||||
want := delivery.Forge{V: delivery.Version, Kind: "merged", Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head,
|
||||
Merge: merge, Root: "r0", Trees: map[string]string{"": "t0", "modules/a": "t1"},
|
||||
Manifests: map[string]string{"": string(manifest), "modules/a": string(manifest)},
|
||||
Locks: map[string]map[string]string{"modules/a": {"go.sum": hex.EncodeToString(sha256Sum(sum))}}}
|
||||
Locks: map[string]map[string]string{"modules/a": {"go.sum": hex.EncodeToString(sha256Sum(sum))}},
|
||||
// The mesh packages its go.sum names (ADR 0573 §2, issue 1585): this module's own go.sum names mesh-sdk.
|
||||
Packages: map[string][]string{"modules/a": {"git.novox.be/novox/mesh-sdk/go"}}}
|
||||
if !delivery.SameForge(f, want) {
|
||||
t.Errorf("message = %+v, want %+v", f, want)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-delivery-forge-bridge
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2 h1:RF73nVI1dTrrmlFosVDTMJmpUQ+rEeB7++BHH/RvZFg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005726-af4e4e60a3a0 h1:q6ywchpTM9fwaJ1UNwDSuOjb5FYDFcsaeiTrKoB4scc=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005726-af4e4e60a3a0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014012-a20bdf795236 h1:JEkSnrmHs5QIRJGhtrghLC1+C0AxqMpef8a32d/BLPg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014012-a20bdf795236/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014310-19c204273bdc h1:kVq7sS4I0EnL8DcAA2a3YstnXS6WmYYzJz7eK7IZ9RY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014310-19c204273bdc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62 h1:R5C8ICjiaDITHRFR/uIATwARPPXKxssUC70m0svstb0=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-delivery-indexer
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005736-7b09de1e5502 h1:XndvkzyAFNde/821lEfYObqMTURzmve/w9Ks+QrBBJ8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005736-7b09de1e5502/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3 h1:an2f0MLx9OhaDLu5i51OMPBbuGwX+aoTzqOkFLMpVfE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c h1:rlUCZ5ifLojqDUTkcCCcPrugTzV2RT6F6JbIVTJkaC8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014012-a20bdf795236 h1:JEkSnrmHs5QIRJGhtrghLC1+C0AxqMpef8a32d/BLPg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014012-a20bdf795236/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014310-19c204273bdc h1:kVq7sS4I0EnL8DcAA2a3YstnXS6WmYYzJz7eK7IZ9RY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014310-19c204273bdc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62 h1:R5C8ICjiaDITHRFR/uIATwARPPXKxssUC70m0svstb0=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101143343-67354f8e019a h1:AChSJUmHyOZ5smiuTrtDZNjetYNTFb+anRFjXIQHbSA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101143343-67354f8e019a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21 h1:4I4ayo0fZYP4PBfEX1sGDDd6arnw5IQJySJ3jcFobPM=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -50,23 +50,37 @@ const Env = "MESH_CHECK_BESIDE"
|
||||
// CapturedSuffix is what a captured file's name carries that the repository's own does not.
|
||||
const CapturedSuffix = ".captured"
|
||||
|
||||
// notPlaced are the repositories the checker places beside no mesh-delivery check (besideRules in
|
||||
// mesh-delivery-checker/internal/check/beside.go places mesh-sdk and the catalogue): a test reads them from the captured
|
||||
// copy even in a merge check, and says so (issue 1585). Every other sibling missing from a merge check fails the test.
|
||||
var notPlaced = map[string]bool{"mesh-host": true}
|
||||
|
||||
// Dir is the named repository a test reads — the clone beside a merge check, or the captured copy — and
|
||||
// says which in the test's log.
|
||||
func Dir(t testing.TB, repository string) string {
|
||||
t.Helper()
|
||||
if root := os.Getenv(Env); root != "" {
|
||||
root := os.Getenv(Env)
|
||||
if root != "" {
|
||||
dir := filepath.Join(root, repository)
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
_, err := os.Stat(dir)
|
||||
switch {
|
||||
case err == nil:
|
||||
t.Logf("judged against %s as cloned beside this check", dir)
|
||||
return dir
|
||||
case !notPlaced[repository]:
|
||||
// A sibling the checker places that is missing is the checker's fault, never judged against a stale copy.
|
||||
t.Fatalf("%s is not beside this check in %s=%s, so its agreement with this repository cannot be "+
|
||||
"judged here: %v", repository, Env, root, err)
|
||||
}
|
||||
t.Logf("judged against %s as cloned beside this check", dir)
|
||||
return dir
|
||||
}
|
||||
from := filepath.Join(Captured(), repository)
|
||||
if _, err := os.Stat(from); err != nil {
|
||||
t.Fatalf("no captured copy of %s at %s: %v", repository, from, err)
|
||||
}
|
||||
if root != "" {
|
||||
t.Logf("%s is not beside this check in %s=%s (the checker places it beside no mesh-delivery check): read from "+
|
||||
"its captured copy", repository, Env, root)
|
||||
}
|
||||
dir, err := layOut(repository, from)
|
||||
if err != nil {
|
||||
t.Fatalf("the captured copy of %s could not be laid out: %v", repository, err)
|
||||
|
||||
@@ -2450,19 +2450,24 @@ func parseManifest(raw []byte, predefined catalogue.PredefinedList) (catalogue.M
|
||||
// holding mesh-broker is granted the snapshot API and nothing else, so anything it declared to say
|
||||
// or hear on the bus would be granted nothing — refused here rather than silently dropped.
|
||||
//
|
||||
// **But its tools bundle may say what it declares** (novox/hq ADR 0357): the node's runtime carries
|
||||
// the bundle and is granted the events the module emits, under the module's name, as for any module it
|
||||
// carries — the bus's account is not who publishes them. So emits are refused only for a bus module
|
||||
// with no bundle to say them.
|
||||
// **But its tools bundle may say and ask what it declares** (novox/hq ADR 0357): the node's runtime carries
|
||||
// the bundle, is granted the events the module emits under the module's name, as for any module it carries,
|
||||
// and asks every tool and seat verb on the bundle's behalf (mesh-sdk go/buscompose, the runtime's invoke of
|
||||
// `*`) — the bus's account is not who publishes or asks. So emits and invokes are refused only for a bus
|
||||
// module with no bundle to say or ask them (issue 774: nats's bundle records its hand act).
|
||||
if _, account := m.OwnSecrets["broker"]; account && m.ClaimsSeat(catalogue.BrokerSeat) {
|
||||
var said []string
|
||||
if len(m.EmitsAll()) > 0 && !manifestShipsABundle(m) {
|
||||
bundled := manifestShipsABundle(m)
|
||||
if len(m.EmitsAll()) > 0 && !bundled {
|
||||
said = append(said, "emits")
|
||||
}
|
||||
if len(m.Invokes) > 0 && !bundled {
|
||||
said = append(said, "invokes")
|
||||
}
|
||||
for _, f := range []struct {
|
||||
name string
|
||||
n int
|
||||
}{{"consumes", len(m.Consumes)}, {"uses", len(m.Uses)}, {"invokes", len(m.Invokes)},
|
||||
}{{"consumes", len(m.Consumes)}, {"uses", len(m.Uses)},
|
||||
{"state", len(m.State)}, {"logs", len(m.Logs)}, {"reads", len(m.Reads)},
|
||||
{"streams", len(m.Streams)}} {
|
||||
if f.n > 0 {
|
||||
|
||||
@@ -4337,6 +4337,41 @@ func TestTheBussAccountSaysNothingOnTheBus(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), "granted the bus's snapshot API and nothing else") {
|
||||
t.Fatalf("a bus module declaring what it emits was not refused, or not for the reason: %v", err)
|
||||
}
|
||||
// Nor what it invokes, with no bundle for the runtime to ask on its behalf.
|
||||
unbundledInvoke := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
|
||||
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
|
||||
"invokes":["seat:mesh-hand-acts.hand-act"]}`)
|
||||
if _, err := ParseManifest(unbundledInvoke); err == nil || !strings.Contains(err.Error(), "its invokes would be granted nothing") {
|
||||
t.Fatalf("a bus module with no bundle declaring what it invokes was not refused, or not for the reason: %v", err)
|
||||
}
|
||||
|
||||
// Its tools bundle asks what the module invokes (novox/hq ADR 0357; issue 774): the bundle asks through the
|
||||
// node's runtime, which is granted every tool and seat verb (mesh-sdk go/buscompose), so a bus module that
|
||||
// ships one may declare them. The shape of mesh-catalog's modules/nats/module.json at fc5b849d, cut to the
|
||||
// parts the rule reads, was refused and held every bus user list from 2026-11-02 01:35.
|
||||
nats := []byte(`{"module":"nats","version":"1","provides":[{"name":"mesh-bus","scope":"mesh","credential":{"form":"none"}}],
|
||||
"claims":[{"name":"mesh-broker","scope":"mesh"}],
|
||||
"own-secrets":{"broker":"/run/broker","broker-nats-tools":"${dir:tools-secret}/broker-nats-tools"},
|
||||
"resources":[{"id":"tools-secret","type":"directory","path":"/var/lib/mesh/nats-tools","mode":"0711","unknown":"program"}],
|
||||
"emits":["consumer-removed","bucket-keys-removed","stream-removed","bucket-expiry-set"],
|
||||
"invokes":["seat:mesh-hand-acts.hand-act"],"consumes":[],
|
||||
"tools":["nats_server","nats_bucket_expiry"],
|
||||
"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/nats-tools",
|
||||
"binary":"nats-tools","loads":["nats-tools"],"bus":{"removes":["DEAD_LETTER_NOTICES.controller","ASSIGNMENTS"]}}]}}`)
|
||||
nm, err := ParseManifest(nats)
|
||||
if err != nil {
|
||||
t.Fatalf("the bus module whose tools bundle invokes a seat verb was refused: %v", err)
|
||||
}
|
||||
nbuilt, err := nm.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle, Reference: "store/tools:1", Digest: "sha256:1"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw, err = json.Marshal(nbuilt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ParseManifestForInputs(raw); err != nil {
|
||||
t.Fatalf("the built manifest of the bus module whose tools bundle invokes a seat verb was refused: %v", err)
|
||||
}
|
||||
|
||||
// Its tools bundle says what it emits (novox/hq ADR 0357): the node's runtime carries the bundle and is
|
||||
// granted the module's events, so a bus module that ships one may declare them.
|
||||
@@ -5013,9 +5048,10 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
// mesh-build-machine row goes, when no registered manifest claims it.
|
||||
// Forty-eight with mesh-delivery-checker (novox/hq ADR 0547 §10, issue 774); forty-nine with node-engine (novox/hq ADR 0563 §1);
|
||||
// fifty-three with the four health seats (novox/hq ADR 0564 clause 1); fifty-five with mesh-wants and mesh-hand-acts
|
||||
// (novox/hq ADR 0565 clause 1); fifty-six with mesh-artifact-sweep (novox/hq ADR 0571 clause 2).
|
||||
if len(catalogue.Seats()) != 56 {
|
||||
t.Errorf("the mesh defines %d seats rather than 56; the set is closed, so a change here is "+
|
||||
// (novox/hq ADR 0565 clause 1); fifty-six with mesh-artifact-sweep (novox/hq ADR 0571 clause 2);
|
||||
// fifty-seven with mesh-grants (ADR 0582 clause 1); fifty-eight with mesh-enrolment (novox/hq ADR 0587 §1).
|
||||
if len(catalogue.Seats()) != 58 {
|
||||
t.Errorf("the mesh defines %d seats rather than 58; the set is closed, so a change here is "+
|
||||
"a decision (novox/hq ADR 0110): %s", len(catalogue.Seats()), seatNames())
|
||||
}
|
||||
}
|
||||
|
||||
+8
-6
@@ -7,20 +7,22 @@ The copy carries the catalogue's private details: domains, first names in module
|
||||
private addresses. That is acceptable while mesh-sdk is private; if it ever goes public, this copy is
|
||||
a second place to clean besides the catalogue itself.
|
||||
|
||||
Only what go/catalogue's tests read is captured (R4b, issue 1379). Refreshed 2026-10-21 from the mains of both
|
||||
repositories; before that it was mesh-controller's copy, which still held two modules that had left the catalogue.
|
||||
Only what go/catalogue's tests read is captured (R4b, issue 1379), copied here from mesh-sdk with the judge's tests
|
||||
(issue 1585). mesh-host refreshed 2026-11-01 from its main, the catalogue 2026-10-21 from its main; before that it
|
||||
was mesh-controller's copy, which still held two modules that had left the catalogue.
|
||||
|
||||
mesh-catalog b5dbfbb784507e211372a7c9640d91f7d6dadd84 modules/*/module.json, modules/*/files/**, modules/*/templates/**, predefined.json
|
||||
mesh-host cdcba9ca73755854d21e4e657d7e46c32b9b5265 internal/apply/placement_guard.go
|
||||
mesh-host 233bb23bbcb1a829ce2a6164a31a5c5cc4f3d950 internal/apply/placement_guard.go
|
||||
|
||||
To move them, from this repository's go/ directory, with the two repositories checked out beside mesh-sdk:
|
||||
To move them, from mesh-delivery's modules/mesh-delivery-indexer directory, with the two repositories checked out
|
||||
beside mesh-delivery:
|
||||
|
||||
rm -rf testdata/beside/mesh-catalog testdata/beside/mesh-host
|
||||
mkdir -p testdata/beside/mesh-catalog testdata/beside/mesh-host/internal/apply
|
||||
git -C ../../mesh-catalog archive <commit> -- predefined.json ':(glob)modules/*/module.json' \
|
||||
git -C ../../../mesh-catalog archive <commit> -- predefined.json ':(glob)modules/*/module.json' \
|
||||
':(glob)modules/*/files/**' ':(glob)modules/*/templates/**' | tar -x -C testdata/beside/mesh-catalog
|
||||
find testdata/beside -name module.json -exec mv {} {}.captured \;
|
||||
git -C ../../mesh-host show <commit>:internal/apply/placement_guard.go \
|
||||
git -C ../../../mesh-host show <commit>:internal/apply/placement_guard.go \
|
||||
> testdata/beside/mesh-host/internal/apply/placement_guard.go.captured
|
||||
|
||||
and write the commits here. The placement guard is kept as .captured so no Go tool reads it as this
|
||||
|
||||
Vendored
+99
-4
@@ -56,7 +56,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
)
|
||||
|
||||
// protectedRoots are paths no directory, access or mount source may be, nor hold.
|
||||
@@ -384,6 +384,9 @@ func ownedByAccount(owner string, a homeAccount) bool {
|
||||
if owner == a.Name {
|
||||
return true
|
||||
}
|
||||
if a.UID < 0 {
|
||||
return false // an account the declaration makes has no uid yet: by name alone, never by a number
|
||||
}
|
||||
user, _, _ := strings.Cut(owner, ":")
|
||||
if uid, err := strconv.Atoi(user); err == nil {
|
||||
return uid == a.UID
|
||||
@@ -413,12 +416,91 @@ const (
|
||||
// refusePath says why a path is not placed or mounted; nil when it may be. module is the resource's module,
|
||||
// owner a directory's declared owner.
|
||||
func refusePath(path string, kind placing, module, owner string) error {
|
||||
return refusePathWith(path, kind, module, owner, nil)
|
||||
}
|
||||
|
||||
// declaredHomes is the home of each account the declaration itself makes (a user resource with a home), by the
|
||||
// account's name; the account is not in the user database yet when the declaration is judged, and the engine makes
|
||||
// it where it is declared, before the directory that is owned by it is reached (novox/hq issue 1542). Its uid is
|
||||
// not known, so a directory is owned by it by name alone. Only for the directory placement guard; no other
|
||||
// reader of accountsOfHomes sees these.
|
||||
func declaredHomes(resources []declaration.Resource) map[string]homeAccount {
|
||||
var accounts []passwdEntry
|
||||
var out map[string]homeAccount
|
||||
for _, r := range resources {
|
||||
u, ok := r.(*declaration.User)
|
||||
if !ok || u.Name == "" {
|
||||
continue
|
||||
}
|
||||
home := filepath.Clean(u.Home)
|
||||
if u.Home == "" || !filepath.IsAbs(home) || home == "/" {
|
||||
continue
|
||||
}
|
||||
if accounts == nil {
|
||||
accounts = passwdEntries()
|
||||
}
|
||||
// Only ever adds a home. An account the machine has keeps the home it has (applyUser never moves it), so
|
||||
// a declared home of one is not its home, unless it is the very home it has: the account an earlier apply
|
||||
// made, as declared, which must be judged the same at every later apply. And a home at or below another
|
||||
// account's, or at or above a protected root, is not a new one.
|
||||
taken := false
|
||||
for _, e := range accounts {
|
||||
if e.name == u.Name && e.home != home {
|
||||
taken = true
|
||||
}
|
||||
if e.name != u.Name && e.home != "" && atOrBelow(home, e.home) {
|
||||
taken = true
|
||||
}
|
||||
}
|
||||
for _, root := range protectedRoots {
|
||||
taken = taken || home == root || below(root, home)
|
||||
}
|
||||
if taken {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = map[string]homeAccount{}
|
||||
}
|
||||
out[home] = homeAccount{Name: u.Name, UID: -1}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// passwdEntry is an account of the user database and its home, whatever its uid.
|
||||
type passwdEntry struct{ name, home string }
|
||||
|
||||
// passwdEntries is every account of the user database with its home (empty and root homes as "").
|
||||
func passwdEntries() []passwdEntry {
|
||||
out := []passwdEntry{}
|
||||
f, err := os.Open(passwdFile)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fields := strings.Split(sc.Text(), ":")
|
||||
if len(fields) < 6 {
|
||||
continue
|
||||
}
|
||||
home := filepath.Clean(fields[5])
|
||||
if home == "." || home == "/" || home == "/nonexistent" {
|
||||
home = ""
|
||||
}
|
||||
out = append(out, passwdEntry{fields[0], home})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// refusePathWith is refusePath with the homes of the accounts the declaration makes (declaredHomes) counted
|
||||
// among the machine's.
|
||||
func refusePathWith(path string, kind placing, module, owner string, declared map[string]homeAccount) error {
|
||||
clean := filepath.Clean(path)
|
||||
if !filepath.IsAbs(clean) {
|
||||
return nil // the declaration refuses a relative path already; a named volume is not a path
|
||||
}
|
||||
for _, p := range []string{clean, resolved(clean)} {
|
||||
if err := refuseOne(p, kind, module, owner); err != nil {
|
||||
if err := refuseOne(p, kind, module, owner, declared); err != nil {
|
||||
if p != clean {
|
||||
err.Why = fmt.Sprintf("through a link, it is %s, and %s", p, err.Why)
|
||||
err.Path = clean
|
||||
@@ -429,7 +511,7 @@ func refusePath(path string, kind placing, module, owner string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func refuseOne(path string, kind placing, module, owner string) *PlacementRefusedError {
|
||||
func refuseOne(path string, kind placing, module, owner string, declared map[string]homeAccount) *PlacementRefusedError {
|
||||
for _, root := range protectedRoots {
|
||||
if path == root || below(root, path) {
|
||||
return &PlacementRefusedError{Path: path, Why: root + " is one of the machine's own directories, " +
|
||||
@@ -482,6 +564,18 @@ func refuseOne(path string, kind placing, module, owner string) *PlacementRefuse
|
||||
return nil
|
||||
}
|
||||
homes := accountsOfHomes()
|
||||
if ssh && len(declared) > 0 {
|
||||
merged := map[string]homeAccount{}
|
||||
for home, a := range homes {
|
||||
merged[home] = a
|
||||
}
|
||||
for home, a := range declared {
|
||||
if _, held := merged[home]; !held {
|
||||
merged[home] = a
|
||||
}
|
||||
}
|
||||
homes = merged
|
||||
}
|
||||
var deepest string
|
||||
for home := range homes {
|
||||
if below(path, home) && len(home) > len(deepest) {
|
||||
@@ -518,11 +612,12 @@ func moduleOfID(id string) string {
|
||||
// a file into it, because nothing judged the file's path.
|
||||
func refusedPlaces(resources []declaration.Resource) map[string]error {
|
||||
out := map[string]error{}
|
||||
declared := declaredHomes(resources)
|
||||
for _, r := range resources {
|
||||
var err error
|
||||
switch res := r.(type) {
|
||||
case *declaration.Directory:
|
||||
err = refusePath(res.Path, placingDirectory, moduleOfID(res.ID), res.Owner)
|
||||
err = refusePathWith(res.Path, placingDirectory, moduleOfID(res.ID), res.Owner, declared)
|
||||
// And outside every home, the walk's judgement before anything is applied (novox/hq issue 358): a
|
||||
// link another account could make in its path refuses it, and with it what is below it, whatever
|
||||
// order they are declared in. The apply walks again at the moment of use. A directory handed over
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-delivery-teller
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2 h1:RF73nVI1dTrrmlFosVDTMJmpUQ+rEeB7++BHH/RvZFg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014012-a20bdf795236 h1:JEkSnrmHs5QIRJGhtrghLC1+C0AxqMpef8a32d/BLPg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014012-a20bdf795236/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014310-19c204273bdc h1:kVq7sS4I0EnL8DcAA2a3YstnXS6WmYYzJz7eK7IZ9RY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014310-19c204273bdc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62 h1:R5C8ICjiaDITHRFR/uIATwARPPXKxssUC70m0svstb0=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-delivery-view
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014012-a20bdf795236 h1:JEkSnrmHs5QIRJGhtrghLC1+C0AxqMpef8a32d/BLPg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014012-a20bdf795236/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014310-19c204273bdc h1:kVq7sS4I0EnL8DcAA2a3YstnXS6WmYYzJz7eK7IZ9RY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014310-19c204273bdc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62 h1:R5C8ICjiaDITHRFR/uIATwARPPXKxssUC70m0svstb0=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
|
||||
"mesh-delivery-watchman/internal/deadletters"
|
||||
@@ -38,6 +39,13 @@ const (
|
||||
routeDead = "dead-letter"
|
||||
routeBusFault = "bus-fault"
|
||||
routeProv = "provisioned"
|
||||
routeLease = "lease"
|
||||
)
|
||||
|
||||
// The lease write's subjects (ADR 0582 clauses 4, 6): the seat mesh-grants' events, and ASSIGNMENTS deleted.
|
||||
var (
|
||||
leaseEventPrefix = "mesh.seat." + catalogue.GrantsSeat + ".event."
|
||||
assignmentsGone = "$JS.EVENT.ADVISORY.STREAM.DELETED." + buscompose.AssignmentsStream
|
||||
)
|
||||
|
||||
// The bus module's events of the connections the bus closed (S9).
|
||||
@@ -61,6 +69,8 @@ func routeOf(subject string) string {
|
||||
return routeTools
|
||||
case subject == CheckedSubject:
|
||||
return routeChecked
|
||||
case strings.HasPrefix(subject, leaseEventPrefix) || subject == assignmentsGone:
|
||||
return routeLease
|
||||
case strings.HasPrefix(subject, "$JS.EVENT.ADVISORY."):
|
||||
return routeAdvisory
|
||||
case strings.HasPrefix(subject, "mesh.events.dead."):
|
||||
@@ -96,6 +106,7 @@ func heard(w *watch.Watchman, dead *deadKeeper, m *nats.Msg, logf func(string, .
|
||||
}
|
||||
if dead.isTheControllersNotices(a.ID) {
|
||||
dead.check() // the lease write's step removed it: the watchman may take the notices now
|
||||
w.RemovalGone(watch.RemovalConsumer)
|
||||
return
|
||||
}
|
||||
w.Advised(a)
|
||||
@@ -110,6 +121,22 @@ func heard(w *watch.Watchman, dead *deadKeeper, m *nats.Msg, logf func(string, .
|
||||
kind = watch.KindConnectionClosed
|
||||
}
|
||||
w.Closed(kind, c)
|
||||
case routeLease:
|
||||
var body struct {
|
||||
Altered string `json:"altered"`
|
||||
Failed string `json:"failed"`
|
||||
}
|
||||
_ = json.Unmarshal(m.Data, &body)
|
||||
switch m.Subject {
|
||||
case assignmentsGone:
|
||||
w.RemovalGone(watch.RemovalStream)
|
||||
case leaseEventPrefix + catalogue.GrantsEventLeaseAltered:
|
||||
w.LeaseAltered(body.Altered)
|
||||
case leaseEventPrefix + catalogue.GrantsEventLeaseHanded:
|
||||
w.LeaseHanded()
|
||||
case leaseEventPrefix + catalogue.GrantsEventRemovalFailed:
|
||||
w.RemovalFailed(body.Failed)
|
||||
}
|
||||
case routeProv:
|
||||
var r watch.ProvReport
|
||||
if err := json.Unmarshal(m.Data, &r); err != nil {
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-delivery-watchman
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2 h1:RF73nVI1dTrrmlFosVDTMJmpUQ+rEeB7++BHH/RvZFg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030201806-eff81f1220e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005736-7b09de1e5502 h1:XndvkzyAFNde/821lEfYObqMTURzmve/w9Ks+QrBBJ8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005736-7b09de1e5502/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3 h1:an2f0MLx9OhaDLu5i51OMPBbuGwX+aoTzqOkFLMpVfE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c h1:rlUCZ5ifLojqDUTkcCCcPrugTzV2RT6F6JbIVTJkaC8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014012-a20bdf795236 h1:JEkSnrmHs5QIRJGhtrghLC1+C0AxqMpef8a32d/BLPg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014012-a20bdf795236/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014310-19c204273bdc h1:kVq7sS4I0EnL8DcAA2a3YstnXS6WmYYzJz7eK7IZ9RY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014310-19c204273bdc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62 h1:R5C8ICjiaDITHRFR/uIATwARPPXKxssUC70m0svstb0=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21 h1:4I4ayo0fZYP4PBfEX1sGDDd6arnw5IQJySJ3jcFobPM=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -59,6 +59,23 @@ func TestTheWatchmansFindings(t *testing.T) {
|
||||
}
|
||||
w.ModuleVerdict("node-tools", value(""), Put)
|
||||
}, map[string]string{}},
|
||||
{"774/S22/AC1, AC2: lease-altered and removal-failed are findings; lease-handed and the removal read gone clear them", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
|
||||
w.LeaseAltered("the key holder names controller@a pid 9, not mesh-grants by lease-write-1")
|
||||
if f := o.findings[FindingKey(ScopeBus, "mesh-controller_lease", KindLeaseAltered)]; !strings.Contains(f.Summary, "controller@a pid 9") {
|
||||
t.Fatalf("findings = %v", o.findings)
|
||||
}
|
||||
w.LeaseHanded()
|
||||
w.RemovalFailed("the stream ASSIGNMENTS: ASSIGNMENTS has 1 consumers")
|
||||
k := FindingKey(ScopeBus, "lease-removal", KindRemovalFailed)
|
||||
if f := o.findings[k]; !strings.Contains(f.Summary, "ASSIGNMENTS has 1 consumers") {
|
||||
t.Fatalf("findings = %v", o.findings)
|
||||
}
|
||||
w.RemovalGone(RemovalConsumer) // not what it waits on
|
||||
if _, ok := o.findings[k]; !ok {
|
||||
t.Fatal("cleared by the consumer, while the stream it names stands")
|
||||
}
|
||||
w.RemovalGone(RemovalStream)
|
||||
}, map[string]string{}},
|
||||
{"WM4: a stuck key not moved is its finding, and its move deletes it", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
|
||||
w.Step("go", "n1.ask1", runningStep("ask1", t0), Put)
|
||||
expire(w, c, pulseKeyOf(t, checksKey, "ask1"), 120*time.Second)
|
||||
@@ -229,7 +246,7 @@ func TestTheWatchmansFindings(t *testing.T) {
|
||||
w.Provision("ace.web.oidc", enc(provisions.Waits, "x"), Put)
|
||||
w.Provision("ace.web.oidc", nil, Delete)
|
||||
}, map[string]string{}},
|
||||
{"774: a node's own report: its network, the resolver file rewritten, failed units, a relogin, a directory used as found", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
|
||||
{"774: a node's own report: its network, the resolver file rewritten, failed units, a relogin, a directory used as found, a login fallen back", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
|
||||
report := func(h delivery.NodeHealth) []byte {
|
||||
b, err := delivery.EncodeNodeHealth(h)
|
||||
if err != nil {
|
||||
@@ -242,11 +259,13 @@ func TestTheWatchmansFindings(t *testing.T) {
|
||||
{Part: "resolv-conf", State: "unhealthy", Writer: "NetworkManager", Owner: "systemd-resolved", Said: "rewritten"},
|
||||
{Part: "names", State: "unhealthy", Reason: "names do not resolve.", Said: "x"},
|
||||
{Part: "route", State: "unhealthy", Reason: "no default route", Said: "y"}}},
|
||||
Units: &delivery.UnitsHealth{State: "degraded", Failed: []delivery.FailedUnit{{Unit: "cups.service", Scope: "system", Result: "exit-code"}}},
|
||||
Relogin: []delivery.NodeAccount{{Account: "jochen", Module: "docker", Reason: "a group"}},
|
||||
Found: []delivery.NodeDirectory{{Module: "photos", Resource: "data", Path: "/srv/photos", Reason: "owner differs"}}}), Put)
|
||||
Units: &delivery.UnitsHealth{State: "degraded", Failed: []delivery.FailedUnit{{Unit: "cups.service", Scope: "system", Result: "exit-code"}}},
|
||||
Relogin: []delivery.NodeAccount{{Account: "jochen", Module: "docker", Reason: "a group"}},
|
||||
Found: []delivery.NodeDirectory{{Module: "photos", Resource: "data", Path: "/srv/photos", Reason: "owner differs"}},
|
||||
Fallback: &delivery.LoginFallback{Node: "novox", From: "node.novox--b", Why: "refused twice", At: time.Date(2026, 11, 2, 12, 0, 0, 0, time.UTC)}}), Put)
|
||||
for key, sev := range map[string]string{"machine.novox.network": Urgent, "machine.novox.systemd-resolved.rewritten": Urgent,
|
||||
"machine.novox.units": Warning, "module.docker.novox.relogin-needed": Warning, "module.photos.novox.directory-used-as-found": Warning} {
|
||||
"machine.novox.units": Warning, "module.docker.novox.relogin-needed": Warning, "module.photos.novox.directory-used-as-found": Warning,
|
||||
"machine.novox.node-account-fallback": Warning} {
|
||||
if o.findings[key].Severity != sev {
|
||||
t.Errorf("%s = %+v", key, o.findings[key])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package watch
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// **The lease write's findings** (novox/hq ADR 0582 clauses 4 and 6, issue 774): mesh-grants says lease-altered when the
|
||||
// lease key or its bucket's expiry differs from what it wrote, and removal-failed when the day-later removal of the
|
||||
// controller's consumer of DEAD_LETTER_NOTICES or the stream ASSIGNMENTS failed; the watchman raises each as a finding.
|
||||
// lease-altered clears when lease-handed is said again (the operator asked the write again); removal-failed clears when
|
||||
// what it names reads gone: the consumer's deletion advisory and ASSIGNMENTS' deletion advisory, each heard.
|
||||
|
||||
// The two kinds.
|
||||
const (
|
||||
KindLeaseAltered = "lease-altered"
|
||||
KindRemovalFailed = "removal-failed"
|
||||
)
|
||||
|
||||
// The finding's IDs: the lease's bucket, and the removal.
|
||||
const (
|
||||
leaseID = "mesh-controller_lease"
|
||||
removalID = "lease-removal"
|
||||
)
|
||||
|
||||
// What a failed removal still waits on.
|
||||
const (
|
||||
RemovalConsumer = "DEAD_LETTER_NOTICES.controller"
|
||||
RemovalStream = "ASSIGNMENTS"
|
||||
)
|
||||
|
||||
// LeaseAltered raises the finding that the lease differs from what mesh-grants wrote, naming what changed.
|
||||
func (w *Watchman) LeaseAltered(what string) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.find(Finding{Scope: ScopeBus, ID: leaseID, Kind: KindLeaseAltered, Severity: Warning, Source: "mesh-grants",
|
||||
Summary: "the controller's lease differs from what mesh-grants wrote: " + what,
|
||||
Said: what,
|
||||
Needs: "a look at who changed the lease; mesh-grants writes nothing until lease-write is asked again.",
|
||||
Resolved: "lease-write is asked and warranted again, and the lease reads as written"})
|
||||
}
|
||||
|
||||
// LeaseHanded clears the lease-altered finding: the lease reads as written again.
|
||||
func (w *Watchman) LeaseHanded() {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.unfind(FindingKey(ScopeBus, leaseID, KindLeaseAltered))
|
||||
}
|
||||
|
||||
// RemovalFailed raises the finding that the day-later removal failed, naming what failed; it waits on each of the
|
||||
// consumer and the stream the failure names.
|
||||
func (w *Watchman) RemovalFailed(failed string) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.removalWaits == nil {
|
||||
w.removalWaits = map[string]bool{}
|
||||
}
|
||||
for _, what := range []string{RemovalConsumer, RemovalStream} {
|
||||
if strings.Contains(failed, strings.SplitN(what, ".", 2)[0]) {
|
||||
w.removalWaits[what] = true
|
||||
}
|
||||
}
|
||||
if len(w.removalWaits) == 0 { // names neither: waits on both
|
||||
w.removalWaits[RemovalConsumer], w.removalWaits[RemovalStream] = true, true
|
||||
}
|
||||
w.find(Finding{Scope: ScopeBus, ID: removalID, Kind: KindRemovalFailed, Severity: Warning, Source: "mesh-grants",
|
||||
Summary: "the day-later removal of the controller's consumer and stream failed: " + failed,
|
||||
Said: failed,
|
||||
Needs: "a look at why; mesh-grants tries again in a day, and grants_removal_due starts it sooner.",
|
||||
Resolved: "the controller's consumer of DEAD_LETTER_NOTICES and the stream ASSIGNMENTS read gone"})
|
||||
}
|
||||
|
||||
// RemovalGone hears one of the two removed (RemovalConsumer or RemovalStream), and clears the finding once neither waits.
|
||||
func (w *Watchman) RemovalGone(what string) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
delete(w.removalWaits, what)
|
||||
if len(w.removalWaits) == 0 {
|
||||
w.unfind(FindingKey(ScopeBus, removalID, KindRemovalFailed))
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,9 @@ import (
|
||||
// said once there, never while its own network or its silence says it;
|
||||
// - machine-units `machine.<node>.units`: failed units no module places;
|
||||
// - relogin-needed `module.<module>.<node>.relogin-needed` and directory-used-as-found
|
||||
// `module.<module>.<node>.directory-used-as-found`.
|
||||
// `module.<module>.<node>.directory-used-as-found`;
|
||||
// - node-account-fallback `machine.<node>.node-account-fallback`: the engine fell back to its node's old login during a
|
||||
// rotation (novox/hq ADR 0583 §6), raised while its report carries the fall back and cleared once it no longer does.
|
||||
//
|
||||
// A failing part that points at other mesh machines (NetworkPart.Targets: the hub's node, each resolver's node) is held
|
||||
// there, as the controller holds it: when every machine it points at is down on the record (silent, its own network
|
||||
@@ -36,6 +38,7 @@ const (
|
||||
KindMachineUnits = "machine-units"
|
||||
KindReloginNeeded = "relogin-needed"
|
||||
KindUsedAsFound = "directory-used-as-found"
|
||||
KindAccountFallback = delivery.NodeAccountFallbackFinding
|
||||
)
|
||||
|
||||
// The parts and states a report names (mesh-control internal/link).
|
||||
@@ -101,6 +104,10 @@ func (w *Watchman) nodeFindings(node string) {
|
||||
want[f.Key()] = true
|
||||
w.find(f)
|
||||
}
|
||||
if f, raise := fallbackOf(node, h.Fallback); raise {
|
||||
want[f.Key()] = true
|
||||
w.find(f)
|
||||
}
|
||||
}
|
||||
for key := range w.findings {
|
||||
if !want[key] && w.ownsHealthKey(key, node) {
|
||||
@@ -249,6 +256,19 @@ func foundOf(node string, dirs []delivery.NodeDirectory) []Finding {
|
||||
return out
|
||||
}
|
||||
|
||||
// fallbackOf is a node's fall back to its old login during a rotation of its account (ADR 0583 §6), one finding.
|
||||
func fallbackOf(node string, f *delivery.LoginFallback) (Finding, bool) {
|
||||
if f == nil || f.Node != node {
|
||||
return Finding{}, false
|
||||
}
|
||||
said := fmt.Sprintf("%s fell back from its new login %s to its old one during a rotation of its bus account: %s", node, f.From, f.Why)
|
||||
return Finding{Scope: ScopeMachine, ID: node, Token: KindAccountFallback, Kind: KindAccountFallback, Machine: node, Severity: Warning,
|
||||
Source: "health-report", Since: f.At, Summary: said, Said: said,
|
||||
Headline: fmt.Sprintf("%s fell back to its old bus login", node),
|
||||
Needs: fmt.Sprintf("abandon the rotation of %s's account, or rotate it again once its new login authenticates.", node),
|
||||
Resolved: fmt.Sprintf("%s no longer falls back to its old bus login", node)}, true
|
||||
}
|
||||
|
||||
func orModule(m string) string {
|
||||
if m == "" {
|
||||
return "_"
|
||||
|
||||
@@ -86,6 +86,8 @@ type Watchman struct {
|
||||
recount func()
|
||||
// waiting is, by `<consumer>.<node>`, each of its provisions in waits, with its words.
|
||||
waiting map[string]map[string]string
|
||||
// removalWaits is what a failed removal of the lease write still waits on to read gone (lease.go).
|
||||
removalWaits map[string]bool
|
||||
// health is each node's own health report, and tunnels each node's tunnel key from its record.
|
||||
health map[string]delivery.NodeHealth
|
||||
tunnels map[string]string
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Confirm ends an enrolment that came through (ADR 0587 §6): the node, dialled as its own login node.<node> after it
|
||||
// redeemed, calls it, and the runtime's proof of that caller is the confirmation (as ADR 0583 §3). Only then does the holder
|
||||
// say node-enrolled, with the key's whole content, and delete the key and its address key. The node's name is the caller's
|
||||
// own; an argument naming another node is refused. A confirm called again after a stop between the event and the deletes
|
||||
// finishes the deletes and says nothing twice (the bus keeps one message of one id).
|
||||
func (h *Holder) Confirm(caller, node string) (map[string]any, error) {
|
||||
own, ok := strings.CutPrefix(caller, nodePrefix)
|
||||
if !ok || CheckNode(own) != nil || strings.Contains(own, "--") {
|
||||
return nil, fmt.Errorf("only the node itself, dialled as node.<node>, confirms its enrolment, and the runtime proved the caller to be %q", caller)
|
||||
}
|
||||
if node != "" && node != own {
|
||||
return nil, fmt.Errorf("%s confirms its own enrolment only, not that of %s", caller, node)
|
||||
}
|
||||
row, found, err := h.Store.Get(NodeKey(own))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the enrolment of %s cannot be read: %w", own, err)
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("no enrolment of %s is open: nothing to confirm", own)
|
||||
}
|
||||
e, err := decodeEnrolment(row.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if e.Stage != StageRedeeming {
|
||||
return nil, fmt.Errorf("the enrolment of %s is %s: it is confirmed after redeem", own, e.Stage)
|
||||
}
|
||||
// The address key goes first (ADR 0589 §6): a confirm stopped midway never leaves one the catch-up would take for an
|
||||
// expiry and unplace a node that joined.
|
||||
if err := h.dropAddress(e); err != nil {
|
||||
return nil, fmt.Errorf("%w (call confirm again)", err)
|
||||
}
|
||||
if err := h.sayEvent(EventEnrolled, e, ""); err != nil {
|
||||
return nil, fmt.Errorf("node-enrolled of %s cannot be said, so nothing is deleted (call confirm again): %w", own, err)
|
||||
}
|
||||
if err := h.release(e); err != nil {
|
||||
return nil, fmt.Errorf("the enrolment of %s is confirmed and not closed (call confirm again): %w", own, err)
|
||||
}
|
||||
return map[string]any{"node": own, "address": e.Address, "says": "the node is enrolled; its enrolment is closed and the vault is asked to drop the token"}, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// 774/S57/AC1: a confirm from any caller but node.<node> is refused; from node.<node> node-enrolled is said and both keys
|
||||
// are deleted; a stop between the event and the deletes is finished by calling confirm again.
|
||||
func confirmIsTheNodesOwnAndEndsTheEnrolment(t *testing.T) {
|
||||
for _, c := range []struct{ name, caller, node, want string }{
|
||||
{"the enrolment user", "enrol.ace", "", "dialled as node.<node>"},
|
||||
{"another node", "node.g14", "ace", "its own enrolment only"},
|
||||
{"the second login", "node.ace--b", "", "dialled as node.<node>"},
|
||||
{"no one", "unproven", "ace", "dialled as node.<node>"},
|
||||
} {
|
||||
r := newRig(t)
|
||||
r.seed("ace", "10.9.0.2", StageRedeeming)
|
||||
if _, err := r.h.Confirm(c.caller, c.node); err == nil {
|
||||
t.Errorf("%s: confirmed", c.name)
|
||||
} else {
|
||||
mustContain(t, err.Error(), c.want)
|
||||
}
|
||||
if len(r.said) != 0 || len(r.keys()) != 2 {
|
||||
t.Errorf("%s: said %v, keys %v", c.name, r.events(), r.keys())
|
||||
}
|
||||
}
|
||||
r := newRig(t)
|
||||
r.seed("ace", "10.9.0.2", StageLive)
|
||||
if _, err := r.h.Confirm("node.ace", ""); err == nil {
|
||||
t.Error("confirmed before redeem")
|
||||
}
|
||||
r.seed("ace", "10.9.0.2", StageRedeeming)
|
||||
r.st.failDelete[NodeKey("ace")] = true
|
||||
if _, err := r.h.Confirm("node.ace", ""); err == nil {
|
||||
t.Fatal("a delete that failed was taken for done")
|
||||
}
|
||||
if r.has(AddressKey("10.9.0.2")) || !r.has(NodeKey("ace")) || len(r.said) != 1 {
|
||||
t.Fatalf("keys %v, said %v", r.keys(), r.events())
|
||||
}
|
||||
if _, err := r.h.Confirm("node.ace", ""); err != nil || len(r.keys()) != 0 {
|
||||
t.Fatalf("%v, keys %v", err, r.keys())
|
||||
}
|
||||
if r.events()[0] != EventEnrolled || r.said[0].ID != r.said[1].ID {
|
||||
t.Errorf("events %v ids %s %s", r.events(), r.said[0].ID, r.said[1].ID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Revoke ends an enrolment at any stage (ADR 0587 §5), by a person's call: at asked it withdraws the ask; at redeeming it also
|
||||
// undoes what redeem had not finished, the vault retiring a node account it already minted, and a node the yes added to
|
||||
// placement is taken off it again. It only takes access away, so it needs no Approve;
|
||||
// it is recorded in the hand-act log. enrolment-revoked carries the key's content and the why; the token is then dropped by
|
||||
// the durable consumer of that event, never before. A revoke that stopped is finished by calling it again.
|
||||
func (h *Holder) Revoke(caller, node, why string) (map[string]any, error) {
|
||||
if caller == "" || caller == stdio.Unproven {
|
||||
return nil, fmt.Errorf("the caller is not proven by the runtime, and a revoke is recorded with who made it")
|
||||
}
|
||||
if strings.TrimSpace(why) == "" {
|
||||
return nil, fmt.Errorf("a revoke says why: it is recorded as an act done by hand")
|
||||
}
|
||||
row, found, err := h.Store.Get(NodeKey(node))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the enrolment of %s cannot be read: %w", node, err)
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("no enrolment of %s is open: mesh-enrolment.tokens lists those that are", node)
|
||||
}
|
||||
e, err := decodeEnrolment(row.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if e.Stage == StageAsked && e.Ask != "" && h.Asker != nil {
|
||||
if err := h.Asker.Cancel(e.Ask); err != nil {
|
||||
h.logf("the ask %s of %s was not taken back at the router: %v", e.Ask, node, err)
|
||||
}
|
||||
}
|
||||
if e.Stage == StageRedeeming {
|
||||
if err := h.Vault.Retire(node); err != nil {
|
||||
return nil, fmt.Errorf("the vault did not retire the account minted for %s, so nothing is ended (call revoke again): %w", node, err)
|
||||
}
|
||||
}
|
||||
// A node that entered placement on the yes and never confirmed leaves it again (the operator's decision of 2026-11-02 01:05).
|
||||
if err := h.unplace(e, "revoked: "+why); err != nil {
|
||||
return nil, fmt.Errorf("%s is not taken off placement, so nothing is ended (call revoke again): %w", node, err)
|
||||
}
|
||||
out := map[string]any{"node": node, "was": e.Stage}
|
||||
if h.Act != nil {
|
||||
if err := h.Act("mesh-enrolment revoke of "+node+" at "+string(e.Stage), why+" (by "+caller+")", "enrolment-revoke"); err != nil {
|
||||
out["recorded"] = "NOT recorded in the hand-act log: " + err.Error()
|
||||
h.logf("the revoke of %s is not recorded in the hand-act log: %v", node, err)
|
||||
}
|
||||
}
|
||||
if err := h.sayEvent(EventRevoked, e, why); err != nil {
|
||||
return nil, fmt.Errorf("enrolment-revoked of %s cannot be said, so its keys stay (call revoke again): %w", node, err)
|
||||
}
|
||||
if err := h.release(e); err != nil {
|
||||
return nil, fmt.Errorf("the enrolment of %s is revoked and its keys are not all deleted (call revoke again): %w", node, err)
|
||||
}
|
||||
out["says"] = "the enrolment is ended; the vault is asked to drop the token and, for a redeem under way, the account was retired"
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Heard is the holder's watch of its own bucket (stdio.State.Follow): it keeps each node key's last content, and the bus's
|
||||
// expiry marker of a key at stage live says enrolment-expired, naming the node and the enrolment-issued event that carried its
|
||||
// content (ADR 0587 §5). redeem checks the expiry itself, so a marker heard late admits no machine.
|
||||
func (h *Holder) Heard(op, key string, value []byte) error {
|
||||
node, ok := strings.CutPrefix(key, "node.")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.seen == nil {
|
||||
h.seen = map[string]Enrolment{}
|
||||
}
|
||||
switch op {
|
||||
case stdio.OpPut:
|
||||
if e, err := decodeEnrolment(value); err == nil {
|
||||
h.seen[node] = e
|
||||
}
|
||||
case stdio.OpDelete:
|
||||
delete(h.seen, node)
|
||||
case stdio.OpExpire:
|
||||
e, known := h.seen[node]
|
||||
delete(h.seen, node)
|
||||
if known {
|
||||
return h.endExpired(e)
|
||||
}
|
||||
// The key expired before the watch handed its content over: its address key still names its ask and issue (ADR 0589 §6).
|
||||
return h.reconcileLocked()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reconcile ends every enrolment whose key is gone while its address key still names its ask: one that expired while the
|
||||
// holder was down, or before its watch caught up, or whose end stopped midway (ADR 0589 §6). Run at start, after each
|
||||
// catch-up and on every expiry; a step that fails is tried again on the next pass.
|
||||
func (h *Holder) Reconcile() error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.reconcileLocked()
|
||||
}
|
||||
|
||||
func (h *Holder) reconcileLocked() error {
|
||||
keys, err := h.Store.Keys()
|
||||
if err != nil {
|
||||
return fmt.Errorf("the enrolments cannot be listed: %w", err)
|
||||
}
|
||||
open := map[string]bool{}
|
||||
for _, k := range keys {
|
||||
if n, ok := strings.CutPrefix(k, "node."); ok {
|
||||
open[n] = true
|
||||
}
|
||||
}
|
||||
var errs []error
|
||||
for _, k := range keys {
|
||||
address, ok := strings.CutPrefix(k, "address.")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row, found, err := h.Store.Get(k)
|
||||
if err != nil || !found {
|
||||
continue
|
||||
}
|
||||
r, err := delivery.DecodeEnrolmentAddress(row.Value)
|
||||
// An address with no ask is reserved by an issue under way: its enrolment key is written next, then the ask.
|
||||
if err != nil || r.Ask == "" || open[r.Node] {
|
||||
continue
|
||||
}
|
||||
// Only an ask answered yes made a token and placed the node (ADR 0589 §6): one declined, or not answered yet, is no
|
||||
// expiry, and one not answered stays until its ask ends.
|
||||
if yes, err := h.answeredYes(r.Ask); err != nil {
|
||||
errs = append(errs, fmt.Errorf("the answer to %s cannot be read, so the address %s waits for the next pass: %w", r.Ask, address, err))
|
||||
continue
|
||||
} else if !yes {
|
||||
continue
|
||||
}
|
||||
h.logf("the enrolment of %s (ask %s) is gone and its address %s is still reserved: it expired unseen, and is ended now", r.Node, r.Ask, address)
|
||||
if err := h.endExpired(Enrolment{Node: r.Node, Address: address, Ask: r.Ask, Issued: r.Issued}); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// answeredYes reads the router's record of ask: whether the operator answered it yes.
|
||||
func (h *Holder) answeredYes(ask string) (bool, error) {
|
||||
_, w, err := h.whereIs(ask)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return w != nil && w.Option == "yes", nil
|
||||
}
|
||||
|
||||
// endExpired ends an enrolment whose key expired, in this order, each step safe to do again: the node, placed on the yes and
|
||||
// never confirmed, is taken out of placement on the warrant of its ask (only when placement holds it); enrolment-expired names
|
||||
// its issued event (said under one id, so said again is said once); the vault drops the token; the address key goes last, so
|
||||
// a step that failed is found and done on the next pass (ADR 0589 §6).
|
||||
func (h *Holder) endExpired(e Enrolment) error {
|
||||
if err := h.unplace(e, "expired"); err != nil {
|
||||
return fmt.Errorf("%s, whose enrolment expired, is not taken out of placement (tried again on the next pass): %w", e.Node, err)
|
||||
}
|
||||
body, err := delivery.EncodeEnrolmentExpiry(delivery.EnrolmentExpiry{Node: e.Node, IssuedID: delivery.EnrolmentIssuedID(e.Node, e.Issued)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.say(EventExpired, json.RawMessage(body), eventID(EventExpired, e)); err != nil {
|
||||
return fmt.Errorf("enrolment-expired of %s cannot be said (tried again on the next pass): %w", e.Node, err)
|
||||
}
|
||||
if err := h.Vault.Drop(e.Node); err != nil {
|
||||
return fmt.Errorf("the token of %s, whose enrolment expired, is not dropped (tried again on the next pass): %w", e.Node, err)
|
||||
}
|
||||
if row, found, err := h.Store.Get(AddressKey(e.Address)); err != nil {
|
||||
return fmt.Errorf("the address key %s cannot be read (tried again on the next pass): %w", e.Address, err)
|
||||
} else if found {
|
||||
if r, err := delivery.DecodeEnrolmentAddress(row.Value); err == nil && r.Node == e.Node && r.Ask == e.Ask {
|
||||
if err := h.Store.Delete(AddressKey(e.Address)); err != nil {
|
||||
return fmt.Errorf("the address key %s is not deleted (tried again on the next pass): %w", e.Address, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ended is the durable consumer's act on one of the holder's own ending events (ADR 0587 §5): the vault is asked to drop the
|
||||
// node's token and its hash, and the event is acknowledged only when the vault answers that both are gone, so a drop that
|
||||
// fails is delivered again by the bus. It never runs before an end: the consumer hears only the end events.
|
||||
func (h *Holder) Ended(event string, body []byte) error {
|
||||
var e struct {
|
||||
Node string `json:"node"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &e); err != nil || CheckNode(e.Node) != nil {
|
||||
h.logf("the event %s names no node and is not acted on: %v", event, err)
|
||||
return nil
|
||||
}
|
||||
if err := h.Vault.Drop(e.Node); err != nil {
|
||||
return fmt.Errorf("the vault did not drop the token of %s (the bus delivers %s again): %w", e.Node, event, err)
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.ended = append(h.ended, json.RawMessage(`{"event":`+fmt.Sprintf("%q", event)+`,"content":`+string(body)+`}`))
|
||||
if len(h.ended) > 200 {
|
||||
h.ended = h.ended[len(h.ended)-200:]
|
||||
}
|
||||
h.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// 774/S58/AC1: a revoke at redeeming, whether the vault minted the account yet or not, takes both keys away, the vault retires
|
||||
// the account, the revoke is recorded and enrolment-revoked is said; one the vault failed to retire ends nothing and is
|
||||
// finished by calling it again. At asked it withdraws the ask; an expiry marker says enrolment-expired with the issued event.
|
||||
func revokeEndsAtAnyStageAndAnExpiryMarkerSaysExpired(t *testing.T) {
|
||||
r := newRig(t)
|
||||
r.seed("ace", "10.9.0.2", StageRedeeming)
|
||||
r.vault.fail["retire ace"] = true
|
||||
if _, err := r.h.Revoke("person.operator", "ace", "never confirmed"); err == nil || len(r.keys()) != 2 || len(r.said) != 0 {
|
||||
t.Fatalf("a failed retire ended it: %v, keys %v, events %v", err, r.keys(), r.events())
|
||||
}
|
||||
if _, err := r.h.Revoke("person.operator", "ace", "never confirmed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The node the yes placed, never confirmed, leaves placement on the enrolment's warrant before the end is said.
|
||||
if _, placed := r.rd["mesh-delivery-assigner.placement"]["ace.node"]; placed || len(r.added) == 0 ||
|
||||
!reflect.DeepEqual(r.added[len(r.added)-1].Change, delivery.PlacementProposalChange{Op: delivery.OpRemoveNode, Node: "ace"}) || r.added[len(r.added)-1].Warrant.Ask != "ask-ace" {
|
||||
t.Errorf("revoked and still placed: %+v", r.added)
|
||||
}
|
||||
if len(r.keys()) != 0 || !slices.Equal(r.events(), []string{EventRevoked}) || len(r.acts) != 1 || !slices.Contains(r.vault.calls, "retire ace") {
|
||||
t.Errorf("keys %v events %v acts %v vault %v", r.keys(), r.events(), r.acts, r.vault.calls)
|
||||
}
|
||||
mustContain(t, string(r.said[0].Body), "never confirmed")
|
||||
// At asked it withdraws the ask and the vault is not asked to retire anything.
|
||||
r.seed("g14", "10.9.0.3", StageAsked)
|
||||
if _, err := r.h.Revoke("person.operator", "g14", "changed our mind"); err != nil || !slices.Equal(r.asker.cancelled, []string{"ask-g14"}) || slices.Contains(r.vault.calls, "retire g14") {
|
||||
t.Errorf("%v, cancelled %v, vault %v", err, r.asker.cancelled, r.vault.calls)
|
||||
}
|
||||
if _, err := r.h.Revoke(stdio.Unproven, "ace", "x"); err == nil {
|
||||
t.Error("revoked for no one")
|
||||
}
|
||||
// The bus's marker of a key at live: the content the watch handed over is said with the issued event.
|
||||
r.seed("novox", "10.9.0.4", StageLive)
|
||||
row, _, _ := r.st.Get(NodeKey("novox"))
|
||||
_ = r.h.Heard(stdio.OpPut, NodeKey("novox"), row.Value)
|
||||
if err := r.h.Heard(stdio.OpExpire, NodeKey("novox"), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, placed := r.rd["mesh-delivery-assigner.placement"]["novox.node"]; placed {
|
||||
t.Error("expired and still placed")
|
||||
}
|
||||
last := r.said[len(r.said)-1]
|
||||
x, err := delivery.DecodeEnrolmentExpiry(last.Body)
|
||||
if err != nil || last.Event != EventExpired || x.Node != "novox" || !strings.HasPrefix(x.IssuedID, "enrolment-issued.novox.") ||
|
||||
!slices.Contains(r.vault.calls, "drop novox") || r.has(AddressKey("10.9.0.4")) {
|
||||
t.Errorf("said %s %s: %+v, %v; vault %v, keys %v", last.Event, last.Body, x, err, r.vault.calls, r.keys())
|
||||
}
|
||||
// ADR 0589 §6: an enrolment whose key expired unseen (before the watch handed it over, or while the holder was down) is
|
||||
// found by its address key, which names its ask and issue: it leaves placement on that ask's warrant, enrolment-expired
|
||||
// names its rebuilt issued event, the token is dropped and the address key goes last. A step that fails is done again on
|
||||
// the next pass, the address key kept until then.
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
node string
|
||||
fail string
|
||||
passS func(r *rig) error
|
||||
}{
|
||||
{"expired unseen", "unseen", "", func(r *rig) error { return r.h.Heard(stdio.OpExpire, NodeKey("unseen"), nil) }},
|
||||
{"the holder was down, and its drop failed once", "down", "drop down", func(r *rig) error { return r.h.Reconcile() }},
|
||||
} {
|
||||
r.seed(c.node, "10.9.0.9", StageLive)
|
||||
r.asker.answer("ask-"+c.node, "yes")
|
||||
issued := r.now.Add(-time.Minute)
|
||||
_ = r.st.Delete(NodeKey(c.node)) // the bus took the key; the watch never handed its content over
|
||||
if c.fail != "" {
|
||||
r.vault.fail[c.fail] = true
|
||||
if err := c.passS(r); err == nil || !r.has(AddressKey("10.9.0.9")) {
|
||||
t.Errorf("%s: a failed drop ended the pass (%v) or took the address key", c.name, err)
|
||||
}
|
||||
}
|
||||
if err := c.passS(r); err != nil {
|
||||
t.Fatalf("%s: %v", c.name, err)
|
||||
}
|
||||
last := r.said[len(r.said)-1]
|
||||
x, err := delivery.DecodeEnrolmentExpiry(last.Body)
|
||||
_, placed := r.rd["mesh-delivery-assigner.placement"][c.node+".node"]
|
||||
removed := r.added[len(r.added)-1]
|
||||
if err != nil || x.IssuedID != delivery.EnrolmentIssuedID(c.node, issued) || placed || removed.Change.Op != delivery.OpRemoveNode ||
|
||||
removed.Change.Node != c.node || removed.Warrant.Ask != "ask-"+c.node || !slices.Contains(r.vault.calls, "drop "+c.node) || r.has(AddressKey("10.9.0.9")) {
|
||||
t.Errorf("%s: said %s (%v), placed %v, last placement call %+v, vault %v, keys %v", c.name, last.Body, err, placed, removed, r.vault.calls, r.keys())
|
||||
}
|
||||
}
|
||||
// ADR 0589 §6, the negative rows: an address key the catch-up finds with its enrolment key gone is no expiry when its ask
|
||||
// was declined or is not answered yet, and none is left behind by a confirm, a decline or a refused add, which delete it
|
||||
// first. In none of them is the node unplaced.
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
end func(r *rig, node string)
|
||||
key bool // the address key stays (an ask not answered yet stays until its ask ends)
|
||||
}{
|
||||
{"after a confirm stopped before the enrolment key went", func(r *rig, node string) {
|
||||
e, _ := r.enrolment(node)
|
||||
e.Stage, e.KeysDigest = StageRedeeming, delivery.KeysDigest("pk", "ov", "se", "sv")
|
||||
_, _ = r.st.Put(NodeKey(node), e, 0)
|
||||
r.asker.answer("ask-"+node, "yes")
|
||||
r.st.failDelete[NodeKey(node)] = true // the confirm stops after the address key, before the enrolment key
|
||||
_, _ = r.h.Confirm(NodeUser(node), "")
|
||||
_ = r.st.Delete(NodeKey(node)) // and the key is gone by the next pass
|
||||
}, false},
|
||||
{"after a decline", func(r *rig, node string) {
|
||||
_ = r.h.Warranted(asks.Option{ID: "no"}, asks.Warrant{Ask: "ask-" + node})
|
||||
r.asker.answer("ask-"+node, "no")
|
||||
}, false},
|
||||
{"after a refused add", func(r *rig, node string) {
|
||||
delete(r.rd["mesh-delivery-assigner.placement"], node+".node") // the add is tried, and refused
|
||||
r.placeErr = errors.New("mesh-placement did not take the node: the proposal is refused: change 1: an address taken")
|
||||
_ = r.h.Warranted(yes, asks.Warrant{Ask: "ask-" + node})
|
||||
r.asker.answer("ask-"+node, "yes")
|
||||
r.placeErr = nil
|
||||
if !slices.Contains(r.events(), EventRefused) {
|
||||
r.t.Errorf("a refused add did not end the enrolment: %v", r.events())
|
||||
}
|
||||
}, false},
|
||||
{"a declined ask whose enrolment key is gone", func(r *rig, node string) {
|
||||
r.asker.answer("ask-"+node, "no")
|
||||
_ = r.st.Delete(NodeKey(node))
|
||||
}, true},
|
||||
{"an ask not answered yet", func(r *rig, node string) { _ = r.st.Delete(NodeKey(node)) }, true},
|
||||
} {
|
||||
r := newRig(t)
|
||||
stage := StageAsked
|
||||
if strings.HasPrefix(c.name, "after a confirm") {
|
||||
stage = StageLive
|
||||
}
|
||||
r.seed("joiner", "10.9.0.11", stage)
|
||||
r.rd["mesh-delivery-assigner.placement"]["joiner.node"] = `{"v":1,"node":"joiner","decided":{"address":"10.9.0.11"},"modules":[]}`
|
||||
c.end(r, "joiner")
|
||||
removes := 0
|
||||
for _, a := range r.added {
|
||||
if a.Change.Op == delivery.OpRemoveNode {
|
||||
removes++
|
||||
}
|
||||
}
|
||||
if err := r.h.Reconcile(); err != nil {
|
||||
t.Errorf("%s: %v", c.name, err)
|
||||
}
|
||||
after := 0
|
||||
for _, a := range r.added {
|
||||
if a.Change.Op == delivery.OpRemoveNode {
|
||||
after++
|
||||
}
|
||||
}
|
||||
if after != removes || r.has(AddressKey("10.9.0.11")) != c.key || slices.Contains(r.events(), EventExpired) {
|
||||
t.Errorf("%s: the catch-up unplaced (%d to %d), address key %v, events %v", c.name, removes, after, r.has(AddressKey("10.9.0.11")), r.events())
|
||||
}
|
||||
}
|
||||
// An address reserved by an issue under way (no ask yet) is never taken for an expired enrolment.
|
||||
_, _ = r.st.Put(AddressKey("10.9.0.10"), Reservation{Node: "busy"}, 0)
|
||||
if err := r.h.Reconcile(); err != nil || !r.has(AddressKey("10.9.0.10")) {
|
||||
t.Errorf("a reservation under way was ended: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 774/S58/AC2 and AC3: the drop is asked of the vault only from an ending event, acknowledged only once the vault answers, and
|
||||
// asked again when its first answer fails; and neither an event nor the bucket ever holds the token, sealed or not.
|
||||
func theDropFollowsOnlyAnEndAndNothingHoldsTheToken(t *testing.T) {
|
||||
r := newRig(t)
|
||||
a, _ := r.h.Issue("person.operator", "ace", "a new machine", tunnelKey, 0)
|
||||
_ = r.h.Warranted(yes, askWarrant(a.Ask))
|
||||
if _, err := r.h.Tokens("ace", false); err != nil { // relays the sealed token
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slices.ContainsFunc(r.vault.calls, func(c string) bool { return strings.HasPrefix(c, "drop") }) {
|
||||
t.Fatalf("the vault was asked to drop a token before an end: %v", r.vault.calls)
|
||||
}
|
||||
if _, err := r.h.Revoke("person.operator", "ace", "lost the machine"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
end := r.said[len(r.said)-1]
|
||||
r.vault.fail["drop ace"] = true
|
||||
if err := r.h.Ended(end.Event, end.Body); err == nil {
|
||||
t.Fatal("an event was acknowledged though the vault did not answer")
|
||||
}
|
||||
if err := r.h.Ended(end.Event, end.Body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := strings.Count(strings.Join(r.vault.calls, ","), "drop ace"); n != 2 {
|
||||
t.Errorf("the drop was asked %d times", n)
|
||||
}
|
||||
for _, s := range r.said {
|
||||
if strings.Contains(string(s.Body), "sealed") || strings.Contains(string(s.Body), "token") {
|
||||
t.Errorf("the event %s holds a token: %s", s.Event, s.Body)
|
||||
}
|
||||
}
|
||||
for k, row := range r.st.rows {
|
||||
if strings.Contains(string(row.Value), "sealed") {
|
||||
t.Errorf("the key %s holds a token", k)
|
||||
}
|
||||
}
|
||||
tk, _ := r.h.Tokens("", true)
|
||||
if raw, _ := json.Marshal(tk); !strings.Contains(string(raw), "enrolment-revoked") {
|
||||
t.Errorf("history %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// mesh-enrolment: the holder of the seat mesh-enrolment (novox/hq ADR 0587 §1), held once for the mesh. A Go bundle served
|
||||
// by a runtime of its own, on its own bus account (`runs-as`). It mints and keeps no secret and holds no signing key: the
|
||||
// vault mints the token and the node's account and signs (ADR 0587 §3). What it keeps is one key per open enrolment in its
|
||||
// state `enrolments` (§2), and what it says is its five events.
|
||||
|
||||
// Module and Seat are the module's name and the seat it holds.
|
||||
const (
|
||||
Module = catalogue.EnrolmentSeat
|
||||
Seat = catalogue.EnrolmentSeat
|
||||
// State is the holder's bucket of open enrolments; its keys are node.<node> and address.<address> (ADR 0587 §2).
|
||||
State = delivery.EnrolmentsState
|
||||
)
|
||||
|
||||
// The seat's six verbs and five events (ADR 0587 §1).
|
||||
var Verbs = []string{catalogue.EnrolmentVerbIssue, catalogue.EnrolmentVerbRedeem, catalogue.EnrolmentVerbConfirm, catalogue.EnrolmentVerbRevoke,
|
||||
catalogue.EnrolmentVerbTokens, catalogue.EnrolmentVerbAgain}
|
||||
|
||||
const (
|
||||
EventIssued = catalogue.EnrolmentIssuedEvent
|
||||
EventEnrolled = catalogue.NodeEnrolledEvent
|
||||
EventRefused = catalogue.EnrolmentRefusedEvent
|
||||
EventRevoked = catalogue.EnrolmentRevokedEvent
|
||||
EventExpired = catalogue.EnrolmentExpiredEvent
|
||||
)
|
||||
|
||||
// EndEvents are the events that end an enrolment; the holder's durable consumer hears each (ADR 0587 §5) and asks the vault
|
||||
// to drop the token. A seat's event is heard as <seat>.<event>.
|
||||
var EndEvents = []string{EventEnrolled, EventRefused, EventRevoked, EventExpired}
|
||||
|
||||
// The stages of an open enrolment (ADR 0587 §2), as the seat's contract names them (delivery.Enrolment).
|
||||
const (
|
||||
// StageAsked: the operator's Approve is asked, no token exists.
|
||||
StageAsked = delivery.EnrolmentAsked
|
||||
// StageLive: the Approve is given, the token exists and expires on the bus with the key.
|
||||
StageLive = delivery.EnrolmentLive
|
||||
// StageRedeeming: the token is spent; the key has no expiry and ends only by confirm or revoke.
|
||||
StageRedeeming = delivery.EnrolmentRedeeming
|
||||
)
|
||||
|
||||
// Enrolment and Reservation are the seat's state contract (delivery.Enrolment, delivery.EnrolmentAddress).
|
||||
type (
|
||||
Enrolment = delivery.Enrolment
|
||||
Reservation = delivery.EnrolmentAddress
|
||||
)
|
||||
|
||||
const (
|
||||
nodePrefix = "node."
|
||||
)
|
||||
|
||||
// NodeKey and AddressKey are the two keys of an enrolment.
|
||||
func NodeKey(node string) string { return delivery.EnrolmentNodeKey(node) }
|
||||
func AddressKey(address string) string { return delivery.EnrolmentAddressKey(address) }
|
||||
|
||||
// EnrolUser and NodeUser are the two bus users of a node: the one it joins with, and its own.
|
||||
func EnrolUser(node string) string { return delivery.EnrolmentUser(node) }
|
||||
func NodeUser(node string) string { return nodePrefix + node }
|
||||
|
||||
var nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
||||
|
||||
// CheckNode says node is a node name a bus user and a key can carry.
|
||||
func CheckNode(node string) error {
|
||||
if !nodeName.MatchString(node) {
|
||||
return fmt.Errorf("%q is not a valid node name: lowercase letters, digits and hyphens, not starting with a hyphen", node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeEnrolment(raw []byte) (Enrolment, error) { return delivery.DecodeEnrolment(raw) }
|
||||
|
||||
// Encode is a value of the bucket as the seat's contract writes it: an Enrolment or a Reservation checked and versioned.
|
||||
func Encode(v any) (json.RawMessage, error) {
|
||||
switch x := v.(type) {
|
||||
case Enrolment:
|
||||
return delivery.EncodeEnrolment(x)
|
||||
case Reservation:
|
||||
return delivery.EncodeEnrolmentAddress(x.Node, x.Ask, x.Issued)
|
||||
}
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
// Row is a key as the store holds it.
|
||||
type Row struct {
|
||||
Value []byte
|
||||
Revision uint64
|
||||
}
|
||||
|
||||
// ErrExists is a create refused because the key has a value; ErrChanged a write refused because the key moved.
|
||||
var (
|
||||
ErrExists = errors.New("the key has a value already")
|
||||
ErrChanged = errors.New("the key was written by another writer first")
|
||||
)
|
||||
|
||||
// Store is the holder's own state: stdio.State in the process, a map in a test. A ttl of zero is no expiry of the key's own.
|
||||
type Store interface {
|
||||
Get(key string) (Row, bool, error)
|
||||
// Create writes a key only when it has none (ErrExists otherwise).
|
||||
Create(key string, v any, ttl time.Duration) error
|
||||
// Update writes a key only when it is still at revision (ErrChanged otherwise), with no expiry of its own.
|
||||
Update(key string, v any, revision uint64) (uint64, error)
|
||||
// Put writes a key whole, with the expiry ttl when it is not zero.
|
||||
Put(key string, v any, ttl time.Duration) (uint64, error)
|
||||
Delete(key string) error
|
||||
Keys() ([]string, error)
|
||||
}
|
||||
|
||||
// Reader reads another module's state, `<module>.<state>`.
|
||||
type Reader interface {
|
||||
Keys(state string) ([]string, error)
|
||||
Get(state, key string) ([]byte, bool, error)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The module's unit tests, as two tables (the repository's test budget): the operator's side (issue, the tokens verb, the
|
||||
// manifest, the read of the bus node's loaded list) and the joining node's side (redeem, confirm and every end).
|
||||
|
||||
// TestIssueTokensAndTheManifest is 774/S54 and S55, and the read of whether the bus node loaded a user.
|
||||
func TestIssueTokensAndTheManifest(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
run func(*testing.T)
|
||||
}{
|
||||
{"S55/AC1 issue refuses naming every finding", issueRefusesNamingEveryFinding},
|
||||
{"S55/AC2 two issues at once hold two addresses and a yes makes the token", twoIssuesAtOnceHoldTwoAddressesAndAYesMakesTheToken},
|
||||
{"S55/AC3 a declined or expired ask takes both keys; a vault failure keeps the enrolment for again", aDeclinedOrExpiredAskTakesBothKeysAndAVaultFailureKeepsTheEnrolmentForAgain},
|
||||
{"S54/AC1 tokens answers each stage and relays the sealed token of one node", tokensAnswersEachStageAndRelaysTheSealedTokenOfOneNode},
|
||||
{"S54/AC2 the manifest says what the code does", theManifestSaysWhatTheCodeDoes},
|
||||
{"S56 the bus node's loaded list is read from the grants part's pass", theLoadedListIsReadFromTheGrantsPass},
|
||||
} {
|
||||
t.Run(c.name, c.run)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedeemConfirmAndEveryEnd is 774/S56, S57 and S58.
|
||||
func TestRedeemConfirmAndEveryEnd(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
run func(*testing.T)
|
||||
}{
|
||||
{"S56/AC1 redeem refuses what it must and spends the token once", redeemRefusesWhatItMustAndSpendsTheTokenOnce},
|
||||
{"S56/AC2 a redeem stopped midway is finished by the same keys again", aRedeemStoppedMidwayIsFinishedByTheSameKeysAgain},
|
||||
{"S57/AC1 confirm is the node's own and ends the enrolment", confirmIsTheNodesOwnAndEndsTheEnrolment},
|
||||
{"S58/AC1-AC2 revoke ends at any stage and an expiry marker says expired", revokeEndsAtAnyStageAndAnExpiryMarkerSaysExpired},
|
||||
{"S58/AC3 the drop follows only an end and nothing holds the token", theDropFollowsOnlyAnEndAndNothingHoldsTheToken},
|
||||
} {
|
||||
t.Run(c.name, c.run)
|
||||
}
|
||||
}
|
||||
|
||||
// The key pass of mesh-grants.user-list holds the part's whole pass result; testdata/grants-pass.json was written by
|
||||
// mesh-grants' own Result (json.Marshal of grants.Result with CredentialsOf), not written by hand: node.ace is in the shadow
|
||||
// list and loaded, node.g14 in the shadow list and not loaded yet.
|
||||
func theLoadedListIsReadFromTheGrantsPass(t *testing.T) {
|
||||
raw, err := os.ReadFile("testdata/grants-pass.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded := loadedVia(memRead{"mesh-grants.user-list": {"pass": string(raw)}})
|
||||
for user, want := range map[string]bool{"node.ace": true, "node.g14": false, "node.none": false} {
|
||||
if got, err := loaded(user); err != nil || got != want {
|
||||
t.Errorf("%s loaded = %v, %v; want %v", user, got, err, want)
|
||||
}
|
||||
}
|
||||
if _, err := loadedVia(memRead{"mesh-grants.user-list": {}})("node.ace"); err == nil {
|
||||
t.Error("no pass composed read as an answer")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Vault is the vault's side of enrolment (ADR 0587 §3, §5): the token and the node's account, and the signatures. The
|
||||
// holder keeps nothing of what it answers.
|
||||
type Vault interface {
|
||||
// Token asks the vault to make the token and its hash for node, expiring at expires, and answers what the joining
|
||||
// machine must trust (the bus's address and fingerprint, the vault's signing key). No token is in the answer.
|
||||
// The token carries tunnel, the node's first tunnel built at issue (ADR 0587 §3, insight of 2026-11-02).
|
||||
Token(node string, expires time.Time, tunnel delivery.TokenTunnel) (delivery.EnrolmentTokenAnswer, error)
|
||||
// Sealed asks the vault for node's token sealed to the operator key, as the vault sealed it.
|
||||
Sealed(node string) (string, error)
|
||||
// Drop asks the vault to drop node's token and its hash; it answers only once both are gone.
|
||||
Drop(node string) error
|
||||
// Account asks for node's account sealed to the sealing key; the same node and keys again answer the same account.
|
||||
Account(node, sealingKey, keysDigest string) (delivery.NodeAccountAnswer, error)
|
||||
// Retire asks the vault to retire the account it minted for node.
|
||||
Retire(node string) error
|
||||
// SignCertificate and SignReply sign through the vault's `sign`; the certificate's call carries every public key the node
|
||||
// presented, which the vault checks against the digest node-account was minted for.
|
||||
SignCertificate(node string, keys delivery.JoiningKeys) (delivery.SignAnswer, error)
|
||||
SignReply(node string, reply []byte) (delivery.SignAnswer, error)
|
||||
// Signer is the vault's public signing key in base64, which the redeem answer names for the node to check it against its token.
|
||||
Signer() (string, error)
|
||||
}
|
||||
|
||||
// Asker is the operator's channel as the holder asks it (asker.Client is one).
|
||||
type Asker interface {
|
||||
Ask(asks.Ask) error
|
||||
Cancel(id string) error
|
||||
}
|
||||
|
||||
// Holder is the seat's holder: the state it keeps, and every other part it reaches through a port.
|
||||
type Holder struct {
|
||||
Store Store
|
||||
Read Reader
|
||||
Vault Vault
|
||||
Asker Asker
|
||||
// Say publishes a seat event under a message id the bus keeps one of.
|
||||
Say func(event string, body any, id string) error
|
||||
// AddNode asks mesh-placement to add the node, or to take a node that never confirmed off again (ADR 0587 §7).
|
||||
AddNode func(AddNode) error
|
||||
// Warrant and Act record in the hand-act log, through the seat mesh-hand-acts: the operator's warrant for an ask, and an
|
||||
// act done by hand (a revoke).
|
||||
Warrant func(ask string) error
|
||||
// WarrantOf is the warrant's id as the teller wrote it (the ask's id and when), read from the router's record of the ask.
|
||||
WarrantOf func(ask string) (string, error)
|
||||
Act func(what, why, cause string) error
|
||||
// Loaded says whether the bus node loaded the bus user (ADR 0587 §3: redeem answers once node.<node> is in its list).
|
||||
Loaded func(user string) (bool, error)
|
||||
Now func() time.Time
|
||||
Logf func(string, ...any)
|
||||
// AskLasts is how long the operator has to answer an issue's ask.
|
||||
AskLasts time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
// seen is each node key's last content the watch handed over, so an expiry, which hands over no value, is said with it.
|
||||
seen map[string]Enrolment
|
||||
// ended is the end events this process heard, newest last: the history `tokens` answers (limit: EVENTS is not readable
|
||||
// from a bundle, so a restart forgets them).
|
||||
ended []json.RawMessage
|
||||
}
|
||||
|
||||
func (h *Holder) now() time.Time {
|
||||
if h.Now != nil {
|
||||
return h.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (h *Holder) logf(f string, a ...any) {
|
||||
if h.Logf != nil {
|
||||
h.Logf(f, a...)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Holder) say(event string, body any, id string) error {
|
||||
if h.Say == nil {
|
||||
return errors.New("the seat's events cannot be said: no publisher")
|
||||
}
|
||||
return h.Say(event, body, id)
|
||||
}
|
||||
|
||||
// AddNode is what the holder asks mesh-placement to add (ADR 0587 §7): the proposal's one change, a joining node with the
|
||||
// address the operator approved and the public halves of its keys, and the issue's warrant, which names this node and this
|
||||
// address (delivery.AddNodeWarrant).
|
||||
type AddNode struct {
|
||||
Why string
|
||||
Change delivery.PlacementProposalChange
|
||||
Warrant delivery.AddNodeWarrant
|
||||
}
|
||||
|
||||
// place adds the node into placement, with the address the operator approved and its tunnel key, on the issue's warrant
|
||||
// (ADR 0587 §7; the operator's decision of 2026-11-02 01:05), unless placement holds it at that address already.
|
||||
func (h *Holder) place(e Enrolment, placements map[string]delivery.Placement) error {
|
||||
if p, ok := placements[e.Node]; ok && p.Decided.Address == e.Address {
|
||||
return nil
|
||||
}
|
||||
w, err := h.warrantOf(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.AddNode(AddNode{Why: "enrolment of " + e.Node + ": " + e.Why, Warrant: w,
|
||||
Change: delivery.PlacementProposalChange{Op: delivery.OpAddNode, Node: e.Node, Decided: &delivery.NodeDecided{Address: e.Address},
|
||||
Keys: &delivery.JoiningKeys{Overlay: e.OverlayKey}}})
|
||||
}
|
||||
|
||||
// unplace takes a node that never confirmed off placement again, on the same warrant, when its enrolment ends revoked, refused
|
||||
// or expired: the hub stops peering a machine that will not join. A node placement does not hold is left as it is.
|
||||
func (h *Holder) unplace(e Enrolment, why string) error {
|
||||
placements, err := h.placements()
|
||||
if err != nil {
|
||||
return fmt.Errorf("placement cannot be read: %w", err)
|
||||
}
|
||||
if _, ok := placements[e.Node]; !ok {
|
||||
return nil
|
||||
}
|
||||
w, err := h.warrantOf(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.AddNode(AddNode{Why: "enrolment of " + e.Node + " ended unconfirmed: " + why, Warrant: w,
|
||||
Change: delivery.PlacementProposalChange{Op: delivery.OpRemoveNode, Node: e.Node}})
|
||||
}
|
||||
|
||||
func (h *Holder) warrantOf(e Enrolment) (delivery.AddNodeWarrant, error) {
|
||||
if h.AddNode == nil || h.WarrantOf == nil {
|
||||
return delivery.AddNodeWarrant{}, errors.New("no route to mesh-placement")
|
||||
}
|
||||
warrant, err := h.WarrantOf(e.Ask)
|
||||
if err != nil {
|
||||
return delivery.AddNodeWarrant{}, fmt.Errorf("the operator's warrant for %s cannot be read from the router: %w", e.Ask, err)
|
||||
}
|
||||
return delivery.AddNodeWarrant{Node: e.Node, Address: e.Address, Ask: e.Ask, Warrant: warrant}, nil
|
||||
}
|
||||
|
||||
// checkTunnelKey says a tunnel key is a WireGuard public key, as `nox-mesh-host key` prints it: 32 bytes in standard base64
|
||||
// (as delivery.Enrolment requires it too); said here so issue names it among its findings.
|
||||
func checkTunnelKey(k string) error {
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(k))
|
||||
if err != nil || len(raw) != 32 {
|
||||
return errors.New("no tunnel key is given (overlay_key: the line `nox-mesh-host key` prints on the new machine)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// issuing says whether issue is on: only while the controller's lease is handed to a holder that is no controller (ADR 0587
|
||||
// §4), and why either way.
|
||||
func (h *Holder) issuing() (bool, string) {
|
||||
raw, found, err := h.Read.Get("mesh-controller.lease", buscompose.ControllerLeaseKey)
|
||||
return buscompose.LeaseHandedAway(raw, found, err)
|
||||
}
|
||||
|
||||
// openEnrolments reads every node key of the bucket.
|
||||
func (h *Holder) openEnrolments() (map[string]Row, map[string]Enrolment, error) {
|
||||
keys, err := h.Store.Keys()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("the enrolments cannot be listed: %w", err)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
rows, out := map[string]Row{}, map[string]Enrolment{}
|
||||
for _, k := range keys {
|
||||
node, ok := strings.CutPrefix(k, "node.")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row, found, err := h.Store.Get(k)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("the enrolment of %s cannot be read: %w", node, err)
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
e, err := decodeEnrolment(row.Value)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%s: %w", k, err)
|
||||
}
|
||||
rows[node], out[node] = row, e
|
||||
}
|
||||
return rows, out, nil
|
||||
}
|
||||
|
||||
// placements reads every node's placement, assembled from its keys.
|
||||
func (h *Holder) placements() (map[string]delivery.Placement, error) {
|
||||
keys, err := h.Read.Keys("mesh-delivery-assigner.placement")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("placement cannot be listed: %w", err)
|
||||
}
|
||||
held := map[string][]byte{}
|
||||
for _, k := range keys {
|
||||
if raw, found, err := h.Read.Get("mesh-delivery-assigner.placement", k); err != nil {
|
||||
return nil, fmt.Errorf("placement key %s cannot be read: %w", k, err)
|
||||
} else if found {
|
||||
held[k] = raw
|
||||
}
|
||||
}
|
||||
p, err := delivery.PlacementsOf(held)
|
||||
if len(p) == 0 && err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// firstTunnel is the joining node's first tunnel as the token carries it (ADR 0587 §3, insight of 2026-11-02; as the
|
||||
// controller's `token issue` builds it): the machine's own tunnel key given at issue, its address, the private network's
|
||||
// range (privateRange), the hub's tunnel key from its record (the key its tunnel holds; on an adopted hub
|
||||
// that has not written one, the found tunnel's key kept in its placement), the hub's endpoint from its placement, and the
|
||||
// node's address on the private network. A mesh with no hub, or a hub with no key or no endpoint, has no tunnel to join
|
||||
// through, and that is said.
|
||||
func (h *Holder) firstTunnel(placements map[string]delivery.Placement, address, overlayKey string) (delivery.TokenTunnel, error) {
|
||||
var hub *delivery.Placement
|
||||
for _, p := range placements {
|
||||
if p.Decided.Hub {
|
||||
p := p
|
||||
hub = &p
|
||||
}
|
||||
}
|
||||
if hub == nil {
|
||||
return delivery.TokenTunnel{}, errors.New("placement names no hub, so there is no tunnel to join through")
|
||||
}
|
||||
key := ""
|
||||
if k, err := delivery.HaveNodeKey(hub.Node); err == nil {
|
||||
if raw, found, err := h.Read.Get("node-engine.have", k); err == nil && found {
|
||||
if hn, err := delivery.DecodeHaveNode(raw); err == nil && hn.Record != nil {
|
||||
key = hn.Record.TunnelKey
|
||||
}
|
||||
}
|
||||
}
|
||||
if key == "" && hub.Decided.Tunnel != nil {
|
||||
key = hub.Decided.Tunnel.PublicKey
|
||||
}
|
||||
var missing []string
|
||||
if key == "" {
|
||||
missing = append(missing, "no tunnel key in its record")
|
||||
}
|
||||
if hub.Decided.Endpoint == "" {
|
||||
missing = append(missing, "no endpoint in its placement")
|
||||
}
|
||||
prefix, rerr := privateRange(placements)
|
||||
if len(missing) > 0 {
|
||||
return delivery.TokenTunnel{}, fmt.Errorf("the hub %s has %s, so there is no tunnel to join through", hub.Node, strings.Join(missing, " and "))
|
||||
}
|
||||
if rerr != nil {
|
||||
return delivery.TokenTunnel{}, rerr
|
||||
}
|
||||
return delivery.TokenTunnel{Key: overlayKey, Address: address + "/32", Range: prefix.String(), HubKey: key, HubEndpoint: hub.Decided.Endpoint}, nil
|
||||
}
|
||||
|
||||
// hasRecord says the node already writes a record (a node of the mesh, ADR 0587 §4: "a node record ... holds it").
|
||||
func (h *Holder) hasRecord(node string) bool {
|
||||
keys, err := h.Read.Keys("node-engine.have")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, k := range keys {
|
||||
if n, _, isNode, err := delivery.ParseHaveKey(k); err == nil && isNode && n == node {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// privateRange is the private network's range: the hub's tunnel range in its placement, as the controller's `token issue`
|
||||
// writes it into the token today (the adopted tunnel's range). A mesh whose hub's placement names none has no range a token
|
||||
// can carry, and that is said rather than guessed.
|
||||
func privateRange(placements map[string]delivery.Placement) (netip.Prefix, error) {
|
||||
for _, p := range placements {
|
||||
if t := p.Decided.Tunnel; p.Decided.Hub && t != nil && t.Range != "" {
|
||||
r, err := netip.ParsePrefix(t.Range)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, fmt.Errorf("the hub %s's tunnel range %q is no range: %w", p.Node, t.Range, err)
|
||||
}
|
||||
return r.Masked(), nil
|
||||
}
|
||||
}
|
||||
return netip.Prefix{}, errors.New("no private network is known: the hub's placement names no tunnel range")
|
||||
}
|
||||
|
||||
// nextAddress is the lowest address of the private network that no placement key and no reservation holds (ADR 0587 §4),
|
||||
// after skip. The network is the hub's range; the hub's own address and the tunnel's are never given.
|
||||
func nextAddress(placements map[string]delivery.Placement, reserved map[string]bool, skip map[string]bool) (string, error) {
|
||||
taken := map[string]bool{}
|
||||
for _, p := range placements {
|
||||
if a := p.Decided.Address; a != "" {
|
||||
taken[a] = true
|
||||
}
|
||||
if t := p.Decided.Tunnel; t != nil {
|
||||
if t.Address != "" {
|
||||
if a, err := netip.ParsePrefix(t.Address); err == nil {
|
||||
taken[a.Addr().String()] = true
|
||||
} else {
|
||||
taken[t.Address] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
prefix, err := privateRange(placements)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for a := prefix.Addr().Next(); prefix.Contains(a); a = a.Next() {
|
||||
s := a.String()
|
||||
if prefix.Addr().Is4() && !prefix.Contains(a.Next()) {
|
||||
break // the broadcast address
|
||||
}
|
||||
if taken[s] || reserved[s] || skip[s] {
|
||||
continue
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
return "", fmt.Errorf("the private network %s has no free address", prefix)
|
||||
}
|
||||
|
||||
func (h *Holder) reservedAddresses() (map[string]bool, error) {
|
||||
keys, err := h.Store.Keys()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the enrolments cannot be listed: %w", err)
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for _, k := range keys {
|
||||
if a, ok := strings.CutPrefix(k, "address."); ok {
|
||||
out[a] = true
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// lifetimeOf is the time a key lives on the bus for an enrolment that expires at expires.
|
||||
func (h *Holder) lifetimeOf(expires time.Time) time.Duration {
|
||||
if d := expires.Sub(h.now()); d > time.Second {
|
||||
return d
|
||||
}
|
||||
return time.Second
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
// mesh-enrolment whole on a real bus (novox/hq ADR 0587 §2-§8; issue 774): the holder's own code over a real nats-server's
|
||||
// key-value bucket `enrolments` with a time to live per key (the keys written as the runtime writes them: a create with the
|
||||
// expiry, a put with a message TTL, an update by revision), its events said on their real subjects, the bus's own expiry marker
|
||||
// heard, and the other parts stood in for — the vault (which writes the new node's key of node-logins at node-account, as the
|
||||
// vault does), the operator's channel, placement, and the grants part's rule that a key at live makes the user enrol.<node> and
|
||||
// a key gone takes it away.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/testbus"
|
||||
)
|
||||
|
||||
// busStore is the holder's bucket as the runtime writes it.
|
||||
type busStore struct {
|
||||
ctx context.Context
|
||||
js jetstream.JetStream
|
||||
kv jetstream.KeyValue
|
||||
}
|
||||
|
||||
func (b *busStore) Get(key string) (Row, bool, error) {
|
||||
e, err := b.kv.Get(b.ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return Row{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Row{}, false, err
|
||||
}
|
||||
return Row{Value: e.Value(), Revision: e.Revision()}, true, nil
|
||||
}
|
||||
|
||||
func (b *busStore) Create(key string, v any, ttl time.Duration) error {
|
||||
raw, err := Encode(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var opts []jetstream.KVCreateOpt
|
||||
if ttl > 0 {
|
||||
opts = append(opts, jetstream.KeyTTL(ttl))
|
||||
}
|
||||
_, err = b.kv.Create(b.ctx, key, raw, opts...)
|
||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||
return ErrExists
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *busStore) Update(key string, v any, revision uint64) (uint64, error) {
|
||||
raw, err := Encode(v)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rev, err := b.kv.Update(b.ctx, key, raw, revision)
|
||||
if err != nil && strings.Contains(err.Error(), "wrong last sequence") {
|
||||
return 0, ErrChanged
|
||||
}
|
||||
return rev, err
|
||||
}
|
||||
|
||||
func (b *busStore) Put(key string, v any, ttl time.Duration) (uint64, error) {
|
||||
raw, err := Encode(v)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if ttl <= 0 {
|
||||
return b.kv.Put(b.ctx, key, raw)
|
||||
}
|
||||
ack, err := b.js.Publish(b.ctx, "$KV."+b.kv.Bucket()+"."+key, raw, jetstream.WithMsgTTL(max(time.Second, ttl.Truncate(time.Second))))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return ack.Sequence, nil
|
||||
}
|
||||
|
||||
func (b *busStore) Delete(key string) error {
|
||||
return b.kv.Delete(b.ctx, key)
|
||||
}
|
||||
|
||||
func (b *busStore) Keys() ([]string, error) {
|
||||
keys, err := b.kv.Keys(b.ctx)
|
||||
if errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return keys, err
|
||||
}
|
||||
|
||||
// busReader reads other parts' buckets, `<module>.<state>`.
|
||||
type busReader struct {
|
||||
ctx context.Context
|
||||
js jetstream.JetStream
|
||||
}
|
||||
|
||||
func (r busReader) bucket(state string) (jetstream.KeyValue, error) {
|
||||
return r.js.KeyValue(r.ctx, strings.Replace(state, ".", "_", 1))
|
||||
}
|
||||
|
||||
func (r busReader) Keys(state string) ([]string, error) {
|
||||
kv, err := r.bucket(state)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys, err := kv.Keys(r.ctx)
|
||||
if errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return keys, err
|
||||
}
|
||||
|
||||
func (r busReader) Get(state, key string) ([]byte, bool, error) {
|
||||
kv, err := r.bucket(state)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
e, err := kv.Get(r.ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return e.Value(), true, nil
|
||||
}
|
||||
|
||||
// standInVault holds the tokens it made, and writes a joining node's key of node-logins when it mints the account.
|
||||
type standInVault struct {
|
||||
ctx context.Context
|
||||
logins jetstream.KeyValue
|
||||
mu sync.Mutex
|
||||
tokens map[string]delivery.Token
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (v *standInVault) note(c string) {
|
||||
v.mu.Lock()
|
||||
v.calls = append(v.calls, c)
|
||||
v.mu.Unlock()
|
||||
}
|
||||
|
||||
// Token makes the token as the vault does: the bus it states, its signing key, a secret, and the first tunnel it is handed.
|
||||
func (v *standInVault) Token(node string, expires time.Time, tunnel delivery.TokenTunnel) (delivery.EnrolmentTokenAnswer, error) {
|
||||
v.mu.Lock()
|
||||
v.tokens[node] = delivery.Token{Version: 1, Node: node, Broker: "bus:4222", Fingerprint: "f", Signer: make([]byte, 32), Secret: "secret-" + node, Tunnel: &tunnel}
|
||||
v.mu.Unlock()
|
||||
v.note("token " + node)
|
||||
return delivery.EnrolmentTokenAnswer{Node: node, Broker: "bus:4222", Fingerprint: "f", Signer: "s"}, nil
|
||||
}
|
||||
func (v *standInVault) Sealed(node string) (string, error) { return "sealed-" + node, nil }
|
||||
func (v *standInVault) Drop(node string) error {
|
||||
v.mu.Lock()
|
||||
delete(v.tokens, node)
|
||||
v.mu.Unlock()
|
||||
v.note("drop " + node)
|
||||
return nil
|
||||
}
|
||||
func (v *standInVault) Account(node, sealing, digest string) (delivery.NodeAccountAnswer, error) {
|
||||
raw, err := delivery.EncodeNodeLogin(delivery.NodeLogin{Node: node, Login: delivery.FirstLogin(node), Since: time.Now().UTC()})
|
||||
if err != nil {
|
||||
return delivery.NodeAccountAnswer{}, err
|
||||
}
|
||||
if _, err := v.logins.Put(v.ctx, node, raw); err != nil {
|
||||
return delivery.NodeAccountAnswer{}, err
|
||||
}
|
||||
return delivery.NodeAccountAnswer{Node: node, Login: delivery.FirstLogin(node), Account: "account-" + node}, nil
|
||||
}
|
||||
func (v *standInVault) Retire(node string) error { return nil }
|
||||
func (v *standInVault) SignCertificate(node string, _ delivery.JoiningKeys) (delivery.SignAnswer, error) {
|
||||
return delivery.SignAnswer{Kind: delivery.KindNodeCertificate, Node: node, Certificate: "PEM"}, nil
|
||||
}
|
||||
func (v *standInVault) SignReply(node string, reply []byte) (delivery.SignAnswer, error) {
|
||||
return delivery.SignAnswer{Kind: delivery.KindEnrolment, Node: node, Signature: "sig", Signer: "fp"}, nil
|
||||
}
|
||||
func (v *standInVault) Signer() (string, error) { return "vault-key", nil }
|
||||
|
||||
// 774/S60/AC1: issue, a warrant, redeem as enrol.<node> and confirm, whole on a real bus. The user enrol.<node> is composed
|
||||
// while the key is live and leaves with it; the node confirms as the login its key of node-logins names; node-enrolled and
|
||||
// placement-changed are said; the vault is asked to drop the token only after the end; and a token never redeemed is ended by
|
||||
// the bus's own expiry, heard, saying enrolment-expired and bringing the drop.
|
||||
func TestMeshEnrolmentWholeOnARealBus(t *testing.T) {
|
||||
srv := testbus.Start(t)
|
||||
nc, err := nats.Connect(srv.ClientURL())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
mk := func(name string, ttl bool) jetstream.KeyValue {
|
||||
cfg := jetstream.KeyValueConfig{Bucket: name, History: 5}
|
||||
if ttl {
|
||||
// A bucket with a time to live per key keeps one revision per key: the bus (nats-server 2.11.17) expires no key of
|
||||
// one that keeps more, which this test found out (a state declared key-ttl takes no history).
|
||||
cfg.History, cfg.LimitMarkerTTL = 1, time.Minute
|
||||
}
|
||||
kv, err := js.CreateKeyValue(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return kv
|
||||
}
|
||||
enrolments := mk(delivery.EnrolmentsBucket, true)
|
||||
logins, lease, placement := mk(delivery.NodeLoginsBucket, false), mk("mesh-controller_lease", false), mk("mesh-delivery-assigner_placement", false)
|
||||
have := mk("node-engine_have", false)
|
||||
if _, err := lease.Put(ctx, "holder", []byte(`{"handed-to":"mesh-grants","handed-by":"operator"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := placement.Put(ctx, "hub.node", []byte(hubPlacement)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := have.Put(ctx, "hub.node", []byte(hubRecord)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The seats' events are kept on a stream of the events, as the bus keeps them, and heard on their real subjects.
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "SEAT_EVENTS",
|
||||
Subjects: []string{"mesh.seat.mesh-enrolment.event.>", "mesh.seat.mesh-placement.event.>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var heardMu sync.Mutex
|
||||
var heard []string
|
||||
for _, subject := range []string{"mesh.seat.mesh-enrolment.event.*", "mesh.seat.mesh-placement.event.*"} {
|
||||
if _, err := nc.Subscribe(subject, func(m *nats.Msg) {
|
||||
heardMu.Lock()
|
||||
heard = append(heard, m.Subject[strings.LastIndex(m.Subject, ".")+1:])
|
||||
heardMu.Unlock()
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
events := func() []string { heardMu.Lock(); defer heardMu.Unlock(); return slices.Clone(heard) }
|
||||
|
||||
store := &busStore{ctx: ctx, js: js, kv: enrolments}
|
||||
vault := &standInVault{ctx: ctx, logins: logins, tokens: map[string]delivery.Token{}}
|
||||
asker := &fakeAsker{}
|
||||
h := &Holder{Store: store, Read: busReader{ctx, js}, Vault: vault, Asker: asker, AskLasts: time.Hour,
|
||||
Say: func(event string, body any, id string) error {
|
||||
raw, _ := json.Marshal(body)
|
||||
_, err := js.Publish(ctx, "mesh.seat.mesh-enrolment.event."+event, raw, jetstream.WithMsgID(id))
|
||||
return err
|
||||
},
|
||||
// mesh-placement, stood in: an add-node writes the node's key, a remove-node deletes it, each saying placement-changed.
|
||||
AddNode: func(a AddNode) error {
|
||||
if a.Change.Op == delivery.OpRemoveNode {
|
||||
if err := placement.Delete(ctx, a.Change.Node+".node"); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
value := `{"v":1,"node":"` + a.Change.Node + `","decided":{"address":"` + a.Change.Decided.Address + `"},"modules":[]}`
|
||||
if _, err := placement.Put(ctx, a.Change.Node+".node", []byte(value)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := js.Publish(ctx, "mesh.seat.mesh-placement.event.placement-changed", []byte(`{}`))
|
||||
return err
|
||||
},
|
||||
WarrantOf: func(ask string) (string, error) { return ask + "@2026-11-02T10:00:00Z", nil },
|
||||
Warrant: func(string) error { return nil },
|
||||
Act: func(string, string, string) error { return nil },
|
||||
Loaded: func(string) (bool, error) { return true, nil },
|
||||
Logf: t.Logf}
|
||||
|
||||
// The grants part's rule, stood in: a key at live is the user enrol.<node>; a key gone takes it away. The holder's watch
|
||||
// of its own bucket hears the bus's expiry marker.
|
||||
var usersMu sync.Mutex
|
||||
users := map[string]bool{}
|
||||
hasUser := func(u string) bool { usersMu.Lock(); defer usersMu.Unlock(); return users[u] }
|
||||
var down atomic.Bool // the holder is down: its watch hands nothing over
|
||||
w, err := enrolments.WatchAll(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = w.Stop() })
|
||||
go func() {
|
||||
for e := range w.Updates() {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
node, ok := strings.CutPrefix(e.Key(), "node.")
|
||||
if !ok || down.Load() {
|
||||
continue
|
||||
}
|
||||
if e.Operation() == jetstream.KeyValuePut {
|
||||
en, err := decodeEnrolment(e.Value())
|
||||
usersMu.Lock()
|
||||
users[EnrolUser(node)] = err == nil && en.Stage == StageLive
|
||||
usersMu.Unlock()
|
||||
_ = h.Heard(stdio.OpPut, e.Key(), e.Value())
|
||||
continue
|
||||
}
|
||||
usersMu.Lock()
|
||||
delete(users, EnrolUser(node))
|
||||
usersMu.Unlock()
|
||||
// A delete is the holder's own; the marker the bus leaves when a key's own time to live ends is a purge.
|
||||
if e.Operation() == jetstream.KeyValuePurge {
|
||||
_ = h.Heard(stdio.OpExpire, e.Key(), nil)
|
||||
} else {
|
||||
_ = h.Heard(stdio.OpDelete, e.Key(), nil)
|
||||
}
|
||||
}
|
||||
}()
|
||||
// The durable consumer of the holder's own end events, stood in by a core subscription that asks the drop.
|
||||
for _, e := range EndEvents {
|
||||
key := Seat + "." + e
|
||||
if _, err := nc.Subscribe("mesh.seat.mesh-enrolment.event."+e, func(m *nats.Msg) {
|
||||
if err := h.Ended(key, m.Data); err != nil {
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
waitFor := func(what string, ok func() bool) {
|
||||
t.Helper()
|
||||
for deadline := time.Now().Add(20 * time.Second); !ok(); time.Sleep(25 * time.Millisecond) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("never: %s (events %v, vault %v)", what, events(), vault.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Issue, the operator's yes, redeem as enrol.ace, confirm as node.ace.
|
||||
a, err := h.Issue("person.operator", "ace", "a new machine", tunnelKey, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.Warranted(asks.Option{ID: "yes"}, asks.Warrant{Ask: a.Ask}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitFor("the user enrol.ace is composed", func() bool { return hasUser("enrol.ace") })
|
||||
// The token is whole (ADR 0587 §3, insight of 2026-11-02): it carries the first tunnel built at issue, the machine's tunnel
|
||||
// key given then, the private network's range and the hub's key and endpoint.
|
||||
vault.mu.Lock()
|
||||
tok := vault.tokens["ace"]
|
||||
vault.mu.Unlock()
|
||||
if missing := tok.Missing(); len(missing) != 0 || tok.Tunnel == nil || tok.Tunnel.Key != tunnelKey || tok.Tunnel.Address != a.Address+"/32" {
|
||||
t.Fatalf("the token misses %v: tunnel %+v", missing, tok.Tunnel)
|
||||
}
|
||||
// The node is in placement before redeem: the operator's yes placed it, so the hub peers it before the machine dials.
|
||||
if ace, err := placement.Get(ctx, "ace.node"); err != nil || !strings.Contains(string(ace.Value()), a.Address) {
|
||||
t.Fatalf("ace is not in placement before redeem: %v", err)
|
||||
}
|
||||
args := redeemArgsWith("ace", 1, "serving-ace", tunnelKey)
|
||||
if _, err := h.Redeem("enrol.somebody", args); err == nil {
|
||||
t.Fatal("redeemed by another caller")
|
||||
}
|
||||
ans, err := h.Redeem("enrol.ace", args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The node dials as the login its key of node-logins names, and confirms as it.
|
||||
e, err := logins.Get(ctx, "ace")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nl, err := delivery.DecodeNodeLogin(e.Value())
|
||||
if err != nil || nl.Login != ans.Login {
|
||||
t.Fatalf("node-logins says %+v, %v, and the answer %s", nl, err, ans.Login)
|
||||
}
|
||||
if _, err := h.Confirm(nl.Login, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitFor("node-enrolled and placement-changed are said and the user leaves the list", func() bool {
|
||||
ev := events()
|
||||
return slices.Contains(ev, EventEnrolled) && slices.Contains(ev, "placement-changed") && !hasUser("enrol.ace")
|
||||
})
|
||||
waitFor("the vault is asked to drop the token after the end", func() bool {
|
||||
vault.mu.Lock()
|
||||
defer vault.mu.Unlock()
|
||||
_, held := vault.tokens["ace"]
|
||||
return slices.Contains(vault.calls, "drop ace") && !held
|
||||
})
|
||||
if keys, _ := store.Keys(); len(keys) != 0 {
|
||||
t.Errorf("an ended enrolment leaves keys %v", keys)
|
||||
}
|
||||
|
||||
// A token never redeemed expires on the bus: the holder hears the marker, says enrolment-expired and the token is dropped.
|
||||
if _, err := h.Issue("person.operator", "g14", "a second machine", tunnelKey, 2*time.Second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.Warranted(asks.Option{ID: "yes"}, asks.Warrant{Ask: asker.asked[len(asker.asked)-1]}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitFor("enrolment-expired is said for g14", func() bool { return slices.Contains(events(), EventExpired) })
|
||||
waitFor("the expired token is dropped", func() bool {
|
||||
vault.mu.Lock()
|
||||
defer vault.mu.Unlock()
|
||||
return slices.Contains(vault.calls, "drop g14")
|
||||
})
|
||||
if hasUser("enrol.g14") {
|
||||
t.Error("the user enrol.g14 stays after its key expired")
|
||||
}
|
||||
// It never confirmed, so it left placement again before its end was said.
|
||||
if _, err := placement.Get(ctx, "g14.node"); err == nil {
|
||||
t.Error("g14 stays in placement after its enrolment expired")
|
||||
}
|
||||
// Its address key had no expiry of its own: the holder took it last, after the drop (ADR 0589 §6).
|
||||
waitFor("the expired enrolment's address key is deleted", func() bool { keys, _ := store.Keys(); return len(keys) == 0 })
|
||||
|
||||
// The holder was down when an enrolment expired: a holder started later finds its address key, which names its ask, and
|
||||
// ends it the same way (here: the key deleted under the holder, then a pass as at start).
|
||||
b, err := h.Issue("person.operator", "novox", "a third machine", tunnelKey, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.Warranted(asks.Option{ID: "yes"}, asks.Warrant{Ask: b.Ask}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asker.answer(b.Ask, "yes") // the router's record of the answer, which the catch-up reads (ADR 0589 §6)
|
||||
if _, err := placement.Get(ctx, "novox.node"); err != nil {
|
||||
t.Fatalf("novox was not placed on the yes: %v", err)
|
||||
}
|
||||
down.Store(true)
|
||||
if err := enrolments.Purge(ctx, NodeKey("novox")); err != nil { // the bus took the key while no holder watched
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitFor("the key is gone", func() bool { _, found, _ := store.Get(NodeKey("novox")); return !found })
|
||||
if _, err := placement.Get(ctx, "novox.node"); err != nil {
|
||||
t.Fatalf("novox left placement while no holder ran: %v", err)
|
||||
}
|
||||
if err := h.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := placement.Get(ctx, "novox.node"); err == nil {
|
||||
t.Error("novox, expired while the holder was down, stays in placement")
|
||||
}
|
||||
waitFor("the token of novox is dropped and its address key goes", func() bool {
|
||||
vault.mu.Lock()
|
||||
dropped := slices.Contains(vault.calls, "drop novox")
|
||||
vault.mu.Unlock()
|
||||
keys, _ := store.Keys()
|
||||
return dropped && len(keys) == 0
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asker"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Event is what each of the seat's events carries (ADR 0587 §2): the key's whole content, which holds no secret and nothing
|
||||
// derived from the token, and for an end the reason. Node is repeated at the top so a consumer reads it without the rest.
|
||||
type Event struct {
|
||||
Node string `json:"node"`
|
||||
Enrolment Enrolment `json:"enrolment"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// eventID is the message id an event of e is said under: the bus keeps one message of it, so an event said again after a
|
||||
// crash is not said twice.
|
||||
func eventID(event string, e Enrolment) string {
|
||||
return event + "." + e.Node + "." + strconv.FormatInt(e.Issued.UnixNano(), 10)
|
||||
}
|
||||
|
||||
func (h *Holder) sayEvent(event string, e Enrolment, reason string) error {
|
||||
return h.say(event, Event{Node: e.Node, Enrolment: e, Reason: reason}, eventID(event, e))
|
||||
}
|
||||
|
||||
// IssueAnswer is what `issue` answers: the ask is the operator's to answer, and nothing else happens until they do.
|
||||
type IssueAnswer struct {
|
||||
Node string `json:"node"`
|
||||
Address string `json:"address"`
|
||||
Ask string `json:"ask"`
|
||||
Expires time.Time `json:"expires"`
|
||||
Says string `json:"says"`
|
||||
}
|
||||
|
||||
// Issue opens an enrolment (ADR 0587 §4): it refuses at once, naming every finding, a name that is no node name or that a
|
||||
// node record, a placement key or an open enrolment holds, and every call before the lease write; chooses the lowest free
|
||||
// address and reserves it by creating its key (the bus refuses a key that exists, so two issues never get one address);
|
||||
// writes the key at stage asked; and asks the operator, on a channel that proves who answered. The token is made only on
|
||||
// the operator's yes (Warranted).
|
||||
func (h *Holder) Issue(caller, node, why, overlayKey string, lasts time.Duration) (IssueAnswer, error) {
|
||||
var findings []string
|
||||
if caller == "" || caller == stdio.Unproven {
|
||||
findings = append(findings, "the caller is not proven by the runtime, and an enrolment records who asked")
|
||||
}
|
||||
if err := CheckNode(node); err != nil {
|
||||
findings = append(findings, err.Error())
|
||||
}
|
||||
if strings.TrimSpace(why) == "" {
|
||||
findings = append(findings, "no why is given: the operator is asked why this machine joins")
|
||||
}
|
||||
// The machine's tunnel key, made there by `nox-mesh-host key`: on the operator's yes the node enters placement with it, so
|
||||
// the hub peers it before the machine redeems (the operator's decision of 2026-11-02 01:05).
|
||||
if err := checkTunnelKey(overlayKey); err != nil {
|
||||
findings = append(findings, err.Error())
|
||||
}
|
||||
if lasts == 0 {
|
||||
lasts = delivery.DefaultTokenLife
|
||||
}
|
||||
if lasts < 0 || lasts > delivery.MostTokenLife {
|
||||
findings = append(findings, fmt.Sprintf("the expiry %s is outside zero to %s", lasts, delivery.MostTokenLife))
|
||||
}
|
||||
if on, reason := h.issuing(); !on {
|
||||
findings = append(findings, "issuing is off until the controller's lease is written to a holder that is no controller: "+reason+
|
||||
"; until then the controller's `token issue` is the only issuer (ADR 0587 §4)")
|
||||
}
|
||||
placements, perr := h.placements()
|
||||
if perr != nil {
|
||||
findings = append(findings, "placement cannot be read: "+perr.Error())
|
||||
}
|
||||
if CheckNode(node) == nil {
|
||||
if _, ok := placements[node]; ok {
|
||||
findings = append(findings, node+" has a placement key already")
|
||||
}
|
||||
if h.hasRecord(node) {
|
||||
findings = append(findings, node+" has a node record already")
|
||||
}
|
||||
if _, found, err := h.Store.Get(NodeKey(node)); err != nil {
|
||||
findings = append(findings, "the enrolments cannot be read: "+err.Error())
|
||||
} else if found {
|
||||
findings = append(findings, "an enrolment of "+node+" is open already: revoke it first")
|
||||
}
|
||||
}
|
||||
if perr == nil {
|
||||
if _, err := h.firstTunnel(placements, "0.0.0.0", overlayKey); err != nil {
|
||||
findings = append(findings, err.Error())
|
||||
}
|
||||
}
|
||||
if len(findings) > 0 {
|
||||
return IssueAnswer{}, fmt.Errorf("no enrolment of %q is issued: %s", node, strings.Join(findings, "; "))
|
||||
}
|
||||
now := h.now()
|
||||
e := Enrolment{Node: node, Why: strings.TrimSpace(why), Caller: caller, Stage: StageAsked, Issued: now, Expires: now.Add(lasts),
|
||||
OverlayKey: overlayKey}
|
||||
e.Ask = "enrol-" + node + "-" + now.UTC().Format("20060102t150405")
|
||||
skip := map[string]bool{}
|
||||
for {
|
||||
reserved, err := h.reservedAddresses()
|
||||
if err != nil {
|
||||
return IssueAnswer{}, err
|
||||
}
|
||||
addr, err := nextAddress(placements, reserved, skip)
|
||||
if err != nil {
|
||||
return IssueAnswer{}, fmt.Errorf("no enrolment of %q is issued: %w", node, err)
|
||||
}
|
||||
switch err := h.Store.Create(AddressKey(addr), Reservation{Node: node}, 0); {
|
||||
case err == nil:
|
||||
e.Address = addr
|
||||
case errors.Is(err, ErrExists):
|
||||
skip[addr] = true // another issue reserved it first: the next
|
||||
continue
|
||||
default:
|
||||
return IssueAnswer{}, fmt.Errorf("the address %s cannot be reserved: %w", addr, err)
|
||||
}
|
||||
break
|
||||
}
|
||||
if err := h.Store.Create(NodeKey(node), e, 0); err != nil {
|
||||
_ = h.Store.Delete(AddressKey(e.Address))
|
||||
if errors.Is(err, ErrExists) {
|
||||
return IssueAnswer{}, fmt.Errorf("no enrolment of %q is issued: an enrolment of it was opened meanwhile", node)
|
||||
}
|
||||
return IssueAnswer{}, fmt.Errorf("the enrolment of %s cannot be written: %w", node, err)
|
||||
}
|
||||
if err := h.raise(e); err != nil {
|
||||
_ = h.release(e)
|
||||
return IssueAnswer{}, err
|
||||
}
|
||||
return IssueAnswer{Node: node, Address: e.Address, Ask: e.Ask, Expires: e.Expires,
|
||||
Says: "the operator is asked on their channel; only their yes makes the token. mesh-enrolment.tokens shows where it stands"}, nil
|
||||
}
|
||||
|
||||
// release takes an enrolment's two keys away, the address key first and only while it still names this node (a key some
|
||||
// other enrolment now holds is never deleted), then the node's key, whose presence is what lets a call made again finish a
|
||||
// release that stopped. It answers what could not be deleted.
|
||||
func (h *Holder) release(e Enrolment) error {
|
||||
var errs []error
|
||||
if row, found, err := h.Store.Get(AddressKey(e.Address)); err != nil {
|
||||
errs = append(errs, fmt.Errorf("the address key %s cannot be read: %w", e.Address, err))
|
||||
} else if found {
|
||||
var r Reservation
|
||||
if json.Unmarshal(row.Value, &r) == nil && r.Node == e.Node {
|
||||
if err := h.Store.Delete(AddressKey(e.Address)); err != nil {
|
||||
errs = append(errs, fmt.Errorf("the address key %s could not be deleted: %w", e.Address, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
if err := h.Store.Delete(NodeKey(e.Node)); err != nil {
|
||||
errs = append(errs, fmt.Errorf("the key of %s could not be deleted: %w", e.Node, err))
|
||||
}
|
||||
}
|
||||
if err := errors.Join(errs...); err != nil {
|
||||
h.logf("%v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AskOf is the ask of an enrolment, in the words the operator reads: that a machine of this name may join until the
|
||||
// expiry at this address. Its yes binds the act (name, address, expiry).
|
||||
func (h *Holder) AskOf(e Enrolment) (asks.Ask, error) {
|
||||
act := asks.Act{"verb": Seat + ".issue", "module": Module, "node": e.Node, "address": e.Address,
|
||||
"expires": e.Expires.UTC().Format(time.RFC3339)}
|
||||
bind, err := asks.ActDigest(act)
|
||||
if err != nil {
|
||||
return asks.Ask{}, err
|
||||
}
|
||||
until := e.Expires.UTC().Format("2006-01-02 15:04 MST")
|
||||
closes := h.now().Add(h.AskLasts)
|
||||
if closes.After(e.Expires) {
|
||||
closes = e.Expires
|
||||
}
|
||||
return asks.Ask{ID: e.Ask, Headline: "Let the machine " + e.Node + " join the mesh?",
|
||||
Explanation: "A machine named " + e.Node + " may join the mesh until " + until + ", at the private address " + e.Address + ".\n" +
|
||||
"Your yes lets the vault make a one-time token for it; you open the token at your own desk and type it on the new machine.\n" +
|
||||
"What the machine runs is placed later, as for any machine.",
|
||||
Who: asks.Operator,
|
||||
Options: []asks.Option{{ID: "yes", Label: "Yes, let it join", Does: "The vault makes a one-time token for " + e.Node + ", valid until " + until + ".", Level: asks.Approve, Binds: bind},
|
||||
{ID: "no", Label: "No", Does: "Nothing is made; the address is free again.", Level: asks.Acknowledge}},
|
||||
Expires: closes, OnExpiry: "No token is made and the address is free again.", About: "mesh-enrolment-" + e.Node,
|
||||
Details: "Asked by " + e.Caller + ": " + e.Why}, nil
|
||||
}
|
||||
|
||||
// raise asks the operator; a question that cannot be asked is taken back at the router, since a publish that timed out may
|
||||
// have reached it, so a yes cannot land on an enrolment the holder has forgotten.
|
||||
func (h *Holder) raise(e Enrolment) error {
|
||||
ask, err := h.AskOf(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The address key names the ask and the issue's time from now, with no expiry of its own: it outlives the enrolment key,
|
||||
// so an enrolment that expires unseen can still be taken out of placement on its ask's warrant (ADR 0589 §6).
|
||||
if _, err := h.Store.Put(AddressKey(e.Address), Reservation{Node: e.Node, Ask: e.Ask, Issued: e.Issued}, 0); err != nil {
|
||||
return fmt.Errorf("the address key %s cannot name the ask %s, so the operator is not asked: %w", e.Address, e.Ask, err)
|
||||
}
|
||||
if err := h.Asker.Ask(ask); err != nil {
|
||||
if cerr := h.Asker.Cancel(ask.ID); cerr != nil {
|
||||
h.logf("the ask %s that could not be published was not taken back at the router: %v", ask.ID, cerr)
|
||||
}
|
||||
return fmt.Errorf("the operator could not be asked about %s: %w", e.Node, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// byAsk finds the open enrolment an ask belongs to.
|
||||
func (h *Holder) byAsk(ask string) (Enrolment, Row, bool, error) {
|
||||
rows, open, err := h.openEnrolments()
|
||||
if err != nil {
|
||||
return Enrolment{}, Row{}, false, err
|
||||
}
|
||||
for n, e := range open {
|
||||
if e.Ask == ask {
|
||||
return e, rows[n], true, nil
|
||||
}
|
||||
}
|
||||
return Enrolment{}, Row{}, false, nil
|
||||
}
|
||||
|
||||
// Warranted is the operator's answer, handed once by the asker (asker.Client.Listen): a no ends the enrolment; a yes records
|
||||
// the warrant, adds the node to placement with its tunnel key, asks the vault for the token, moves the key to live with the bus's expiry at the token's, and says
|
||||
// enrolment-issued (ADR 0587 §4, §5).
|
||||
func (h *Holder) Warranted(o asks.Option, w asks.Warrant) error {
|
||||
e, _, found, err := h.byAsk(w.Ask)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("the warrant answers the ask %s, which no open enrolment holds: nothing is made", w.Ask)
|
||||
}
|
||||
if e.Stage != StageAsked {
|
||||
return nil // answered already
|
||||
}
|
||||
if o.ID != "yes" {
|
||||
return h.refuse(e, "the operator declined")
|
||||
}
|
||||
if !h.now().Before(e.Expires) {
|
||||
return h.refuse(e, "the enrolment's expiry passed before the operator answered")
|
||||
}
|
||||
if h.Warrant != nil {
|
||||
if err := h.Warrant(w.Ask); err != nil {
|
||||
return fmt.Errorf("the warrant %s is not recorded in the hand-act log, so no token is made (mesh-enrolment.again asks again): %w", w.Ask, err)
|
||||
}
|
||||
}
|
||||
placements, err := h.placements()
|
||||
if err != nil {
|
||||
return fmt.Errorf("placement cannot be read, so the token's tunnel is not built (mesh-enrolment.again asks again): %w", err)
|
||||
}
|
||||
tunnel, err := h.firstTunnel(placements, e.Address, e.OverlayKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the token's tunnel is not built (mesh-enrolment.again asks again): %w", err)
|
||||
}
|
||||
// The node enters placement now, with its tunnel key, on this warrant: the hub peers it before the machine redeems over
|
||||
// the tunnel (the operator's decision of 2026-11-02 01:05). Only then is the token made.
|
||||
if err := h.place(e, placements); err != nil {
|
||||
if addRefused(err) {
|
||||
// mesh-placement refused the node itself, not the call to it: the enrolment ends (ADR 0589 §6).
|
||||
return h.refuse(e, "mesh-placement refused the node: "+err.Error())
|
||||
}
|
||||
return fmt.Errorf("the node %s is not added to placement, so no token is made (mesh-enrolment.again asks again): %w", e.Node, err)
|
||||
}
|
||||
if _, err := h.Vault.Token(e.Node, e.Expires, tunnel); err != nil {
|
||||
return fmt.Errorf("the vault did not make the token of %s (mesh-enrolment.again asks the operator again): %w", e.Node, err)
|
||||
}
|
||||
e.Stage = StageLive
|
||||
ttl := h.lifetimeOf(e.Expires)
|
||||
if _, err := h.Store.Put(NodeKey(e.Node), e, ttl); err != nil {
|
||||
return fmt.Errorf("the enrolment of %s cannot be moved to live: %w", e.Node, err)
|
||||
}
|
||||
return h.sayEvent(EventIssued, e, "")
|
||||
}
|
||||
|
||||
// addRefused says mesh-placement answered the add-node with a refusal of the proposal (the assigner's words, "the proposal is
|
||||
// refused: …"), as against a call that did not reach it or was not answered, which `again` asks once more.
|
||||
func addRefused(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "the proposal is refused")
|
||||
}
|
||||
|
||||
// dropAddress deletes the enrolment's address key while it still names this node: the first step of a confirm, a decline and a
|
||||
// refused add (ADR 0589 §6), so the catch-up never takes a key left by one of them for an expiry never heard.
|
||||
func (h *Holder) dropAddress(e Enrolment) error {
|
||||
row, found, err := h.Store.Get(AddressKey(e.Address))
|
||||
if err != nil {
|
||||
return fmt.Errorf("the address key %s cannot be read, so the end of %s waits: %w", e.Address, e.Node, err)
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
if r, err := delivery.DecodeEnrolmentAddress(row.Value); err == nil && r.Node != e.Node {
|
||||
return nil // another enrolment holds the address now
|
||||
}
|
||||
if err := h.Store.Delete(AddressKey(e.Address)); err != nil {
|
||||
return fmt.Errorf("the address key %s is not deleted, so the end of %s waits: %w", e.Address, e.Node, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refuse ends an enrolment at stage asked: both keys go and enrolment-refused carries the key's content (ADR 0587 §4).
|
||||
func (h *Holder) refuse(e Enrolment, reason string) error {
|
||||
// The address key goes first (ADR 0589 §6): a refusal stopped midway never leaves one that looks like an expiry.
|
||||
if err := h.dropAddress(e); err != nil {
|
||||
return err
|
||||
}
|
||||
// A yes whose token failed added the node already; a refusal takes it off again.
|
||||
if err := h.unplace(e, reason); err != nil {
|
||||
return fmt.Errorf("%s is not taken off placement, so its enrolment stays: %w", e.Node, err)
|
||||
}
|
||||
if err := h.sayEvent(EventRefused, e, reason); err != nil {
|
||||
return fmt.Errorf("enrolment-refused of %s cannot be said, so its keys stay: %w", e.Node, err)
|
||||
}
|
||||
return h.release(e)
|
||||
}
|
||||
|
||||
// Said is the asker's note of an ask that ended with no yes or no (expired, or refused by the router): the enrolment ends.
|
||||
func (h *Holder) Said(hd asker.Handled, w asks.Warrant, err error) {
|
||||
if err != nil {
|
||||
h.logf("the answer to %s (%s) ended in an error: %v", w.Ask, hd, err)
|
||||
}
|
||||
if hd != asker.Recorded {
|
||||
return
|
||||
}
|
||||
e, _, found, ferr := h.byAsk(w.Ask)
|
||||
if ferr != nil || !found || e.Stage != StageAsked {
|
||||
return
|
||||
}
|
||||
if rerr := h.refuse(e, "the ask ended without an answer ("+string(w.Outcome)+")"); rerr != nil {
|
||||
h.logf("%v", rerr)
|
||||
}
|
||||
}
|
||||
|
||||
// Again asks the operator once more, when the router no longer holds the ask of an enrolment that is still at stage asked
|
||||
// (ADR 0587 §8): a new ask under a new id, for the same node, address and expiry.
|
||||
func (h *Holder) Again(node string) (IssueAnswer, error) {
|
||||
row, found, err := h.Store.Get(NodeKey(node))
|
||||
if err != nil {
|
||||
return IssueAnswer{}, err
|
||||
}
|
||||
if !found {
|
||||
return IssueAnswer{}, fmt.Errorf("no enrolment of %s is open", node)
|
||||
}
|
||||
e, err := decodeEnrolment(row.Value)
|
||||
if err != nil {
|
||||
return IssueAnswer{}, err
|
||||
}
|
||||
if e.Stage != StageAsked {
|
||||
return IssueAnswer{}, fmt.Errorf("the enrolment of %s is %s, not asked: there is nothing to ask again (a redeem stopped midway is finished by the machine calling redeem again)", node, e.Stage)
|
||||
}
|
||||
if !h.now().Before(e.Expires) {
|
||||
return IssueAnswer{}, fmt.Errorf("the enrolment of %s has expired; revoke it and issue it again", node)
|
||||
}
|
||||
if h.Asker != nil {
|
||||
if state, _, werr := h.whereIs(e.Ask); werr == nil && (state == "open" || state == "held") {
|
||||
return IssueAnswer{}, fmt.Errorf("the ask %s is still %s at the router: the operator can answer it", e.Ask, state)
|
||||
}
|
||||
}
|
||||
old := e.Ask
|
||||
e.Ask = "enrol-" + node + "-" + h.now().UTC().Format("20060102t150405")
|
||||
if e.Ask == old {
|
||||
e.Ask += "b"
|
||||
}
|
||||
if _, err := h.Store.Update(NodeKey(node), e, row.Revision); err != nil {
|
||||
return IssueAnswer{}, fmt.Errorf("the enrolment of %s moved meanwhile: %w", node, err)
|
||||
}
|
||||
if err := h.raise(e); err != nil {
|
||||
return IssueAnswer{}, err
|
||||
}
|
||||
return IssueAnswer{Node: node, Address: e.Address, Ask: e.Ask, Expires: e.Expires, Says: "the operator is asked again"}, nil
|
||||
}
|
||||
|
||||
// whereIs is the router's state of an ask, when the asker can read it.
|
||||
func (h *Holder) whereIs(id string) (string, *asks.Warrant, error) {
|
||||
if w, ok := h.Asker.(interface {
|
||||
Where(string) (string, *asks.Warrant, error)
|
||||
}); ok {
|
||||
return w.Where(id)
|
||||
}
|
||||
return "", nil, errors.New("the asker cannot read the router's record")
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asker"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
var yes = asks.Option{ID: "yes"}
|
||||
|
||||
// 774/S55/AC1: issue refuses, naming every finding, what it must: the lease with the controller, a bad name, a name a node
|
||||
// record, a placement key or an open enrolment holds, and an expiry past a day.
|
||||
func issueRefusesNamingEveryFinding(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
setup func(r *rig)
|
||||
node string
|
||||
lasts time.Duration
|
||||
want []string
|
||||
}{
|
||||
{"the lease names the controller", func(r *rig) { r.rd["mesh-controller.lease"]["holder"] = `{"instance":"novox.mesh-controller"}` }, "ace", 0,
|
||||
[]string{"issuing is off until the controller's lease is written", "token issue"}},
|
||||
{"a bad name and a long expiry", nil, "Ace.1", 48 * time.Hour, []string{"not a valid node name", "outside zero to 24h0m0s"}},
|
||||
{"a placement key and a node record", func(r *rig) {
|
||||
r.rd["mesh-delivery-assigner.placement"]["ace.node"] = `{"v":1,"node":"ace","modules":[]}`
|
||||
r.rd["node-engine.have"]["ace"] = `{}`
|
||||
}, "ace", 0, []string{"ace has a placement key already"}},
|
||||
{"an open enrolment", func(r *rig) { r.seed("ace", "10.9.0.2", StageLive) }, "ace", 0, []string{"an enrolment of ace is open already"}},
|
||||
{"no tunnel key and no range", func(r *rig) {
|
||||
r.rd["mesh-delivery-assigner.placement"]["hub.node"] = strings.Replace(hubPlacement, `,"range":"10.9.0.0/24"`, "", 1)
|
||||
}, "ace", -1, []string{"no tunnel key is given", "names no tunnel range"}},
|
||||
// The token carries the first tunnel (ADR 0587 §3, insight of 2026-11-02): no hub, or a hub with no key or endpoint.
|
||||
{"no hub", func(r *rig) {
|
||||
r.rd["mesh-delivery-assigner.placement"]["hub.node"] = strings.Replace(hubPlacement, `"hub":true,`, "", 1)
|
||||
}, "ace", 0, []string{"placement names no hub"}},
|
||||
{"a hub with no key and no endpoint", func(r *rig) {
|
||||
r.rd["mesh-delivery-assigner.placement"]["hub.node"] = strings.Replace(hubPlacement, `"endpoint":"192.0.2.1:51820",`, "", 1)
|
||||
delete(r.rd["node-engine.have"], "hub.node")
|
||||
}, "ace", 0, []string{"the hub hub has no tunnel key in its record and no endpoint in its placement"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := newRig(t)
|
||||
if c.setup != nil {
|
||||
c.setup(r)
|
||||
}
|
||||
key, lasts := tunnelKey, c.lasts
|
||||
if lasts == -1 {
|
||||
key, lasts = "", 0
|
||||
}
|
||||
_, err := r.h.Issue("person.operator", c.node, "a new machine", key, lasts)
|
||||
if err == nil {
|
||||
t.Fatalf("%s: issued", c.name)
|
||||
}
|
||||
for _, w := range c.want {
|
||||
mustContain(t, err.Error(), w)
|
||||
}
|
||||
if len(r.asker.asked) != 0 || strings.Contains(strings.Join(r.keys(), " "), "address.10.9.0.3") {
|
||||
t.Errorf("%s: asked %v, keys %v", c.name, r.asker.asked, r.keys())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 774/S55/AC2: two issues at once for two nodes hold two addresses; a yes makes the token, moves both keys to live with the
|
||||
// token's expiry, records the warrant, says enrolment-issued, and a second answer changes nothing.
|
||||
func twoIssuesAtOnceHoldTwoAddressesAndAYesMakesTheToken(t *testing.T) {
|
||||
r := newRig(t)
|
||||
var wg sync.WaitGroup
|
||||
addrs := make([]string, 2)
|
||||
for i, n := range []string{"ace", "g14"} {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
a, err := r.h.Issue("person.operator", n, "a new machine", tunnelKey, 0)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
addrs[i] = a.Address
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if addrs[0] == "" || addrs[0] == addrs[1] || addrs[0] == "10.9.0.1" || addrs[1] == "10.9.0.1" {
|
||||
t.Fatalf("addresses %v", addrs)
|
||||
}
|
||||
e, _ := r.enrolment("ace")
|
||||
if e.Stage != StageAsked || e.Caller != "person.operator" || e.Ask == "" || len(r.asker.asked) != 2 {
|
||||
t.Fatalf("%+v, asked %v", e, r.asker.asked)
|
||||
}
|
||||
ask, _ := r.h.AskOf(e)
|
||||
if ask.Options[0].Level != asks.Approve || ask.Options[0].Binds == "" || ask.Check(r.now) != nil {
|
||||
t.Errorf("the ask does not authorise the act: %+v, %v", ask.Options[0], ask.Check(r.now))
|
||||
}
|
||||
if err := r.h.Warranted(yes, asks.Warrant{Ask: e.Ask}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
live, _ := r.enrolment("ace")
|
||||
if live.Stage != StageLive || r.st.ttl[NodeKey("ace")] != time.Hour || r.st.ttl[AddressKey(live.Address)] != 0 ||
|
||||
len(r.warr) != 1 || r.warr[0] != e.Ask || r.events()[0] != EventIssued {
|
||||
t.Errorf("live %+v, ttl %v/%v, warrants %v, events %v", live, r.st.ttl[NodeKey("ace")], r.st.ttl[AddressKey(live.Address)], r.warr, r.events())
|
||||
}
|
||||
// The address key names the ask and the issue, with no expiry of its own, so it outlives the enrolment key (ADR 0589 §6).
|
||||
if row, _, _ := r.st.Get(AddressKey(live.Address)); true {
|
||||
if res, err := delivery.DecodeEnrolmentAddress(row.Value); err != nil || res.Ask != e.Ask || !res.Issued.Equal(live.Issued) {
|
||||
t.Errorf("the address key %s: %+v, %v", row.Value, res, err)
|
||||
}
|
||||
}
|
||||
if got, want := r.vault.tunnels["ace"], (delivery.TokenTunnel{Key: tunnelKey, Address: live.Address + "/32", Range: "10.9.0.0/24",
|
||||
HubKey: "hub-tunnel-key", HubEndpoint: "192.0.2.1:51820"}); got != want {
|
||||
t.Errorf("the token's tunnel %+v, want %+v", got, want)
|
||||
}
|
||||
// The yes added the node to placement with its tunnel key alone, on the warrant, before the token was made.
|
||||
if p, tok := slices.Index(r.vault.calls, "add-node ace"), slices.Index(r.vault.calls, "token ace"); p < 0 || tok < p {
|
||||
t.Errorf("the token was made before placement held the node: %v", r.vault.calls)
|
||||
}
|
||||
for _, a := range r.added {
|
||||
if a.Change.Node == "ace" && (*a.Change.Keys != delivery.JoiningKeys{Overlay: tunnelKey} || a.Warrant.Ask != e.Ask || a.Change.Decided.Address != live.Address) {
|
||||
t.Errorf("the add-node at the yes %+v", a)
|
||||
}
|
||||
}
|
||||
if strings.Contains(string(r.said[0].Body), "secret") || r.said[0].ID != eventID(EventIssued, live) {
|
||||
t.Errorf("the event %s carries a secret-like word or no stable id", r.said[0].Body)
|
||||
}
|
||||
if err := r.h.Warranted(yes, asks.Warrant{Ask: e.Ask}); err != nil || len(r.said) != 1 {
|
||||
t.Errorf("a second answer said %v: %v", r.events(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// 774/S55/AC3: a declined or expired ask, heard, takes both keys away, and enrolment-refused carries the key's content; a
|
||||
// vault that fails leaves the enrolment at asked, for `again` to ask once more.
|
||||
func aDeclinedOrExpiredAskTakesBothKeysAndAVaultFailureKeepsTheEnrolmentForAgain(t *testing.T) {
|
||||
r := newRig(t)
|
||||
a, _ := r.h.Issue("person.operator", "ace", "a new machine", tunnelKey, 0)
|
||||
if err := r.h.Warranted(asks.Option{ID: "no"}, asks.Warrant{Ask: a.Ask}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(r.keys()) != 0 || r.events()[0] != EventRefused || len(r.added) != 0 {
|
||||
t.Fatalf("keys %v events %v placement %v", r.keys(), r.events(), r.added)
|
||||
}
|
||||
mustContain(t, string(r.said[0].Body), `"address":"`+a.Address+`"`)
|
||||
b, _ := r.h.Issue("person.operator", "g14", "a new machine", tunnelKey, 0)
|
||||
r.h.Said(asker.Recorded, asks.Warrant{Ask: b.Ask, Outcome: asks.OutcomeExpired}, nil)
|
||||
if len(r.keys()) != 0 || r.events()[1] != EventRefused {
|
||||
t.Fatalf("keys %v events %v", r.keys(), r.events())
|
||||
}
|
||||
c, _ := r.h.Issue("person.operator", "novox", "a new machine", tunnelKey, 0)
|
||||
r.vault.fail["token novox"] = true
|
||||
if err := r.h.Warranted(yes, asks.Warrant{Ask: c.Ask}); err == nil {
|
||||
t.Fatal("a vault that failed was taken for a token")
|
||||
}
|
||||
if e, _ := r.enrolment("novox"); e.Stage != StageAsked {
|
||||
t.Fatalf("stage %s", e.Stage)
|
||||
}
|
||||
// The yes placed the node before the vault failed: a refusal now would take it off again (decline, below for ace).
|
||||
if _, placed := r.rd["mesh-delivery-assigner.placement"]["novox.node"]; !placed {
|
||||
t.Fatal("the yes did not place novox before the vault was asked")
|
||||
}
|
||||
r.now = r.now.Add(time.Minute)
|
||||
again, err := r.h.Again("novox")
|
||||
if err != nil || again.Ask == c.Ask || len(r.asker.asked) != 4 {
|
||||
t.Fatalf("again %+v, %v, asked %v", again, err, r.asker.asked)
|
||||
}
|
||||
if err := r.h.Warranted(yes, asks.Warrant{Ask: again.Ask}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e, _ := r.enrolment("novox"); e.Stage != StageLive {
|
||||
t.Errorf("stage %s", e.Stage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// memStore is the holder's bucket in memory: revisions, and the expiry each key was written with.
|
||||
type memStore struct {
|
||||
mu sync.Mutex
|
||||
rows map[string]Row
|
||||
ttl map[string]time.Duration
|
||||
rev uint64
|
||||
// failDelete names keys whose delete fails once.
|
||||
failDelete map[string]bool
|
||||
}
|
||||
|
||||
func newMem() *memStore {
|
||||
return &memStore{rows: map[string]Row{}, ttl: map[string]time.Duration{}, failDelete: map[string]bool{}}
|
||||
}
|
||||
|
||||
func (m *memStore) write(key string, v any, ttl time.Duration) uint64 {
|
||||
raw, err := Encode(v)
|
||||
if err != nil {
|
||||
panic(err) // a test wrote a value the contract refuses
|
||||
}
|
||||
m.rev++
|
||||
m.rows[key] = Row{Value: raw, Revision: m.rev}
|
||||
m.ttl[key] = ttl
|
||||
return m.rev
|
||||
}
|
||||
|
||||
func (m *memStore) Get(key string) (Row, bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
r, ok := m.rows[key]
|
||||
return r, ok, nil
|
||||
}
|
||||
|
||||
func (m *memStore) Create(key string, v any, ttl time.Duration) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.rows[key]; ok {
|
||||
return ErrExists
|
||||
}
|
||||
m.write(key, v, ttl)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) Update(key string, v any, revision uint64) (uint64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if r, ok := m.rows[key]; !ok || r.Revision != revision {
|
||||
return 0, ErrChanged
|
||||
}
|
||||
return m.write(key, v, 0), nil
|
||||
}
|
||||
|
||||
func (m *memStore) Put(key string, v any, ttl time.Duration) (uint64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.write(key, v, ttl), nil
|
||||
}
|
||||
|
||||
func (m *memStore) Delete(key string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.failDelete[key] {
|
||||
delete(m.failDelete, key)
|
||||
return errors.New("the bus did not answer")
|
||||
}
|
||||
delete(m.rows, key)
|
||||
delete(m.ttl, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) Keys() ([]string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []string
|
||||
for k := range m.rows {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// memRead is other modules' states, `<module>.<state>` then key.
|
||||
type memRead map[string]map[string]string
|
||||
|
||||
func (r memRead) Keys(state string) ([]string, error) {
|
||||
var out []string
|
||||
for k := range r[state] {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r memRead) Get(state, key string) ([]byte, bool, error) {
|
||||
v, ok := r[state][key]
|
||||
return []byte(v), ok, nil
|
||||
}
|
||||
|
||||
// fakeVault records its calls in order and fails on the verbs named in fail, once each.
|
||||
type fakeVault struct {
|
||||
calls []string
|
||||
fail map[string]bool
|
||||
tunnels map[string]delivery.TokenTunnel
|
||||
cert delivery.JoiningKeys // the keys the certificate's call carried
|
||||
}
|
||||
|
||||
func (v *fakeVault) do(what string) error {
|
||||
v.calls = append(v.calls, what)
|
||||
if v.fail[what] {
|
||||
delete(v.fail, what)
|
||||
return errors.New("the vault did not answer " + what)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *fakeVault) Token(node string, expires time.Time, tunnel delivery.TokenTunnel) (delivery.EnrolmentTokenAnswer, error) {
|
||||
if v.tunnels == nil {
|
||||
v.tunnels = map[string]delivery.TokenTunnel{}
|
||||
}
|
||||
v.tunnels[node] = tunnel
|
||||
return delivery.EnrolmentTokenAnswer{Node: node, Broker: "b", Fingerprint: "f", Signer: "s"}, v.do("token " + node)
|
||||
}
|
||||
func (v *fakeVault) Sealed(node string) (string, error) {
|
||||
return "sealed-for-" + node, v.do("sealed " + node)
|
||||
}
|
||||
func (v *fakeVault) Drop(node string) error { return v.do("drop " + node) }
|
||||
func (v *fakeVault) Account(node, sealing, digest string) (delivery.NodeAccountAnswer, error) {
|
||||
return delivery.NodeAccountAnswer{Node: node, Login: "node." + node, Account: "account-" + node}, v.do("account " + node)
|
||||
}
|
||||
func (v *fakeVault) Retire(node string) error { return v.do("retire " + node) }
|
||||
func (v *fakeVault) SignCertificate(node string, k delivery.JoiningKeys) (delivery.SignAnswer, error) {
|
||||
serving := k.Serving
|
||||
v.cert = k
|
||||
return delivery.SignAnswer{Kind: delivery.KindNodeCertificate, Node: node, Certificate: "PEM-" + serving}, v.do("certificate " + node)
|
||||
}
|
||||
func (v *fakeVault) SignReply(node string, reply []byte) (delivery.SignAnswer, error) {
|
||||
return delivery.SignAnswer{Kind: delivery.KindEnrolment, Node: node, Signature: "sig-" + delivery.DigestOf(reply)[7:15], Signer: "fp"}, v.do("sign " + node)
|
||||
}
|
||||
func (v *fakeVault) Signer() (string, error) { return "vault-signer", nil }
|
||||
|
||||
type fakeAsker struct {
|
||||
mu sync.Mutex
|
||||
asked, cancelled []string
|
||||
err error
|
||||
answers map[string]string // the router's record: the option each ask was answered with
|
||||
}
|
||||
|
||||
// answer records that the operator answered ask with option, as the router keeps it.
|
||||
func (a *fakeAsker) answer(ask, option string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.answers == nil {
|
||||
a.answers = map[string]string{}
|
||||
}
|
||||
a.answers[ask] = option
|
||||
}
|
||||
|
||||
func (a *fakeAsker) Where(id string) (string, *asks.Warrant, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if o, ok := a.answers[id]; ok {
|
||||
return "answered", &asks.Warrant{Ask: id, Option: o}, nil
|
||||
}
|
||||
return "", nil, nil // no record read: as a router whose record of the ask is gone
|
||||
}
|
||||
|
||||
func (a *fakeAsker) Ask(k asks.Ask) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.err != nil {
|
||||
return a.err
|
||||
}
|
||||
a.asked = append(a.asked, k.ID)
|
||||
return nil
|
||||
}
|
||||
func (a *fakeAsker) Cancel(id string) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.cancelled = append(a.cancelled, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
type said struct {
|
||||
Event, ID string
|
||||
Body json.RawMessage
|
||||
}
|
||||
|
||||
// rig is a holder over fakes, with the lease handed to mesh-grants, one hub in placement and a clock the test moves.
|
||||
type rig struct {
|
||||
mu sync.Mutex
|
||||
t *testing.T
|
||||
h *Holder
|
||||
st *memStore
|
||||
vault *fakeVault
|
||||
asker *fakeAsker
|
||||
rd memRead
|
||||
now time.Time
|
||||
said []said
|
||||
added []AddNode
|
||||
acts []string
|
||||
warr []string
|
||||
loaded bool
|
||||
placeErr error
|
||||
}
|
||||
|
||||
// hubRecord is the hub's own record in node-engine.have: its tunnel key, which the token's tunnel names.
|
||||
const hubRecord = `{"v":1,"record":{"v":1,"node":"hub","tunnel-key":"hub-tunnel-key"}}`
|
||||
|
||||
// tunnelKey is a machine's tunnel public key as `nox-mesh-host key` prints it.
|
||||
const tunnelKey = "dHVubmVsLWtleS1vZi10aGUtbmV3LW1hY2hpbmUtMDE="
|
||||
|
||||
const hubPlacement = `{"v":1,"node":"hub","decided":{"hub":true,"endpoint":"192.0.2.1:51820","address":"10.9.0.1","tunnel":{"interface":"wg0","address":"10.9.0.1/24","range":"10.9.0.0/24"}},"modules":[]}`
|
||||
|
||||
func newRig(t *testing.T) *rig {
|
||||
r := &rig{t: t, st: newMem(), vault: &fakeVault{fail: map[string]bool{}}, asker: &fakeAsker{}, now: time.Date(2026, 11, 2, 10, 0, 0, 0, time.UTC), loaded: true}
|
||||
r.rd = memRead{
|
||||
"mesh-controller.lease": {"holder": `{"handed-to":"mesh-grants","handed-by":"operator"}`},
|
||||
"mesh-delivery-assigner.placement": {"hub.node": hubPlacement},
|
||||
"node-engine.have": {"hub.node": hubRecord},
|
||||
}
|
||||
r.h = &Holder{Store: r.st, Read: r.rd, Vault: r.vault, Asker: r.asker, AskLasts: time.Hour,
|
||||
Say: func(event string, body any, id string) error {
|
||||
raw, _ := json.Marshal(body)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.said = append(r.said, said{event, id, raw})
|
||||
return nil
|
||||
},
|
||||
AddNode: func(a AddNode) error {
|
||||
r.added = append(r.added, a)
|
||||
r.vault.calls = append(r.vault.calls, a.Change.Op+" "+a.Change.Node)
|
||||
if r.placeErr == nil { // placement writes or deletes the node as the assigner does on the warrant
|
||||
n := a.Change.Node
|
||||
if a.Change.Op == delivery.OpRemoveNode {
|
||||
delete(r.rd["mesh-delivery-assigner.placement"], n+".node")
|
||||
} else {
|
||||
r.rd["mesh-delivery-assigner.placement"][n+".node"] = `{"v":1,"node":"` + n + `","decided":{"address":"` + a.Change.Decided.Address + `"},"modules":[]}`
|
||||
}
|
||||
}
|
||||
return r.placeErr
|
||||
},
|
||||
Warrant: func(ask string) error { r.warr = append(r.warr, ask); return nil },
|
||||
WarrantOf: func(ask string) (string, error) { return ask + "@2026-11-02T10:00:00Z", nil },
|
||||
Act: func(what, why, cause string) error { r.acts = append(r.acts, what); return nil },
|
||||
Loaded: func(string) (bool, error) { return r.loaded, nil },
|
||||
Now: func() time.Time { return r.now },
|
||||
Logf: func(f string, a ...any) { t.Logf(f, a...) }}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *rig) events() []string {
|
||||
var out []string
|
||||
for _, s := range r.said {
|
||||
out = append(out, s.Event)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *rig) enrolment(node string) (Enrolment, bool) {
|
||||
row, ok, _ := r.st.Get(NodeKey(node))
|
||||
if !ok {
|
||||
return Enrolment{}, false
|
||||
}
|
||||
e, err := decodeEnrolment(row.Value)
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
return e, true
|
||||
}
|
||||
|
||||
func (r *rig) keys() []string { k, _ := r.st.Keys(); return k }
|
||||
|
||||
func (r *rig) has(key string) bool { return slices.Contains(r.keys(), key) }
|
||||
|
||||
func mustContain(t *testing.T, got, want string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("%q does not contain %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func errText(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(err)
|
||||
}
|
||||
|
||||
func askWarrant(ask string) asks.Warrant { return asks.Warrant{Ask: ask} }
|
||||
@@ -0,0 +1,179 @@
|
||||
// mesh-enrolment: the holder of the seat mesh-enrolment (novox/hq ADR 0587). A Go bundle served by a runtime of its own,
|
||||
// on its own bus account (`runs-as`), so that its account alone writes its bucket `enrolments` with per-key expiry. It
|
||||
// asks the operator on their channel, the vault to mint and sign, mesh-placement to add the node and mesh-hand-acts to
|
||||
// record the acts. stdout is the MCP channel; what this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asker"
|
||||
"git.novox.be/novox/mesh-sdk/go/handacts"
|
||||
)
|
||||
|
||||
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
|
||||
// runtimeStore is the holder's bucket, through the runtime that launched this bundle.
|
||||
type runtimeStore struct{}
|
||||
|
||||
func (runtimeStore) Get(key string) (Row, bool, error) {
|
||||
e, err := stdio.State(State).Get(key)
|
||||
if err != nil || e == nil {
|
||||
return Row{}, false, err
|
||||
}
|
||||
return Row{Value: e.Value, Revision: e.Revision}, true, nil
|
||||
}
|
||||
|
||||
func (runtimeStore) Create(key string, v any, ttl time.Duration) error {
|
||||
raw, err := Encode(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ttl > 0 {
|
||||
_, err = stdio.State(State).CreateTTL(key, raw, ttl)
|
||||
} else {
|
||||
_, err = stdio.State(State).Create(key, raw)
|
||||
}
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
return ErrExists
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (runtimeStore) Update(key string, v any, revision uint64) (uint64, error) {
|
||||
raw, err := Encode(v)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rev, err := stdio.State(State).Update(key, raw, revision)
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
return 0, ErrChanged
|
||||
}
|
||||
return rev, err
|
||||
}
|
||||
|
||||
func (runtimeStore) Put(key string, v any, ttl time.Duration) (uint64, error) {
|
||||
raw, err := Encode(v)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if ttl > 0 {
|
||||
return stdio.State(State).PutTTL(key, raw, ttl)
|
||||
}
|
||||
return stdio.State(State).Put(key, raw)
|
||||
}
|
||||
|
||||
func (runtimeStore) Delete(key string) error { return stdio.State(State).Delete(key) }
|
||||
func (runtimeStore) Keys() ([]string, error) { return stdio.State(State).Keys() }
|
||||
|
||||
// runtimeReader reads another module's state through the runtime.
|
||||
type runtimeReader struct{}
|
||||
|
||||
func (runtimeReader) Keys(state string) ([]string, error) { return stdio.State(state).Keys() }
|
||||
|
||||
func (runtimeReader) Get(state, key string) ([]byte, bool, error) {
|
||||
e, err := stdio.State(state).Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return e.Value, true, nil
|
||||
}
|
||||
|
||||
// SeatEventSubject is where the seat says an event: mesh.seat.<seat>.event.<name>.
|
||||
func SeatEventSubject(event string) string { return "mesh.seat." + Seat + ".event." + event }
|
||||
|
||||
// newHolder is the holder over the runtime.
|
||||
func newHolder() *Holder {
|
||||
call := Call(stdio.Ask)
|
||||
rec := handacts.Recorder{Ask: stdio.Ask}
|
||||
rd := runtimeReader{}
|
||||
client := asker.New("asked")
|
||||
return &Holder{
|
||||
Store: runtimeStore{}, Read: rd, Vault: vaultOver{call: call, read: rd}, Asker: client,
|
||||
Say: func(event string, body any, id string) error {
|
||||
return stdio.SeatPublish(SeatEventSubject(event), body, id)
|
||||
},
|
||||
AddNode: addNodeOver(call),
|
||||
WarrantOf: warrantOf(client),
|
||||
Warrant: func(ask string) error { _, err := rec.RecordWarrant(Module, ask); return err },
|
||||
Act: func(what, why, cause string) error {
|
||||
_, err := rec.Record(handacts.Request{What: what, Why: why, Cause: cause})
|
||||
return err
|
||||
},
|
||||
Loaded: loadedVia(rd),
|
||||
Now: time.Now,
|
||||
Logf: func(f string, a ...any) { logf("["+Module+"] "+f, a...) },
|
||||
AskLasts: 55 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// warrantOf is the warrant id the teller writes, `<ask>@<when>`, read from the router's record of the ask.
|
||||
func warrantOf(c *asker.Client) func(string) (string, error) {
|
||||
return func(ask string) (string, error) {
|
||||
state, w, err := c.Where(ask)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if w == nil {
|
||||
return "", fmt.Errorf("the router records the ask %s as %q, with no warrant", ask, state)
|
||||
}
|
||||
return ask + "@" + w.At.UTC().Format(time.RFC3339Nano), nil
|
||||
}
|
||||
}
|
||||
|
||||
// loadedVia says a bus user is loaded when the grants part's last pass lists it among the composed users and the bus node's
|
||||
// loaded list holds it (its credentials row says loaded).
|
||||
func loadedVia(rd Reader) func(string) (bool, error) {
|
||||
return func(user string) (bool, error) {
|
||||
raw, found, err := rd.Get("mesh-grants.user-list", "pass")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !found {
|
||||
return false, errors.New("the grants part has composed no list yet")
|
||||
}
|
||||
// The key holds the part's whole pass result (mesh-grants Result): the list state sits under user_list.
|
||||
var pass struct {
|
||||
UserList struct {
|
||||
Compare struct {
|
||||
Unread string `json:"unread"`
|
||||
} `json:"compare"`
|
||||
Credentials struct {
|
||||
Users []struct {
|
||||
User string `json:"user"`
|
||||
InShadow bool `json:"in_shadow"`
|
||||
Loaded bool `json:"loaded"`
|
||||
} `json:"users"`
|
||||
} `json:"credentials"`
|
||||
} `json:"user_list"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &pass); err != nil {
|
||||
return false, fmt.Errorf("the grants part's list state does not read: %w", err)
|
||||
}
|
||||
st := pass.UserList
|
||||
if st.Compare.Unread != "" {
|
||||
return false, errors.New("the bus node's loaded list cannot be read: " + st.Compare.Unread)
|
||||
}
|
||||
for _, u := range st.Credentials.Users {
|
||||
if u.User == user {
|
||||
return u.InShadow && u.Loaded, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
h := newHolder()
|
||||
client := h.Asker.(*asker.Client)
|
||||
h.Run(func() error { return client.Listen(h.Warranted, h.Said) })
|
||||
if err := stdio.Serve("", Tools(h)); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Redeem is the joining node-engine's one call (ADR 0587 §5, §6). It answers only a caller the runtime proves to be
|
||||
// enrol.<node>, for a key at stage live (or at redeeming with the same keys' digest), checks the proof against the identity key
|
||||
// presented, and then, each step read back and none repeated that is done: spends the token (the key to redeeming, by
|
||||
// compare-and-set, with no expiry) once placement holds the node (written on the operator's yes; the vault reads the node's
|
||||
// address there) and the tunnel key presented is the one issued for, asks the vault for the node's account and the certificate
|
||||
// of the keys presented,
|
||||
// waits for the bus node to load node.<node>, and answers the whole, signed by the vault. Called again with the same keys it
|
||||
// finishes what is not done and answers the same.
|
||||
func (h *Holder) Redeem(caller string, a delivery.Redeem) (delivery.RedeemAnswer, error) {
|
||||
if err := CheckNode(a.Node); err != nil {
|
||||
return delivery.RedeemAnswer{}, err
|
||||
}
|
||||
if caller != EnrolUser(a.Node) {
|
||||
return delivery.RedeemAnswer{}, fmt.Errorf("only %s may redeem the enrolment of %s, and the runtime proved the caller to be %q", EnrolUser(a.Node), a.Node, caller)
|
||||
}
|
||||
pub, err := base64.StdEncoding.DecodeString(a.PublicKey)
|
||||
proof, perr := a.ProofBytes()
|
||||
if err != nil || perr != nil || len(pub) != ed25519.PublicKeySize ||
|
||||
!ed25519.Verify(pub, delivery.RedeemProof(a.Node, a.PublicKey, a.OverlayKey, a.SealingKey, a.ServingKey), proof) {
|
||||
return delivery.RedeemAnswer{}, errors.New("the proof does not verify against the identity key presented: the enrolment is not spent")
|
||||
}
|
||||
if a.SealingKey == "" {
|
||||
return delivery.RedeemAnswer{}, errors.New("no sealing key is presented, so the account cannot be sealed to the node: the enrolment is not spent")
|
||||
}
|
||||
digest := a.KeysDigest()
|
||||
e, err := h.spend(a.Node, digest, a.OverlayKey)
|
||||
if err != nil {
|
||||
return delivery.RedeemAnswer{}, err
|
||||
}
|
||||
account, err := h.Vault.Account(e.Node, a.SealingKey, digest)
|
||||
if err != nil {
|
||||
return delivery.RedeemAnswer{}, fmt.Errorf("the vault did not give the account of %s (call redeem again with the same keys): %w", e.Node, err)
|
||||
}
|
||||
cert, err := h.Vault.SignCertificate(e.Node, delivery.JoiningKeys{Identity: a.PublicKey, Overlay: a.OverlayKey, Sealing: a.SealingKey, Serving: a.ServingKey})
|
||||
if err != nil {
|
||||
return delivery.RedeemAnswer{}, fmt.Errorf("the vault did not sign the certificate of %s (call redeem again with the same keys): %w", e.Node, err)
|
||||
}
|
||||
if h.Loaded != nil {
|
||||
loaded, err := h.Loaded(NodeUser(e.Node))
|
||||
if err != nil {
|
||||
return delivery.RedeemAnswer{}, fmt.Errorf("whether the bus node loaded %s cannot be read (call redeem again with the same keys): %w", NodeUser(e.Node), err)
|
||||
}
|
||||
if !loaded {
|
||||
return delivery.RedeemAnswer{}, fmt.Errorf("the bus node has not loaded %s yet (call redeem again with the same keys)", NodeUser(e.Node))
|
||||
}
|
||||
}
|
||||
signer, err := h.Vault.Signer()
|
||||
if err != nil {
|
||||
return delivery.RedeemAnswer{}, fmt.Errorf("the vault's signing key cannot be named (call redeem again with the same keys): %w", err)
|
||||
}
|
||||
out := delivery.RedeemAnswer{Node: e.Node, Login: account.Login, Account: account.Account, Certificate: cert.Certificate, Signer: signer}
|
||||
if out.Login == "" {
|
||||
out.Login = NodeUser(e.Node)
|
||||
}
|
||||
signed, err := h.Vault.SignReply(e.Node, out.SignedBytes())
|
||||
if err != nil {
|
||||
return delivery.RedeemAnswer{}, fmt.Errorf("the vault did not sign the answer (call redeem again with the same keys): %w", err)
|
||||
}
|
||||
out.Signature = signed.Signature
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// spend moves the key from live to redeeming with the presented keys' digest, by compare-and-set, before its expiry; at
|
||||
// redeeming it only checks the digest. A key that moved meanwhile is read again once.
|
||||
func (h *Holder) spend(node, digest, overlay string) (Enrolment, error) {
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
row, found, err := h.Store.Get(NodeKey(node))
|
||||
if err != nil {
|
||||
return Enrolment{}, fmt.Errorf("the enrolment of %s cannot be read: %w", node, err)
|
||||
}
|
||||
if !found {
|
||||
return Enrolment{}, fmt.Errorf("no enrolment of %s is open: it was never issued, has ended or has expired", node)
|
||||
}
|
||||
e, err := decodeEnrolment(row.Value)
|
||||
if err != nil {
|
||||
return Enrolment{}, err
|
||||
}
|
||||
// The tunnel key presented is the one the operator approved at issue, which placement and the hub already hold.
|
||||
if overlay != e.OverlayKey {
|
||||
return Enrolment{}, fmt.Errorf("the tunnel key presented is not the one %s's enrolment was issued for: the enrolment is not spent", node)
|
||||
}
|
||||
// The node entered placement on the operator's yes; the vault reads its address there.
|
||||
if placements, err := h.placements(); err != nil {
|
||||
return Enrolment{}, fmt.Errorf("placement cannot be read (call redeem again with the same keys): %w", err)
|
||||
} else if p, ok := placements[node]; !ok || p.Decided.Address != e.Address {
|
||||
return Enrolment{}, fmt.Errorf("placement does not hold %s at %s yet (call redeem again with the same keys)", node, e.Address)
|
||||
}
|
||||
switch e.Stage {
|
||||
case StageRedeeming:
|
||||
if e.KeysDigest != digest {
|
||||
return Enrolment{}, fmt.Errorf("the enrolment of %s was redeemed with other keys: a token is spent once", node)
|
||||
}
|
||||
return e, nil
|
||||
case StageAsked:
|
||||
return Enrolment{}, fmt.Errorf("the enrolment of %s waits for the operator's yes: no token exists yet", node)
|
||||
}
|
||||
if !h.now().Before(e.Expires) {
|
||||
return Enrolment{}, fmt.Errorf("the enrolment of %s expired at %s", node, e.Expires.UTC().Format(time.RFC3339))
|
||||
}
|
||||
e.Stage, e.KeysDigest = StageRedeeming, digest
|
||||
if _, err := h.Store.Update(NodeKey(node), e, row.Revision); err != nil {
|
||||
if errors.Is(err, ErrChanged) {
|
||||
continue
|
||||
}
|
||||
return Enrolment{}, fmt.Errorf("the enrolment of %s cannot be spent: %w", node, err)
|
||||
}
|
||||
// The move to redeeming rewrites the key without an expiry, so a redeem under way never loses it (§5); the address key
|
||||
// never had one.
|
||||
return e, nil
|
||||
}
|
||||
return Enrolment{}, fmt.Errorf("the enrolment of %s kept changing: call redeem again", node)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// redeemArgs are a node's redeem call with a proof signed by the key seeded, over the keys given.
|
||||
func redeemArgs(node string, seed byte, serving string) delivery.Redeem {
|
||||
return redeemArgsWith(node, seed, serving, tunnelKey)
|
||||
}
|
||||
|
||||
// redeemArgsWith is redeemArgs presenting the tunnel key given.
|
||||
func redeemArgsWith(node string, seed byte, serving, overlay string) delivery.Redeem {
|
||||
priv := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{seed}, ed25519.SeedSize))
|
||||
a := delivery.Redeem{Node: node, PublicKey: base64.StdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey)),
|
||||
OverlayKey: overlay, SealingKey: "sealing-" + node, ServingKey: serving}
|
||||
a.Proof = base64.StdEncoding.EncodeToString(ed25519.Sign(priv, delivery.RedeemProof(node, a.PublicKey, a.OverlayKey, a.SealingKey, a.ServingKey)))
|
||||
return a
|
||||
}
|
||||
|
||||
// 774/S56/AC1: a redeem from another caller, with a bad proof, after the expiry, or with other keys than the first, is
|
||||
// refused (one row each), and a good one spends the token, writes the node into placement on the issue's warrant and
|
||||
// answers once the bus node loaded the account.
|
||||
func redeemRefusesWhatItMustAndSpendsTheTokenOnce(t *testing.T) {
|
||||
good := redeemArgs("ace", 1, "serving-a")
|
||||
bad := good
|
||||
raw, _ := good.ProofBytes()
|
||||
raw[0] ^= 1
|
||||
bad.Proof = base64.StdEncoding.EncodeToString(raw)
|
||||
cases := []struct {
|
||||
name string
|
||||
caller string
|
||||
args delivery.Redeem
|
||||
setup func(r *rig)
|
||||
want string
|
||||
}{
|
||||
{"another caller", "enrol.g14", good, nil, "only enrol.ace may redeem"},
|
||||
{"a bad proof", "enrol.ace", bad, nil, "proof does not verify"},
|
||||
{"after the expiry", "enrol.ace", good, func(r *rig) { r.now = r.now.Add(2 * time.Hour) }, "expired at"},
|
||||
{"other keys than the first", "enrol.ace", redeemArgs("ace", 2, "serving-b"), func(r *rig) {
|
||||
e, _ := r.enrolment("ace")
|
||||
e.Stage, e.KeysDigest = StageRedeeming, good.KeysDigest()
|
||||
_, _ = r.st.Put(NodeKey("ace"), e, 0)
|
||||
}, "spent once"},
|
||||
{"before the operator's yes", "enrol.ace", good, func(r *rig) {
|
||||
e, _ := r.enrolment("ace")
|
||||
e.Stage = StageAsked
|
||||
_, _ = r.st.Put(NodeKey("ace"), e, 0)
|
||||
}, "waits for the operator's yes"},
|
||||
{"no enrolment", "enrol.novox", redeemArgs("novox", 3, "s"), nil, "no enrolment of novox is open"},
|
||||
{"another tunnel key than issued", "enrol.ace", func() delivery.Redeem {
|
||||
priv := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize))
|
||||
a := good
|
||||
a.OverlayKey = "another-tunnel-key"
|
||||
a.Proof = base64.StdEncoding.EncodeToString(ed25519.Sign(priv, delivery.RedeemProof("ace", a.PublicKey, a.OverlayKey, a.SealingKey, a.ServingKey)))
|
||||
return a
|
||||
}(), nil, "not the one ace's enrolment was issued for"},
|
||||
{"not in placement yet", "enrol.ace", good, func(r *rig) { delete(r.rd["mesh-delivery-assigner.placement"], "ace.node") }, "placement does not hold ace"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := newRig(t)
|
||||
r.seed("ace", "10.9.0.2", StageLive)
|
||||
if c.setup != nil {
|
||||
c.setup(r)
|
||||
}
|
||||
_, err := r.h.Redeem(c.caller, c.args)
|
||||
if err == nil {
|
||||
t.Errorf("%s: redeemed", c.name)
|
||||
continue
|
||||
}
|
||||
mustContain(t, err.Error(), c.want)
|
||||
if len(r.vault.calls) != 0 || len(r.added) != 0 {
|
||||
t.Errorf("%s: the vault %v or placement %v was called", c.name, r.vault.calls, r.added)
|
||||
}
|
||||
}
|
||||
r := newRig(t)
|
||||
r.seed("ace", "10.9.0.2", StageLive)
|
||||
r.st.ttl[NodeKey("ace")] = time.Hour
|
||||
ans, err := r.h.Redeem("enrol.ace", good)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e, _ := r.enrolment("ace")
|
||||
if e.Stage != StageRedeeming || e.KeysDigest != good.KeysDigest() || r.st.ttl[NodeKey("ace")] != 0 {
|
||||
t.Errorf("spent %+v, ttl %v", e, r.st.ttl[NodeKey("ace")])
|
||||
}
|
||||
// Redeem writes no placement: the yes did. It binds the identity keys, which the vault checks against the account's digest.
|
||||
if len(r.added) != 0 || ans.Signature == "" || ans.Account != "account-ace" || ans.Login != "node.ace" || ans.Signer != "vault-signer" || ans.Certificate != "PEM-serving-a" {
|
||||
t.Errorf("placement %+v, answer %+v", r.added, ans)
|
||||
}
|
||||
// The certificate's call carries every key presented, which the vault checks against the account's digest.
|
||||
if r.vault.cert.Digest() != good.KeysDigest() {
|
||||
t.Errorf("the certificate was asked with %+v", r.vault.cert)
|
||||
}
|
||||
r.loaded = false
|
||||
if _, err := r.h.Redeem("enrol.ace", good); err == nil {
|
||||
t.Error("answered before the bus node loaded the account")
|
||||
}
|
||||
}
|
||||
|
||||
// 774/S56/AC2: a redeem stopped after the vault's call, or after the placement call, called again with the same keys,
|
||||
// finishes and answers the same, and the vault minted once (it is asked for the same account).
|
||||
func aRedeemStoppedMidwayIsFinishedByTheSameKeysAgain(t *testing.T) {
|
||||
good := redeemArgs("ace", 1, "serving-a")
|
||||
whole := newRig(t)
|
||||
whole.seed("ace", "10.9.0.2", StageLive)
|
||||
want, err := whole.h.Redeem("enrol.ace", good)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, stop := range []string{"certificate ace", "sign ace"} {
|
||||
r := newRig(t)
|
||||
r.seed("ace", "10.9.0.2", StageLive)
|
||||
r.vault.fail[stop] = true
|
||||
if _, err := r.h.Redeem("enrol.ace", good); err == nil {
|
||||
t.Fatalf("%s: no error", stop)
|
||||
}
|
||||
got, err := r.h.Redeem("enrol.ace", good)
|
||||
if err != nil || got != want {
|
||||
t.Errorf("%s: %v, answer %+v, want %+v", stop, err, got, want)
|
||||
}
|
||||
if len(r.added) != 0 {
|
||||
t.Errorf("redeem wrote placement %d times", len(r.added))
|
||||
}
|
||||
}
|
||||
// Placement that already holds the node at the approved address is not written again.
|
||||
r := newRig(t)
|
||||
r.seed("ace", "10.9.0.2", StageRedeeming)
|
||||
e, _ := r.enrolment("ace")
|
||||
e.KeysDigest = good.KeysDigest()
|
||||
_, _ = r.st.Put(NodeKey("ace"), e, 0)
|
||||
r.rd["mesh-delivery-assigner.placement"]["ace.node"] = `{"v":1,"node":"ace","decided":{"address":"10.9.0.2"},"modules":[]}`
|
||||
if _, err := r.h.Redeem("enrol.ace", good); err != nil || len(r.added) != 0 {
|
||||
t.Errorf("%v, placement written %d times", err, len(r.added))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Run starts what the holder does beside its tools, none of it on a timer: the watch of its own bucket (the bus's expiry
|
||||
// markers), the durable consumer of its own ending events (the drop), and the operator's answers. Each is asked of the
|
||||
// runtime as soon as it serves, and again with a growing wait until the runtime takes it.
|
||||
func (h *Holder) Run(listen func() error) {
|
||||
go func() {
|
||||
stdio.State(State).Follow("", func(c stdio.StateChange) error {
|
||||
err := h.Heard(c.Op, c.Key, c.Value)
|
||||
if err != nil && c.Op == stdio.OpExpire {
|
||||
// An expiry whose end stopped is finished by a later pass (ADR 0589 §6).
|
||||
go retry("finish the enrolments that expired", h.logf, h.Reconcile)
|
||||
}
|
||||
return err
|
||||
})
|
||||
// Caught up: an enrolment that expired while the holder was down is ended now.
|
||||
retry("end the enrolments that expired unseen", h.logf, h.Reconcile)
|
||||
}()
|
||||
for _, event := range EndEvents {
|
||||
go func(key string) {
|
||||
<-stdio.Served()
|
||||
retry("hear "+key, h.logf, func() error {
|
||||
return stdio.Subscribe(key, func(e stdio.Envelope) error { return h.Ended(key, e.Body) })
|
||||
})
|
||||
}(Seat + "." + event)
|
||||
}
|
||||
if listen != nil {
|
||||
go func() {
|
||||
<-stdio.Served()
|
||||
retry("hear the operator's answers", h.logf, listen)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func retry(what string, logf func(string, ...any), do func() error) {
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
err := do()
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
logf("does not %s yet (%v); asking again in %s", what, err, wait)
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"user_list": {
|
||||
"v": 1,
|
||||
"mode": "shadow",
|
||||
"inputs": "",
|
||||
"digest": "",
|
||||
"users": 0,
|
||||
"left_out": null,
|
||||
"compare": {
|
||||
"shadow": 0,
|
||||
"loaded": 0,
|
||||
"added": null,
|
||||
"dropped": null,
|
||||
"differ": null,
|
||||
"same": 0
|
||||
},
|
||||
"credentials": {
|
||||
"users": [
|
||||
{
|
||||
"user": "node.ace",
|
||||
"in_shadow": true,
|
||||
"loaded": true,
|
||||
"hash_held": true,
|
||||
"generation": 1,
|
||||
"would": "same"
|
||||
},
|
||||
{
|
||||
"user": "node.g14",
|
||||
"in_shadow": true,
|
||||
"loaded": false,
|
||||
"hash_held": true,
|
||||
"generation": 1,
|
||||
"would": "adds"
|
||||
}
|
||||
],
|
||||
"hashes_held": 2,
|
||||
"left_out": [],
|
||||
"loses_login": [],
|
||||
"adds": [
|
||||
"node.g14"
|
||||
]
|
||||
},
|
||||
"gaps": null,
|
||||
"at": "0001-01-01T00:00:00Z"
|
||||
},
|
||||
"revision": 0,
|
||||
"written": false,
|
||||
"memberships": 0,
|
||||
"memberships_written": 0,
|
||||
"assertions": 0,
|
||||
"assertions_written": 0,
|
||||
"published_or_asserted": ""
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TokenRow is one open enrolment as `tokens` answers it (ADR 0587 §8): its stage, expiry, ask and revision, and for a node
|
||||
// at stage live the token sealed to the operator key as the vault sealed it (the holder never reads it and keeps it nowhere).
|
||||
type TokenRow struct {
|
||||
Node string `json:"node"`
|
||||
Address string `json:"address"`
|
||||
Why string `json:"why"`
|
||||
Caller string `json:"caller"`
|
||||
Stage string `json:"stage"`
|
||||
Ask string `json:"ask,omitempty"`
|
||||
Issued time.Time `json:"issued"`
|
||||
Expires time.Time `json:"expires"`
|
||||
Age string `json:"age"`
|
||||
Revision uint64 `json:"revision"`
|
||||
Keys string `json:"keys-digest,omitempty"`
|
||||
Sealed string `json:"sealed,omitempty"`
|
||||
// Unsealed says why no sealed token is given for a node that should have one.
|
||||
Unsealed string `json:"unsealed,omitempty"`
|
||||
}
|
||||
|
||||
// Issuing is whether issue is on, and why.
|
||||
type Issuing struct {
|
||||
On bool `json:"on"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// Fill is how full the bucket is: its keys and the bytes of their values (the state's cap is the bus's, which no bundle
|
||||
// can read, so the fill is stated without it).
|
||||
type Fill struct {
|
||||
Keys int `json:"keys"`
|
||||
Bytes int `json:"bytes"`
|
||||
}
|
||||
|
||||
// TokensAnswer is what `tokens` answers.
|
||||
type TokensAnswer struct {
|
||||
Open []TokenRow `json:"open"`
|
||||
Issuing Issuing `json:"issuing"`
|
||||
Fill Fill `json:"fill"`
|
||||
History []json.RawMessage `json:"history,omitempty"`
|
||||
// HistoryFrom says where the history comes from, when one was asked for.
|
||||
HistoryFrom string `json:"history-from,omitempty"`
|
||||
}
|
||||
|
||||
// Tokens answers one open enrolment or all, with the bucket's fill, whether issuing is on and why, and with history the
|
||||
// ended enrolments the holder's own events say (ADR 0587 §8).
|
||||
func (h *Holder) Tokens(node string, history bool) (TokensAnswer, error) {
|
||||
rows, open, err := h.openEnrolments()
|
||||
if err != nil {
|
||||
return TokensAnswer{}, err
|
||||
}
|
||||
var out TokensAnswer
|
||||
on, why := h.issuing()
|
||||
out.Issuing = Issuing{On: on, Why: why}
|
||||
if keys, err := h.Store.Keys(); err == nil {
|
||||
out.Fill.Keys = len(keys)
|
||||
for _, k := range keys {
|
||||
if r, found, err := h.Store.Get(k); err == nil && found {
|
||||
out.Fill.Bytes += len(r.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if node != "" {
|
||||
if _, ok := open[node]; !ok && !history {
|
||||
return TokensAnswer{}, fmt.Errorf("no enrolment of %s is open: tokens without a node lists those that are", node)
|
||||
}
|
||||
}
|
||||
names := make([]string, 0, len(open))
|
||||
for n := range open {
|
||||
if node == "" || n == node {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
out.Open = []TokenRow{}
|
||||
for _, n := range names {
|
||||
e := open[n]
|
||||
row := TokenRow{Node: e.Node, Address: e.Address, Why: e.Why, Caller: e.Caller, Stage: e.Stage, Ask: e.Ask,
|
||||
Issued: e.Issued, Expires: e.Expires, Age: h.now().Sub(e.Issued).Round(time.Second).String(),
|
||||
Revision: rows[n].Revision, Keys: e.KeysDigest}
|
||||
if e.Stage == StageLive && node != "" {
|
||||
// Asked of the vault at this moment and relayed as the vault sealed it: kept nowhere here (ADR 0587 §8).
|
||||
sealed, err := h.Vault.Sealed(n)
|
||||
if err != nil {
|
||||
row.Unsealed = "the vault did not give the token: " + err.Error()
|
||||
} else {
|
||||
row.Sealed = sealed
|
||||
}
|
||||
}
|
||||
out.Open = append(out.Open, row)
|
||||
}
|
||||
if history {
|
||||
h.mu.Lock()
|
||||
for _, e := range h.ended {
|
||||
if node == "" || endedNode(e) == node {
|
||||
out.History = append(out.History, e)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
out.HistoryFrom = "the end events this holder heard since it started; the bus keeps them 7 days, and a bundle cannot read that stream"
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func endedNode(raw json.RawMessage) string {
|
||||
var e struct {
|
||||
Node string `json:"node"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &e)
|
||||
return e.Node
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// seed writes an open enrolment of node at stage, with its address key, as the holder would have.
|
||||
func (r *rig) seed(node, address string, stage string) {
|
||||
e := Enrolment{Node: node, Address: address, Why: "a new machine", Caller: "person.operator", Ask: "ask-" + node,
|
||||
Stage: stage, Issued: r.now.Add(-time.Minute), Expires: r.now.Add(time.Hour), OverlayKey: tunnelKey}
|
||||
if stage == StageRedeeming {
|
||||
e.KeysDigest = delivery.KeysDigest("pk", "ov", "se", "sv")
|
||||
}
|
||||
_, _ = r.st.Put(NodeKey(node), e, 0)
|
||||
_, _ = r.st.Put(AddressKey(address), Reservation{Node: node, Ask: e.Ask, Issued: e.Issued}, 0)
|
||||
if stage != StageAsked { // the operator's yes placed it, with its tunnel key
|
||||
r.rd["mesh-delivery-assigner.placement"][node+".node"] = `{"v":1,"node":"` + node + `","decided":{"address":"` + address + `"},"modules":[]}`
|
||||
}
|
||||
}
|
||||
|
||||
// 774/S54/AC1 and AC2: tokens answers each stage with its expiry, ask and revision, the fill and whether issuing is on;
|
||||
// for one node at live it relays the vault's sealed token and keeps nothing; and the manifest says what the code does.
|
||||
func tokensAnswersEachStageAndRelaysTheSealedTokenOfOneNode(t *testing.T) {
|
||||
r := newRig(t)
|
||||
r.seed("ace", "10.9.0.2", StageAsked)
|
||||
r.seed("g14", "10.9.0.3", StageLive)
|
||||
r.seed("novox", "10.9.0.4", StageRedeeming)
|
||||
all, err := r.h.Tokens("", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stages []string
|
||||
for _, row := range all.Open {
|
||||
stages = append(stages, row.Node+":"+string(row.Stage))
|
||||
if row.Revision == 0 || row.Expires.IsZero() || row.Ask == "" || row.Sealed != "" {
|
||||
t.Errorf("row %+v lacks revision, expiry or ask, or relays a token for a list of all", row)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(stages, []string{"ace:asked", "g14:live", "novox:redeeming"}) || all.Fill.Keys != 6 || !all.Issuing.On {
|
||||
t.Errorf("tokens %v, fill %+v, issuing %+v", stages, all.Fill, all.Issuing)
|
||||
}
|
||||
one, err := r.h.Tokens("g14", false)
|
||||
if err != nil || len(one.Open) != 1 || one.Open[0].Sealed != "sealed-for-g14" {
|
||||
t.Fatalf("one node: %+v, %v", one, err)
|
||||
}
|
||||
for k, row := range r.st.rows {
|
||||
if slices.Contains(r.vault.calls, "sealed g14") && string(row.Value) != "" {
|
||||
var any map[string]json.RawMessage
|
||||
_ = json.Unmarshal(row.Value, &any)
|
||||
if _, has := any["sealed"]; has {
|
||||
t.Errorf("the key %s holds a sealed token", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Before the lease write issuing is off, and says why.
|
||||
r.rd["mesh-controller.lease"]["holder"] = `{"instance":"novox.mesh-controller"}`
|
||||
off, _ := r.h.Tokens("", false)
|
||||
mustContain(t, off.Issuing.Why, "controller holds the lease")
|
||||
if off.Issuing.On {
|
||||
t.Error("issuing is on while the controller holds the lease")
|
||||
}
|
||||
}
|
||||
|
||||
func theManifestSaysWhatTheCodeDoes(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Runs string `json:"runs-as"`
|
||||
Consumes []string `json:"consumes"`
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Serves []string `json:"serves"`
|
||||
} `json:"claims"`
|
||||
Emits []string `json:"emits"`
|
||||
State []json.RawMessage `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Module != Module || m.Runs != Module || len(m.Claims) != 1 || m.Claims[0].Name != Seat || m.Claims[0].Scope != "mesh" ||
|
||||
!slices.Equal(m.Claims[0].Serves, Verbs) || len(m.Emits) != 0 {
|
||||
t.Errorf("module %q runs as %q, claims %+v, emits %v", m.Module, m.Runs, m.Claims, m.Emits)
|
||||
}
|
||||
var heard []string
|
||||
for _, e := range EndEvents {
|
||||
heard = append(heard, Seat+"."+e)
|
||||
}
|
||||
if !slices.Equal(m.Consumes, heard) {
|
||||
t.Errorf("consumes %v, the end events are %v", m.Consumes, heard)
|
||||
}
|
||||
served := map[string]bool{}
|
||||
for _, tool := range Tools(&Holder{}) {
|
||||
served[tool.Name] = true
|
||||
}
|
||||
for _, v := range Verbs {
|
||||
if !served[Seat+"."+v] {
|
||||
t.Errorf("the verb %s has no tool", v)
|
||||
}
|
||||
}
|
||||
if len(m.State) != 2 || string(m.State[0]) == "" || !slices.ContainsFunc(m.State, func(s json.RawMessage) bool {
|
||||
var d struct {
|
||||
Name string `json:"name"`
|
||||
TTL bool `json:"key-ttl"`
|
||||
}
|
||||
return json.Unmarshal(s, &d) == nil && d.Name == State && d.TTL
|
||||
}) {
|
||||
t.Errorf("state %s: enrolments must allow a time to live per key", m.State)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Tool names: the seat's verbs, as the manifest claims them (ADR 0587 §1, §8).
|
||||
const (
|
||||
IssueTool = Seat + ".issue"
|
||||
AgainTool = Seat + ".again"
|
||||
RedeemTool = Seat + ".redeem"
|
||||
ConfirmTool = Seat + ".confirm"
|
||||
RevokeTool = Seat + ".revoke"
|
||||
TokensTool = Seat + ".tokens"
|
||||
)
|
||||
|
||||
func obj(props map[string]any, required ...string) map[string]any {
|
||||
m := map[string]any{"type": "object", "properties": props}
|
||||
if len(required) > 0 {
|
||||
m["required"] = required
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func str(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
|
||||
|
||||
func arg(a map[string]any, k string) string {
|
||||
s, _ := a[k].(string)
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func flag(a map[string]any, k string) bool {
|
||||
return a[k] == true || strings.EqualFold(arg(a, k), "true")
|
||||
}
|
||||
|
||||
// lastsOf reads an expiry as a duration (delivery.TokenLife); none is the default.
|
||||
func lastsOf(s string) (time.Duration, error) { return delivery.TokenLife(s) }
|
||||
|
||||
// Tools are what the runtime serves.
|
||||
func Tools(h *Holder) []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{Name: IssueTool,
|
||||
Description: "Open an enrolment: let a new machine join the mesh (novox/hq ADR 0587 §4). Takes the node's name, a why and an " +
|
||||
"expiry (one hour unless given, at most 24 hours). Refused at once, naming every finding, for a name that is no 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 " +
|
||||
"written to a holder that is no controller. Otherwise it chooses the lowest free private-network address, reserves it, " +
|
||||
"writes the enrolment at stage asked and asks the operator on a channel that proves who answered; only their yes adds the " +
|
||||
"node to placement with the machine's tunnel key (overlay_key, as `nox-mesh-host key` prints it there), so the hub peers " +
|
||||
"it, and then makes the token, which carries the first tunnel (the vault makes it, this seat never holds it). The caller is the one the runtime proves.",
|
||||
Input: obj(map[string]any{"node": str("the new machine's name"), "why": str("why this machine joins"),
|
||||
"expires": str("how long the token lasts, e.g. 1h (default) up to 24h"),
|
||||
"overlay_key": str("the new machine's tunnel public key, as `nox-mesh-host key` prints it there")}, "node", "why", "overlay_key"),
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
lasts, err := lastsOf(arg(a, "expires"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h.Issue(stdio.CallerOf(a), arg(a, "node"), arg(a, "why"), arg(a, "overlay_key"), lasts)
|
||||
}},
|
||||
{Name: RedeemTool,
|
||||
Description: "The joining machine's one call (novox/hq ADR 0587 §5, §6), answered only for a caller the runtime proves to be " +
|
||||
"enrol.<node>: its node-engine presents the public halves of the keys it made where it runs and a proof signed with its " +
|
||||
"identity key, and the tunnel key it was issued for. The token is spent (once, with those keys) once placement holds the " +
|
||||
"node (placed on the operator's yes), the vault gives the node's account and signs its certificate, and the answer is given once the bus node loaded node.<node>, " +
|
||||
"signed by the vault. Called again with the same keys it finishes what is not done and answers the same.",
|
||||
Input: obj(map[string]any{"node": str("the machine's name"), "public_key": str("its identity key, base64"),
|
||||
"overlay_key": str("its private-network key"), "sealing_key": str("its sealing key, base64"),
|
||||
"serving_key": str("its serving key"), "proof": str("the proof, base64")}, "node", "public_key", "proof"),
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
var args delivery.Redeem
|
||||
raw, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return nil, fmt.Errorf("the redeem call does not read: %w", err)
|
||||
}
|
||||
return h.Redeem(stdio.CallerOf(a), args)
|
||||
}},
|
||||
{Name: ConfirmTool,
|
||||
Description: "The new machine's confirmation (novox/hq ADR 0587 §6), answered only for the node dialled as its own login " +
|
||||
"node.<node> after it redeemed: the runtime's proof of that caller is the confirmation. Says node-enrolled and closes " +
|
||||
"the enrolment; the vault is then asked to drop the token.",
|
||||
Input: obj(map[string]any{"node": str("the machine's own name (optional)")}),
|
||||
Run: func(a map[string]any) (any, error) { return h.Confirm(stdio.CallerOf(a), arg(a, "node")) }},
|
||||
{Name: RevokeTool,
|
||||
Description: "End an enrolment at any stage (novox/hq ADR 0587 §5): at asked it withdraws the ask; at live or redeeming it deletes " +
|
||||
"the key and its address key, and at redeeming the vault retires an account it already minted; a node the yes placed " +
|
||||
"and that never confirmed is taken off placement again. It only takes access away, so it needs no Approve; it is recorded as an act done by hand. Says " +
|
||||
"enrolment-revoked; the vault then drops the token. A machine stuck at redeeming is ended by this. A revoke that stopped is " +
|
||||
"finished by calling it again.",
|
||||
Input: obj(map[string]any{"node": str("the machine"), "why": str("why, kept with the act")}, "node", "why"),
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
return h.Revoke(stdio.CallerOf(a), arg(a, "node"), arg(a, "why"))
|
||||
}},
|
||||
{Name: AgainTool,
|
||||
Description: "Ask the operator again about an enrolment still at stage asked whose ask the router no longer holds open " +
|
||||
"(novox/hq ADR 0587 §8): a new ask for the same node, address and expiry. Nothing more: a redeem stopped midway is finished " +
|
||||
"by the machine calling redeem again with the same keys.",
|
||||
Input: obj(map[string]any{"node": str("the machine")}, "node"),
|
||||
Run: func(a map[string]any) (any, error) { return h.Again(arg(a, "node")) }},
|
||||
{Name: TokensTool,
|
||||
Description: "What the seat holds (novox/hq ADR 0587 §8): every open enrolment, or one node's, with its stage (asked, live or " +
|
||||
"redeeming), address, expiry, ask, revision and age; whether issuing is on and why (it is on only from the controller's " +
|
||||
"lease write); the bucket's fill. With a node at stage live, the token sealed to the operator key, asked of the vault at " +
|
||||
"this moment and relayed as the vault sealed it: the one route by which the operator gets a token, which this seat never " +
|
||||
"reads and keeps nowhere. With history=true the ended enrolments its events say. A machine stuck at redeeming shows here " +
|
||||
"with its age; revoke ends it. Read-only.",
|
||||
Input: obj(map[string]any{"node": str("one node"), "history": str("\"true\": the ended enrolments")}),
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
return h.Tokens(arg(a, "node"), flag(a, "history"))
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Seats the holder calls (ADR 0587 §3, §5, §7). The vault's two verbs are the ones its manifest names this seat the only
|
||||
// caller of; its `sign` names this seat for a node's certificate and a redeem answer.
|
||||
const (
|
||||
vaultToken = "seat:mesh-vault." + delivery.EnrolmentTokenVerb
|
||||
vaultAccount = "seat:mesh-vault." + delivery.NodeAccountVerb
|
||||
vaultSign = "seat:mesh-vault." + delivery.SignVerb
|
||||
placementAdd = "seat:mesh-placement.propose"
|
||||
)
|
||||
|
||||
// Call asks a seat verb by its address (stdio.Ask through the runtime, or a test's).
|
||||
type Call func(key string, body any) (json.RawMessage, error)
|
||||
|
||||
// vaultOver is the vault as the holder reaches it: the seat's verbs over a Call, and its public signing key from its state.
|
||||
type vaultOver struct {
|
||||
call Call
|
||||
read Reader
|
||||
}
|
||||
|
||||
func answer[T any](raw json.RawMessage, err error) (T, error) {
|
||||
var out T
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if uerr := json.Unmarshal(raw, &out); uerr != nil {
|
||||
return out, fmt.Errorf("the vault's answer does not read: %w", uerr)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (v vaultOver) Token(node string, expires time.Time, t delivery.TokenTunnel) (delivery.EnrolmentTokenAnswer, error) {
|
||||
// The whole first tunnel the token carries (delivery.TokenTunnel, catalogue's enrolment-token).
|
||||
tunnel := map[string]any{"hub_key": t.HubKey, "hub_endpoint": t.HubEndpoint, "address": t.Address, "key": t.Key, "range": t.Range}
|
||||
return answer[delivery.EnrolmentTokenAnswer](v.call(vaultToken, map[string]any{"node": node,
|
||||
"expires": expires.UTC().Format(time.RFC3339), "tunnel": tunnel}))
|
||||
}
|
||||
|
||||
func (v vaultOver) Sealed(node string) (string, error) {
|
||||
a, err := answer[delivery.EnrolmentTokenAnswer](v.call(vaultToken, map[string]any{"node": node, "sealed": "true"}))
|
||||
if err == nil && a.Sealed == "" {
|
||||
err = errors.New("the vault gave no sealed token")
|
||||
}
|
||||
return a.Sealed, err
|
||||
}
|
||||
|
||||
func (v vaultOver) Drop(node string) error {
|
||||
a, err := answer[delivery.EnrolmentTokenAnswer](v.call(vaultToken, map[string]any{"node": node, "drop": "true"}))
|
||||
if err == nil && !a.Dropped {
|
||||
err = errors.New("the vault did not say the token and its hash are gone")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (v vaultOver) Account(node, sealingKey, keysDigest string) (delivery.NodeAccountAnswer, error) {
|
||||
return answer[delivery.NodeAccountAnswer](v.call(vaultAccount, map[string]any{"node": node, "sealing_key": sealingKey, "keys_digest": keysDigest}))
|
||||
}
|
||||
|
||||
func (v vaultOver) Retire(node string) error {
|
||||
a, err := answer[delivery.NodeAccountAnswer](v.call(vaultAccount, map[string]any{"node": node, "retire": "true"}))
|
||||
if err == nil && !a.Retired {
|
||||
err = errors.New("the vault did not say the account is retired")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (v vaultOver) SignCertificate(node string, k delivery.JoiningKeys) (delivery.SignAnswer, error) {
|
||||
return answer[delivery.SignAnswer](v.call(vaultSign, map[string]any{"kind": delivery.KindNodeCertificate, "node": node,
|
||||
"public_key": k.Identity, "overlay_key": k.Overlay, "sealing_key": k.Sealing, "serving_key": k.Serving}))
|
||||
}
|
||||
|
||||
func (v vaultOver) SignReply(node string, reply []byte) (delivery.SignAnswer, error) {
|
||||
return answer[delivery.SignAnswer](v.call(vaultSign, map[string]any{"kind": delivery.KindEnrolment, "node": node,
|
||||
"reply": base64.StdEncoding.EncodeToString(reply)}))
|
||||
}
|
||||
|
||||
func (v vaultOver) Signer() (string, error) {
|
||||
raw, found, err := v.read.Get(delivery.SignersRead, delivery.SignersKeyKey)
|
||||
if err != nil || !found {
|
||||
return "", fmt.Errorf("the vault's signing key is not readable in %s: %v", delivery.SignersRead, err)
|
||||
}
|
||||
k, err := delivery.DecodeSignerKey(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return k.Key, nil
|
||||
}
|
||||
|
||||
// addNodeOver asks mesh-placement to add the node with the issue's warrant (ADR 0587 §7).
|
||||
func addNodeOver(call Call) func(AddNode) error {
|
||||
return func(a AddNode) error {
|
||||
_, err := call(placementAdd, map[string]any{"why": a.Why, "changes": []any{a.Change}, "warrant": a.Warrant})
|
||||
if err != nil {
|
||||
return fmt.Errorf("mesh-placement did not take the node %s: %w", a.Change.Node, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
module mesh-enrolment
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
github.com/minio/highwayhash v1.0.4 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 h1:a33dsavoaqllh40eVICbhxi6Kero1EGSjt1V+2fa2Hk=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101151945-b2a27ed5cec4 h1:xeuMBwOHNKrVUdGVg7Bdv5tkdueBvngwJFyk6n10rp0=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101151945-b2a27ed5cec4/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21 h1:4I4ayo0fZYP4PBfEX1sGDDd6arnw5IQJySJ3jcFobPM=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
|
||||
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
|
||||
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"module": "mesh-enrolment",
|
||||
"version": "1",
|
||||
"runs-as": "mesh-enrolment",
|
||||
"claims": [
|
||||
{
|
||||
"name": "mesh-enrolment",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"issue",
|
||||
"redeem",
|
||||
"confirm",
|
||||
"revoke",
|
||||
"tokens",
|
||||
"again"
|
||||
]
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-enrolment.node-enrolled",
|
||||
"mesh-enrolment.enrolment-refused",
|
||||
"mesh-enrolment.enrolment-revoked",
|
||||
"mesh-enrolment.enrolment-expired"
|
||||
],
|
||||
"uses": [
|
||||
"operator-channel"
|
||||
],
|
||||
"invokes": [
|
||||
"seat:mesh-hand-acts.hand-act",
|
||||
"seat:mesh-hand-acts.warranted",
|
||||
"seat:mesh-placement.propose",
|
||||
"seat:mesh-vault.enrolment-token",
|
||||
"seat:mesh-vault.node-account",
|
||||
"seat:mesh-vault.sign"
|
||||
],
|
||||
"reads": [
|
||||
"mesh-controller.lease",
|
||||
"mesh-delivery-assigner.placement",
|
||||
"mesh-grants.user-list",
|
||||
"mesh-vault.signers",
|
||||
"node-engine.have"
|
||||
],
|
||||
"state": [
|
||||
{
|
||||
"name": "enrolments",
|
||||
"key-ttl": true
|
||||
},
|
||||
{
|
||||
"name": "asked",
|
||||
"history": 5
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:state}/broker"
|
||||
},
|
||||
"secrets-owner": "mesh-enrolment",
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "mesh-enrolment",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/mesh-enrolment"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-enrolment",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-enrolment",
|
||||
"binary": "mesh-enrolment",
|
||||
"loads": [
|
||||
"mesh-enrolment"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// The three bus adapters for mesh-sdk go/asker on the part's own credential, as mesh-delivery-teller has them (copied: the
|
||||
@@ -81,3 +82,16 @@ func sayVia(ctx context.Context, js jetstream.JetStream, module string) func(c a
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// seatSaying says the seat mesh-grants' events on its seat subject into EVENTS, as the module that holds it (novox/hq
|
||||
// ADR 0582 clauses 3, 4, 6): lease-handed, lease-altered and removal-failed.
|
||||
func seatSaying(ctx context.Context, js jetstream.JetStream) func(event string, body any) error {
|
||||
return func(event string, body any) error {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = js.Publish(ctx, "mesh.seat."+catalogue.GrantsSeat+".event."+event, data)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asker"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
|
||||
"mesh-grants/internal/grants"
|
||||
)
|
||||
|
||||
// standIns are the seats and tools the lease write asks, standing in on the real bus's side: node-service-manager on
|
||||
// the controller's node (a unit's ActiveState, as the systemd module answers it), the bus module's expiry tool acting on
|
||||
// the real lease bucket's stream (as nats-tools' SetBucketExpiry does), and the checks answering green.
|
||||
type standIns struct {
|
||||
ctx context.Context
|
||||
js jetstream.JetStream
|
||||
mu sync.Mutex
|
||||
active string
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (s *standIns) Call(key string, body any) (json.RawMessage, error) {
|
||||
s.mu.Lock()
|
||||
s.calls = append(s.calls, key)
|
||||
s.mu.Unlock()
|
||||
base, _, _ := strings.Cut(key, "@")
|
||||
args, _ := body.(map[string]any)
|
||||
var out any
|
||||
switch base {
|
||||
case grants.CallServiceStop:
|
||||
s.active, out = "inactive", map[string]any{"ok": true}
|
||||
case grants.CallServiceStart:
|
||||
s.active, out = "active", map[string]any{"ok": true}
|
||||
case grants.CallServiceStatus:
|
||||
out = map[string]any{"ActiveState": s.active}
|
||||
case grants.CallBucketExpiry:
|
||||
now := fmt.Sprint(args["max_age"])
|
||||
d, err := time.ParseDuration(strings.Replace(now, "none", "0s", 1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st, err := s.js.Stream(s.ctx, "KV_"+fmt.Sprint(args["bucket"]))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := st.CachedInfo().Config
|
||||
cfg.MaxAge = d
|
||||
if d > 0 && (cfg.Duplicates == 0 || cfg.Duplicates > d) {
|
||||
cfg.Duplicates = d
|
||||
}
|
||||
if _, err := s.js.UpdateStream(s.ctx, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = map[string]any{"bucket": args["bucket"], "now": now, "changed": true}
|
||||
case grants.CallIssue:
|
||||
out = map[string]any{"status": "resolved"}
|
||||
case grants.CallNodeTrusts:
|
||||
out = map[string]any{"every": true, "not_yet": []string{}}
|
||||
case grants.CallConfigCompare:
|
||||
out = map[string]any{"source": "indexer", "count": 0}
|
||||
case grants.CallUpgrade:
|
||||
out = map[string]any{"empty": true, "splits": []any{}}
|
||||
case grants.CallVaultStatus:
|
||||
out = map[string]any{"ready": true, "minting": map[string]any{"on": true, "held_back": map[string]string{}}}
|
||||
case grants.CallStreams:
|
||||
out = map[string]any{"detail": []any{}}
|
||||
case grants.CallUsers:
|
||||
out = map[string]any{"users": []any{}}
|
||||
case grants.CallRecordWarrant, grants.CallRecordHandAct:
|
||||
out = map[string]any{}
|
||||
default:
|
||||
return nil, fmt.Errorf("no stand-in for %s", key)
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
// memStore is the part's own state, in memory, with its time to live per key recorded.
|
||||
type memStore struct {
|
||||
mu sync.Mutex
|
||||
vals map[string][]byte
|
||||
revs map[string]uint64
|
||||
}
|
||||
|
||||
func (m *memStore) Get(state, key string) ([]byte, uint64, bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
v, ok := m.vals[state+"/"+key]
|
||||
return v, m.revs[state+"/"+key], ok, nil
|
||||
}
|
||||
|
||||
func (m *memStore) Put(state, key string, value []byte) (uint64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.vals[state+"/"+key] = append([]byte(nil), value...)
|
||||
m.revs[state+"/"+key]++
|
||||
return m.revs[state+"/"+key], nil
|
||||
}
|
||||
|
||||
func (m *memStore) PutTTL(state, key string, value []byte, _ time.Duration) (uint64, error) {
|
||||
return m.Put(state, key, value)
|
||||
}
|
||||
|
||||
func (m *memStore) Keys(state string) ([]string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []string
|
||||
for k := range m.vals {
|
||||
if s, key, _ := strings.Cut(k, "/"); s == state {
|
||||
out = append(out, key)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *memStore) Delete(state, key string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.vals, state+"/"+key)
|
||||
return nil
|
||||
}
|
||||
|
||||
type memObjects struct{}
|
||||
|
||||
func (memObjects) PutObject(string, []byte) error { return nil }
|
||||
func (memObjects) DeleteObject(string) error { return nil }
|
||||
|
||||
// yesAsker answers every ask the part raises with the operator's yes, as the router's warrant hands it.
|
||||
type yesAsker struct{ asked []string }
|
||||
|
||||
func (a *yesAsker) Ask(q asks.Ask) error { a.asked = append(a.asked, q.ID); return nil }
|
||||
func (a *yesAsker) Where(string) (string, *asks.Warrant, error) { return "open", nil, nil }
|
||||
func (a *yesAsker) Cancel(string) error { return nil }
|
||||
func (a *yesAsker) Take(w asks.Warrant, act func(asks.Option, asks.Warrant) error) (asker.Handled, error) {
|
||||
return asker.Acted, act(asks.Option{ID: w.Option}, w)
|
||||
}
|
||||
|
||||
// Issue 774, 774/S23 (novox/hq ADR 0582 clauses 3, 5, "How it is checked"): mesh-grants' lease write and hand-back
|
||||
// against the real bus, with a stand-in controller holding the lease in a bucket with an expiry (2 s standing in for 15
|
||||
// s, so the test waits past it in seconds), and stand-in node-service-manager and bus tool seats.
|
||||
func TestTheLeaseWriteOnTheBus(t *testing.T) {
|
||||
dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "lease")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(dir) })
|
||||
srv, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: -1, JetStream: true, StoreDir: filepath.Join(dir, "js"), NoLog: true, NoSigs: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go srv.Start()
|
||||
if !srv.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("the bus did not come up")
|
||||
}
|
||||
t.Cleanup(srv.Shutdown)
|
||||
nc, err := nats.Connect(srv.ClientURL())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
js, _ := jetstream.New(nc)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
const ttl = 2 * time.Second
|
||||
was := grants.LeaseExpiry
|
||||
grants.LeaseExpiry = ttl
|
||||
t.Cleanup(func() { grants.LeaseExpiry = was })
|
||||
lease, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: buscompose.LeaseBucket, History: 1, TTL: ttl})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
controller, _ := json.Marshal(buscompose.LeaseHolder{Instance: "controller@a pid 1", Host: "a", Epoch: 60})
|
||||
if _, err := lease.Create(ctx, buscompose.ControllerLeaseKey, controller); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bucket := func(state string) jetstream.KeyValue {
|
||||
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: bucketOf(state), History: 5})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return kv
|
||||
}
|
||||
put := func(kv jetstream.KeyValue, key string, v any) {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := kv.Put(ctx, key, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
nats := `{"module":"nats","version":"1","claims":[{"name":"mesh-broker"}],"own-secrets":{"broker":"/var/lib/nats/broker"},` +
|
||||
`"tools":["nats_bucket_expiry","nats_consumer_remove","nats_stream_remove"]}`
|
||||
digest := delivery.ManifestDigest([]byte(nats))
|
||||
put(bucket(grants.ReadManifests), digest, delivery.Manifest{V: delivery.Version, Module: "nats", JSON: nats})
|
||||
put(bucket(grants.ReadModules), "nats", delivery.Module{V: delivery.Version, FP2: "src2:" + strings.Repeat("ab", 32), Manifest: digest,
|
||||
Repository: "novox/mesh-catalog", Commit: "c"})
|
||||
pl, _ := delivery.EncodePlacement(delivery.Placement{Node: "a", Modules: []delivery.PlacedModule{{Module: "nats"}}})
|
||||
if _, err := bucket(grants.ReadPlacement).Put(ctx, "placement.a", pl); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
put(bucket(grants.ReadTakeover), health.TakeoverKey, health.Takeover{V: 1, Follow: health.FollowKeeper, By: "operator", Why: "taken"})
|
||||
put(bucket(grants.ReadAccounts), buscompose.ControllerName, map[string]any{"hash": "$2a$11$controller", "generation": 1, "at": "2026-11-01T00:00:00Z"})
|
||||
busKey, _ := seal.GenerateKeyPair()
|
||||
have := bucket(grants.ReadHave)
|
||||
loads := func(source, digest string) {
|
||||
put(have, "a.nats", delivery.Have{V: delivery.Version, Phase: delivery.PhaseRunning, Health: delivery.HealthOK,
|
||||
UserList: &delivery.UserListWritten{Source: source, Revision: 1, Digest: digest, Users: 1, Key: busKey.PublicKey}})
|
||||
}
|
||||
loads("a.nats.src2", "sha256:aa")
|
||||
|
||||
seats := &standIns{ctx: ctx, js: js, active: "active"}
|
||||
store := &memStore{vals: map[string][]byte{}, revs: map[string]uint64{}}
|
||||
p := &grants.Part{Read: newBusReader(ctx, js), Call: seats, Store: store, Lease: &leaseHand{ctx: ctx, js: js},
|
||||
Asker: &yesAsker{}, Objects: memObjects{}, Nonce: func() string { return "0001" }}
|
||||
act := func(ask func(string, string) (any, error)) {
|
||||
t.Helper()
|
||||
if _, err := ask("operator@shanks", "issue 774's integration test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.Compose() // the process raises the ask
|
||||
r, _ := p.LeaseState()
|
||||
if r.Open == nil {
|
||||
t.Fatalf("no act open: %+v", r)
|
||||
}
|
||||
if err := p.Warranted(asks.Option{ID: "yes"}, asks.Warrant{Ask: r.Open.Ask, Option: "yes", Outcome: asks.OutcomeChosen}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, _ = p.LeaseState()
|
||||
if r.Open == nil || r.Open.List == "" {
|
||||
t.Fatalf("the act is not waiting on its list: %+v", r)
|
||||
}
|
||||
loads(delivery.GrantsUserListSource, r.Open.List) // the bus node loads it
|
||||
p.Compose()
|
||||
if r, _ := p.LeaseState(); r.Open != nil {
|
||||
t.Fatalf("the act did not finish: %+v", r.Open)
|
||||
}
|
||||
}
|
||||
expiry := func() time.Duration {
|
||||
st, err := lease.Status(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st.TTL()
|
||||
}
|
||||
|
||||
// 774/S23/AC1.
|
||||
act(p.LeaseWrite)
|
||||
e, err := lease.Get(ctx, buscompose.ControllerLeaseKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if away, says := buscompose.LeaseHandedAway(e.Value(), true, nil); !away || !strings.Contains(string(e.Value()), `"handed-to":"mesh-grants"`) {
|
||||
t.Fatalf("the lease after the write: %s (%s)", e.Value(), says)
|
||||
}
|
||||
if expiry() != 0 || seats.active != "inactive" {
|
||||
t.Fatalf("the lease's expiry is %s, the controller %s", expiry(), seats.active)
|
||||
}
|
||||
if _, err := lease.Create(ctx, buscompose.ControllerLeaseKey, controller); !errors.Is(err, jetstream.ErrKeyExists) {
|
||||
t.Fatalf("the controller's retake: %v, want refused", err)
|
||||
}
|
||||
time.Sleep(ttl + time.Second)
|
||||
if e, err := lease.Get(ctx, buscompose.ControllerLeaseKey); err != nil {
|
||||
t.Fatalf("the lease is gone past the old expiry: %v", err)
|
||||
} else if away, _ := buscompose.LeaseHandedAway(e.Value(), true, nil); !away {
|
||||
t.Fatalf("the lease past the old expiry: %s", e.Value())
|
||||
}
|
||||
|
||||
// 774/S23/AC2.
|
||||
act(p.LeaseHandBack)
|
||||
if expiry() != ttl || seats.active != "active" {
|
||||
t.Fatalf("after the hand-back the expiry is %s, the controller %s", expiry(), seats.active)
|
||||
}
|
||||
if _, err := lease.Create(ctx, buscompose.ControllerLeaseKey, controller); err != nil {
|
||||
t.Fatalf("the controller cannot take the lease back: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
)
|
||||
|
||||
// leaseHand is the process's hand on the controller's lease (novox/hq ADR 0582 clause 3): its key holder, which the
|
||||
// lease write's cut grants the part alone, and the bucket's expiry, read from the bucket's own stream.
|
||||
type leaseHand struct {
|
||||
ctx context.Context
|
||||
js jetstream.JetStream
|
||||
|
||||
mu sync.Mutex
|
||||
kv jetstream.KeyValue
|
||||
}
|
||||
|
||||
func (l *leaseHand) bucket() (jetstream.KeyValue, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.kv != nil {
|
||||
return l.kv, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(l.ctx, requestWithin)
|
||||
defer cancel()
|
||||
kv, err := l.js.KeyValue(ctx, buscompose.LeaseBucket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the lease's bucket cannot be opened: %w", err)
|
||||
}
|
||||
l.kv = kv
|
||||
return kv, nil
|
||||
}
|
||||
|
||||
func (l *leaseHand) PutHolder(value []byte) error {
|
||||
kv, err := l.bucket()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(l.ctx, requestWithin)
|
||||
defer cancel()
|
||||
_, err = kv.Put(ctx, buscompose.ControllerLeaseKey, value)
|
||||
return err
|
||||
}
|
||||
|
||||
func (l *leaseHand) DeleteHolder() error {
|
||||
kv, err := l.bucket()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(l.ctx, requestWithin)
|
||||
defer cancel()
|
||||
return kv.Delete(ctx, buscompose.ControllerLeaseKey)
|
||||
}
|
||||
|
||||
func (l *leaseHand) Expiry() (time.Duration, error) {
|
||||
kv, err := l.bucket()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(l.ctx, requestWithin)
|
||||
defer cancel()
|
||||
st, err := kv.Status(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return st.TTL(), nil
|
||||
}
|
||||
|
||||
// PutTTL writes a key of the part's own state with its own time to live (the state lease).
|
||||
func (s ownStore) PutTTL(state, key string, value []byte, ttl time.Duration) (uint64, error) {
|
||||
return s.c.State(state).RenewTTL(key, json.RawMessage(value), ttl)
|
||||
}
|
||||
@@ -16,10 +16,8 @@
|
||||
// It holds no timer, and never exits for a refused read, write or publish: a refusal is said and tried again at the next
|
||||
// event.
|
||||
//
|
||||
// **No seat in this milestone.** ADR 0547 clause 5 gives the part the seat `mesh-grants`; a `mesh-*` seat must be in
|
||||
// mesh-sdk's own seat list, which the running controller's composer does not know, so the part serves module tools
|
||||
// (grants_*) and claims no seat until take-over. The composer grants the membership publish by the module's name,
|
||||
// mesh-grants.
|
||||
// **The seat mesh-grants** (novox/hq ADR 0582 clause 1, issue 774): the part holds it for the lease write, serving
|
||||
// lease-write and lease-hand-back beside its own grants_* tools, and says lease-handed, lease-altered and removal-failed.
|
||||
//
|
||||
// Started by the tool runner, with no credential of its own, it serves those tools (a process never serves, ADR 0436).
|
||||
package main
|
||||
@@ -121,6 +119,8 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
client := &asker.Client{Module: account.Module, State: conn.State(grants.StateTakeover), Publish: publishVia(ctx, js),
|
||||
Record: recordVia(ctx, nc, account.Module), Subscribe: decidedVia(nc, account.Module, logger.Printf)}
|
||||
f.Part.Asker, f.Part.Say = client, sayVia(ctx, js, account.Module)
|
||||
// The lease write's hand on the lease, and its seat's events (ADR 0582 clauses 3, 4, 6).
|
||||
f.Part.Lease, f.Part.SaySeat = &leaseHand{ctx: ctx, js: js}, seatSaying(ctx, js)
|
||||
f.Part.Forget = func(id string) error { return conn.State(grants.StateTakeover).Delete("ask." + id) }
|
||||
if err := client.Listen(f.Part.Warranted, f.Part.Said); err != nil {
|
||||
logger.Printf("the operator's answers cannot be heard yet, so take-over and hand-back wait: %v", err)
|
||||
|
||||
@@ -3,8 +3,10 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
|
||||
"mesh-grants/internal/grants"
|
||||
)
|
||||
@@ -44,6 +46,10 @@ func (runtimeStore) Keys(state string) ([]string, error) { return stdio.State(st
|
||||
|
||||
func (runtimeStore) Delete(state, key string) error { return stdio.State(state).Delete(key) }
|
||||
|
||||
func (runtimeStore) PutTTL(state, key string, value []byte, ttl time.Duration) (uint64, error) {
|
||||
return stdio.State(state).PutTTL(key, json.RawMessage(value), ttl)
|
||||
}
|
||||
|
||||
// runtimeBus calls tools and says events through the runtime.
|
||||
type runtimeBus struct{}
|
||||
|
||||
@@ -65,9 +71,9 @@ func schema(properties map[string]string, required ...string) map[string]any {
|
||||
// toolsOf are the part's tools over a part (grants.Tools names them).
|
||||
func toolsOf(p *grants.Part) []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{Name: grants.ToolGrants, Description: "What the grants part holds of grants (novox/hq ADR 0547 clause 6): in this milestone it " +
|
||||
"writes none (grants.<module>.<fp> from the third milestone, clause 5), and shows beside it the grants the controller " +
|
||||
"wrote (mesh-controller_grants), all or one module's with the accounts each names. Read-only.",
|
||||
{Name: grants.ToolGrants, Description: "What the grants part holds of grants (novox/hq ADR 0547 clause 5, ADR 0571 clause 9): the grants it wrote " +
|
||||
"(grants.<module>.<fp> in its state grants, the one key the assigner reads), all or one module's with the accounts each names. " +
|
||||
"Read-only.",
|
||||
Input: schema(map[string]string{"module": "one module"}),
|
||||
Run: func(a map[string]any) (any, error) { return p.Grants(grants.Answer(a, "module")) }},
|
||||
{Name: grants.ToolMembership, Description: "One module's membership on one node (ADR 0547 clause 6): as composed now from the " +
|
||||
@@ -171,9 +177,40 @@ func toolsOf(p *grants.Part) []stdio.Tool {
|
||||
"with the tool that answers each. Asks the controller nothing; writes nothing. Read-only.",
|
||||
Input: schema(nil),
|
||||
Run: func(map[string]any) (any, error) { return p.LeaseCheck() }},
|
||||
// The seat's two verbs (ADR 0582 clauses 1, 5): asked of the operator by the part's process.
|
||||
{Name: grants.SeatTool(grants.ActLeaseWrite), Description: seatVerb(grants.ActLeaseWrite).Description,
|
||||
Input: seatVerb(grants.ActLeaseWrite).Input,
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
return p.LeaseWrite(stdio.CallerOf(a), grants.Answer(a, "why"))
|
||||
}},
|
||||
{Name: grants.SeatTool(grants.ActLeaseHandBack), Description: seatVerb(grants.ActLeaseHandBack).Description,
|
||||
Input: seatVerb(grants.ActLeaseHandBack).Input,
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
return p.LeaseHandBack(stdio.CallerOf(a), grants.Answer(a, "why"))
|
||||
}},
|
||||
{Name: grants.ToolRemovalDue, Description: "Write the lease write's removal-due again with a short time to live, so " +
|
||||
"its expiry marker starts the day-later removal of the controller's consumer of DEAD_LETTER_NOTICES and the " +
|
||||
"stream ASSIGNMENTS (novox/hq ADR 0582 clause 6): the recovery for a marker missed while the part was down, the " +
|
||||
"removal still started by the marker and nothing else. Refused unless the lease is handed to mesh-grants and the " +
|
||||
"removal has not run. Recorded as a hand act.",
|
||||
Input: schema(map[string]string{"why": "why, kept with the hand act", "ttl": "the time to live, e.g. 10s (1s to 24h)"}, "why", "ttl"),
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
return p.RemovalDue(stdio.CallerOf(a), grants.Answer(a, "why"), grants.Answer(a, "ttl"))
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// seatVerb is the seat mesh-grants' verb as mesh-sdk declares it.
|
||||
func seatVerb(name string) catalogue.Verb {
|
||||
seat, _ := catalogue.SeatNamed(catalogue.GrantsSeat)
|
||||
for _, v := range seat.Serves {
|
||||
if v.Name == name {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return catalogue.Verb{Name: name}
|
||||
}
|
||||
|
||||
// serveTools serves the tools to the runtime until it stops.
|
||||
func serveTools() error {
|
||||
p := &grants.Part{Read: runtimeReader{}, Call: runtimeBus{}, Store: runtimeStore{}, Emit: runtimeBus{}}
|
||||
|
||||
@@ -3,14 +3,21 @@ module mesh-grants
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
github.com/minio/highwayhash v1.0.4 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031230538-c28c645ca406 h1:Z4RcH5Ft4OAAG3F538NXIHP9F3pkyk3j0LtP9mqu1qQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031230538-c28c645ca406/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005736-7b09de1e5502 h1:XndvkzyAFNde/821lEfYObqMTURzmve/w9Ks+QrBBJ8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005736-7b09de1e5502/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3 h1:an2f0MLx9OhaDLu5i51OMPBbuGwX+aoTzqOkFLMpVfE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101012031-7cab079e2ec3/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c h1:rlUCZ5ifLojqDUTkcCCcPrugTzV2RT6F6JbIVTJkaC8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101013319-25a2cecd989c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014012-a20bdf795236 h1:JEkSnrmHs5QIRJGhtrghLC1+C0AxqMpef8a32d/BLPg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014012-a20bdf795236/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014310-19c204273bdc h1:kVq7sS4I0EnL8DcAA2a3YstnXS6WmYYzJz7eK7IZ9RY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101014310-19c204273bdc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62 h1:R5C8ICjiaDITHRFR/uIATwARPPXKxssUC70m0svstb0=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101020750-62cddf4efc62/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101151945-b2a27ed5cec4 h1:xeuMBwOHNKrVUdGVg7Bdv5tkdueBvngwJFyk6n10rp0=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101151945-b2a27ed5cec4/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21 h1:4I4ayo0fZYP4PBfEX1sGDDd6arnw5IQJySJ3jcFobPM=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102004146-dff0ce2abb21/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
@@ -30,8 +22,11 @@ github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
|
||||
@@ -3,6 +3,7 @@ package manifest
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -59,7 +60,7 @@ type fakeBus struct {
|
||||
refuseCreate bool
|
||||
// refuseUsers refuses the bus module's nats_users: the loaded list cannot be read.
|
||||
refuseUsers bool
|
||||
// rootFree answers the controller's root-free verb: nil is no answerer at all (no responders).
|
||||
// rootFree answers a machine's engine's root-free verb: nil is no answerer at all (no responders).
|
||||
rootFree func(machines []string) (any, error)
|
||||
// consumers are the durable consumers the bus holds, by stream, as nats_streams answers them for one stream; and
|
||||
// consumerCreates each consumer the part asked to create, `<stream>.<name>`.
|
||||
@@ -191,8 +192,13 @@ func (b *fakeBus) Call(key string, body any) (json.RawMessage, error) {
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.calls = append(b.calls, key)
|
||||
args, _ := body.(map[string]any)
|
||||
// A node seat's verb is asked of one machine: `<verb>@<machine>`; the call is kept and judged by the verb alone.
|
||||
machine := ""
|
||||
if base, m, ok := strings.Cut(key, "@"); ok {
|
||||
key, machine = base, m
|
||||
}
|
||||
b.calls = append(b.calls, key)
|
||||
switch key {
|
||||
case grants.CallUsers:
|
||||
if b.refuseUsers {
|
||||
@@ -222,7 +228,7 @@ func (b *fakeBus) Call(key string, body any) (json.RawMessage, error) {
|
||||
if b.rootFree == nil {
|
||||
return nil, errors.New("nats: no responders available for request")
|
||||
}
|
||||
out, err := b.rootFree(args["machines"].([]string))
|
||||
out, err := b.rootFree([]string{machine})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -314,8 +320,38 @@ func (b *fakeBus) refuse(module, raw, problem string) {
|
||||
Problems: []string{problem}})
|
||||
}
|
||||
|
||||
// assign places module on node in the seat's placement, the one place the part reads it (ADR 0549 §5; issue 774).
|
||||
func (b *fakeBus) assign(node, module string) {
|
||||
b.put(grants.ReadAssignments, node+"."+module, map[string]any{"node": node, "module": module, "assigned": "2026-10-25T20:00:00Z"})
|
||||
var pl delivery.Placement
|
||||
if raw, ok := b.states[grants.ReadPlacement]["placement."+node]; ok {
|
||||
pl, _ = delivery.DecodePlacement(raw)
|
||||
}
|
||||
pl.Node = node
|
||||
for _, m := range pl.Modules {
|
||||
if m.Module == module {
|
||||
return
|
||||
}
|
||||
}
|
||||
pl.Modules = append(pl.Modules, delivery.PlacedModule{Module: module})
|
||||
raw, err := delivery.EncodePlacement(pl)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if b.states[grants.ReadPlacement] == nil {
|
||||
b.states[grants.ReadPlacement] = map[string][]byte{}
|
||||
}
|
||||
b.states[grants.ReadPlacement]["placement."+node] = raw
|
||||
}
|
||||
|
||||
// unassign takes module off node in the seat's placement.
|
||||
func (b *fakeBus) unassign(node, module string) {
|
||||
pl, err := delivery.DecodePlacement(b.states[grants.ReadPlacement]["placement."+node])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
pl.Modules = slices.DeleteFunc(pl.Modules, func(m delivery.PlacedModule) bool { return m.Module == module })
|
||||
raw, _ := delivery.EncodePlacement(pl)
|
||||
b.states[grants.ReadPlacement]["placement."+node] = raw
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -323,6 +359,16 @@ const (
|
||||
xManifest = `{"module":"mod-x","version":"1","own-secrets":{"broker":"/var/lib/x/broker"},"state":[{"name":"kept","history":3}],` +
|
||||
`"streams":[{"name":"things","events":["thing.added"],"max-age-days":2,"max-mib":5}],"logs":[{"name":"history","max-age-days":30}]}`
|
||||
yManifest = `{"module":"mod-y","version":"1","own-secrets":{"broker":"/var/lib/y/broker"},"reads":["mod-x.kept"]}`
|
||||
// vaultManifest is the vault as the composition needs it for a rotation (ADR 0583 §2, §3): its claim serving the verbs.
|
||||
vaultManifest = `{"module":"mesh-vault","version":"1","own-secrets":{"broker":"/var/lib/v/broker"},` +
|
||||
`"provides":[{"name":"secret","scope":"mesh"}],` +
|
||||
`"claims":[{"name":"mesh-vault","serves":["rotate-node","node-account-confirm","abandon"]}],` +
|
||||
`"tools":["rotate-node","node-account-confirm","abandon"],"state":[{"name":"node-logins"}]}`
|
||||
// enrolmentManifest is the holder of mesh-enrolment as the composition needs it (ADR 0587 §1): the claim, its six verbs
|
||||
// and its bucket.
|
||||
enrolmentManifest = `{"module":"mesh-enrolment","version":"1","own-secrets":{"broker":"/var/lib/e/broker"},` +
|
||||
`"claims":[{"name":"mesh-enrolment","serves":["issue","redeem","confirm","revoke","tokens","again"]}],` +
|
||||
`"tools":["issue","redeem","confirm","revoke","tokens","again"],"state":[{"name":"enrolments","key-ttl":true}]}`
|
||||
)
|
||||
|
||||
// brokerSeat is one verb of the bus's seat as a membership serves it: nats claims the seat in fixture and no membership
|
||||
@@ -345,12 +391,10 @@ func fixture() *fakeBus {
|
||||
b.assign("a", "nats")
|
||||
b.assign("a", "mod-x")
|
||||
b.assign("b", "mod-y")
|
||||
b.put(grants.ReadAssignments, "b.gone", map[string]any{"node": "b", "module": "gone", "assigned": "2026-10-01T00:00:00Z", "ended": "2026-10-02T00:00:00Z"})
|
||||
b.put(grants.ReadHave, "a.node", map[string]any{"v": 1})
|
||||
b.put(grants.ReadHave, "b.node", map[string]any{"v": 1})
|
||||
b.put(grants.ReadHave, "a.nats", delivery.Have{V: delivery.Version, Phase: delivery.PhaseRunning, Health: delivery.HealthOK,
|
||||
UserList: &delivery.UserListWritten{Source: "a.nats.src2", Revision: 7, Wanted: "sha256:aa", Digest: "sha256:aa", Users: 6}})
|
||||
b.put(grants.ReadControllerGrant, "mod-x.0123456789ab0123456789ab0123456789ab0123456789ab0123456789ab0123", delivery.ControllerGrant{V: delivery.Version, Accounts: []string{"a.mod-x"}})
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -394,8 +438,18 @@ func TestGrants(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("mesh-sdk cannot decode the manifest: %v", err)
|
||||
}
|
||||
if m.Module != "mesh-grants" || len(m.Claims) != 0 {
|
||||
t.Errorf("module %q claims %v: the part claims no seat until mesh-sdk's seat list names mesh-grants", m.Module, m.Claims)
|
||||
// The part holds the seat mesh-grants for the lease write (ADR 0582 clause 1, issue 774, 774/S14), as mesh-sdk's seat
|
||||
// list declares it.
|
||||
if seat, ok := catalogue.SeatNamed(catalogue.GrantsSeat); m.Module != "mesh-grants" || len(m.Claims) != 1 || !ok ||
|
||||
catalogue.CanHold(m, seat) != nil {
|
||||
t.Errorf("module %q claims %v: the part holds mesh-grants alone (%v)", m.Module, m.Claims, catalogue.CanHold(m, seat))
|
||||
}
|
||||
var keyTTL bool
|
||||
for _, s := range m.State {
|
||||
keyTTL = keyTTL || (s.Name == grants.StateLease && s.KeyTTL)
|
||||
}
|
||||
if !keyTTL {
|
||||
t.Errorf("the state %s gives no time to live per key, which removal-due needs", grants.StateLease)
|
||||
}
|
||||
for _, row := range []struct {
|
||||
what string
|
||||
@@ -445,8 +499,9 @@ func TestGrants(t *testing.T) {
|
||||
if path != publishOnly && path != asksOnly && (strings.Contains(string(src), ".Publish(") || strings.Contains(string(src), "mesh.assignment")) {
|
||||
t.Errorf("%s publishes: only publish.go publishes memberships, and asking.go the operator's asks", path)
|
||||
}
|
||||
if path == asksOnly && (strings.Count(string(src), ".Publish(") != 2 || strings.Contains(string(src), "mesh.assignment")) {
|
||||
t.Errorf("asking.go publishes other than an ask, a cancel and a condition, through js.Publish twice")
|
||||
// And the seat mesh-grants' events (ADR 0582 clauses 3, 4, 6; issue 774).
|
||||
if path == asksOnly && (strings.Count(string(src), ".Publish(") != 3 || strings.Contains(string(src), "mesh.assignment")) {
|
||||
t.Errorf("asking.go publishes other than an ask, a cancel and a condition, and the seat's events, through js.Publish thrice")
|
||||
}
|
||||
if path == publishOnly && strings.Count(string(src), ".Publish(") != 1 {
|
||||
t.Errorf("publish.go publishes other than one membership")
|
||||
@@ -497,8 +552,8 @@ func TestGrants(t *testing.T) {
|
||||
key, _ := delivery.BuilderBuildKey("mod-x", fp)
|
||||
b.put(grants.ReadBuilds, key, map[string]any{"v": delivery.Version, "state": "done", "manifest": digest})
|
||||
}, []string{"a.mod-x", "a.nats", "b.mod-y", "controller", "node.a", "node.b"}, "enrolment tokens"},
|
||||
{"placement refused is a gap; the machines come from the haves", func(b *fakeBus) { b.refused[grants.ReadAssignments] = true },
|
||||
[]string{"controller", "node.a", "node.b"}, grants.ReadAssignments},
|
||||
{"placement refused is a gap; the machines come from the haves", func(b *fakeBus) { b.refused[grants.ReadPlacement] = true },
|
||||
[]string{"controller", "node.a", "node.b"}, grants.ReadPlacement},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
b := fixture()
|
||||
@@ -613,7 +668,7 @@ func TestGrants(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("root-free: the controller's word fills the composition, failing closed (issue 1945)", func(t *testing.T) {
|
||||
t.Run("root-free: each engine's word fills the composition, failing closed (issue 1945)", func(t *testing.T) {
|
||||
const router = `{"module":"messenger","version":"1","runs-as":"messenger","resources":[{"id":"account","type":"user","name":"messenger","shell":"/usr/bin/nologin","home":"/var/lib/messenger"}],"own-secrets":{"broker":"/var/lib/m/broker"},"secrets-owner":"messenger",` +
|
||||
`"seats":[{"name":"operator-channel","scope":"mesh","serves":["open"]},{"name":"channel","scope":"mesh","kinded":true,"accepts":["show"]}],` +
|
||||
`"claims":[{"name":"operator-channel","scope":"mesh","serves":["open"]}]}`
|
||||
@@ -1086,8 +1141,10 @@ func TestGrants(t *testing.T) {
|
||||
t.Errorf("read of the keys per module: placed %v, gaps %v", got.Placed, got.Gaps)
|
||||
}
|
||||
b.refused[grants.ReadPlacement] = true
|
||||
if read := grants.ReadInputs(b, &grants.Manifests{}); slices.ContainsFunc(read.Gaps, func(g grants.Gap) bool { return g.Input == grants.ReadPlacement && g.Failed }) {
|
||||
t.Errorf("placement not on the bus is a failed read: %v", read.Gaps)
|
||||
// The placement alone places (issue 774, 774/S29): not read, nothing is placed, and it is a failed read.
|
||||
if read := grants.ReadInputs(b, &grants.Manifests{}); !slices.ContainsFunc(read.Gaps, func(g grants.Gap) bool { return g.Input == grants.ReadPlacement && g.Failed }) ||
|
||||
len(read.Assigned) != 0 {
|
||||
t.Errorf("placement not on the bus is no failed read, or something is placed: %v, %v", read.Gaps, read.Assigned)
|
||||
}
|
||||
// Issue 1945, the user list of 2026-10-27 09:21: a placement's holds are the holders on record, not every holder.
|
||||
// A mesh seat no placement holds keeps the module claiming it as its holder, wherever placement puts it.
|
||||
@@ -1317,7 +1374,7 @@ func TestGrants(t *testing.T) {
|
||||
t.Errorf("membership = %v", ms)
|
||||
}
|
||||
gr, _ := p.Grants("mod-x")
|
||||
if g, _ := gr.(map[string]any); g["writes"] != grants.WritesGrants || g["controller_count"] != 1 {
|
||||
if g, _ := gr.(map[string]any); g["writes"] != grants.WritesGrants || g["controller"] != nil || g["controller_count"] != nil {
|
||||
t.Errorf("grants = %v", gr)
|
||||
}
|
||||
|
||||
@@ -1359,7 +1416,7 @@ func TestGrants(t *testing.T) {
|
||||
// settled is the placement of the lists agreeing, every hash held, and the loaded list as edit leaves it.
|
||||
settled := func(edit func(modX *grants.User)) func() {
|
||||
return func() {
|
||||
b.states[grants.ReadAssignments] = nil
|
||||
b.states[grants.ReadPlacement] = nil
|
||||
b.assign("a", "nats")
|
||||
b.assign("a", "mod-x")
|
||||
b.assign("b", "mod-y")
|
||||
@@ -1570,7 +1627,7 @@ func TestGrants(t *testing.T) {
|
||||
b.loaded[i].Subscribe = append(slices.Clone(b.loaded[i].Subscribe), seatGrant)
|
||||
}
|
||||
}
|
||||
b.put(grants.ReadAssignments, "a.mod-y", map[string]any{"node": "a", "module": "mod-y", "assigned": "2026-10-27T08:20:00Z"})
|
||||
b.assign("a", "mod-y")
|
||||
b.put(grants.ReadAccounts, "a.mod-y", map[string]any{"user": "a.mod-y", "hash": "$2a$10$x", "generation": 1})
|
||||
said = nil
|
||||
res := p.Compose()
|
||||
@@ -1592,7 +1649,7 @@ func TestGrants(t *testing.T) {
|
||||
b.refuseUsers = true
|
||||
b.mu.Unlock()
|
||||
b.mu.Lock()
|
||||
delete(b.states[grants.ReadAssignments], "a.mod-y")
|
||||
b.unassign("a", "mod-y")
|
||||
b.mu.Unlock()
|
||||
if res := p.Compose(); res.Sealed == nil || !strings.Contains(res.Sealed.Why, "the grants of the list in force cannot be read") {
|
||||
t.Errorf("a pass whose loaded list cannot be read = %+v", res.Sealed)
|
||||
@@ -2030,11 +2087,11 @@ func TestGrants(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { f.Run(ctx); close(done) }()
|
||||
waitOpened(t, w, grants.ReadAssignments)
|
||||
waitOpened(t, w, grants.ReadPlacement)
|
||||
if r := f.Refused(); r[grants.ReadModules] == "" {
|
||||
waitFor(t, func() bool { return f.Refused()[grants.ReadModules] != "" })
|
||||
}
|
||||
w.change(grants.ReadAssignments, "a.mod-x")
|
||||
w.change(grants.ReadPlacement, "placement.a")
|
||||
waitOpened(t, w, grants.ReadModules)
|
||||
waitFor(t, func() bool { return len(f.Refused()) == 0 })
|
||||
select {
|
||||
@@ -2064,9 +2121,6 @@ func TestGrants(t *testing.T) {
|
||||
b.put(grants.ReadAccounts, user, map[string]any{"user": user, "hash": "$2a$10$x", "generation": 1})
|
||||
}
|
||||
held := func() []string { return slices.Sorted(maps.Keys(b.own[grants.StateGrants])) }
|
||||
// The controller's grant for this fingerprint, beside the stale one the fixture holds for another.
|
||||
delete(b.states[grants.ReadControllerGrant], "mod-x.0123456789ab0123456789ab0123456789ab0123456789ab0123456789ab0123")
|
||||
b.put(grants.ReadControllerGrant, key("mod-x"), delivery.ControllerGrant{V: delivery.Version, Accounts: []string{"a.mod-x"}})
|
||||
hash("a.mod-x")
|
||||
hash("a.nats")
|
||||
res := p.Compose()
|
||||
@@ -2091,11 +2145,10 @@ func TestGrants(t *testing.T) {
|
||||
if res := p.Compose(); len(res.Grants.Written) != 0 || said() != 2 {
|
||||
t.Errorf("a second pass wrote %v and said %d", res.Grants.Written, said())
|
||||
}
|
||||
// The tool: equal on mod-x, and nats is the part's alone (the controller's fixture holds none).
|
||||
// The tool answers what the part wrote, and reads nothing of the controller's.
|
||||
got, err := p.Grants("")
|
||||
gm, _ := got.(map[string]any)
|
||||
diff, _ := gm["differences"].([]grants.GrantDifference)
|
||||
if err != nil || gm["equal"] != false || len(diff) != 1 || diff[0].Key != key("nats") || diff[0].Kind != "only the part's" {
|
||||
if _, ok := gm["controller"]; err != nil || gm["count"] != 2 || ok {
|
||||
t.Errorf("grants = %+v, %v", got, err)
|
||||
}
|
||||
// A user with a hash later: written once. Without it again: deleted. Unread hashes: nothing deleted.
|
||||
@@ -2231,83 +2284,64 @@ func TestGrants(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// The guard of ADR 0549 §6 for this repository, as mesh-catalog's checks/placement is for the catalogue: no module.json
|
||||
// here reads the controller's assignments unless ../assignments-readers names it with why, and that list only shrinks.
|
||||
t.Run("assignments readers: only the named modules read the controller's assignments, and the list only shrinks", func(t *testing.T) {
|
||||
raw, err := os.ReadFile("../assignments-readers")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allowed := map[string]bool{}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if f := strings.Fields(line); len(f) > 1 && !strings.HasPrefix(f[0], "#") {
|
||||
allowed[f[0]] = true
|
||||
} else if len(f) == 1 {
|
||||
t.Errorf("../assignments-readers names %s with no why", f[0])
|
||||
}
|
||||
}
|
||||
// ADR 0571 §13's test for this repository (issue 774, 774/S24/AC1), replacing the guard of ADR 0549 §6 and its list
|
||||
// ../assignments-readers: at phase write no module.json here depends on mesh-controller beyond the lease and the three
|
||||
// kept to the write (mesh-sdk catalogue.ManifestRefusals, the judge grants_lease_check runs).
|
||||
t.Run("no module of this repository depends on the controller beyond what the lease write keeps", func(t *testing.T) {
|
||||
paths, _ := filepath.Glob("../*/module.json")
|
||||
reads := map[string]bool{}
|
||||
manifests := map[string]catalogue.Manifest{}
|
||||
for _, path := range paths {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Reads []string `json:"reads"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
m, err := catalogue.Decode(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
if slices.Contains(m.Reads, grants.ReadAssignments) {
|
||||
reads[m.Module] = true
|
||||
if !allowed[m.Module] {
|
||||
t.Errorf("%s reads %s: read where modules run from the seat mesh-placement's placement (ADR 0549 §3); the lease write "+
|
||||
"is refused while a module reads the assignments (ADR 0549 §6)", m.Module, grants.ReadAssignments)
|
||||
}
|
||||
}
|
||||
manifests[m.Module] = m
|
||||
}
|
||||
for m := range allowed {
|
||||
if !reads[m] {
|
||||
t.Errorf("../assignments-readers names %s, which no longer reads %s: take it off the list", m, grants.ReadAssignments)
|
||||
}
|
||||
if len(manifests) < 8 {
|
||||
t.Fatalf("read %d manifests, want every module of the repository", len(manifests))
|
||||
}
|
||||
for _, why := range catalogue.ManifestRefusals(manifests, catalogue.PhaseWrite) {
|
||||
t.Error(why)
|
||||
}
|
||||
})
|
||||
|
||||
// 774 (the L3 review, ADR 0549 §3, §6): once the controller's lease is handed away the assignments are frozen and place
|
||||
// nothing; the seat's placement alone does. A module left only in the stale assignments loses its account and its
|
||||
// grant names no account of it; before the lease write the two are still unioned.
|
||||
t.Run("placement alone once the lease is handed away: a module only in the stale assignments has no account or grant of it", func(t *testing.T) {
|
||||
// 774/S29/AC1 (ADR 0549 §5, ADR 0571 §10): the memberships composed from the placement alone, with no read of the
|
||||
// controller, are the ones the union of placement and assignments gave on the same input: the fixture's (two nodes,
|
||||
// the bus and mod-x on a, mod-y on b, as placed on the live mesh's own shape of nats beside its modules), whose
|
||||
// assignments named the same modules. Neither the controller's assignments nor its condition events are read.
|
||||
t.Run("placement alone places, with the memberships the union gave", func(t *testing.T) {
|
||||
b := fixture()
|
||||
b.loaded = shadowOf(t, b)
|
||||
p := partOn(b)
|
||||
p.Bus = grants.BusAddress{URL: "nats://a.mesh:4222", Fingerprint: "ab"}
|
||||
pl, _ := delivery.EncodePlacement(delivery.Placement{Node: "a", Modules: []delivery.PlacedModule{{Module: "nats"}, {Module: "mod-x"}}})
|
||||
b.states[grants.ReadPlacement] = map[string][]byte{"placement.a": pl}
|
||||
for _, u := range []string{"a.mod-x", "a.nats", "b.mod-y"} {
|
||||
b.put(grants.ReadAccounts, u, map[string]any{"user": u, "hash": "$2a$10$x", "generation": 1})
|
||||
}
|
||||
fp := "src2:" + strings.Repeat("ab", 32)
|
||||
key, _ := delivery.PartGrantKey("mod-y", fp)
|
||||
grantOf := func() []string {
|
||||
g, _ := delivery.DecodeControllerGrant(b.own[grants.StateGrants][key])
|
||||
return g.Accounts
|
||||
}
|
||||
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"instance": "novox/1/2026-10-28", "epoch": 60})
|
||||
p.Compose()
|
||||
if b.own[grants.StateAccounts]["b.mod-y"] == nil || !slices.Equal(grantOf(), []string{"b.mod-y"}) {
|
||||
t.Fatalf("under the controller's lease the assignments no longer count: account %v, grant %v",
|
||||
b.own[grants.StateAccounts]["b.mod-y"] != nil, grantOf())
|
||||
want := map[string]string{"a.nats": "", "a.mod-x": "", "b.mod-y": ""}
|
||||
for key := range want {
|
||||
raw, _, found, _ := b.ownGet(grants.StateMemberships, key)
|
||||
if !found {
|
||||
t.Fatalf("no membership of %s from the placement", key)
|
||||
}
|
||||
want[key] = string(raw)
|
||||
}
|
||||
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"handed-to": "the operator's hand-over"})
|
||||
in := grants.ReadInputs(b, &grants.Manifests{})
|
||||
if len(in.Assigned["b"]) != 0 || !slices.Equal(in.Assigned["a"], []string{"mod-x", "nats"}) || in.BusNode != "a" {
|
||||
t.Errorf("after the lease write the stale assignments still place: %v, bus %q", in.Assigned, in.BusNode)
|
||||
if got := slices.Sorted(maps.Keys(b.own[grants.StateMemberships])); !slices.Equal(got, []string{"a.mod-x", "a.nats", "b.mod-y"}) {
|
||||
t.Errorf("memberships %v", got)
|
||||
}
|
||||
res := p.Compose()
|
||||
if res.Accounts == nil || !slices.Equal(res.Accounts.Deleted, []string{"b.mod-y"}) || slices.Contains(grantOf(), "b.mod-y") {
|
||||
t.Fatalf("a module only in the stale assignments kept its account or grant: %+v, grant %v", res.Accounts, grantOf())
|
||||
for _, r := range grants.Reads {
|
||||
if strings.HasPrefix(r, "mesh-controller.") && r != grants.ReadLease {
|
||||
t.Errorf("the part reads %s", r)
|
||||
}
|
||||
}
|
||||
for _, c := range grants.Consumes {
|
||||
if strings.HasPrefix(c, "mesh-controller.") {
|
||||
t.Errorf("the part consumes %s", c)
|
||||
}
|
||||
}
|
||||
for _, c := range b.calls {
|
||||
if strings.Contains(c, "mesh-controller") {
|
||||
t.Errorf("the part called %s", c)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2349,7 +2383,7 @@ func TestGrants(t *testing.T) {
|
||||
t.Errorf("the wanted accounts are read by %v", readers)
|
||||
}
|
||||
// A read that fails retires nothing; once read, the user placement dropped is retired.
|
||||
delete(b.states[grants.ReadAssignments], "b.mod-y")
|
||||
b.unassign("b", "mod-y")
|
||||
b.refused[grants.ReadResolved] = true
|
||||
if res := p.Compose(); res.Accounts == nil || len(res.Accounts.Deleted) != 0 || b.own[grants.StateAccounts]["b.mod-y"] == nil {
|
||||
t.Fatalf("a pass whose read failed deleted %+v", res.Accounts)
|
||||
@@ -2381,6 +2415,103 @@ func TestGrants(t *testing.T) {
|
||||
|
||||
// 774/AC5 (novox/hq ADR 0561 §1): the vault's account-issued wakes a pass, so the next list carries the new user's hash
|
||||
// within one pass; an event of another kind does not.
|
||||
// 774/S52/AC1 (novox/hq ADR 0587 §4, §5): a key of the seat mesh-enrolment at live, with the vault's hash, composes
|
||||
// enrol.<node>, which from the lease write may publish the seat's redeem in its own name and subscribe its own inbox,
|
||||
// nothing else; a key at asked composes none; when the key ends the user is gone.
|
||||
t.Run("an enrolment key at live composes its enrolment user, and its end takes it out", func(t *testing.T) {
|
||||
b := fixture()
|
||||
b.manifestOf("mesh-enrolment", enrolmentManifest)
|
||||
b.assign("a", "mesh-enrolment")
|
||||
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"handed-to": "the operator's hand-over"})
|
||||
issued := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC)
|
||||
for node, stage := range map[string]string{"lab": delivery.EnrolmentLive, "pi": delivery.EnrolmentAsked} {
|
||||
raw, err := delivery.EncodeEnrolment(delivery.Enrolment{Node: node, Address: "10.0.0.7", Why: "joins",
|
||||
Caller: "a.mesh-enrolment", Stage: stage, Issued: issued, Expires: issued.Add(time.Hour),
|
||||
OverlayKey: base64.StdEncoding.EncodeToString(make([]byte, 32))})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.put(grants.ReadEnrolments, delivery.EnrolmentNodeKey(node), json.RawMessage(raw))
|
||||
}
|
||||
b.put(grants.ReadAccounts, "enrol.lab", map[string]any{"user": "enrol.lab", "hash": "$2a$10$x", "generation": 1})
|
||||
in := grants.ReadInputs(b, &grants.Manifests{})
|
||||
if !slices.Equal(in.Enrolling, []string{"lab"}) || gapNamed(in.Gaps, "enrolment tokens") {
|
||||
t.Fatalf("enrolling %v, gaps %v", in.Enrolling, in.Gaps)
|
||||
}
|
||||
users := shadowOf(t, b)
|
||||
var lab *grants.User
|
||||
for i := range users {
|
||||
if users[i].User == "enrol.lab" {
|
||||
lab = &users[i]
|
||||
}
|
||||
if users[i].User == "enrol.pi" {
|
||||
t.Error("a key at asked composed enrol.pi")
|
||||
}
|
||||
}
|
||||
if lab == nil || !slices.Equal(lab.Publish, []string{"mesh.seat.mesh-enrolment.call.redeem.enrol~lab"}) ||
|
||||
!slices.Equal(lab.Subscribe, []string{"_INBOX.enrol.lab.>"}) || lab.AllowResponses {
|
||||
t.Fatalf("enrol.lab: %+v", lab)
|
||||
}
|
||||
if c := grants.CredentialsOf(users, nil, in.Hashes); !slices.Contains(c.Adds, "enrol.lab") {
|
||||
t.Errorf("enrol.lab's hash is not carried: %+v", c)
|
||||
}
|
||||
b.mu.Lock()
|
||||
delete(b.states[grants.ReadEnrolments], delivery.EnrolmentNodeKey("lab"))
|
||||
b.mu.Unlock()
|
||||
if slices.Contains(names(shadowOf(t, b)), "enrol.lab") {
|
||||
t.Error("enrol.lab stays after its key ended")
|
||||
}
|
||||
if !slices.Contains(grants.Followed, grants.ReadEnrolments) {
|
||||
t.Error("a key's change composes nothing again")
|
||||
}
|
||||
})
|
||||
// 774/S53 (novox/hq ADR 0583 §1, §2): while node-logins says b rotates to node.b--b, with its hash, both logins are in
|
||||
// the list and b's machine stays placed under either; after rotation-done (b on node.b--b, nothing rotating) the old
|
||||
// login is not (AC1). Each node's engine may read its own node-logins key alone (AC2).
|
||||
t.Run("a node rotating is composed under both its logins, then the new one alone", func(t *testing.T) {
|
||||
b := fixture()
|
||||
b.manifestOf("mesh-vault", vaultManifest)
|
||||
b.assign("a", "mesh-vault")
|
||||
since := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC)
|
||||
login := func(l delivery.NodeLogin) {
|
||||
raw, err := delivery.EncodeNodeLogin(l)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.put(grants.ReadNodeLogins, l.Node, json.RawMessage(raw))
|
||||
}
|
||||
login(delivery.NodeLogin{Node: "b", Login: "node.b", Rotating: "node.b--b", Stage: delivery.RotationBegun, Since: since})
|
||||
b.put(grants.ReadAccounts, "node.b--b", map[string]any{"user": "node.b--b", "hash": "$2a$10$x", "generation": 1})
|
||||
users := shadowOf(t, b)
|
||||
in := grants.ReadInputs(b, &grants.Manifests{})
|
||||
if got := names(users); !slices.Contains(got, "node.b") || !slices.Contains(got, "node.b--b") || !slices.Contains(got, "node.a") {
|
||||
t.Fatalf("the node users are %v", got)
|
||||
}
|
||||
if c := grants.CredentialsOf(users, nil, in.Hashes); !slices.Contains(c.Adds, "node.b--b") {
|
||||
t.Errorf("node.b--b's hash is not carried: %+v", c)
|
||||
}
|
||||
if placed := grants.Placed(in); !placed("node.b--b") || placed("node.c--b") || !in.Rotating()["node.b--b"] {
|
||||
t.Error("the second login is not judged its node's, or not rotating")
|
||||
}
|
||||
get := func(key string) string {
|
||||
return "$JS.API.DIRECT.GET.KV_" + delivery.NodeLoginsBucket + ".$KV." + delivery.NodeLoginsBucket + "." + key
|
||||
}
|
||||
for _, u := range users {
|
||||
if !strings.HasPrefix(u.User, "node.") {
|
||||
continue
|
||||
}
|
||||
node, _ := delivery.LoginNode(u.User)
|
||||
for _, of := range []string{"a", "b"} {
|
||||
if slices.Contains(u.Publish, get(of)) != (of == node) {
|
||||
t.Errorf("%s reads %s's node-logins key: %v", u.User, of, of != node)
|
||||
}
|
||||
}
|
||||
}
|
||||
login(delivery.NodeLogin{Node: "b", Login: "node.b--b", Since: since.Add(time.Hour)})
|
||||
if got := names(shadowOf(t, b)); slices.Contains(got, "node.b") || !slices.Contains(got, "node.b--b") {
|
||||
t.Errorf("after rotation-done the node users are %v", got)
|
||||
}
|
||||
})
|
||||
t.Run("the vault's account-issued composes the list again", func(t *testing.T) {
|
||||
b := fixture()
|
||||
var handle func(string, []byte)
|
||||
|
||||
@@ -20,7 +20,15 @@ type Watcher interface {
|
||||
// Followed are the states whose change composes again: placement, the indexer's manifests by module, the resolved
|
||||
// config values, the node agents' haves (a machine's first have, and the bus node's loaded list) and the assigner's
|
||||
// wants (the manifest each machine is wanted to run), and the vault's hashes of the bus accounts.
|
||||
var Followed = []string{ReadAssignments, ReadModules, ReadResolved, ReadHave, ReadWants, ReadAccounts, ReadPlacement, ReadLease, ReadTakeover}
|
||||
//
|
||||
// And its own state lease, whose key removal-due's expiry marker starts the day-later removal (ADR 0582 clause 6), and
|
||||
// the seat mesh-enrolment's keys (ADR 0587 §4, §5): a key at live composes enrol.<node>, and its end, by the holder or by
|
||||
// the bus's expiry, is a change of the key that takes the user out; no event is needed beside the watch.
|
||||
var Followed = []string{ReadModules, ReadResolved, ReadHave, ReadWants, ReadAccounts, ReadPlacement, ReadLease, ReadTakeover,
|
||||
ReadEnrolments, ReadNodeLogins, OwnLease}
|
||||
|
||||
// OwnLease is the part's own state lease as the follower watches it, `<module>.<state>`.
|
||||
const OwnLease = Module + "." + StateLease
|
||||
|
||||
// Follower composes again on every change it hears, never on a timer. It never stops for a refusal: a watch it cannot
|
||||
// open is said, composed without, and opened again at the next wake (any change it does hear, or a reconnect).
|
||||
@@ -121,14 +129,23 @@ func (f *Follower) heard(state, key string, value []byte) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
if state == OwnLease {
|
||||
// The marker of removal-due (its value gone: expired, or deleted by a hand-back, which the removal tells apart).
|
||||
if key == KeyRemovalDue && value == nil && f.Part != nil {
|
||||
f.Part.RemovalDueHeard()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if state == ReadLease {
|
||||
// Renewed every few seconds by its holder: only the write that hands it away, or back, composes again (ADR 0561 §6).
|
||||
// Once handed away nobody renews it, so any change is heard: it may be the lease altered (ADR 0582 clause 4).
|
||||
on, _ := buscompose.LeaseHandedAway(value, len(value) > 0, nil)
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
was, seen := f.leaseOn, f.leaseSeen
|
||||
f.leaseOn, f.leaseSeen = on, true
|
||||
return !seen || was != on
|
||||
return !seen || was != on || on
|
||||
}
|
||||
if state != ReadHave {
|
||||
return true
|
||||
@@ -169,7 +186,8 @@ func (f *Follower) open() {
|
||||
}
|
||||
if f.Hear != nil && !f.heard_ {
|
||||
err := f.Hear(func(event string, body []byte) {
|
||||
if IsAccountIssued(event) {
|
||||
// An account the vault minted, or a bucket's expiry set (the lease's, altered: ADR 0582 clause 4).
|
||||
if IsAccountIssued(event) || IsBucketExpirySet(event) {
|
||||
f.Wake()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
// the part's state grants, naming the accounts (the module's own and each process's) the vault holds a hash for. It is the
|
||||
// controller's grant as the controller composes it (cmd/mesh-controller grants.go grantsFrom), so the two compare by key.
|
||||
// Until the controller's lease is handed away nobody reads the part's keys: the assigner reads the controller's
|
||||
// (delivery.GrantSource), and the tool grants says every difference, so the switch is made on a measured equality.
|
||||
//, and the tool grants says every difference, so the switch is made on a measured equality.
|
||||
//
|
||||
// A module of which an account has no hash yet is granted the accounts that have one; a module none of whose accounts has
|
||||
// one has no grant, and one it was written for is deleted (an older grant naming a node whose account is gone would let the
|
||||
@@ -469,38 +469,3 @@ func withVersion(g delivery.ControllerGrant) delivery.ControllerGrant {
|
||||
g.V = delivery.Version
|
||||
return g
|
||||
}
|
||||
|
||||
// GrantDifference is one key on which the part's grants and the controller's differ.
|
||||
type GrantDifference struct {
|
||||
Key string `json:"key"`
|
||||
// Kind is `only the controller's`, `only the part's` or `accounts differ`.
|
||||
Kind string `json:"kind"`
|
||||
Controller []string `json:"controller_accounts,omitempty"`
|
||||
Part []string `json:"part_accounts,omitempty"`
|
||||
}
|
||||
|
||||
// CompareGrants lists every key the two sets differ on, in key order: a key held by one alone, or held by both with other
|
||||
// accounts. Pure.
|
||||
func CompareGrants(controller, part map[string]delivery.ControllerGrant) []GrantDifference {
|
||||
keys := map[string]bool{}
|
||||
for k := range controller {
|
||||
keys[k] = true
|
||||
}
|
||||
for k := range part {
|
||||
keys[k] = true
|
||||
}
|
||||
out := []GrantDifference{}
|
||||
for _, k := range sortedKeys(keys) {
|
||||
c, inC := controller[k]
|
||||
g, inP := part[k]
|
||||
switch {
|
||||
case inC && !inP:
|
||||
out = append(out, GrantDifference{Key: k, Kind: "only the controller's", Controller: c.Accounts})
|
||||
case inP && !inC:
|
||||
out = append(out, GrantDifference{Key: k, Kind: "only the part's", Part: g.Accounts})
|
||||
case !delivery.SameControllerGrant(withVersion(c), withVersion(g)):
|
||||
out = append(out, GrantDifference{Key: k, Kind: "accounts differ", Controller: c.Accounts, Part: g.Accounts})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -24,26 +24,29 @@ import (
|
||||
|
||||
// The states of other parts the grants part reads, as its manifest's reads name them (`<module>.<state>`).
|
||||
const (
|
||||
ReadAssignments = "mesh-controller.assignments"
|
||||
ReadControllerGrant = "mesh-controller.grants"
|
||||
ReadModules = "mesh-delivery-indexer.modules"
|
||||
ReadManifests = "mesh-delivery-indexer.manifests"
|
||||
ReadHave = "node-engine.have"
|
||||
ReadResolved = "mesh-config.resolved"
|
||||
ReadWants = "mesh-delivery-assigner.wants"
|
||||
ReadBuilds = "mesh-delivery-builder.builds"
|
||||
ReadModules = "mesh-delivery-indexer.modules"
|
||||
ReadManifests = "mesh-delivery-indexer.manifests"
|
||||
ReadHave = "node-engine.have"
|
||||
ReadResolved = "mesh-config.resolved"
|
||||
ReadWants = "mesh-delivery-assigner.wants"
|
||||
ReadBuilds = "mesh-delivery-builder.builds"
|
||||
// ReadAccounts is the vault's bcrypt hashes of the bus accounts it holds, by bus user (ADR 0547 clause 9).
|
||||
ReadAccounts = "mesh-vault.bus-accounts"
|
||||
// ReadPlacement is the seat mesh-placement's placement (ADR 0567 §1, §9): each node's modules and the seats
|
||||
// they hold.
|
||||
ReadPlacement = delivery.PlacementRead
|
||||
// ReadEnrolments is the seat mesh-enrolment's open enrolments, read as a named reader (ADR 0587 §2, §4): a key at
|
||||
// live or redeeming composes the user enrol.<node>.
|
||||
ReadEnrolments = delivery.EnrolmentsRead
|
||||
// ReadNodeLogins is the vault's node-logins, read as its named reader (ADR 0583 §1 insight): each node's login in use
|
||||
// and the one rotating, both composed as users of the node's permissions.
|
||||
ReadNodeLogins = delivery.NodeLoginsRead
|
||||
)
|
||||
|
||||
// Reads is every state the part reads, in the manifest's order. ReadAssignments stays (and in Followed) only while the
|
||||
// controller holds the lease, when the assignments are unioned with the placement (ReadInputs); after the lease write
|
||||
// they place nothing, and the first change after it takes them out (../assignments-readers names the part until then).
|
||||
var Reads = []string{ReadAssignments, ReadControllerGrant, ReadModules, ReadManifests, ReadResolved, ReadHave, ReadWants, ReadBuilds,
|
||||
ReadAccounts, ReadPlacement, ReadLease, ReadTakeover}
|
||||
// Reads is every state the part reads, in the manifest's order. Not the controller's assignments: the seat's placement
|
||||
// alone places modules (novox/hq ADR 0549 §5, ADR 0571 §10; issue 774).
|
||||
var Reads = []string{ReadModules, ReadManifests, ReadResolved, ReadHave, ReadWants, ReadBuilds,
|
||||
ReadAccounts, ReadPlacement, ReadLease, ReadTakeover, ReadEnrolments, ReadNodeLogins}
|
||||
|
||||
// The bus module's tools the part calls, as its manifest's invokes name them: read-only, each.
|
||||
const (
|
||||
@@ -53,7 +56,7 @@ const (
|
||||
)
|
||||
|
||||
// Invokes is every tool the part calls.
|
||||
var Invokes = []string{CallUsers, CallUserCan, CallStreams, CallRootFree, CallRecordWarrant, CallRecordWarrantController}
|
||||
var Invokes = append([]string{CallUsers, CallUserCan, CallStreams, CallRootFree, CallRecordWarrant}, LeaseCalls...)
|
||||
|
||||
// Reader reads another part's state, named `<module>.<state>`. A refusal (a read not granted yet, a bucket not there) is
|
||||
// an error the part names as a gap and composes past: it never stops for one.
|
||||
@@ -78,24 +81,13 @@ type Gap struct {
|
||||
|
||||
// Standing gaps: what the controller composes from its own store, which no bus value carries in this milestone.
|
||||
var standingGaps = []Gap{
|
||||
{Input: "enrolment tokens", Why: "the live enrolment tokens are in the controller's store alone: no enrolment user is composed"},
|
||||
{Input: "enrolment tokens", Why: "while the controller holds the lease, its live enrolment tokens are in its store alone: " +
|
||||
"an enrolment user is composed only from the seat mesh-enrolment's keys"},
|
||||
{Input: "people", Why: "the people and the tools they may invoke are in the controller's store alone: no person user is composed"},
|
||||
{Input: "seat holdings", Why: "who holds each seat held once for the mesh is in the controller's store alone: a mesh seat claimed on two machines is composed as held on both"},
|
||||
{Input: "announced states", Why: "the controller does not announce its states-announced readable, so every read is composed from `reads`, as when announcements cannot be read"},
|
||||
}
|
||||
|
||||
// assignment is a key of the controller's assignments as composing needs it (mesh-controller internal/chain Assignment):
|
||||
// a value that names no time assigned (a module pulled in), has ended, or waits in a state, is no assignment.
|
||||
type assignment struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Assigned time.Time `json:"assigned,omitzero"`
|
||||
Ended time.Time `json:"ended,omitzero"`
|
||||
State string `json:"state,omitempty"`
|
||||
}
|
||||
|
||||
func (a assignment) stands() bool { return !a.Assigned.IsZero() && a.Ended.IsZero() && a.State == "" }
|
||||
|
||||
// resolved is a key of mesh-config's resolved values, `<node>.<module>.<key>`.
|
||||
type resolved struct {
|
||||
Node string `json:"node"`
|
||||
@@ -106,10 +98,9 @@ type resolved struct {
|
||||
|
||||
// Inputs is what the shadow composes from, read from the bus.
|
||||
type Inputs struct {
|
||||
// Nodes is every machine seen: in a node agent's have or a standing assignment.
|
||||
// Nodes is every machine seen: in a node agent's have or the placement.
|
||||
Nodes []string
|
||||
// Assigned is each machine's standing assignments and what the seat's placement puts there, sorted: while both
|
||||
// stand (ADR 0549 §5) a module either names keeps its membership.
|
||||
// Assigned is what the seat's placement puts on each machine, sorted (issue 774: never the controller's assignments).
|
||||
Assigned map[string][]string
|
||||
// Placed is each node's placement as the seat mesh-placement holds it (ADR 0567 §1); empty before any is read.
|
||||
// Where it is, a seat held once for the mesh is held where placement says, not wherever a module claiming it runs.
|
||||
@@ -134,6 +125,15 @@ type Inputs struct {
|
||||
Loaded *delivery.UserListWritten
|
||||
// Hashes are the vault's hashes of the bus accounts, by bus user (ReadAccounts); kept in memory, never written.
|
||||
Hashes map[string]HeldHash
|
||||
// Enrolling is every node whose enrolment key is at live or redeeming (ReadEnrolments, ADR 0587 §4, §5), sorted: one
|
||||
// user enrol.<node> each. A key at asked has no token yet; an ended or expired key is gone from the bucket.
|
||||
Enrolling []string
|
||||
// NodeLogins is each node's key of the vault's node-logins (ReadNodeLogins, ADR 0583 §1); a node absent logs in as
|
||||
// node.<n> alone.
|
||||
NodeLogins map[string]delivery.NodeLogin
|
||||
// LeaseAway says the controller's lease is handed away, as ReadInputs read it: an enrolment user then enrols through
|
||||
// the seat alone (ADR 0587 §6).
|
||||
LeaseAway bool
|
||||
// Refused is every manifest the indexer holds that the part cannot read or mesh-sdk's reader refuses, assigned or
|
||||
// not, each with why: its module counts as placed nowhere, so it is said loudly, never passed over (issue 1945).
|
||||
Refused []Gap
|
||||
@@ -249,26 +249,11 @@ func ReadInputs(r Reader, cache *Manifests) Inputs {
|
||||
NoFingerprint: map[string]string{}, Running: map[string]catalogue.Manifest{},
|
||||
Values: map[string]any{}, Gaps: append([]Gap(nil), standingGaps...)}
|
||||
nodes := map[string]bool{}
|
||||
// **After the lease write the assignments place nothing** (novox/hq ADR 0549 §3, §6): they are frozen then, so a
|
||||
// module the catalogue takes off a node would keep its account, grant, membership and user-list entry. Once the lease
|
||||
// is handed away and the placement is read, the placement alone places modules, as the assigner's Placed does with
|
||||
// the same lease reading (buscompose.LeaseHandedAway); before, or with no placement on the bus, the two are unioned.
|
||||
byAssignment, busByAssignment := map[string][]string{}, ""
|
||||
for _, raw := range each(r, ReadAssignments, &in.Gaps) {
|
||||
var a assignment
|
||||
if json.Unmarshal(raw, &a) != nil || !a.stands() || a.Node == "" || a.Module == "" {
|
||||
continue
|
||||
}
|
||||
byAssignment[a.Node] = append(byAssignment[a.Node], a.Module)
|
||||
if a.Module == BusModule {
|
||||
busByAssignment = a.Node
|
||||
}
|
||||
}
|
||||
// The seat's placement (ADR 0567 §9): what it places is composed beside the assignments, and the seats held once
|
||||
// for the mesh are held where it says. Not on the bus yet is a standing gap, never a failed read, so the list is still
|
||||
// composed and sealed from the assignments alone.
|
||||
// **The seat's placement alone places modules** (novox/hq ADR 0549 §5, ADR 0567 §9; issue 774): the controller's
|
||||
// assignments are read no more, and the seats held once for the mesh are held where it says. Not read is a failed
|
||||
// read: nothing is placed, so after take-over the list composed past it is not sealed.
|
||||
if keys, err := r.Keys(ReadPlacement); err != nil {
|
||||
in.Gaps = append(in.Gaps, Gap{Input: ReadPlacement, Why: "not read yet: the assignments alone place modules (" + err.Error() + ")"})
|
||||
in.Gaps = append(in.Gaps, Gap{Input: ReadPlacement, Why: "refused or absent: nothing is placed (" + err.Error() + ")", Failed: true})
|
||||
} else {
|
||||
// Each node's keys in either form, assembled by the seat's one reader (delivery.PlacementsOf; research 072
|
||||
// row 37): its node key and a key per module, or its legacy key `placement.<node>`.
|
||||
@@ -295,17 +280,7 @@ func ReadInputs(r Reader, cache *Manifests) Inputs {
|
||||
in.Placed[node] = p
|
||||
}
|
||||
}
|
||||
away, _ := leaseHandedAway(r)
|
||||
if away && len(in.Placed) > 0 {
|
||||
in.Gaps = append(in.Gaps, Gap{Input: ReadAssignments, Why: "not used: the controller's lease is handed away, " +
|
||||
"so the seat's placement alone places modules (ADR 0549 §3, §6)"})
|
||||
} else {
|
||||
for node, ms := range byAssignment {
|
||||
nodes[node] = true
|
||||
in.Assigned[node] = append(in.Assigned[node], ms...)
|
||||
}
|
||||
in.BusNode = busByAssignment
|
||||
}
|
||||
in.LeaseAway, _ = leaseHandedAway(r)
|
||||
for node, p := range in.Placed {
|
||||
nodes[node] = true
|
||||
for _, m := range p.Assigned() {
|
||||
@@ -329,7 +304,7 @@ func ReadInputs(r Reader, cache *Manifests) Inputs {
|
||||
nodes[node] = true
|
||||
}
|
||||
}
|
||||
in.Gaps = append(in.Gaps, Gap{Input: "machines", Why: "read from the node agents' haves and the assignments: a machine with neither is not seen"})
|
||||
in.Gaps = append(in.Gaps, Gap{Input: "machines", Why: "read from the node agents' haves and the placement: a machine with neither is not seen"})
|
||||
for n := range nodes {
|
||||
in.Nodes = append(in.Nodes, n)
|
||||
}
|
||||
@@ -473,6 +448,13 @@ func ReadInputs(r Reader, cache *Manifests) Inputs {
|
||||
in.Gaps = append(in.Gaps, Gap{Input: "wanted manifests", Why: "no build record names the manifest at the wanted fingerprint, so the trunk's is used: " + strings.Join(fromTrunk, ", ")})
|
||||
}
|
||||
in.Hashes = readHashes(r, &in.Gaps)
|
||||
// The controller's tokens stand as a gap while it holds the lease; from the write the seat's keys are every token.
|
||||
var enrolmentsRead bool
|
||||
in.Enrolling, enrolmentsRead = readEnrolling(r, &in.Gaps)
|
||||
if enrolmentsRead && in.LeaseAway {
|
||||
in.Gaps = slices.DeleteFunc(in.Gaps, func(g Gap) bool { return g.Input == "enrolment tokens" })
|
||||
}
|
||||
in.NodeLogins = readNodeLogins(r, &in.Gaps)
|
||||
for _, raw := range each(r, ReadResolved, &in.Gaps) {
|
||||
var v resolved
|
||||
if json.Unmarshal(raw, &v) != nil || v.Node == "" || v.Module == "" || v.Key == "" || len(v.Value) == 0 {
|
||||
@@ -487,6 +469,86 @@ func ReadInputs(r Reader, cache *Manifests) Inputs {
|
||||
return in
|
||||
}
|
||||
|
||||
// readEnrolling is every node with an enrolment key at live or redeeming (ADR 0587 §4, §5), and whether the seat's state
|
||||
// was read. Not on the bus yet is a standing gap, never a failed read: the list is still composed and sealed, with no
|
||||
// enrolment user. A key that does not read is named and composes no user.
|
||||
func readEnrolling(r Reader, gaps *[]Gap) ([]string, bool) {
|
||||
keys, err := r.Keys(ReadEnrolments)
|
||||
if err != nil {
|
||||
*gaps = append(*gaps, Gap{Input: ReadEnrolments, Why: "not read yet: no enrolment user is composed (" + err.Error() + ")"})
|
||||
return nil, false
|
||||
}
|
||||
var out []string
|
||||
for _, k := range keys {
|
||||
node, isNode := delivery.EnrolmentKeyNode(k)
|
||||
if !isNode {
|
||||
continue
|
||||
}
|
||||
raw, found, err := r.Get(ReadEnrolments, k)
|
||||
if err != nil {
|
||||
*gaps = append(*gaps, Gap{Input: ReadEnrolments + " " + k, Why: err.Error(), Failed: true})
|
||||
continue
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
e, err := delivery.DecodeEnrolment(raw)
|
||||
if err != nil || e.Node != node {
|
||||
*gaps = append(*gaps, Gap{Input: ReadEnrolments + " " + k, Why: "does not read as the enrolment of " + node +
|
||||
", so it composes no user"})
|
||||
continue
|
||||
}
|
||||
if e.Stage == delivery.EnrolmentLive || e.Stage == delivery.EnrolmentRedeeming {
|
||||
out = append(out, node)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, true
|
||||
}
|
||||
|
||||
// readNodeLogins is every node's key of the vault's node-logins (ADR 0583 §1). Not on the bus yet is a standing gap,
|
||||
// never a failed read: every node then logs in as node.<n> alone, as before any rotation. A key that does not read is
|
||||
// named and leaves its node at node.<n>; a read that fails is a failed read, since a rotating node's new login would be
|
||||
// left out.
|
||||
func readNodeLogins(r Reader, gaps *[]Gap) map[string]delivery.NodeLogin {
|
||||
keys, err := r.Keys(ReadNodeLogins)
|
||||
if err != nil {
|
||||
*gaps = append(*gaps, Gap{Input: ReadNodeLogins, Why: "not read yet: every node logs in as node.<node> (" + err.Error() + ")"})
|
||||
return nil
|
||||
}
|
||||
out := map[string]delivery.NodeLogin{}
|
||||
for _, node := range keys {
|
||||
raw, found, err := r.Get(ReadNodeLogins, node)
|
||||
if err != nil {
|
||||
*gaps = append(*gaps, Gap{Input: ReadNodeLogins + " " + node, Why: err.Error(), Failed: true})
|
||||
continue
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
l, err := delivery.DecodeNodeLogin(raw)
|
||||
if err != nil || l.Node != node {
|
||||
*gaps = append(*gaps, Gap{Input: ReadNodeLogins + " " + node, Why: "does not read as the logins of " + node +
|
||||
", so it logs in as node." + node})
|
||||
continue
|
||||
}
|
||||
out[node] = l
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Rotating are the logins the vault's node-logins names in a rotation under way, as a set: a hash of one changes by the
|
||||
// vault's rotation (ADR 0583 §2), never counted as a writer changing several hashes at once.
|
||||
func (in Inputs) Rotating() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, l := range in.NodeLogins {
|
||||
if l.Rotates() {
|
||||
out[l.Login], out[l.Rotating] = true, true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func gapNamed(gaps []Gap, input string) bool {
|
||||
for _, g := range gaps {
|
||||
if g.Input == input {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user