Merge branch 'b6/registry' into feat/registry-milestone

This commit is contained in:
2026-11-05 15:23:24 +01:00
44 changed files with 650 additions and 549 deletions
+9 -9
View File
@@ -3,15 +3,15 @@ package main
import ( import (
"context" "context"
"errors" "errors"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/liveness"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
"testing" "testing"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/liveness"
) )
// owedBus is a link that takes reports and statements and keeps a have bucket in memory. // owedBus is a link that takes reports and statements and keeps a have bucket in memory.
@@ -44,10 +44,10 @@ func (b *owedBus) Set(_ context.Context, key string, value []byte, revision uint
return nil return nil
} }
func (b *owedBus) have(key string) (delivery.Have, bool) { func (b *owedBus) have(key string) (nodeengine.HaveByNodeModuleValue, bool) {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
h, err := delivery.DecodeHave(b.kv[key]) h, err := nodeengineclient.DecodeHave(b.kv[key])
return h, err == nil return h, err == nil
} }
@@ -66,19 +66,19 @@ func TestAModuleWithNothingLongRunningIsOkWithinALookOfItsApply(t *testing.T) {
} }
fact := link.ModuleFact{Module: "xorg", FP: "src2:" + strings.Repeat("a", 64), Composed: "sha256:" + strings.Repeat("b", 64)} fact := link.ModuleFact{Module: "xorg", FP: "src2:" + strings.Repeat("a", 64), Composed: "sha256:" + strings.Repeat("b", 64)}
applying := fact applying := fact
applying.Phase = string(delivery.PhaseApplying) applying.Phase = string(nodeengineclient.PhaseApplying)
queue.NoteModule(ctx, applying) queue.NoteModule(ctx, applying)
go judgeWhatRuns(ctx, j, queue, func(string) {}, nil, nil) go judgeWhatRuns(ctx, j, queue, func(string) {}, nil, nil)
// The facts look inside the apply, while the module is still applying. // The facts look inside the apply, while the module is still applying.
queue.SayFacts(ctx, link.Report{Health: &link.Health{Contract: link.LivenessContract, At: time.Now().UTC()}}) queue.SayFacts(ctx, link.Report{Health: &link.Health{Contract: link.LivenessContract, At: time.Now().UTC()}})
if h, _ := bus.have("n1.xorg"); h.Health != delivery.HealthUnknown { if h, _ := bus.have("n1.xorg"); h.Health != nodeengineclient.HealthUnknown {
t.Fatalf("judged while applying: %+v", h) t.Fatalf("judged while applying: %+v", h)
} }
queue.NoteModule(ctx, fact) queue.NoteModule(ctx, fact)
deadline := time.Now().Add(2 * time.Second) // well within one look (liveness.LookEvery, 15 s) deadline := time.Now().Add(2 * time.Second) // well within one look (liveness.LookEvery, 15 s)
for { for {
h, _ := bus.have("n1.xorg") h, _ := bus.have("n1.xorg")
if h.Phase == delivery.PhaseRunning && h.Health == delivery.HealthOK { if h.Phase == nodeengineclient.PhaseRunning && h.Health == nodeengineclient.HealthOK {
return return
} }
if time.Now().After(deadline) { if time.Now().After(deadline) {
+1 -1
View File
@@ -1413,7 +1413,7 @@ func healthAsReported(st liveness.Statement, ns *network.Statement) *link.Health
h.Network = &link.NetworkHealth{State: ns.State, Since: ns.Since.UTC(), Parts: []link.NetworkPart{}} h.Network = &link.NetworkHealth{State: ns.State, Since: ns.Since.UTC(), Parts: []link.NetworkPart{}}
for _, p := range ns.Parts { for _, p := range ns.Parts {
h.Network.Parts = append(h.Network.Parts, link.NetworkPart{Part: p.Part, State: p.State, Reason: p.Reason, h.Network.Parts = append(h.Network.Parts, link.NetworkPart{Part: p.Part, State: p.State, Reason: p.Reason,
Said: p.Said, Writer: p.Writer, Owner: p.Owner, Toward: p.Toward, Since: p.Since.UTC(), Streak: p.Streak}) Said: p.Said, Writer: p.Writer, Owner: p.Owner, Toward: p.Toward, Since: p.Since.UTC(), Streak: int64(p.Streak)})
} }
} }
return h return h
+7 -6
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"github.com/novox/mesh-host/internal/compositions" "github.com/novox/mesh-host/internal/compositions"
"os" "os"
@@ -86,7 +87,7 @@ func (h sessionHaves) Read(ctx context.Context, module string) (agent.Have, bool
if !ok { if !ok {
return agent.Have{}, false return agent.Have{}, false
} }
return agent.Have{Module: module, FP: got.FP, Composed: got.Composed, Phase: string(got.Phase), Detail: got.Detail, return agent.Have{Module: module, FP: got.Fp, Composed: got.Composed, Phase: string(got.Phase), Detail: got.Detail,
Sealed: got.SealedAt}, true Sealed: got.SealedAt}, true
} }
@@ -104,24 +105,24 @@ func (h sessionHaves) ReadErr(ctx context.Context, module string) (agent.Have, b
if !ok || err != nil { if !ok || err != nil {
return agent.Have{}, false, err return agent.Have{}, false, err
} }
return agent.Have{Module: module, FP: got.FP, Composed: got.Composed, Phase: string(got.Phase), Detail: got.Detail, return agent.Have{Module: module, FP: got.Fp, Composed: got.Composed, Phase: string(got.Phase), Detail: got.Detail,
Sealed: got.SealedAt}, true, nil Sealed: got.SealedAt}, true, nil
} }
func (h sessionHaves) Write(ctx context.Context, x agent.Have) { func (h sessionHaves) Write(ctx context.Context, x agent.Have) {
if x.Module == agent.NodeModule { if x.Module == agent.NodeModule {
// The node's own key holds its facts and the digest of the composition it runs. // The node's own key holds its facts and the digest of the composition it runs.
if x.Phase == "" || x.Phase == string(delivery.PhaseRunning) { if x.Phase == "" || x.Phase == string(nodeengineclient.PhaseRunning) {
h.session.NoteNode(ctx, x.Composed, x.Sealed) h.session.NoteNode(ctx, x.Composed, x.Sealed)
} }
return return
} }
phase := x.Phase phase := x.Phase
if phase == string(delivery.PhaseRunning) { if phase == string(nodeengineclient.PhaseRunning) {
phase = "" phase = ""
} }
h.session.NoteModule(ctx, link.ModuleFact{Module: x.Module, FP: x.FP, Composed: x.Composed, Phase: phase, h.session.NoteModule(ctx, link.ModuleFact{Module: x.Module, FP: x.FP, Composed: x.Composed, Phase: phase,
Failed: phase == string(delivery.PhaseFailed), Detail: shorten(x.Detail), Judgement: x.Judgement, Waits: x.Waits, Failed: phase == string(nodeengineclient.PhaseFailed), Detail: shorten(x.Detail), Judgement: x.Judgement, Waits: x.Waits,
BuildAlone: x.BuildAlone, Kept: x.Kept, Sealed: x.Sealed, PullWait: x.PullWait}) BuildAlone: x.BuildAlone, Kept: x.Kept, Sealed: x.Sealed, PullWait: x.PullWait})
} }
@@ -653,7 +654,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
composerNow.Store(composer) // what the verb compose asks a pass of, this run's alone composerNow.Store(composer) // what the verb compose asks a pass of, this run's alone
env.Composed, env.SelfComposed, env.Policy, env.ComposedToday = composer, true, composer.Policy, composer.ComposedToday env.Composed, env.SelfComposed, env.Policy, env.ComposedToday = composer, true, composer.Policy, composer.ComposedToday
env.HoldsData = composer.HoldsData // what runs from the kept declaration says Data as its manifest does env.HoldsData = composer.HoldsData // what runs from the kept declaration says Data as its manifest does
session.NoteComposes(linked, delivery.ComposesOwn, "it composes its own modules, and applies no composition made elsewhere") session.NoteComposes(linked, nodeengineclient.ComposesOwn, "it composes its own modules, and applies no composition made elsewhere")
ag = agent.New(env) ag = agent.New(env)
own.set(ag) own.set(ag)
current.set(ag) current.set(ag)
+29 -28
View File
@@ -8,6 +8,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"github.com/novox/mesh-host/internal/buckets"
engcomp "github.com/novox/mesh-host/internal/compositions" engcomp "github.com/novox/mesh-host/internal/compositions"
"os" "os"
"path/filepath" "path/filepath"
@@ -136,7 +137,7 @@ func newItBedAs(t *testing.T, perms nodegrant.Permissions) *itBed {
into *nats.KeyValue into *nats.KeyValue
history uint8 history uint8
}{{delivery.AssignerWantsBucket, &b.wants, 8}, {composedBucket, &b.composed, engcomp.History}, }{{delivery.AssignerWantsBucket, &b.wants, 8}, {composedBucket, &b.composed, engcomp.History},
{delivery.HaveBucket, &b.haves, delivery.HaveHistory}} { {buckets.Have, &b.haves, buckets.HaveHistory}} {
kv, err := b.js.CreateKeyValue(&nats.KeyValueConfig{Bucket: c.name, History: c.history}) kv, err := b.js.CreateKeyValue(&nats.KeyValueConfig{Bucket: c.name, History: c.history})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -200,13 +201,13 @@ func (b *itBed) file(module string) string {
return string(got) return string(got)
} }
func (b *itBed) have(module string) delivery.Have { func (b *itBed) have(module string) nodeengine.HaveByNodeModuleValue {
key, _ := delivery.HaveKey(itNode, module) key, _ := nec.HaveKey(itNode, module)
e, err := b.haves.Get(key) e, err := b.haves.Get(key)
if err != nil { if err != nil {
return delivery.Have{} return nodeengine.HaveByNodeModuleValue{}
} }
h, _ := delivery.DecodeHave(e.Value()) h, _ := nec.DecodeHave(e.Value())
return h return h
} }
@@ -293,11 +294,11 @@ func h8once(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, place []
if settled == 0 { if settled == 0 {
t.Fatal("feat-x was never answered") t.Fatal("feat-x was never answered")
} }
hkey, _ := delivery.HaveKey(itNode, "web") hkey, _ := nec.HaveKey(itNode, "web")
for _, detail := range []string{"its tool web_health: no link to the bus is open", "its tool web_health: no answer within 10s"} { for _, detail := range []string{"its tool web_health: no link to the bus is open", "its tool web_health: no answer within 10s"} {
h := b.have("web") h := b.have("web")
h.Health, h.Detail = delivery.HealthFailed, detail h.Health, h.Detail = nec.HealthFailed, detail
raw, err := delivery.EncodeHave(h) raw, err := nec.EncodeHave(h)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -334,15 +335,15 @@ func h8once(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, place []
// composes its own. // composes its own.
func h8back(t *testing.T, b *itBed, placement, resolved, candidates nats.KeyValue, hkey string) { func h8back(t *testing.T, b *itBed, placement, resolved, candidates nats.KeyValue, hkey string) {
t.Helper() t.Helper()
node := func() delivery.HaveNode { node := func() nodeengine.HaveByNodeNodeValue {
e, err := b.haves.Get(hkey) e, err := b.haves.Get(hkey)
if err != nil { if err != nil {
return delivery.HaveNode{} return nodeengine.HaveByNodeNodeValue{}
} }
n, _ := delivery.DecodeHaveNode(e.Value()) n, _ := nec.DecodeHaveNode(e.Value())
return n return n
} }
if n := node(); n.Composes != delivery.ComposesOwn { if n := node(); n.Composes != nec.ComposesOwn {
t.Errorf("a node composing its own says it composes %q", n.Composes) t.Errorf("a node composing its own says it composes %q", n.Composes)
} }
craw, err := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Proposal: "revert-x"}) craw, err := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Proposal: "revert-x"})
@@ -377,10 +378,10 @@ func h8back(t *testing.T, b *itBed, placement, resolved, candidates nats.KeyValu
b.mu.Lock() b.mu.Lock()
said := strings.Join(b.said, "\n") said := strings.Join(b.said, "\n")
b.mu.Unlock() b.mu.Unlock()
if n := node(); n.Composes != delivery.ComposesOwn || strings.Contains(said, "applying the controller's compositions") { if n := node(); n.Composes != nec.ComposesOwn || strings.Contains(said, "applying the controller's compositions") {
t.Errorf("without its placement the node turned to the controller's compositions: %q", n.Composes) t.Errorf("without its placement the node turned to the controller's compositions: %q", n.Composes)
} }
if h := b.have("web"); h.Composed != ran || h.Phase == delivery.PhaseFailed || b.file("web") != "greeting=salut\n" { if h := b.have("web"); h.Composed != ran || h.Phase == nec.PhaseFailed || b.file("web") != "greeting=salut\n" {
t.Errorf("without its placement what runs moved: %+v, %q", h, b.file("web")) t.Errorf("without its placement what runs moved: %+v, %q", h, b.file("web"))
} }
} }
@@ -422,12 +423,12 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) {
fp := fpIt("a") fp := fpIt("a")
modv, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/web", Commit: strings.Repeat("1", 40)}) modv, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/web", Commit: strings.Repeat("1", 40)})
place, _ := delivery.EncodePlacement(delivery.Placement{Node: itNode, Modules: []delivery.PlacedModule{{Module: "web"}}}) place, _ := delivery.EncodePlacement(delivery.Placement{Node: itNode, Modules: []delivery.PlacedModule{{Module: "web"}}})
rec, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: itNode, Capabilities: []string{"service-manager", "package-manager"}}}) rec, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: itNode, Capabilities: []string{"service-manager", "package-manager"}}})
// it1 in one key per module and node (research 072 row 37), it2 in the form before: a node reads both. // it1 in one key per module and node (research 072 row 37), it2 in the form before: a node reads both.
pkey, _ := delivery.PlacementNodeKey(itNode) pkey, _ := delivery.PlacementNodeKey(itNode)
places, _ := delivery.PlacementValues(delivery.Placement{Node: itNode, Modules: []delivery.PlacedModule{{Module: "web"}}}) places, _ := delivery.PlacementValues(delivery.Placement{Node: itNode, Modules: []delivery.PlacedModule{{Module: "web"}}})
place = places[pkey] place = places[pkey]
hkey, _ := delivery.HaveNodeKey(itNode) hkey, _ := nec.HaveNodeKey(itNode)
// A provider on it2 serving from its own config value: a mail domain, which it1's composition settles. // A provider on it2 serving from its own config value: a mail domain, which it1's composition settles.
mailer := `{"module":"mailer","version":"1","config":{"domain":{"type":"string","description":"the mail domain","required":true}},` + mailer := `{"module":"mailer","version":"1","config":{"domain":{"type":"string","description":"the mail domain","required":true}},` +
`"provides":[{"name":"smtp","scope":"mesh"}],"serves":{"smtp":{"port":587,"domain":"${config:domain}"}}}` `"provides":[{"name":"smtp","scope":"mesh"}],"serves":{"smtp":{"port":587,"domain":"${config:domain}"}}}`
@@ -436,9 +437,9 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) {
mlkey, _ := delivery.ManifestsKey(mdigest) mlkey, _ := delivery.ManifestsKey(mdigest)
mlmod, _ := delivery.EncodeModule(delivery.Module{FP2: fpIt("b"), Manifest: mdigest, Repository: "novox/mailer", Commit: strings.Repeat("2", 40)}) mlmod, _ := delivery.EncodeModule(delivery.Module{FP2: fpIt("b"), Manifest: mdigest, Repository: "novox/mailer", Commit: strings.Repeat("2", 40)})
place2, _ := delivery.EncodePlacement(delivery.Placement{Node: "it2", Modules: []delivery.PlacedModule{{Module: "mailer"}}}) place2, _ := delivery.EncodePlacement(delivery.Placement{Node: "it2", Modules: []delivery.PlacedModule{{Module: "mailer"}}})
rec2, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "it2", Capabilities: []string{"service-manager"}}}) rec2, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "it2", Capabilities: []string{"service-manager"}}})
pkey2, _ := delivery.LegacyPlacementKey("it2") pkey2, _ := delivery.LegacyPlacementKey("it2")
hkey2, _ := delivery.HaveNodeKey("it2") hkey2, _ := nec.HaveNodeKey("it2")
buildKey, _ := delivery.BuilderBuildKey("web", fp) buildKey, _ := delivery.BuilderBuildKey("web", fp)
done, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: digest, done, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: digest,
Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://web/files/blobs/" + fpDigest("f"), Digest: fpDigest("f")}}}) Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://web/files/blobs/" + fpDigest("f"), Digest: fpDigest("f")}}})
@@ -503,7 +504,7 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str
t.Helper() t.Helper()
within(t, 2*time.Second, "the have does not name the build", func() bool { within(t, 2*time.Second, "the have does not name the build", func() bool {
h := b.have("web") h := b.have("web")
return h.FP == fp && h.Composed != "" && h.Phase == delivery.PhaseRunning return h.Fp == fp && h.Composed != "" && h.Phase == nec.PhaseRunning
}) })
// ADR 0593 §6: the node publishes what it composed in its composition state, under its own grant: web at the build // ADR 0593 §6: the node publishes what it composed in its composition state, under its own grant: web at the build
// its want names, with the digest its have names (the plan left out of it), and the node's own. // its want names, with the digest its have names (the plan left out of it), and the node's own.
@@ -527,7 +528,7 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str
within(t, 3*time.Second, "a changed config value was not composed and applied again", func() bool { return b.file("web") == "greeting=salut\n" }) within(t, 3*time.Second, "a changed config value was not composed and applied again", func() bool { return b.file("web") == "greeting=salut\n" })
within(t, 2*time.Second, "the have does not name the revision", func() bool { within(t, 2*time.Second, "the have does not name the revision", func() bool {
h := b.have("web") h := b.have("web")
return h.FP == fp && h.Composed != first && h.Phase == delivery.PhaseRunning return h.Fp == fp && h.Composed != first && h.Phase == nec.PhaseRunning
}) })
// The node says its facts (its record, as the test wrote it), then a candidate equal to its placement is written: the // The node says its facts (its record, as the test wrote it), then a candidate equal to its placement is written: the
@@ -551,7 +552,7 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str
if err != nil { if err != nil {
return false return false
} }
n, err := delivery.DecodeHaveNode(e.Value()) n, err := nec.DecodeHaveNode(e.Value())
wire, _ := json.Marshal(n.Candidates["feat-x"]) wire, _ := json.Marshal(n.Candidates["feat-x"])
_ = json.Unmarshal(wire, &answer) _ = json.Unmarshal(wire, &answer)
return err == nil && answer.Proposal == "feat-x" && n.Record != nil return err == nil && answer.Proposal == "feat-x" && n.Record != nil
@@ -787,13 +788,13 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test
t.Fatal(err) t.Fatal(err)
} }
} }
rec, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: itNode, rec, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: itNode,
Capabilities: []string{"service-manager", "package-manager", "container-runtime"}}}) Capabilities: []string{"service-manager", "package-manager", "container-runtime"}}})
hkey, _ := delivery.HaveNodeKey(itNode) hkey, _ := nec.HaveNodeKey(itNode)
places, _ := delivery.PlacementValues(delivery.Placement{Node: itNode, Modules: []delivery.PlacedModule{{Module: "nats"}}}) places, _ := delivery.PlacementValues(delivery.Placement{Node: itNode, Modules: []delivery.PlacedModule{{Module: "nats"}}})
// The have the engine wrote of the folded bus: the old-form build, no fingerprint. // The have the engine wrote of the folded bus: the old-form build, no fingerprint.
natsHave, _ := delivery.HaveKey(itNode, "nats") natsHave, _ := nec.HaveKey(itNode, "nats")
oldHave, _ := delivery.EncodeHave(delivery.Have{Build: "build-1792674595610176744", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) oldHave, _ := nec.EncodeHave(nodeengine.HaveByNodeModuleValue{Build: "build-1792674595610176744", Phase: nec.PhaseRunning, Health: nec.HealthOK})
for key, value := range map[string][]byte{hkey: rec, natsHave: oldHave} { for key, value := range map[string][]byte{hkey: rec, natsHave: oldHave} {
if _, err := b.haves.Put(key, value); err != nil { if _, err := b.haves.Put(key, value); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -848,7 +849,7 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test
within(t, 10*time.Second, "the bus module's first want was not applied", func() bool { within(t, 10*time.Second, "the bus module's first want was not applied", func() bool {
h := b.have("nats") h := b.have("nats")
got, _ := os.ReadFile(conf) got, _ := os.ReadFile(conf)
return h.FP == first && h.Phase == delivery.PhaseRunning && string(got) == "listen: new\n" return h.Fp == first && h.Phase == nec.PhaseRunning && string(got) == "listen: new\n"
}) })
if moved := runtime.moved(server); len(moved) != 0 || runtime.containers[server] != made { if moved := runtime.moved(server); len(moved) != 0 || runtime.containers[server] != made {
t.Fatalf("the first want moved the bus server its build does not change: %v", moved) t.Fatalf("the first want moved the bus server its build does not change: %v", moved)
@@ -871,10 +872,10 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test
busWant(delivery.Want{FP: next, Self: true}) busWant(delivery.Want{FP: next, Self: true})
within(t, 10*time.Second, "the build that changes the server was not applied", func() bool { within(t, 10*time.Second, "the build that changes the server was not applied", func() bool {
h := b.have("nats") h := b.have("nats")
if h.Phase == delivery.PhaseWaitingYes && h.Waits != "" { if h.Phase == nec.PhaseWaitingYes && h.Waits != "" {
busWant(delivery.Want{FP: next, Self: true, Yes: h.Waits}) busWant(delivery.Want{FP: next, Self: true, Yes: h.Waits})
} }
return h.FP == next && h.Phase == delivery.PhaseRunning return h.Fp == next && h.Phase == nec.PhaseRunning
}) })
var runs int var runs int
for _, a := range runtime.moved(server) { for _, a := range runtime.moved(server) {
+5 -3
View File
@@ -7,6 +7,8 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -193,7 +195,7 @@ func placedAllHave(ctx context.Context, session *link.Session, node string) (boo
if err != nil { if err != nil {
return false, err.Error() return false, err.Error()
} }
return allHave(p, func(module string) (delivery.Have, bool, error) { return allHave(p, func(module string) (nodeengine.HaveByNodeModuleValue, bool, error) {
return session.ReadHaveErr(ctx, module) return session.ReadHaveErr(ctx, module)
}) })
} }
@@ -226,7 +228,7 @@ func placedModules(node string, values map[string][]byte) (delivery.Placement, e
// declaration is the one record of what runs of it: retired then, the node's own composed it as nothing and refused to // declaration is the one record of what runs of it: retired then, the node's own composed it as nothing and refused to
// remove what runs (issue 774, 2026-10-30 19:43: systemd-resolved on shanks, nats and restic on novox, each node's own // remove what runs (issue 774, 2026-10-30 19:43: systemd-resolved on shanks, nats and restic on novox, each node's own
// apply refused from then on). // apply refused from then on).
func allHave(p delivery.Placement, has func(module string) (delivery.Have, bool, error)) (bool, string) { func allHave(p delivery.Placement, has func(module string) (nodeengine.HaveByNodeModuleValue, bool, error)) (bool, string) {
if len(p.Modules) == 0 { if len(p.Modules) == 0 {
return false, "the seat mesh-module-assigner places no module here" return false, "the seat mesh-module-assigner places no module here"
} }
@@ -240,7 +242,7 @@ func allHave(p delivery.Placement, has func(module string) (delivery.Have, bool,
} }
// A module the mesh provides (mesh-wireguard) never has a fingerprint: the node's own composes it from the manifest // A module the mesh provides (mesh-wireguard) never has a fingerprint: the node's own composes it from the manifest
// mesh-sdk provides (delivery.ProvidedManifest), never from the kept declaration, so it keeps no read. // mesh-sdk provides (delivery.ProvidedManifest), never from the kept declaration, so it keeps no read.
if h.Phase != delivery.PhaseRemoved && !strings.HasPrefix(h.FP, delivery.SourcePrefix2) && !delivery.ProvidedByMesh(m.Module) { if h.Phase != nodeengineclient.PhaseRemoved && !strings.HasPrefix(h.Fp, delivery.SourcePrefix2) && !delivery.ProvidedByMesh(m.Module) {
return false, m.Module + " runs at a build of the old form (" + h.Build + "), which only the kept declaration records" return false, m.Module + " runs at a build of the old form (" + h.Build + "), which only the kept declaration records"
} }
} }
+10 -8
View File
@@ -5,6 +5,8 @@ import (
"crypto/ed25519" "crypto/ed25519"
"encoding/json" "encoding/json"
"errors" "errors"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
engcomp "github.com/novox/mesh-host/internal/compositions" engcomp "github.com/novox/mesh-host/internal/compositions"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -120,24 +122,24 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) {
placed := delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "zsh"}, {Module: "lemurs"}}} placed := delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "zsh"}, {Module: "lemurs"}}}
// Issue 774, 2026-10-30 19:43: a have naming only a build of the old form (systemd-resolved on shanks, folded in the // Issue 774, 2026-10-30 19:43: a have naming only a build of the old form (systemd-resolved on shanks, folded in the
// node's own) keeps the kept declaration read, since it is the one record of what runs of it. // node's own) keeps the kept declaration read, since it is the one record of what runs of it.
newForm := delivery.Have{FP: delivery.SourcePrefix2 + strings.Repeat("a", 64), Phase: delivery.PhaseRunning} newForm := nodeengine.HaveByNodeModuleValue{Fp: delivery.SourcePrefix2 + strings.Repeat("a", 64), Phase: nodeengineclient.PhaseRunning}
oldForm := delivery.Have{Build: "build-1791404191702719760", Phase: delivery.PhaseRunning} oldForm := nodeengine.HaveByNodeModuleValue{Build: "build-1791404191702719760", Phase: nodeengineclient.PhaseRunning}
for _, row := range []struct { for _, row := range []struct {
name string name string
has map[string]delivery.Have has map[string]nodeengine.HaveByNodeModuleValue
want bool want bool
}{ }{
{"every placed module has a have of the new form", map[string]delivery.Have{"zsh": newForm, "lemurs": newForm}, true}, {"every placed module has a have of the new form", map[string]nodeengine.HaveByNodeModuleValue{"zsh": newForm, "lemurs": newForm}, true},
{"one has none yet", map[string]delivery.Have{"zsh": newForm}, false}, {"one has none yet", map[string]nodeengine.HaveByNodeModuleValue{"zsh": newForm}, false},
{"one runs at a build of the old form", map[string]delivery.Have{"zsh": newForm, "lemurs": oldForm}, false}, {"one runs at a build of the old form", map[string]nodeengine.HaveByNodeModuleValue{"zsh": newForm, "lemurs": oldForm}, false},
} { } {
if got, why := allHave(placed, func(m string) (delivery.Have, bool, error) { h, ok := row.has[m]; return h, ok, nil }); got != row.want { if got, why := allHave(placed, func(m string) (nodeengine.HaveByNodeModuleValue, bool, error) { h, ok := row.has[m]; return h, ok, nil }); got != row.want {
t.Errorf("%s: %v (%s)", row.name, got, why) t.Errorf("%s: %v (%s)", row.name, got, why)
} }
} }
// The module the mesh provides never has a fingerprint, and keeps no read (review of issue 774). // The module the mesh provides never has a fingerprint, and keeps no read (review of issue 774).
withWireguard := delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "zsh"}, {Module: overlay.Name}}} withWireguard := delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "zsh"}, {Module: overlay.Name}}}
if got, why := allHave(withWireguard, func(m string) (delivery.Have, bool, error) { if got, why := allHave(withWireguard, func(m string) (nodeengine.HaveByNodeModuleValue, bool, error) {
if m == overlay.Name { if m == overlay.Name {
return oldForm, true, nil return oldForm, true, nil
} }
+64 -58
View File
@@ -17,6 +17,8 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"github.com/novox/mesh-host/internal/compositions" "github.com/novox/mesh-host/internal/compositions"
"slices" "slices"
"sort" "sort"
@@ -61,18 +63,18 @@ type Have struct {
// waits for a person's yes (ADR 0547 §3). // waits for a person's yes (ADR 0547 §3).
Judgement string Judgement string
Waits string Waits string
// BuildAlone says, in phase waiting-yes, the move changes the build alone (novox/hq ADR 0601 §1, delivery.Have.BuildAlone). // BuildAlone says, in phase waiting-yes, the move changes the build alone (novox/hq ADR 0601 §1, nodeengine.HaveByNodeModuleValue.BuildAlone).
BuildAlone bool BuildAlone bool
// Kept says, in words, what the node keeps of the module as it runs because its manifest does not yet say it (ADR 0601 // Kept says, in words, what the node keeps of the module as it runs because its manifest does not yet say it (ADR 0601
// §4, delivery.Have.Kept): the watchman raises a condition from it. // §4, nodeengine.HaveByNodeModuleValue.Kept): the watchman raises a condition from it.
Kept []string Kept []string
// Sealed is, on a node composing its own, each value the composition it names carries sealed to the node's key, by // Sealed is, on a node composing its own, each value the composition it names carries sealed to the node's key, by
// slot (delivery.Have.SealedAt): kept in the have so the composer reuses them after a restart (ADR 0547 §3), each at // slot (nodeengine.HaveByNodeModuleValue.SealedAt): kept in the have so the composer reuses them after a restart (ADR 0547 §3), each at
// its own slot alone (issue 1126, C1). // its own slot alone (issue 1126, C1).
Sealed compositions.Seals Sealed compositions.Seals
// PullWait is, on a failed have, the pull from a store its apply failed on and waits on the holder of (issue 1966 // PullWait is, on a failed have, the pull from a store its apply failed on and waits on the holder of (issue 1966
// ENG-9, delivery.Have.PullWait): read through the have, as node_haves shows it. // ENG-9, nodeengine.HaveByNodeModuleValue.PullWait): read through the have, as node_haves shows it.
PullWait *delivery.PullWait PullWait *nodeengine.PullWait
} }
// Haves is the node's have bucket as the agent uses it. // Haves is the node's have bucket as the agent uses it.
@@ -220,7 +222,7 @@ type Agent struct {
mu sync.Mutex mu sync.Mutex
wants map[string]wantNow // by module wants map[string]wantNow // by module
comps map[string]compNow // by composed key comps map[string]compNow // by composed key
running map[string]delivery.Have running map[string]nodeengine.HaveByNodeModuleValue
// applied is the composition each module was last applied from: a revision a want named from the key's history is // applied is the composition each module was last applied from: a revision a want named from the key's history is
// not the key's latest value, and what it holds is still held out of the others' applies. // not the key's latest value, and what it holds is still held out of the others' applies.
applied map[string]compNow applied map[string]compNow
@@ -303,7 +305,7 @@ func New(env Env) *Agent {
if env.Say == nil { if env.Say == nil {
env.Say = func(string) {} env.Say = func(string) {}
} }
return &Agent{env: env, wants: map[string]wantNow{}, comps: map[string]compNow{}, running: map[string]delivery.Have{}, applied: map[string]compNow{}, had: map[string]compNow{}, keptData: map[string]bool{}, return &Agent{env: env, wants: map[string]wantNow{}, comps: map[string]compNow{}, running: map[string]nodeengine.HaveByNodeModuleValue{}, applied: map[string]compNow{}, had: map[string]compNow{}, keptData: map[string]bool{},
attempt: map[string]attempt{}, checked: map[string]bool{}, prior: map[string]bool{}, said: map[string]string{}, notComposed: map[string]notComposedMark{}, attempt: map[string]attempt{}, checked: map[string]bool{}, prior: map[string]bool{}, said: map[string]string{}, notComposed: map[string]notComposedMark{},
pulls: map[string]*pullWait{}, settledAt: map[string]time.Time{}, settledSeq: map[string]int{}, pulls: map[string]*pullWait{}, settledAt: map[string]time.Time{}, settledSeq: map[string]int{},
retry: make(chan []string, 8), reconcile: make(chan []string, 64), failedAgain: make(chan string, 8), tries: map[string]int{}, again: make(chan againAsk, 64), retry: make(chan []string, 8), reconcile: make(chan []string, 64), failedAgain: make(chan string, 8), tries: map[string]int{}, again: make(chan againAsk, 64),
@@ -495,7 +497,7 @@ const NotComposedDetail = "the node cannot compose it, and keeps running what it
// composerMark says h is a mark NotComposed wrote: failed, its detail opening with NotComposedDetail. // composerMark says h is a mark NotComposed wrote: failed, its detail opening with NotComposedDetail.
func composerMark(h Have) bool { func composerMark(h Have) bool {
return h.Phase == string(delivery.PhaseFailed) && strings.HasPrefix(h.Detail, NotComposedDetail) return h.Phase == string(nodeengineclient.PhaseFailed) && strings.HasPrefix(h.Detail, NotComposedDetail)
} }
// HavesReadErr is what a Haves may answer besides Read: a have absent (false, nil) told from one that could not be read // HavesReadErr is what a Haves may answer besides Read: a have absent (false, nil) told from one that could not be read
@@ -590,7 +592,7 @@ func (a *Agent) NotComposedEach(ctx context.Context, why string, held map[string
a.mu.Unlock() a.mu.Unlock()
back := mark.prior back := mark.prior
if ran { if ran {
back = Have{Module: module, FP: r.FP, Composed: r.Composed, Phase: string(delivery.PhaseRunning)} back = Have{Module: module, FP: r.Fp, Composed: r.Composed, Phase: string(nodeengineclient.PhaseRunning)}
if r.Phase != "" { if r.Phase != "" {
back.Phase = string(r.Phase) back.Phase = string(r.Phase)
} }
@@ -603,9 +605,9 @@ func (a *Agent) NotComposedEach(ctx context.Context, why string, held map[string
} }
if !marked { if !marked {
if ran { if ran {
mark.prior = Have{Module: module, FP: r.FP, Composed: r.Composed, Phase: string(r.Phase)} mark.prior = Have{Module: module, FP: r.Fp, Composed: r.Composed, Phase: string(r.Phase)}
if mark.prior.Phase == "" { if mark.prior.Phase == "" {
mark.prior.Phase = string(delivery.PhaseRunning) mark.prior.Phase = string(nodeengineclient.PhaseRunning)
} }
} else if h, found, unread := a.readHave(ctx, module); found { } else if h, found, unread := a.readHave(ctx, module); found {
mark.prior = h // what its last have said runs: this agent has run nothing of it since its start mark.prior = h // what its last have said runs: this agent has run nothing of it since its start
@@ -624,7 +626,7 @@ func (a *Agent) NotComposedEach(ctx context.Context, why string, held map[string
// A have that already says another cause (an apply that failed, a backup held, an apply under way) keeps it: a // A have that already says another cause (an apply that failed, a backup held, an apply under way) keeps it: a
// composer's mark over it would hide that cause, and baseline would take the mark for what runs (review of the // composer's mark over it would hide that cause, and baseline would take the mark for what runs (review of the
// switch, round 1). // switch, round 1).
if p := mark.prior.Phase; !marked && p != "" && p != string(delivery.PhaseRunning) && !composerMark(mark.prior) { if p := mark.prior.Phase; !marked && p != "" && p != string(nodeengineclient.PhaseRunning) && !composerMark(mark.prior) {
a.mu.Lock() a.mu.Lock()
a.sayOnce("unmarked-cause:"+module, fmt.Sprintf("%s is not marked: its have says %s (%s), and the node cannot compose it: %s", a.sayOnce("unmarked-cause:"+module, fmt.Sprintf("%s is not marked: its have says %s (%s), and the node cannot compose it: %s",
module, p, mark.prior.Detail, why)) module, p, mark.prior.Detail, why))
@@ -639,7 +641,7 @@ func (a *Agent) NotComposedEach(ctx context.Context, why string, held map[string
a.notComposed[module] = mark a.notComposed[module] = mark
a.mu.Unlock() a.mu.Unlock()
a.writeHave(ctx, Have{Module: module, FP: mark.prior.FP, Composed: mark.prior.Composed, Sealed: mark.prior.Sealed, a.writeHave(ctx, Have{Module: module, FP: mark.prior.FP, Composed: mark.prior.Composed, Sealed: mark.prior.Sealed,
Phase: string(delivery.PhaseFailed), Detail: NotComposedDetail + why}) Phase: string(nodeengineclient.PhaseFailed), Detail: NotComposedDetail + why})
} }
} }
@@ -696,7 +698,7 @@ func (a *Agent) KeptNow(ctx context.Context, kept map[string][]string) {
a.mu.Unlock() a.mu.Unlock()
slices.Sort(changed) slices.Sort(changed)
for _, m := range changed { for _, m := range changed {
if h, ok := a.env.Haves.Read(ctx, m); ok && m != NodeModule && h.Phase != string(delivery.PhaseWaitingYes) { if h, ok := a.env.Haves.Read(ctx, m); ok && m != NodeModule && h.Phase != string(nodeengineclient.PhaseWaitingYes) {
h.Module = m h.Module = m
a.writeHave(ctx, h) a.writeHave(ctx, h)
} }
@@ -733,10 +735,10 @@ func (a *Agent) Running() []compositions.Composed {
var out []compositions.Composed var out []compositions.Composed
for _, m := range mods { for _, m := range mods {
h := a.running[m] h := a.running[m]
if h.Phase == delivery.PhaseRemoved { if h.Phase == nodeengineclient.PhaseRemoved {
continue continue
} }
if c, ok := a.compositionFor(m, h.FP); ok && c.Err == nil && c.Dig == h.Composed { if c, ok := a.compositionFor(m, h.Fp); ok && c.Err == nil && c.Dig == h.Composed {
out = append(out, c.C) out = append(out, c.C)
} else if c, ok := a.applied[m]; ok && c.Dig == h.Composed { } else if c, ok := a.applied[m]; ok && c.Dig == h.Composed {
out = append(out, c.C) out = append(out, c.C)
@@ -890,10 +892,10 @@ func (a *Agent) others(except string) []compositions.Composed {
sort.Strings(mods) sort.Strings(mods)
for _, m := range mods { for _, m := range mods {
h := a.running[m] h := a.running[m]
if h.Phase == delivery.PhaseRemoved { if h.Phase == nodeengineclient.PhaseRemoved {
continue continue
} }
if c, ok := a.compositionFor(m, h.FP); ok && c.Err == nil && c.Dig == h.Composed { if c, ok := a.compositionFor(m, h.Fp); ok && c.Err == nil && c.Dig == h.Composed {
out = append(out, c.C) out = append(out, c.C)
} else if c, ok := a.applied[m]; ok && c.Dig == h.Composed { } else if c, ok := a.applied[m]; ok && c.Dig == h.Composed {
out = append(out, c.C) out = append(out, c.C)
@@ -1091,8 +1093,8 @@ func (a *Agent) keepApplied(module string, comp compNow) {
// backup, or a composer's mark over what still runs. A have backing up, applying, checking, failed or waiting names a // backup, or a composer's mark over what still runs. A have backing up, applying, checking, failed or waiting names a
// composition not applied, or not yet (issue 924, review of 031e106). // composition not applied, or not yet (issue 924, review of 031e106).
func haveRuns(h Have) bool { func haveRuns(h Have) bool {
switch delivery.Phase(h.Phase) { switch string(h.Phase) {
case "", delivery.PhaseRunning, delivery.PhaseHeld: case "", nodeengineclient.PhaseRunning, nodeengineclient.PhaseHeld:
return true return true
} }
return composerMark(h) return composerMark(h)
@@ -1171,7 +1173,7 @@ func (a *Agent) heldOut(except string) []compositions.Composed {
if err != nil || isNode || c.Err != nil || m == except || held[m] { if err != nil || isNode || c.Err != nil || m == except || held[m] {
continue continue
} }
if h, ok := a.running[m]; ok && h.Phase != delivery.PhaseRemoved { if h, ok := a.running[m]; ok && h.Phase != nodeengineclient.PhaseRemoved {
continue continue
} }
if w := a.wants[m]; w.Present && w.Want.Removed { if w := a.wants[m]; w.Present && w.Want.Removed {
@@ -1281,7 +1283,7 @@ func (a *Agent) RetryFailed(module string) error {
return fmt.Errorf("%s has no want on this machine, so there is no apply of it to try again", module) return fmt.Errorf("%s has no want on this machine, so there is no apply of it to try again", module)
case tried && att.Held: case tried && att.Held:
return fmt.Errorf("%s is held for a failed backup: the operator's answer releases it", module) return fmt.Errorf("%s is held for a failed backup: the operator's answer releases it", module)
case !tried && ran && r.FP == w.Want.FP && r.Phase != delivery.PhaseRemoved: case !tried && ran && r.Fp == w.Want.FP && r.Phase != nodeengineclient.PhaseRemoved:
return fmt.Errorf("%s runs what its want names, nothing to retry", module) return fmt.Errorf("%s runs what its want names, nothing to retry", module)
case !tried: case !tried:
return fmt.Errorf("%s has no failed apply to try again: it waits for its composition, or settles on its own", module) return fmt.Errorf("%s has no failed apply to try again: it waits for its composition, or settles on its own", module)
@@ -1314,7 +1316,7 @@ func (a *Agent) HoldsNode() bool {
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock() defer a.mu.Unlock()
h, ok := a.running[NodeModule] h, ok := a.running[NodeModule]
return ok && h.Phase != delivery.PhaseRemoved return ok && h.Phase != nodeengineclient.PhaseRemoved
} }
func (a *Agent) settleOne(ctx context.Context, module string) { func (a *Agent) settleOne(ctx context.Context, module string) {
@@ -1370,7 +1372,7 @@ func (a *Agent) settleOne(ctx context.Context, module string) {
} }
a.mu.Lock() a.mu.Lock()
r, runs := a.running[module] r, runs := a.running[module]
runsWant := runs && r.FP == w.Want.FP && r.Composed == w.Want.Composed && r.Phase != delivery.PhaseRemoved runsWant := runs && r.Fp == w.Want.FP && r.Composed == w.Want.Composed && r.Phase != nodeengineclient.PhaseRemoved
att, tried := a.attempt[module] att, tried := a.attempt[module]
same := tried && att.FP == w.Want.FP && att.Composed == w.Want.Composed same := tried && att.FP == w.Want.FP && att.Composed == w.Want.Composed
if same && att.Held { if same && att.Held {
@@ -1445,7 +1447,7 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com
a.mu.Lock() a.mu.Lock()
a.prior[module] = true a.prior[module] = true
a.mu.Unlock() a.mu.Unlock()
if h.Phase == string(delivery.PhaseHeld) && h.Detail == BackupInterrupted && w.Want.Backup != delivery.BackupSkip { if h.Phase == string(nodeengineclient.PhaseHeld) && h.Detail == BackupInterrupted && w.Want.Backup != delivery.BackupSkip {
// Held only because this node agent stopped while the backup ran (postgres on novox, 2026-10-31): no // Held only because this node agent stopped while the backup ran (postgres on novox, 2026-10-31): no
// failure and nothing for the operator to answer. It is backed up again and applied (a.prior makes the // failure and nothing for the operator to answer. It is backed up again and applied (a.prior makes the
// apply back it up first); the want still asking a backup is all the answer needed. // apply back it up first); the want still asking a backup is all the answer needed.
@@ -1454,7 +1456,7 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com
a.mu.Unlock() a.mu.Unlock()
return false return false
} }
if h.Phase == string(delivery.PhaseHeld) && (w.Want.Backup == delivery.BackupAgain || w.Want.Backup == delivery.BackupSkip) { if h.Phase == string(nodeengineclient.PhaseHeld) && (w.Want.Backup == delivery.BackupAgain || w.Want.Backup == delivery.BackupSkip) {
// Held for a failed backup, and the want already carries the operator's answer, written before this engine // Held for a failed backup, and the want already carries the operator's answer, written before this engine
// started (nats on novox, 2026-11-04: "Back up again" answered, then the engine restarted and took the // started (nats on novox, 2026-11-04: "Back up again" answered, then the engine restarted and took the
// answer for the hold's own revision, so it was never acted on; issue 1585). The answer is acted on now: // answer for the hold's own revision, so it was never acted on; issue 1585). The answer is acted on now:
@@ -1467,7 +1469,7 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com
a.mu.Unlock() a.mu.Unlock()
return false return false
} }
if h.Phase == string(delivery.PhaseHeld) { if h.Phase == string(nodeengineclient.PhaseHeld) {
// Held for a failed backup, and the operator's answer is still to come: the hold is kept, nothing is // Held for a failed backup, and the operator's answer is still to come: the hold is kept, nothing is
// backed up again or applied, and the next revision of the want is the answer (ADR 0568 §4). Said, so // backed up again or applied, and the next revision of the want is the answer (ADR 0568 §4). Said, so
// why it holds is read from the journal and not guessed (issue 1585: this branch was silent). // why it holds is read from the journal and not guessed (issue 1585: this branch was silent).
@@ -1478,7 +1480,7 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com
a.attempt[module] = attempt{FP: w.Want.FP, Composed: w.Want.Composed, WantRev: w.Rev, CompRev: comp.Rev, Held: true, a.attempt[module] = attempt{FP: w.Want.FP, Composed: w.Want.Composed, WantRev: w.Rev, CompRev: comp.Rev, Held: true,
AnsweredRev: w.Rev} AnsweredRev: w.Rev}
if h.Composed != "" { if h.Composed != "" {
a.running[module] = delivery.Have{FP: h.FP, Composed: h.Composed, Phase: delivery.PhaseRunning} a.running[module] = nodeengine.HaveByNodeModuleValue{Fp: h.FP, Composed: h.Composed, Phase: nodeengineclient.PhaseRunning}
} }
a.mu.Unlock() a.mu.Unlock()
a.runsChanged(module + " was taken as held") a.runsChanged(module + " was taken as held")
@@ -1495,18 +1497,18 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com
h.Detail = "" h.Detail = ""
} }
if h.FP == w.Want.FP && h.Composed == w.Want.Composed && if h.FP == w.Want.FP && h.Composed == w.Want.Composed &&
(h.Phase == "" || h.Phase == string(delivery.PhaseRunning) || marked) { (h.Phase == "" || h.Phase == string(nodeengineclient.PhaseRunning) || marked) {
if a.inactiveAtStart(ctx, module, comp) { if a.inactiveAtStart(ctx, module, comp) {
return false return false
} }
a.mu.Lock() a.mu.Lock()
a.running[module] = delivery.Have{FP: h.FP, Composed: h.Composed, Phase: delivery.PhaseRunning} a.running[module] = nodeengine.HaveByNodeModuleValue{Fp: h.FP, Composed: h.Composed, Phase: nodeengineclient.PhaseRunning}
a.mu.Unlock() a.mu.Unlock()
a.runsChanged(module + " was taken from its have") a.runsChanged(module + " was taken from its have")
// Said again, so the session holds the module and its key carries the health this engine judges, // Said again, so the session holds the module and its key carries the health this engine judges,
// not the one an older engine left there (xorg's unknown on g14 and shanks, 2026-10-25). // not the one an older engine left there (xorg's unknown on g14 and shanks, 2026-10-25).
a.writeHave(ctx, Have{Module: module, FP: h.FP, Composed: h.Composed, a.writeHave(ctx, Have{Module: module, FP: h.FP, Composed: h.Composed,
Phase: string(delivery.PhaseRunning), Detail: h.Detail, Sealed: h.Sealed}) Phase: string(nodeengineclient.PhaseRunning), Detail: h.Detail, Sealed: h.Sealed})
return true return true
} }
return false return false
@@ -1529,10 +1531,10 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com
return false return false
} }
a.mu.Lock() a.mu.Lock()
a.running[module] = delivery.Have{FP: w.Want.FP, Composed: w.Want.Composed, Phase: delivery.PhaseRunning} a.running[module] = nodeengine.HaveByNodeModuleValue{Fp: w.Want.FP, Composed: w.Want.Composed, Phase: nodeengineclient.PhaseRunning}
a.mu.Unlock() a.mu.Unlock()
a.runsChanged(module + " was taken from the kept declaration") a.runsChanged(module + " was taken from the kept declaration")
a.writeHave(ctx, Have{Module: module, FP: w.Want.FP, Composed: w.Want.Composed, Phase: string(delivery.PhaseRunning)}) a.writeHave(ctx, Have{Module: module, FP: w.Want.FP, Composed: w.Want.Composed, Phase: string(nodeengineclient.PhaseRunning)})
return true return true
} }
@@ -1567,10 +1569,10 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo
a.mu.Unlock() a.mu.Unlock()
if !runs { if !runs {
if h, found, _ := a.readHave(ctx, module); found { if h, found, _ := a.readHave(ctx, module); found {
prev = delivery.Have{FP: h.FP, Composed: h.Composed} prev = nodeengine.HaveByNodeModuleValue{Fp: h.FP, Composed: h.Composed}
} }
} }
a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(delivery.PhaseBackingUp)}) a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseBackingUp)})
if got := a.env.Backup(ctx, module); !got.Done { if got := a.env.Backup(ctx, module); !got.Done {
words := got.Words words := got.Words
if got.Interrupted || ctx.Err() != nil { if got.Interrupted || ctx.Err() != nil {
@@ -1585,12 +1587,12 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo
a.attempt[module] = attempt{FP: fp, Composed: dig, WantRev: w.Rev, CompRev: comp.Rev, Held: true, AnsweredRev: w.Rev} a.attempt[module] = attempt{FP: fp, Composed: dig, WantRev: w.Rev, CompRev: comp.Rev, Held: true, AnsweredRev: w.Rev}
a.mu.Unlock() a.mu.Unlock()
// It keeps running what it has: the have says what it holds and why it waits. // It keeps running what it has: the have says what it holds and why it waits.
a.writeHave(ctx, Have{Module: module, FP: prev.FP, Composed: prev.Composed, Phase: string(delivery.PhaseHeld), a.writeHave(ctx, Have{Module: module, FP: prev.Fp, Composed: prev.Composed, Phase: string(nodeengineclient.PhaseHeld),
Detail: words}) Detail: words})
return return
} }
} }
a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(delivery.PhaseApplying)}) a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseApplying)})
res := a.env.Apply(ctx, comp.C, others) res := a.env.Apply(ctx, comp.C, others)
a.mu.Lock() a.mu.Lock()
if !res.Failed || res.Transient { if !res.Failed || res.Transient {
@@ -1607,7 +1609,7 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo
gen := a.gen gen := a.gen
a.attempt[module] = attempt{FP: fp, Composed: dig, WantRev: w.Rev, CompRev: comp.Rev, Transient: true, Gen: gen} a.attempt[module] = attempt{FP: fp, Composed: dig, WantRev: w.Rev, CompRev: comp.Rev, Transient: true, Gen: gen}
a.mu.Unlock() a.mu.Unlock()
phase, when := delivery.PhaseApplying, "when the bus answers again" phase, when := nodeengineclient.PhaseApplying, "when the bus answers again"
if a.env.Backoff != nil { if a.env.Backoff != nil {
wait := a.env.Backoff(try) wait := a.env.Backoff(try)
when = "in " + wait.String() when = "in " + wait.String()
@@ -1622,7 +1624,7 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo
}) })
} }
if try > transientFor { if try > transientFor {
phase = delivery.PhaseFailed // still retried, and said as failed: no longer a moment phase = nodeengineclient.PhaseFailed // still retried, and said as failed: no longer a moment
} }
a.env.Say(fmt.Sprintf("applying %s failed because the bus did not answer (try %d), retried %s: %s", what, try, when, res.Detail)) a.env.Say(fmt.Sprintf("applying %s failed because the bus did not answer (try %d), retried %s: %s", what, try, when, res.Detail))
a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(phase), a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(phase),
@@ -1640,7 +1642,7 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo
wait := a.pullWaitOf(module) wait := a.pullWaitOf(module)
a.mu.Unlock() a.mu.Unlock()
a.env.Say(fmt.Sprintf("applying %s failed: %s", what, res.Detail)) a.env.Say(fmt.Sprintf("applying %s failed: %s", what, res.Detail))
a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(delivery.PhaseFailed), Detail: detail, a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseFailed), Detail: detail,
PullWait: wait}) PullWait: wait})
if again != "" { if again != "" {
a.askPullAgain(module, again) a.askPullAgain(module, again)
@@ -1650,14 +1652,14 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo
delete(a.pulls, module) delete(a.pulls, module)
delete(a.attempt, module) delete(a.attempt, module)
delete(a.notComposed, module) // the apply says what it runs now delete(a.notComposed, module) // the apply says what it runs now
a.running[module] = delivery.Have{FP: fp, Composed: dig, Phase: delivery.PhaseRunning} a.running[module] = nodeengine.HaveByNodeModuleValue{Fp: fp, Composed: dig, Phase: nodeengineclient.PhaseRunning}
a.applied[module] = comp a.applied[module] = comp
a.mu.Unlock() a.mu.Unlock()
a.keepApplied(module, comp) a.keepApplied(module, comp)
if a.env.Settled != nil { if a.env.Settled != nil {
a.env.Settled(module) a.env.Settled(module)
} }
a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(delivery.PhaseRunning), Detail: res.Detail}) a.writeHave(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseRunning), Detail: res.Detail})
} }
func (a *Agent) remove(ctx context.Context, module string, w wantNow) { func (a *Agent) remove(ctx context.Context, module string, w wantNow) {
@@ -1668,7 +1670,7 @@ func (a *Agent) remove(ctx context.Context, module string, w wantNow) {
a.mu.Unlock() a.mu.Unlock()
return return
} }
if ran && r.Phase == delivery.PhaseRemoved { if ran && r.Phase == nodeengineclient.PhaseRemoved {
a.mu.Unlock() a.mu.Unlock()
return return
} }
@@ -1685,7 +1687,7 @@ func (a *Agent) remove(ctx context.Context, module string, w wantNow) {
delete(a.attempt, module) delete(a.attempt, module)
delete(a.pulls, module) delete(a.pulls, module)
delete(a.had, module) delete(a.had, module)
a.running[module] = delivery.Have{Phase: delivery.PhaseRemoved} a.running[module] = nodeengine.HaveByNodeModuleValue{Phase: nodeengineclient.PhaseRemoved}
a.mu.Unlock() a.mu.Unlock()
if a.env.Applied != nil { if a.env.Applied != nil {
if err := a.env.Applied.Drop(module); err != nil { if err := a.env.Applied.Drop(module); err != nil {
@@ -1766,17 +1768,17 @@ func (a *Agent) waitsForYes(ctx context.Context, module string, w wantNow, comp
a.readKeptData(ctx, module) // the manifest may read now though it did not at the start a.readKeptData(ctx, module) // the manifest may read now though it did not at the start
a.mu.Lock() a.mu.Lock()
r, ran := a.running[module] r, ran := a.running[module]
if ran && r.Composed == comp.Dig && r.Phase != delivery.PhaseRemoved { if ran && r.Composed == comp.Dig && r.Phase != nodeengineclient.PhaseRemoved {
a.mu.Unlock() a.mu.Unlock()
// Composed back to what runs: a have still waiting for a yes for a move since gone says running again, or it waits // Composed back to what runs: a have still waiting for a yes for a move since gone says running again, or it waits
// for a yes nobody needs (issue 774, 2026-10-31: four providers stayed waiting-yes once their consumers composed). // for a yes nobody needs (issue 774, 2026-10-31: four providers stayed waiting-yes once their consumers composed).
if h, ok := a.env.Haves.Read(ctx, module); ok && h.Phase == string(delivery.PhaseWaitingYes) { if h, ok := a.env.Haves.Read(ctx, module); ok && h.Phase == string(nodeengineclient.PhaseWaitingYes) {
// Its sealed values only when they are of what runs; else writeHave reads them (sealedOf). // Its sealed values only when they are of what runs; else writeHave reads them (sealedOf).
var sealed compositions.Seals var sealed compositions.Seals
if h.Composed == r.Composed { if h.Composed == r.Composed {
sealed = h.Sealed sealed = h.Sealed
} }
a.writeHave(ctx, Have{Module: module, FP: r.FP, Composed: r.Composed, Phase: string(delivery.PhaseRunning), Sealed: sealed}) a.writeHave(ctx, Have{Module: module, FP: r.Fp, Composed: r.Composed, Phase: string(nodeengineclient.PhaseRunning), Sealed: sealed})
} }
return false return false
} }
@@ -1799,12 +1801,12 @@ func (a *Agent) waitsForYes(ctx context.Context, module string, w wantNow, comp
case known: case known:
j = judge.JudgeMove(&from, comp.C) j = judge.JudgeMove(&from, comp.C)
case prior: case prior:
j = judge.Judged{Judgement: delivery.JudgedCannot, Words: "what " + module + " runs on this node cannot be read"} j = judge.Judged{Judgement: nodeengineclient.JudgedCannot, Words: "what " + module + " runs on this node cannot be read"}
default: default:
j = judge.JudgeMove(nil, comp.C) j = judge.JudgeMove(nil, comp.C)
} }
switch j.Judgement { switch j.Judgement {
case delivery.JudgedRecreates, delivery.JudgedMovesData, delivery.JudgedCannot: case nodeengineclient.JudgedRecreates, nodeengineclient.JudgedMovesData, nodeengineclient.JudgedCannot:
default: default:
return false return false
} }
@@ -1812,7 +1814,7 @@ func (a *Agent) waitsForYes(ctx context.Context, module string, w wantNow, comp
a.sayOnce("yes:"+module+":"+comp.Dig, fmt.Sprintf("%s at %s waits for a person's yes: %s", module, w.Want.FP, j.Words)) a.sayOnce("yes:"+module+":"+comp.Dig, fmt.Sprintf("%s at %s waits for a person's yes: %s", module, w.Want.FP, j.Words))
prev := a.running[module] prev := a.running[module]
a.mu.Unlock() a.mu.Unlock()
a.writeHave(ctx, Have{Module: module, FP: prev.FP, Composed: prev.Composed, Phase: string(delivery.PhaseWaitingYes), a.writeHave(ctx, Have{Module: module, FP: prev.Fp, Composed: prev.Composed, Phase: string(nodeengineclient.PhaseWaitingYes),
Detail: j.Words, Judgement: string(j.Judgement), Waits: comp.Dig, BuildAlone: a.buildAlone(ctx, module, prev, w.Want.FP)}) Detail: j.Words, Judgement: string(j.Judgement), Waits: comp.Dig, BuildAlone: a.buildAlone(ctx, module, prev, w.Want.FP)})
return true return true
} }
@@ -1820,17 +1822,17 @@ func (a *Agent) waitsForYes(ctx context.Context, module string, w wantNow, comp
// buildAlone says a move of module from what runs to the build want changes the build alone (ADR 0601 §1): another build than // buildAlone says a move of module from what runs to the build want changes the build alone (ADR 0601 §1): another build than
// the one it runs, and the build it runs composed with today's settings and secrets is the composition it runs. Anything it // the one it runs, and the build it runs composed with today's settings and secrets is the composition it runs. Anything it
// cannot tell is no: the move is then asked of a person. // cannot tell is no: the move is then asked of a person.
func (a *Agent) buildAlone(ctx context.Context, module string, prev delivery.Have, want string) bool { func (a *Agent) buildAlone(ctx context.Context, module string, prev nodeengine.HaveByNodeModuleValue, want string) bool {
if prev.FP == "" && prev.Composed == "" { if prev.Fp == "" && prev.Composed == "" {
// Nothing of it run since this agent's start: its last have says what runs, as the have written keeps it. // Nothing of it run since this agent's start: its last have says what runs, as the have written keeps it.
if h, ok := a.env.Haves.Read(ctx, module); ok { if h, ok := a.env.Haves.Read(ctx, module); ok {
prev.FP, prev.Composed = h.FP, h.Composed prev.Fp, prev.Composed = h.FP, h.Composed
} }
} }
if a.env.ComposedToday == nil || prev.FP == "" || prev.Composed == "" || prev.FP == want { if a.env.ComposedToday == nil || prev.Fp == "" || prev.Composed == "" || prev.Fp == want {
return false return false
} }
dig, ok := a.env.ComposedToday(ctx, module, prev.FP) dig, ok := a.env.ComposedToday(ctx, module, prev.Fp)
return ok && dig == prev.Composed return ok && dig == prev.Composed
} }
@@ -2001,11 +2003,15 @@ func (a *Agent) Settled(holder string) {
// pullWaitOf is the pull a failed apply of module waits on, as its have carries it (ENG-9): the image, the seat and its // pullWaitOf is the pull a failed apply of module waits on, as its have carries it (ENG-9): the image, the seat and its
// holder, when the pull began, and the last retry with what triggered it; nil when it waits on no holder. a.mu is held. // holder, when the pull began, and the last retry with what triggered it; nil when it waits on no holder. a.mu is held.
func (a *Agent) pullWaitOf(module string) *delivery.PullWait { func (a *Agent) pullWaitOf(module string) *nodeengine.PullWait {
pw := a.pulls[module] pw := a.pulls[module]
if pw == nil { if pw == nil {
return nil return nil
} }
return &delivery.PullWait{Image: pw.Image, Seat: pw.Seat, Holder: pw.Holder, Began: pw.Started, Retried: pw.last, out := &nodeengine.PullWait{Image: pw.Image, Seat: pw.Seat, Holder: pw.Holder, Began: pw.Started, Retried: pw.last}
RetriedAt: pw.lastAt} if !pw.lastAt.IsZero() {
at := pw.lastAt
out.RetriedAt = &at
}
return out
} }
+55 -53
View File
@@ -4,6 +4,8 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"github.com/novox/mesh-host/internal/compositions" "github.com/novox/mesh-host/internal/compositions"
"maps" "maps"
"slices" "slices"
@@ -300,7 +302,7 @@ func TestAWantAndItsCompositionApplyTheModuleAndTheHaveNamesBoth(t *testing.T) {
eventually(t, "the module was not applied", func() bool { return len(b.appliedList()) == 1 }) eventually(t, "the module was not applied", func() bool { return len(b.appliedList()) == 1 })
eventually(t, "the have does not name both", func() bool { eventually(t, "the have does not name both", func() bool {
h := b.haves.last("web") h := b.haves.last("web")
return h.FP == fp && h.Composed == dig && h.Phase == string(delivery.PhaseRunning) return h.FP == fp && h.Composed == dig && h.Phase == string(nodeengineclient.PhaseRunning)
}) })
// Settled is told once what runs holds the module, so a declaration read then names its files (the filling's Knock; // Settled is told once what runs holds the module, so a declaration read then names its files (the filling's Knock;
// ace, 2026-10-25 21:17). // ace, 2026-10-25 21:17).
@@ -337,7 +339,7 @@ func TestStartingOnWhatTheKeptDeclarationHoldsAppliesNothing(t *testing.T) {
stop := b.run() stop := b.run()
defer stop() defer stop()
eventually(t, "the have was not written", func() bool { return b.haves.last("web").Composed == dig }) eventually(t, "the have was not written", func() bool { return b.haves.last("web").Composed == dig })
if h := b.haves.last("web"); h.FP != fp || h.Phase != string(delivery.PhaseRunning) { if h := b.haves.last("web"); h.FP != fp || h.Phase != string(nodeengineclient.PhaseRunning) {
t.Fatalf("the have: %+v", h) t.Fatalf("the have: %+v", h)
} }
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
@@ -357,7 +359,7 @@ func TestStartingOnItsOwnHaveAppliesNothing(t *testing.T) {
fp := fpOf("a") fp := fpOf("a")
ck, craw, dig := composition(t, "avahi", fp, false, file("avahi", "x", "1")) ck, craw, dig := composition(t, "avahi", fp, false, file("avahi", "x", "1"))
wk, wraw := wantFor(t, "avahi", fp, dig, "") wk, wraw := wantFor(t, "avahi", fp, dig, "")
b.haves.stored = map[string]Have{"avahi": {Module: "avahi", FP: fp, Composed: dig, Phase: string(delivery.PhaseFailed), b.haves.stored = map[string]Have{"avahi": {Module: "avahi", FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseFailed),
Detail: "apply: unit failed to start"}} Detail: "apply: unit failed to start"}}
b.comps.before(ck, craw) b.comps.before(ck, craw)
b.wants.before(wk, wraw) b.wants.before(wk, wraw)
@@ -372,7 +374,7 @@ func TestStartingOnItsOwnHaveAppliesNothing(t *testing.T) {
fp := fpOf("a") fp := fpOf("a")
ck, craw, dig := composition(t, "avahi", fp, false, file("avahi", "x", "1")) ck, craw, dig := composition(t, "avahi", fp, false, file("avahi", "x", "1"))
wk, wraw := wantFor(t, "avahi", fp, dig, "") wk, wraw := wantFor(t, "avahi", fp, dig, "")
b.haves.stored = map[string]Have{"avahi": {Module: "avahi", FP: fp, Composed: dig, Phase: string(delivery.PhaseFailed), b.haves.stored = map[string]Have{"avahi": {Module: "avahi", FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseFailed),
Detail: NotComposedDetail + "frombus: the credentials of ace: context canceled"}} Detail: NotComposedDetail + "frombus: the credentials of ace: context canceled"}}
b.comps.before(ck, craw) b.comps.before(ck, craw)
b.wants.before(wk, wraw) b.wants.before(wk, wraw)
@@ -380,7 +382,7 @@ func TestStartingOnItsOwnHaveAppliesNothing(t *testing.T) {
defer stop() defer stop()
eventually(t, "the mark was not cleared", func() bool { eventually(t, "the mark was not cleared", func() bool {
h := b.haves.last("avahi") h := b.haves.last("avahi")
return h.Phase == string(delivery.PhaseRunning) && h.FP == fp && h.Composed == dig && h.Detail == "" return h.Phase == string(nodeengineclient.PhaseRunning) && h.FP == fp && h.Composed == dig && h.Detail == ""
}) })
if got := b.appliedList(); len(got) != 0 { if got := b.appliedList(); len(got) != 0 {
t.Fatalf("a module whose mark names what the want names was applied: %v", got) t.Fatalf("a module whose mark names what the want names was applied: %v", got)
@@ -427,7 +429,7 @@ func testStartingOnItsOwnHaveSaysTheHaveAgainSoItsHealthIsThisEngines(t *testing
if len(log) != 1 { if len(log) != 1 {
t.Fatalf("the have was said %d times, want once: %+v", len(log), log) t.Fatalf("the have was said %d times, want once: %+v", len(log), log)
} }
if h := log[0]; h.Module != "xorg" || h.FP != fp || h.Composed != dig || h.Phase != string(delivery.PhaseRunning) || h.Detail != "kept" { if h := log[0]; h.Module != "xorg" || h.FP != fp || h.Composed != dig || h.Phase != string(nodeengineclient.PhaseRunning) || h.Detail != "kept" {
t.Fatalf("the have said again: %+v", h) t.Fatalf("the have said again: %+v", h)
} }
if got := b.appliedList(); len(got) != 0 { if got := b.appliedList(); len(got) != 0 {
@@ -454,7 +456,7 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) {
eventually(t, "not applied", func() bool { return len(b.appliedList()) == 1 }) eventually(t, "not applied", func() bool { return len(b.appliedList()) == 1 })
eventually(t, "the have does not say failed", func() bool { eventually(t, "the have does not say failed", func() bool {
h := b.haves.last("web") h := b.haves.last("web")
return h.Phase == string(delivery.PhaseFailed) && strings.Contains(h.Detail, "no space") && h.FP == fp return h.Phase == string(nodeengineclient.PhaseFailed) && strings.Contains(h.Detail, "no space") && h.FP == fp
}) })
// An unrelated key changes: not a retry. // An unrelated key changes: not a retry.
ck9, craw9, _ := composition(t, "web", fpOf("9"), false, file("web", "x", "9")) ck9, craw9, _ := composition(t, "web", fpOf("9"), false, file("web", "x", "9"))
@@ -472,7 +474,7 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) {
t.Fatalf("a failed apply was refused a retry: %v", err) t.Fatalf("a failed apply was refused a retry: %v", err)
} }
eventually(t, "the asked retry did not apply", func() bool { return len(b.appliedList()) == 2 }) eventually(t, "the asked retry did not apply", func() bool { return len(b.appliedList()) == 2 })
eventually(t, "not running", func() bool { return b.haves.last("web").Phase == string(delivery.PhaseRunning) }) eventually(t, "not running", func() bool { return b.haves.last("web").Phase == string(nodeengineclient.PhaseRunning) })
for module, words := range map[string]string{"web": "runs what its want names", "ghost": "has no want"} { for module, words := range map[string]string{"web": "runs what its want names", "ghost": "has no want"} {
if err := b.ag.RetryFailed(module); err == nil || !strings.Contains(err.Error(), words) { if err := b.ag.RetryFailed(module); err == nil || !strings.Contains(err.Error(), words) {
t.Errorf("a retry of %s: %v, want refused saying %q", module, err, words) t.Errorf("a retry of %s: %v, want refused saying %q", module, err, words)
@@ -509,7 +511,7 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) {
eventually(t, "not applied", func() bool { return len(b.appliedList()) == 1 }) eventually(t, "not applied", func() bool { return len(b.appliedList()) == 1 })
eventually(t, "the have does not say whom it waits on", func() bool { eventually(t, "the have does not say whom it waits on", func() bool {
h := b.haves.last("web") h := b.haves.last("web")
return h.Phase == string(delivery.PhaseFailed) && strings.Contains(h.Detail, "tried again once distribution has settled") return h.Phase == string(nodeengineclient.PhaseFailed) && strings.Contains(h.Detail, "tried again once distribution has settled")
}) })
b.idle() b.idle()
if n := len(b.appliedList()); n != 1 { if n := len(b.appliedList()); n != 1 {
@@ -527,7 +529,7 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) {
eventually(t, "not tried again on the holder's settled", func() bool { return len(b.appliedList()) == 2 }) eventually(t, "not tried again on the holder's settled", func() bool { return len(b.appliedList()) == 2 })
eventually(t, "the have does not say what triggered the retry", func() bool { eventually(t, "the have does not say what triggered the retry", func() bool {
h := b.haves.last("web") h := b.haves.last("web")
return h.Phase == string(delivery.PhaseFailed) && strings.Contains(h.Detail, "last tried again at") && strings.Contains(h.Detail, "distribution settled") return h.Phase == string(nodeengineclient.PhaseFailed) && strings.Contains(h.Detail, "last tried again at") && strings.Contains(h.Detail, "distribution settled")
}) })
b.idle() b.idle()
if n := len(b.appliedList()); n != 2 { if n := len(b.appliedList()); n != 2 {
@@ -536,7 +538,7 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) {
// The failed have carries the pull it waits on (ENG-9, read through node_haves): the image, whose settled it waits // The failed have carries the pull it waits on (ENG-9, read through node_haves): the image, whose settled it waits
// on, and what triggered the last retry. // on, and what triggered the last retry.
if w := b.haves.last("web").PullWait; w == nil || w.Holder != "distribution" || w.Image != "reg/x@sha256:1" || if w := b.haves.last("web").PullWait; w == nil || w.Holder != "distribution" || w.Image != "reg/x@sha256:1" ||
w.Began.IsZero() || !strings.Contains(w.Retried, "distribution settled") || w.RetriedAt.IsZero() { w.Began.IsZero() || !strings.Contains(w.Retried, "distribution settled") || w.RetriedAt == nil {
t.Fatalf("the failed have says it waits on %+v", w) t.Fatalf("the failed have says it waits on %+v", w)
} }
// The next settled answers once more, and the pull works: the module runs, and waits on nothing. // The next settled answers once more, and the pull works: the module runs, and waits on nothing.
@@ -546,7 +548,7 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) {
tick() tick()
b.ag.Settled("distribution") b.ag.Settled("distribution")
eventually(t, "not tried again on the next settled", func() bool { return len(b.appliedList()) == 3 }) eventually(t, "not tried again on the next settled", func() bool { return len(b.appliedList()) == 3 })
eventually(t, "not running", func() bool { return b.haves.last("web").Phase == string(delivery.PhaseRunning) }) eventually(t, "not running", func() bool { return b.haves.last("web").Phase == string(nodeengineclient.PhaseRunning) })
if w := b.haves.last("web").PullWait; w != nil { if w := b.haves.last("web").PullWait; w != nil {
t.Fatalf("a running module's have still waits on %+v", w) t.Fatalf("a running module's have still waits on %+v", w)
} }
@@ -620,11 +622,11 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) {
b.ag.RetryTransient() b.ag.RetryTransient()
} }
eventually(t, "not applied again without a new revision", func() bool { return len(b.appliedList()) == 2 }) eventually(t, "not applied again without a new revision", func() bool { return len(b.appliedList()) == 2 })
eventually(t, "not running", func() bool { return b.haves.last("vault").Phase == string(delivery.PhaseRunning) }) eventually(t, "not running", func() bool { return b.haves.last("vault").Phase == string(nodeengineclient.PhaseRunning) })
b.haves.mu.Lock() b.haves.mu.Lock()
defer b.haves.mu.Unlock() defer b.haves.mu.Unlock()
for _, h := range b.haves.log { for _, h := range b.haves.log {
if h.Phase == string(delivery.PhaseFailed) { if h.Phase == string(nodeengineclient.PhaseFailed) {
t.Errorf("a transient failure was written failed: %+v", h) t.Errorf("a transient failure was written failed: %+v", h)
} }
} }
@@ -664,7 +666,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
}) })
eventually(t, "the have does not say held", func() bool { eventually(t, "the have does not say held", func() bool {
h := b.haves.last("db") h := b.haves.last("db")
return h.Phase == string(delivery.PhaseHeld) && strings.Contains(h.Detail, "repository is locked") && h.FP == fp1 return h.Phase == string(nodeengineclient.PhaseHeld) && strings.Contains(h.Detail, "repository is locked") && h.FP == fp1
}) })
if got := b.appliedList(); len(got) != 0 { if got := b.appliedList(); len(got) != 0 {
t.Fatalf("something was applied behind a failed backup: %v", got) t.Fatalf("something was applied behind a failed backup: %v", got)
@@ -703,7 +705,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
} }
eventually(t, "not running at the new fingerprint", func() bool { eventually(t, "not running at the new fingerprint", func() bool {
h := b.haves.last("db") h := b.haves.last("db")
return h.Phase == string(delivery.PhaseRunning) && h.FP == fp2 && h.Composed == dig2 return h.Phase == string(nodeengineclient.PhaseRunning) && h.FP == fp2 && h.Composed == dig2
}) })
// The node agent stopped while the backup ran, the module known only from its have: the held have names what runs // The node agent stopped while the backup ran, the module known only from its have: the held have names what runs
@@ -713,7 +715,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
ib.comps.before(ck2, craw2) ib.comps.before(ck2, craw2)
iwk, iwraw := wantFor(t, "db", fp2, dig2, delivery.BackupAgain) iwk, iwraw := wantFor(t, "db", fp2, dig2, delivery.BackupAgain)
ib.wants.before(iwk, iwraw) ib.wants.before(iwk, iwraw)
ib.haves.stored = map[string]Have{"db": {Module: "db", FP: fp1, Composed: dig1, Phase: string(delivery.PhaseRunning)}} ib.haves.stored = map[string]Have{"db": {Module: "db", FP: fp1, Composed: dig1, Phase: string(nodeengineclient.PhaseRunning)}}
began := make(chan struct{}) began := make(chan struct{})
ib.env.Backup = func(ctx context.Context, _ string) Backup { ib.env.Backup = func(ctx context.Context, _ string) Backup {
close(began) close(began)
@@ -723,7 +725,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
stopI := ib.run() stopI := ib.run()
<-began <-began
stopI() stopI()
if h := ib.haves.last("db"); h.Phase != string(delivery.PhaseHeld) || h.FP != fp1 || h.Composed != dig1 || h.Detail != BackupInterrupted { if h := ib.haves.last("db"); h.Phase != string(nodeengineclient.PhaseHeld) || h.FP != fp1 || h.Composed != dig1 || h.Detail != BackupInterrupted {
t.Errorf("held after an interrupted backup: %+v", h) t.Errorf("held after an interrupted backup: %+v", h)
} }
@@ -733,24 +735,24 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
for _, row := range []struct { for _, row := range []struct {
detail, backup string detail, backup string
backups, applied int backups, applied int
phase delivery.Phase phase string
namesNothing bool // the have names no fp and no composition namesNothing bool // the have names no fp and no composition
}{ }{
{BackupInterrupted, delivery.BackupAgain, 1, 1, delivery.PhaseRunning, false}, {BackupInterrupted, delivery.BackupAgain, 1, 1, nodeengineclient.PhaseRunning, false},
// novox's postgres: its held have, written before mesh-host #189, names nothing it runs. // novox's postgres: its held have, written before mesh-host #189, names nothing it runs.
{BackupInterrupted, "", 1, 1, delivery.PhaseRunning, true}, {BackupInterrupted, "", 1, 1, nodeengineclient.PhaseRunning, true},
// A backup that failed, and the want carries the operator's answer, written before this start (nats on novox, // A backup that failed, and the want carries the operator's answer, written before this start (nats on novox,
// 2026-11-04, issue 1585): again backs it up and applies, skip applies without one; no answer keeps the hold. // 2026-11-04, issue 1585): again backs it up and applies, skip applies without one; no answer keeps the hold.
{"restic: the repository is locked", delivery.BackupAgain, 1, 1, delivery.PhaseRunning, false}, {"restic: the repository is locked", delivery.BackupAgain, 1, 1, nodeengineclient.PhaseRunning, false},
{"restic: the repository is locked", delivery.BackupSkip, 0, 1, delivery.PhaseRunning, false}, {"restic: the repository is locked", delivery.BackupSkip, 0, 1, nodeengineclient.PhaseRunning, false},
{"restic: the repository is locked", "", 0, 0, delivery.PhaseHeld, false}, {"restic: the repository is locked", "", 0, 0, nodeengineclient.PhaseHeld, false},
} { } {
r := newBed(t) r := newBed(t)
r.comps.before(ck1, craw1) r.comps.before(ck1, craw1)
r.comps.before(ck2, craw2) r.comps.before(ck2, craw2)
wk, wraw := wantFor(t, "db", fp2, dig2, row.backup) wk, wraw := wantFor(t, "db", fp2, dig2, row.backup)
r.wants.before(wk, wraw) r.wants.before(wk, wraw)
held := Have{Module: "db", FP: fp1, Composed: dig1, Phase: string(delivery.PhaseHeld), Detail: row.detail} held := Have{Module: "db", FP: fp1, Composed: dig1, Phase: string(nodeengineclient.PhaseHeld), Detail: row.detail}
if row.namesNothing { if row.namesNothing {
held.FP, held.Composed = "", "" held.FP, held.Composed = "", ""
} }
@@ -800,7 +802,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
return raw return raw
} }
r.wants.before(wk, selfWant(row.atStart)) r.wants.before(wk, selfWant(row.atStart))
r.haves.stored = map[string]Have{"nats": {Module: "nats", FP: fp2, Composed: dig, Phase: string(delivery.PhaseHeld), r.haves.stored = map[string]Have{"nats": {Module: "nats", FP: fp2, Composed: dig, Phase: string(nodeengineclient.PhaseHeld),
Detail: "the backup failed: sh: exit status 1"}} Detail: "the backup failed: sh: exit status 1"}}
stopR := r.run() stopR := r.run()
defer stopR() defer stopR()
@@ -831,7 +833,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
eventually(t, "the answer was not acted on", func() bool { return len(r.appliedList()) == 1 }) eventually(t, "the answer was not acted on", func() bool { return len(r.appliedList()) == 1 })
eventually(t, "not running after the answer", func() bool { eventually(t, "not running after the answer", func() bool {
h := r.haves.last("nats") h := r.haves.last("nats")
return h.Phase == string(delivery.PhaseRunning) && h.Composed == dig return h.Phase == string(nodeengineclient.PhaseRunning) && h.Composed == dig
}) })
r.mu.Lock() r.mu.Lock()
n := len(r.backups) n := len(r.backups)
@@ -959,9 +961,9 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) {
if got := a.Everything(); len(got) != 2 { if got := a.Everything(); len(got) != 2 {
t.Fatalf("before the agent holds anything: %d compositions, want the 2 kept", len(got)) t.Fatalf("before the agent holds anything: %d compositions, want the 2 kept", len(got))
} }
a.running["web"] = delivery.Have{FP: fpOf("a"), Composed: "sha256:" + strings.Repeat("1", 64), Phase: delivery.PhaseRunning} a.running["web"] = nodeengine.HaveByNodeModuleValue{Fp: fpOf("a"), Composed: "sha256:" + strings.Repeat("1", 64), Phase: nodeengineclient.PhaseRunning}
ck, craw, dig := composition(t, "web", fpOf("a"), false, file("web", "x", "new")) ck, craw, dig := composition(t, "web", fpOf("a"), false, file("web", "x", "new"))
a.running["web"] = delivery.Have{FP: fpOf("a"), Composed: dig, Phase: delivery.PhaseRunning} a.running["web"] = nodeengine.HaveByNodeModuleValue{Fp: fpOf("a"), Composed: dig, Phase: nodeengineclient.PhaseRunning}
a.takeComposition(Update{Key: ck, Value: craw, Revision: 1}) a.takeComposition(Update{Key: ck, Value: craw, Revision: 1})
got := a.Everything() got := a.Everything()
if len(got) != 2 { if len(got) != 2 {
@@ -998,7 +1000,7 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) {
webDig, _ := compositions.DigestOf(web) webDig, _ := compositions.DigestOf(web)
store.kept[NodeModule], store.kept["web"] = ownRaw, webRaw store.kept[NodeModule], store.kept["web"] = ownRaw, webRaw
b.haves.Write(context.Background(), Have{Module: NodeModule, Composed: ownDig}) b.haves.Write(context.Background(), Have{Module: NodeModule, Composed: ownDig})
b.haves.Write(context.Background(), Have{Module: "web", FP: fpOf("a"), Composed: webDig, Phase: string(delivery.PhaseRunning)}) b.haves.Write(context.Background(), Have{Module: "web", FP: fpOf("a"), Composed: webDig, Phase: string(nodeengineclient.PhaseRunning)})
// The composer's first pass reads what runs when its watch begins, before the agent reads any have. // The composer's first pass reads what runs when its watch begins, before the agent reads any have.
firstCh := make(chan []compositions.Composed, 1) firstCh := make(chan []compositions.Composed, 1)
comps := b.comps comps := b.comps
@@ -1143,7 +1145,7 @@ func TestAFailedApplyIsAppliedAgainAfterARestartWhateverIsKept(t *testing.T) {
b.comps.before(ck, craw) b.comps.before(ck, craw)
b.wants.before(wk, wraw) b.wants.before(wk, wraw)
b.kept = map[string][]json.RawMessage{"web": {file("web", "x", "1")}} b.kept = map[string][]json.RawMessage{"web": {file("web", "x", "1")}}
b.haves.stored = map[string]Have{"web": {Module: "web", FP: fp, Composed: dig, Phase: string(delivery.PhaseFailed)}} b.haves.stored = map[string]Have{"web": {Module: "web", FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseFailed)}}
stop := b.run() stop := b.run()
defer stop() defer stop()
eventually(t, "a failed apply was marked running from the kept declaration", func() bool { return len(b.appliedList()) == 1 }) eventually(t, "a failed apply was marked running from the kept declaration", func() bool { return len(b.appliedList()) == 1 })
@@ -1425,7 +1427,7 @@ func TestWhatRunsBeforeTheAgentTakesAModuleNamesItsFilledPaths(t *testing.T) {
{name: "a have naming the bucket's value", have: &Have{Module: "power", FP: fp, Composed: appliedDig}, {name: "a have naming the bucket's value", have: &Have{Module: "power", FP: fp, Composed: appliedDig},
bucket: appliedRaw, filled: old + "," + added, told: "the haves were read at the start", data: true}, bucket: appliedRaw, filled: old + "," + added, told: "the haves were read at the start", data: true},
{name: "a have applying names nothing that runs yet", have: &Have{Module: "power", FP: fp, Composed: appliedDig, {name: "a have applying names nothing that runs yet", have: &Have{Module: "power", FP: fp, Composed: appliedDig,
Phase: string(delivery.PhaseApplying)}, bucket: appliedRaw, filled: old, told: "the haves were read at the start"}, Phase: string(nodeengineclient.PhaseApplying)}, bucket: appliedRaw, filled: old, told: "the haves were read at the start"},
{name: "a data module from the kept declaration says Data as its manifest does", filled: old, {name: "a data module from the kept declaration says Data as its manifest does", filled: old,
told: "the haves were read at the start", data: true, told: "the haves were read at the start", data: true,
holdsData: func(context.Context, string) (bool, bool) { return true, true }}, holdsData: func(context.Context, string) (bool, bool) { return true, true }},
@@ -1583,7 +1585,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
// names what runs; composed again, the have is what it runs. web was applied by this agent; idle was not since the // names what runs; composed again, the have is what it runs. web was applied by this agent; idle was not since the
// agent's start (the switch and every delivery of the node-engine restart it), so what runs is what its have said. // agent's start (the switch and every delivery of the node-engine restart it), so what runs is what its have said.
idleFP, idleDig := fpOf("c"), "sha256:"+strings.Repeat("9", 64) idleFP, idleDig := fpOf("c"), "sha256:"+strings.Repeat("9", 64)
b.haves.Write(context.Background(), Have{Module: "idle", FP: idleFP, Composed: idleDig, Phase: string(delivery.PhaseRunning), b.haves.Write(context.Background(), Have{Module: "idle", FP: idleFP, Composed: idleDig, Phase: string(nodeengineclient.PhaseRunning),
Sealed: compositions.Seals{"idle.key#sealed": `{"box":"idle"}`}}) Sealed: compositions.Seals{"idle.key#sealed": `{"box":"idle"}`}})
want("idle", idleFP, "") want("idle", idleFP, "")
eventually(t, "idle's want was not taken", func() bool { eventually(t, "idle's want was not taken", func() bool {
@@ -1599,7 +1601,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
module, fp, composed, slot, sealed string module, fp, composed, slot, sealed string
}{{"web", fp, dig, "web.pw#sealed", "1"}, {"idle", idleFP, idleDig, "idle.key#sealed", `{"box":"idle"}`}} { }{{"web", fp, dig, "web.pw#sealed", "1"}, {"idle", idleFP, idleDig, "idle.key#sealed", `{"box":"idle"}`}} {
h := b.haves.last(row.module) h := b.haves.last(row.module)
if h.Phase != string(delivery.PhaseFailed) || h.FP != row.fp || h.Composed != row.composed || if h.Phase != string(nodeengineclient.PhaseFailed) || h.FP != row.fp || h.Composed != row.composed ||
len(h.Sealed) != 1 || h.Sealed[row.slot] != row.sealed || !strings.Contains(h.Detail, "web's password") { len(h.Sealed) != 1 || h.Sealed[row.slot] != row.sealed || !strings.Contains(h.Detail, "web's password") {
t.Errorf("%s's have while the node cannot compose: %+v", row.module, h) t.Errorf("%s's have while the node cannot compose: %+v", row.module, h)
} }
@@ -1614,7 +1616,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
want("fresh", fpOf("e"), "") // never ran, its have absent: marked, naming nothing it runs want("fresh", fpOf("e"), "") // never ran, its have absent: marked, naming nothing it runs
// A have that already says another cause keeps it (review of the switch, round 1): an apply that failed. // A have that already says another cause keeps it (review of the switch, round 1): an apply that failed.
brokenFP, brokenDig := fpOf("f"), "sha256:"+strings.Repeat("8", 64) brokenFP, brokenDig := fpOf("f"), "sha256:"+strings.Repeat("8", 64)
b.haves.Write(context.Background(), Have{Module: "broken", FP: brokenFP, Composed: brokenDig, Phase: string(delivery.PhaseFailed), b.haves.Write(context.Background(), Have{Module: "broken", FP: brokenFP, Composed: brokenDig, Phase: string(nodeengineclient.PhaseFailed),
Detail: "apply: unit failed to start"}) Detail: "apply: unit failed to start"})
want("broken", brokenFP, "") want("broken", brokenFP, "")
eventually(t, "unread's want was not taken", func() bool { eventually(t, "unread's want was not taken", func() bool {
@@ -1626,7 +1628,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
if h := b.haves.last("unread"); h.Module != "" { if h := b.haves.last("unread"); h.Module != "" {
t.Errorf("a module whose have could not be read was marked: %+v", h) t.Errorf("a module whose have could not be read was marked: %+v", h)
} }
if h := b.haves.last("fresh"); h.Phase != string(delivery.PhaseFailed) || h.FP != "" { if h := b.haves.last("fresh"); h.Phase != string(nodeengineclient.PhaseFailed) || h.FP != "" {
t.Errorf("a module that never ran, its have absent, is not marked: %+v", h) t.Errorf("a module that never ran, its have absent, is not marked: %+v", h)
} }
if h := b.haves.last("broken"); h.Detail != "apply: unit failed to start" || h.Composed != brokenDig { if h := b.haves.last("broken"); h.Detail != "apply: unit failed to start" || h.Composed != brokenDig {
@@ -1642,7 +1644,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
} }
b.ag.NotComposed(context.Background(), "") b.ag.NotComposed(context.Background(), "")
for _, row := range []struct{ module, composed string }{{"web", dig}, {"idle", idleDig}} { for _, row := range []struct{ module, composed string }{{"web", dig}, {"idle", idleDig}} {
if h := b.haves.last(row.module); h.Phase != string(delivery.PhaseRunning) || h.Composed != row.composed || len(h.Sealed) != 1 { if h := b.haves.last(row.module); h.Phase != string(nodeengineclient.PhaseRunning) || h.Composed != row.composed || len(h.Sealed) != 1 {
t.Errorf("%s's have once the node composes again: %+v", row.module, h) t.Errorf("%s's have once the node composes again: %+v", row.module, h)
} }
} }
@@ -1650,14 +1652,14 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
// novox) is marked alone, naming its own words; web, which composes, keeps running, unmarked. Composed again, idle's // novox) is marked alone, naming its own words; web, which composes, keeps running, unmarked. Composed again, idle's
// have is what it runs. // have is what it runs.
b.ag.NotComposedEach(context.Background(), "", map[string]string{"idle": "idle says ${config:name}, and nothing sets it"}) b.ag.NotComposedEach(context.Background(), "", map[string]string{"idle": "idle says ${config:name}, and nothing sets it"})
if h := b.haves.last("idle"); h.Phase != string(delivery.PhaseFailed) || h.Composed != idleDig || !strings.Contains(h.Detail, "${config:name}") { if h := b.haves.last("idle"); h.Phase != string(nodeengineclient.PhaseFailed) || h.Composed != idleDig || !strings.Contains(h.Detail, "${config:name}") {
t.Errorf("idle's have while it alone is held: %+v", h) t.Errorf("idle's have while it alone is held: %+v", h)
} }
if h := b.haves.last("web"); h.Phase != string(delivery.PhaseRunning) || h.Composed != dig { if h := b.haves.last("web"); h.Phase != string(nodeengineclient.PhaseRunning) || h.Composed != dig {
t.Errorf("web, which composes, is marked beside idle: %+v", h) t.Errorf("web, which composes, is marked beside idle: %+v", h)
} }
b.ag.NotComposedEach(context.Background(), "", nil) b.ag.NotComposedEach(context.Background(), "", nil)
if h := b.haves.last("idle"); h.Phase != string(delivery.PhaseRunning) || h.Composed != idleDig { if h := b.haves.last("idle"); h.Phase != string(nodeengineclient.PhaseRunning) || h.Composed != idleDig {
t.Errorf("idle's have once it composes again: %+v", h) t.Errorf("idle's have once it composes again: %+v", h)
} }
// A revision at the same build (a config value changed) is applied. // A revision at the same build (a config value changed) is applied.
@@ -1682,7 +1684,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
b.comps.put(dk, drawMoved) b.comps.put(dk, drawMoved)
eventually(t, "db's revision did not wait for a yes", func() bool { eventually(t, "db's revision did not wait for a yes", func() bool {
h := b.haves.last("db") h := b.haves.last("db")
return h.Phase == string(delivery.PhaseWaitingYes) && h.Waits == ddigMoved return h.Phase == string(nodeengineclient.PhaseWaitingYes) && h.Waits == ddigMoved
}) })
if h := b.haves.last("db"); h.BuildAlone { if h := b.haves.last("db"); h.BuildAlone {
t.Errorf("a revision at the build it runs is said to change the build alone: %+v", h) t.Errorf("a revision at the build it runs is said to change the build alone: %+v", h)
@@ -1692,8 +1694,8 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
keep(dk, draw) keep(dk, draw)
keep(dk, drawMoved) keep(dk, drawMoved)
view, err := b.ag.Composition(context.Background(), "db", "", true) view, err := b.ag.Composition(context.Background(), "db", "", true)
if err != nil || view.Have == nil || view.Have.Phase != string(delivery.PhaseWaitingYes) || view.Composition.Digest != ddigMoved || if err != nil || view.Have == nil || view.Have.Phase != string(nodeengineclient.PhaseWaitingYes) || view.Composition.Digest != ddigMoved ||
view.Runs == nil || view.Runs.Composed != ddig || view.Judged.Judgement != string(delivery.JudgedRecreates) || view.Runs == nil || view.Runs.Composed != ddig || view.Judged.Judgement != string(nodeengineclient.JudgedRecreates) ||
!view.Judged.WaitsForYes || len(view.Diff) != 1 || view.Diff[0].ID != "db.server" || view.Diff[0].Change != "changed" || !view.Judged.WaitsForYes || len(view.Diff) != 1 || view.Diff[0].ID != "db.server" || view.Diff[0].Change != "changed" ||
len(view.Revisions) != 2 { len(view.Revisions) != 2 {
t.Errorf("the composition of db waiting for a yes reads %+v (%v)", view, err) t.Errorf("the composition of db waiting for a yes reads %+v (%v)", view, err)
@@ -1731,7 +1733,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
b.comps.put(dk, draw) b.comps.put(dk, draw)
eventually(t, "db's have stays waiting-yes after its composition went back to what runs", func() bool { eventually(t, "db's have stays waiting-yes after its composition went back to what runs", func() bool {
h := b.haves.last("db") h := b.haves.last("db")
return h.Phase == string(delivery.PhaseRunning) && h.Composed == ddig && h.Waits == "" return h.Phase == string(nodeengineclient.PhaseRunning) && h.Composed == ddig && h.Waits == ""
}) })
if got := len(b.appliedList()); got != 4 { if got := len(b.appliedList()); got != 4 {
t.Fatalf("a composition equal to what runs was applied again: %d applies", got) t.Fatalf("a composition equal to what runs was applied again: %d applies", got)
@@ -1742,7 +1744,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
// node-engine restart it): its have alone says what runs. Its move waits for a yes as db's does, and its have keeps // node-engine restart it): its have alone says what runs. Its move waits for a yes as db's does, and its have keeps
// what runs (fingerprint, digest, sealed values) rather than naming nothing (review of L2, round 3). // what runs (fingerprint, digest, sealed values) rather than naming nothing (review of L2, round 3).
recFP, recDig := fpOf("d"), "sha256:"+strings.Repeat("8", 64) recFP, recDig := fpOf("d"), "sha256:"+strings.Repeat("8", 64)
b.haves.Write(context.Background(), Have{Module: "rec", FP: recFP, Composed: recDig, Phase: string(delivery.PhaseRunning), b.haves.Write(context.Background(), Have{Module: "rec", FP: recFP, Composed: recDig, Phase: string(nodeengineclient.PhaseRunning),
Sealed: compositions.Seals{"rec.key#sealed": `{"box":"rec"}`}}) Sealed: compositions.Seals{"rec.key#sealed": `{"box":"rec"}`}})
rk, rraw, rdig := unsigned("rec", fpB, container("rec@sha256:"+strings.Repeat("3", 64))) rk, rraw, rdig := unsigned("rec", fpB, container("rec@sha256:"+strings.Repeat("3", 64)))
b.comps.put(dk2, draw2) b.comps.put(dk2, draw2)
@@ -1757,7 +1759,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
}{{"db", fp, "", ddig2, nil, false}, {"rec", recFP, recDig, rdig, compositions.Seals{"rec.key#sealed": `{"box":"rec"}`}, true}} { }{{"db", fp, "", ddig2, nil, false}, {"rec", recFP, recDig, rdig, compositions.Seals{"rec.key#sealed": `{"box":"rec"}`}, true}} {
eventually(t, row.module+"'s move did not wait for a yes", func() bool { eventually(t, row.module+"'s move did not wait for a yes", func() bool {
h := b.haves.last(row.module) h := b.haves.last(row.module)
return h.Phase == string(delivery.PhaseWaitingYes) && h.Waits == row.waits return h.Phase == string(nodeengineclient.PhaseWaitingYes) && h.Waits == row.waits
}) })
h := b.haves.last(row.module) h := b.haves.last(row.module)
if h.FP != row.fp || (row.composed != "" && h.Composed != row.composed) || h.Composed == "" || if h.FP != row.fp || (row.composed != "" && h.Composed != row.composed) || h.Composed == "" ||
@@ -1775,8 +1777,8 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
want("rec", fpB, rdig) want("rec", fpB, rdig)
eventually(t, "the yes did not apply the move", func() bool { eventually(t, "the yes did not apply the move", func() bool {
h, r := b.haves.last("db"), b.haves.last("rec") h, r := b.haves.last("db"), b.haves.last("rec")
return len(b.appliedList()) == applied+2 && h.Phase == string(delivery.PhaseRunning) && h.FP == fpB && return len(b.appliedList()) == applied+2 && h.Phase == string(nodeengineclient.PhaseRunning) && h.FP == fpB &&
r.Phase == string(delivery.PhaseRunning) && r.FP == fpB && r.Composed == rdig r.Phase == string(nodeengineclient.PhaseRunning) && r.FP == fpB && r.Composed == rdig
}) })
} }
@@ -1816,7 +1818,7 @@ func testAnEngineStartReconcilesAModuleFromTheMachinesFacts(t *testing.T) {
} }
if c.held { if c.held {
b.haves.stored = map[string]Have{"web": {Module: "web", FP: fpOf("0"), Composed: "sha256:" + strings.Repeat("0", 64), b.haves.stored = map[string]Have{"web": {Module: "web", FP: fpOf("0"), Composed: "sha256:" + strings.Repeat("0", 64),
Phase: string(delivery.PhaseHeld), Detail: "restic: the repository is locked"}} Phase: string(nodeengineclient.PhaseHeld), Detail: "restic: the repository is locked"}}
} }
if c.kept { if c.kept {
b.kept = map[string][]json.RawMessage{"web": rs} b.kept = map[string][]json.RawMessage{"web": rs}
@@ -1867,7 +1869,7 @@ func testReconcileAppliesARunningModuleAgainAndLeavesTheHeldAndTheUnwantedAlone(
stop := b.run() stop := b.run()
defer stop() defer stop()
eventually(t, "web was not settled", func() bool { return b.haves.last("web").Composed == digW }) eventually(t, "web was not settled", func() bool { return b.haves.last("web").Composed == digW })
eventually(t, "db was not held", func() bool { return b.haves.last("db").Phase == string(delivery.PhaseHeld) }) eventually(t, "db was not held", func() bool { return b.haves.last("db").Phase == string(nodeengineclient.PhaseHeld) })
if got := b.appliedList(); len(got) != 0 { if got := b.appliedList(); len(got) != 0 {
t.Fatalf("applied at the start: %v", got) t.Fatalf("applied at the start: %v", got)
} }
@@ -1882,13 +1884,13 @@ func testReconcileAppliesARunningModuleAgainAndLeavesTheHeldAndTheUnwantedAlone(
if got := b.appliedList(); len(got) != 0 { if got := b.appliedList(); len(got) != 0 {
t.Fatalf("a held module and an unwanted one were applied: %v", got) t.Fatalf("a held module and an unwanted one were applied: %v", got)
} }
if h := b.haves.last("db"); h.Phase != string(delivery.PhaseHeld) { if h := b.haves.last("db"); h.Phase != string(nodeengineclient.PhaseHeld) {
t.Fatalf("the held module's have changed: %+v", h) t.Fatalf("the held module's have changed: %+v", h)
} }
b.ag.Reconcile("web") b.ag.Reconcile("web")
eventually(t, "web was not applied again", func() bool { return len(b.appliedList()) == 1 }) eventually(t, "web was not applied again", func() bool { return len(b.appliedList()) == 1 })
eventually(t, "web is not running again", func() bool { return b.haves.last("web").Phase == string(delivery.PhaseRunning) }) eventually(t, "web is not running again", func() bool { return b.haves.last("web").Phase == string(nodeengineclient.PhaseRunning) })
b.mu.Lock() b.mu.Lock()
b.failWith = "systemctl start web.service: exit status 1" b.failWith = "systemctl start web.service: exit status 1"
@@ -1896,7 +1898,7 @@ func testReconcileAppliesARunningModuleAgainAndLeavesTheHeldAndTheUnwantedAlone(
b.ag.Reconcile("web") b.ag.Reconcile("web")
eventually(t, "the failed re-apply did not write the have failed with its words", func() bool { eventually(t, "the failed re-apply did not write the have failed with its words", func() bool {
h := b.haves.last("web") h := b.haves.last("web")
return h.Phase == string(delivery.PhaseFailed) && h.Detail == "systemctl start web.service: exit status 1" return h.Phase == string(nodeengineclient.PhaseFailed) && h.Detail == "systemctl start web.service: exit status 1"
}) })
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
if got := b.appliedList(); len(got) != 2 { if got := b.appliedList(); len(got) != 2 {
+4 -4
View File
@@ -4,11 +4,11 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
engcomp "github.com/novox/mesh-host/internal/compositions" engcomp "github.com/novox/mesh-host/internal/compositions"
"git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/judge" "github.com/novox/mesh-host/internal/judge"
) )
@@ -147,7 +147,7 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo
have = &h have = &h
phase := h.Phase phase := h.Phase
if phase == "" { if phase == "" {
phase = string(delivery.PhaseRunning) phase = string(nodeengineclient.PhaseRunning)
} }
view.Have = &HaveView{FP: h.FP, Composed: h.Composed, Phase: phase, Detail: h.Detail, Judgement: h.Judgement, Waits: h.Waits} view.Have = &HaveView{FP: h.FP, Composed: h.Composed, Phase: phase, Detail: h.Detail, Judgement: h.Judgement, Waits: h.Waits}
} }
@@ -224,7 +224,7 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo
running = &from running = &from
rv := &RunsView{From: source, Resources: len(from.Resources), FP: from.FP} rv := &RunsView{From: source, Resources: len(from.Resources), FP: from.FP}
if ran { if ran {
rv.FP, rv.Composed, rv.Phase = r.FP, r.Composed, string(r.Phase) rv.FP, rv.Composed, rv.Phase = r.Fp, r.Composed, string(r.Phase)
} }
view.Runs = rv view.Runs = rv
} }
@@ -243,7 +243,7 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo
view.Judged = JudgedView{Judgement: string(j.Judgement), Words: j.Words} view.Judged = JudgedView{Judgement: string(j.Judgement), Words: j.Words}
if a.env.SelfComposed && a.env.Policy != nil && module != NodeModule && (!w.Present || w.Want.Yes != read.Dig) { if a.env.SelfComposed && a.env.Policy != nil && module != NodeModule && (!w.Present || w.Want.Yes != read.Dig) {
switch j.Judgement { switch j.Judgement {
case delivery.JudgedRecreates, delivery.JudgedMovesData, delivery.JudgedCannot: case nodeengineclient.JudgedRecreates, nodeengineclient.JudgedMovesData, nodeengineclient.JudgedCannot:
view.Judged.WaitsForYes = a.env.Policy(ctx, module) == catalogue.PolicyRecord view.Judged.WaitsForYes = a.env.Policy(ctx, module) == catalogue.PolicyRecord
} }
} }
+17
View File
@@ -0,0 +1,17 @@
// Package buckets holds the names of the bus buckets the node-engine reads and writes, and how many revisions it keeps of
// each. A contract carries the local name of a state, not the module that holds it (ADR 0625 §4, §7); the node-engine
// reads through grants composed in code, so the holder's name is kept here, in its own code (ADR 0634 §1, finding F4).
package buckets
// The node-engine's own states and the have state, written by every node's engine for its own keys.
const (
// Have holds a module's have on a node, `<node>.<module>`, and the node's own facts, `<node>.node`.
Have = "node-engine_have"
// Data holds a module's data items on a node.
Data = "mesh-host_data"
// Health holds a node's own health report.
Health = "mesh-host_health"
)
// HaveHistory is the number of revisions the have bucket keeps per key.
const HaveHistory = 20
+34
View File
@@ -0,0 +1,34 @@
package buckets
import (
"encoding/json"
"os"
"testing"
)
// The engine's own states are named <module>_<state> by the manifest; a constant that drifts from it reads and writes a
// bucket nobody else knows (ADR 0634 §1, finding F4: the holder's name is kept in code and held to the manifest by this row).
func TestTheOwnStatesAreTheManifestsStates(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Module string `json:"module"`
State []struct {
Name string `json:"name"`
} `json:"state"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
declared := map[string]bool{}
for _, s := range m.State {
declared[m.Module+"_"+s.Name] = true
}
for name, bucket := range map[string]string{"data": Data, "health": Health} {
if !declared[bucket] {
t.Errorf("the bucket %s for the state %s is not one the manifest of %s declares (%v)", bucket, name, m.Module, declared)
}
}
}
+4 -4
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"github.com/novox/mesh-host/internal/compositions" "github.com/novox/mesh-host/internal/compositions"
"maps" "maps"
@@ -13,7 +14,6 @@ import (
"strings" "strings"
"git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose"
"github.com/novox/mesh-host/internal/compose/resolution" "github.com/novox/mesh-host/internal/compose/resolution"
) )
@@ -81,7 +81,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
if len(failed) == 0 { if len(failed) == 0 {
return refuse(err) return refuse(err)
} }
failed[delivery.HaveNodeWord] = err.Error() failed[nodeengineclient.NodeWord] = err.Error()
composed, skipped, stood = nil, nil, nil composed, skipped, stood = nil, nil, nil
} }
// The node's own composition is judged only where the node runs one: one that runs none applies it only once a want // The node's own composition is judged only where the node runs one: one that runs none applies it only once a want
@@ -97,7 +97,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
} }
} }
} }
if _, whole := failed[delivery.HaveNodeWord]; whole { if _, whole := failed[nodeengineclient.NodeWord]; whole {
// Nothing composed to compare: every refusal is said, and nothing else. // Nothing composed to compare: every refusal is said, and nothing else.
for _, m := range sortedKeys(failed) { for _, m := range sortedKeys(failed) {
a.Refusals = append(a.Refusals, nodeengine.CandidateRefusal{Module: m, Why: failed[m]}) a.Refusals = append(a.Refusals, nodeengine.CandidateRefusal{Module: m, Why: failed[m]})
@@ -173,7 +173,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
} // another node's addition: the module here that carries it differs, which the assigner judges } // another node's addition: the module here that carries it differs, which the assigner judges
case module == "" || node != s.Node: case module == "" || node != s.Node:
// The node's own, or a value this node holds for a module elsewhere (a grant it provides): said as the node's. // The node's own, or a value this node holds for a module elsewhere (a grant it provides): said as the node's.
refusals[delivery.HaveNodeWord] = append(refusals[delivery.HaveNodeWord], x) refusals[nodeengineclient.NodeWord] = append(refusals[nodeengineclient.NodeWord], x)
default: default:
refusals[module] = append(refusals[module], x) refusals[module] = append(refusals[module], x)
} }
+4 -7
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
engcomp "github.com/novox/mesh-host/internal/compositions" engcomp "github.com/novox/mesh-host/internal/compositions"
"os" "os"
@@ -42,7 +43,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
} }
b := &bus{placements: map[string]delivery.Placement{"n1": p}, b := &bus{placements: map[string]delivery.Placement{"n1": p},
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}}, records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}},
modules: map[string]delivery.Module{"lemurs": {Manifest: delivery.ManifestDigest(lemurs)}}, modules: map[string]delivery.Module{"lemurs": {Manifest: delivery.ManifestDigest(lemurs)}},
manifests: map[string][]byte{delivery.ManifestDigest(lemurs): lemurs}, manifests: map[string][]byte{delivery.ManifestDigest(lemurs): lemurs},
builds: map[string]*delivery.BuilderBuild{}} builds: map[string]*delivery.BuilderBuild{}}
@@ -88,11 +89,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
t.Errorf("%s refused as %q, want %q", r.Module, r.Why, want) t.Errorf("%s refused as %q, want %q", r.Module, r.Why, want)
} }
} }
var wireAnswer delivery.CandidateAnswer // the form mesh-sdk's have still holds, until the Have group moves if raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Candidates: map[string]nodeengine.CandidateAnswer{"g": a}}); err != nil {
if wire, err := json.Marshal(a); err != nil || json.Unmarshal(wire, &wireAnswer) != nil {
t.Fatalf("the answer does not read back: %v", err)
}
if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": wireAnswer}}); err != nil {
t.Errorf("the answer is not one a have holds: %v (%s)", err, raw) t.Errorf("the answer is not one a have holds: %v (%s)", err, raw)
} }
if lemursOK := slices.ContainsFunc(a.Added, func(m nodeengine.CandidateAdded) bool { return m.Module == "lemurs" }); !lemursOK { if lemursOK := slices.ContainsFunc(a.Added, func(m nodeengine.CandidateAdded) bool { return m.Module == "lemurs" }); !lemursOK {
@@ -594,7 +591,7 @@ func otherNodeNotResolvedIsNamed(t *testing.T) {
b := &bus{placements: map[string]delivery.Placement{ b := &bus{placements: map[string]delivery.Placement{
"n1": {Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Endpoint: "n1.example:51820"}, Modules: []delivery.PlacedModule{{Module: "cons"}, {Module: overlay.Name}}}, "n1": {Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Endpoint: "n1.example:51820"}, Modules: []delivery.PlacedModule{{Module: "cons"}, {Module: overlay.Name}}},
"n2": {Node: "n2", Decided: delivery.NodeDecided{Address: "10.77.0.2"}, Modules: []delivery.PlacedModule{{Module: "prov"}, {Module: overlay.Name}}}}, "n2": {Node: "n2", Decided: delivery.NodeDecided{Address: "10.77.0.2"}, Modules: []delivery.PlacedModule{{Module: "prov"}, {Module: overlay.Name}}}},
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t)}, "n2": {Node: "n2", TunnelKey: publicKey(t)}}, records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t)}, "n2": {Node: "n2", TunnelKey: publicKey(t)}},
modules: map[string]delivery.Module{"prov": {Manifest: delivery.ManifestDigest(prov)}, "cons": {Manifest: delivery.ManifestDigest(cons)}}, modules: map[string]delivery.Module{"prov": {Manifest: delivery.ManifestDigest(prov)}, "cons": {Manifest: delivery.ManifestDigest(cons)}},
manifests: map[string][]byte{delivery.ManifestDigest(prov): prov, delivery.ManifestDigest(cons): cons}} manifests: map[string][]byte{delivery.ManifestDigest(prov): prov, delivery.ManifestDigest(cons): cons}}
given := map[string]*delivery.Placement{} given := map[string]*delivery.Placement{}
+18 -8
View File
@@ -35,6 +35,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
engcomp "github.com/novox/mesh-host/internal/compositions" engcomp "github.com/novox/mesh-host/internal/compositions"
"maps" "maps"
"net/netip" "net/netip"
@@ -57,8 +58,8 @@ type Reader interface {
// Placements is every node's placement, by node (delivery.PlacementBucket). // Placements is every node's placement, by node (delivery.PlacementBucket).
Placements(ctx context.Context) (map[string]delivery.Placement, error) Placements(ctx context.Context) (map[string]delivery.Placement, error)
// Records is every node's record, by node: HaveNode.Record of each node's own have key, `<node>.node` of // Records is every node's record, by node: HaveNode.Record of each node's own have key, `<node>.node` of
// delivery.HaveBucket (ADR 0555 §3). // buckets.Have (ADR 0555 §3).
Records(ctx context.Context) (map[string]delivery.NodeRecord, error) Records(ctx context.Context) (map[string]nodeengine.NodeRecord, error)
// Modules is every module the indexer holds, by module (delivery.ModulesBucket). // Modules is every module the indexer holds, by module (delivery.ModulesBucket).
Modules(ctx context.Context) (map[string]delivery.Module, error) Modules(ctx context.Context) (map[string]delivery.Module, error)
// Manifest is the raw manifest of a digest (delivery.ManifestsBucket). // Manifest is the raw manifest of a digest (delivery.ManifestsBucket).
@@ -252,7 +253,7 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
type base struct { type base struct {
in compose.Input in compose.Input
placements map[string]delivery.Placement placements map[string]delivery.Placement
records map[string]delivery.NodeRecord records map[string]nodeengine.NodeRecord
modules map[string]delivery.Module modules map[string]delivery.Module
// ownRefused is each module placed on the composing node whose manifest the indexer refused, with why, in the order // ownRefused is each module placed on the composing node whose manifest the indexer refused, with why, in the order
// read: every scope holds it. // read: every scope holds it.
@@ -987,15 +988,24 @@ func declaresOwnSecrets(in compose.Input, node string) bool {
return false return false
} }
// attachedOf is a record's attached networks as the composer holds them (the same two fields).
func attachedOf(in []nodeengine.AttachedNetwork) []catalogue.AttachedNetwork {
var out []catalogue.AttachedNetwork
for _, n := range in {
out = append(out, catalogue.AttachedNetwork{Link: n.Link, Network: n.Network})
}
return out
}
// nodeOf is the composition's node from its record (what the machine observes) and its placement (what a person // nodeOf is the composition's node from its record (what the machine observes) and its placement (what a person
// decided of it, and where its modules run). // decided of it, and where its modules run).
func nodeOf(rec delivery.NodeRecord, p delivery.Placement) compose.Node { func nodeOf(rec nodeengine.NodeRecord, p delivery.Placement) compose.Node {
n := compose.Node{ n := compose.Node{
Name: rec.Node, Name: rec.Node,
Place: compose.Place{Key: rec.TunnelKey}, Place: compose.Place{Key: rec.TunnelKey},
SealingKey: rec.SealingKey, SealingKey: rec.SealingKey,
OutwardLinks: rec.OutwardLinks, Networks: rec.Networks, NetworksSaid: rec.NetworksSaid, OutwardLinks: rec.OutwardLinks, Networks: attachedOf(rec.Networks), NetworksSaid: rec.NetworksSaid,
ServingKey: rec.ServingKey, Certificate: rec.Certificate, Contract: rec.Contract, Stated: rec.Contract > 0, ServingKey: rec.ServingKey, Certificate: rec.Certificate, Contract: int(rec.Contract), Stated: rec.Contract > 0,
} }
placeNode(&n, p) placeNode(&n, p)
if len(rec.Capabilities) > 0 { if len(rec.Capabilities) > 0 {
@@ -1092,7 +1102,7 @@ func tunnelOf(t delivery.RecordTunnel) compose.Tunnel {
// carriedPeers is every peer of the hub's adopted tunnel, by address, each with the node that enrolled under its key // carriedPeers is every peer of the hub's adopted tunnel, by address, each with the node that enrolled under its key
// where one has (a node whose tunnel key is the peer's) and the name a person gave it (inv.CarriedPeers). // where one has (a node whose tunnel key is the peer's) and the name a person gave it (inv.CarriedPeers).
func carriedPeers(t compose.Tunnel, names map[string]string, records map[string]delivery.NodeRecord) []compose.CarriedPeer { func carriedPeers(t compose.Tunnel, names map[string]string, records map[string]nodeengine.NodeRecord) []compose.CarriedPeer {
byKey := map[string]string{} byKey := map[string]string{}
for node, rec := range records { for node, rec := range records {
if rec.TunnelKey != "" { if rec.TunnelKey != "" {
@@ -1116,7 +1126,7 @@ func carriedPeers(t compose.Tunnel, names map[string]string, records map[string]
} }
// nodesOf is every node named by a placement or a record, by name: the order the controller reads them in. // nodesOf is every node named by a placement or a record, by name: the order the controller reads them in.
func nodesOf(placements map[string]delivery.Placement, records map[string]delivery.NodeRecord) []string { func nodesOf(placements map[string]delivery.Placement, records map[string]nodeengine.NodeRecord) []string {
seen := map[string]bool{} seen := map[string]bool{}
for n := range placements { for n := range placements {
seen[n] = true seen[n] = true
+13 -14
View File
@@ -11,6 +11,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"github.com/novox/mesh-host/internal/compositions" "github.com/novox/mesh-host/internal/compositions"
"os" "os"
@@ -31,7 +32,7 @@ type bus struct {
vaultAuthority string vaultAuthority string
handed map[string]bool handed map[string]bool
placements map[string]delivery.Placement placements map[string]delivery.Placement
records map[string]delivery.NodeRecord records map[string]nodeengine.NodeRecord
modules map[string]delivery.Module modules map[string]delivery.Module
manifests map[string][]byte manifests map[string][]byte
values map[string]map[string]any // by node.module values map[string]map[string]any // by node.module
@@ -81,10 +82,12 @@ func (b *bus) Served(_ context.Context, node, module string, keys []string) (map
func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error) { func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error) {
return b.placements, nil return b.placements, nil
} }
func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority } func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority }
func (b *bus) Handed(context.Context, string) map[string]bool { return b.handed } func (b *bus) Handed(context.Context, string) map[string]bool { return b.handed }
func (b *bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.records, nil } func (b *bus) Records(context.Context) (map[string]nodeengine.NodeRecord, error) {
func (b *bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.modules, nil } return b.records, nil
}
func (b *bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.modules, nil }
func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) { func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) {
raw, ok := b.manifests[digest] raw, ok := b.manifests[digest]
if !ok { if !ok {
@@ -180,7 +183,7 @@ func TestInput(t *testing.T) {
{Module: "media", HasMesh: true, MeshValues: map[string]any{"name": "mesh"}, HasLayer: true, {Module: "media", HasMesh: true, MeshValues: map[string]any{"name": "mesh"}, HasLayer: true,
Layer: map[string]any{"places": map[string]any{"data": "/srv/data"}}, Values: map[string]any{"lines": float64(1000)}}}}, Layer: map[string]any{"places": map[string]any{"data": "/srv/data"}}, Values: map[string]any{"lines": float64(1000)}}}},
}, },
records: map[string]delivery.NodeRecord{ records: map[string]nodeengine.NodeRecord{
"anchor": {Node: "anchor", TunnelKey: keys["anchor"], Capabilities: caps, Contract: 2}, "anchor": {Node: "anchor", TunnelKey: keys["anchor"], Capabilities: caps, Contract: 2},
"laptop": {Node: "laptop", TunnelKey: keys["laptop"], Capabilities: caps, Authority: "the mesh's certificate"}, "laptop": {Node: "laptop", TunnelKey: keys["laptop"], Capabilities: caps, Authority: "the mesh's certificate"},
}, },
@@ -331,7 +334,7 @@ func TestInput(t *testing.T) {
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
} }
return &bus{placements: map[string]delivery.Placement{"n1": p}, return &bus{placements: map[string]delivery.Placement{"n1": p},
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}}, records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}},
modules: map[string]delivery.Module{"keeper": {Manifest: delivery.ManifestDigest(keeper)}, "lemurs": {Manifest: delivery.ManifestDigest(lemurs)}, modules: map[string]delivery.Module{"keeper": {Manifest: delivery.ManifestDigest(keeper)}, "lemurs": {Manifest: delivery.ManifestDigest(lemurs)},
"objects": {Manifest: delivery.ManifestDigest(objects)}, "files": {Manifest: delivery.ManifestDigest(files)}}, "objects": {Manifest: delivery.ManifestDigest(objects)}, "files": {Manifest: delivery.ManifestDigest(files)}},
manifests: map[string][]byte{delivery.ManifestDigest(keeper): keeper, delivery.ManifestDigest(lemurs): lemurs, manifests: map[string][]byte{delivery.ManifestDigest(keeper): keeper, delivery.ManifestDigest(lemurs): lemurs,
@@ -490,11 +493,7 @@ func TestInput(t *testing.T) {
if a.Verdict != c.verdict || !c.check(a) { if a.Verdict != c.verdict || !c.check(a) {
t.Errorf("%s: %+v", name, a) t.Errorf("%s: %+v", name, a)
} }
var wireAnswer delivery.CandidateAnswer // the form mesh-sdk's have still holds, until the Have group moves if raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Candidates: map[string]nodeengine.CandidateAnswer{"g": a}}); err != nil {
if wire, err := json.Marshal(a); err != nil || json.Unmarshal(wire, &wireAnswer) != nil {
t.Fatalf("%s: the answer does not read back: %v", name, err)
}
if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": wireAnswer}}); err != nil {
t.Errorf("%s: the answer is not one a have holds: %v (%s)", name, err, raw) t.Errorf("%s: the answer is not one a have holds: %v (%s)", name, err, raw)
} }
} }
@@ -844,9 +843,9 @@ func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates {
} }
records, _ := b.Records(ctx) records, _ := b.Records(ctx)
for node, r := range records { for node, r := range records {
k, _ := delivery.HaveNodeKey(node) k, _ := nodeengineclient.HaveNodeKey(node)
rec := r rec := r
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec}) raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &rec})
put(HaveRead, k, raw, err) put(HaveRead, k, raw, err)
} }
modules, _ := b.Modules(ctx) modules, _ := b.Modules(ctx)
@@ -12,6 +12,7 @@ import (
"embed" "embed"
"encoding/json" "encoding/json"
"errors" "errors"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"strings" "strings"
"git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/delivery"
@@ -25,7 +26,7 @@ var testdata embed.FS
// Bus is a frombus.Reader over values held in memory. A credential asked is answered held when Held is set. // Bus is a frombus.Reader over values held in memory. A credential asked is answered held when Held is set.
type Bus struct { type Bus struct {
Placement map[string]delivery.Placement Placement map[string]delivery.Placement
Record map[string]delivery.NodeRecord Record map[string]nodeengine.NodeRecord
Module map[string]delivery.Module Module map[string]delivery.Module
Manifests map[string][]byte Manifests map[string][]byte
Value map[string]map[string]any // by node.module Value map[string]map[string]any // by node.module
@@ -41,9 +42,11 @@ var _ frombus.Reader = (*Bus)(nil)
func (b *Bus) Placements(context.Context) (map[string]delivery.Placement, error) { func (b *Bus) Placements(context.Context) (map[string]delivery.Placement, error) {
return b.Placement, nil return b.Placement, nil
} }
func (b *Bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.Record, nil } func (b *Bus) Records(context.Context) (map[string]nodeengine.NodeRecord, error) {
func (b *Bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.Module, nil } return b.Record, nil
func (b *Bus) VaultAuthority(context.Context) string { return "" } }
func (b *Bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.Module, nil }
func (b *Bus) VaultAuthority(context.Context) string { return "" }
func (b *Bus) Manifest(_ context.Context, digest string) ([]byte, error) { func (b *Bus) Manifest(_ context.Context, digest string) ([]byte, error) {
raw, ok := b.Manifests[digest] raw, ok := b.Manifests[digest]
@@ -152,7 +155,7 @@ func Provisioned() (*Bus, error) {
Placement: map[string]delivery.Placement{Node: {Node: Node, Placement: map[string]delivery.Placement{Node: {Node: Node,
Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Account: "operator", AccountHome: "/home/operator"}, Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Account: "operator", AccountHome: "/home/operator"},
Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "postgres"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}}}, Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "postgres"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}}},
Record: map[string]delivery.NodeRecord{Node: {Node: Node, TunnelKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=", Record: map[string]nodeengine.NodeRecord{Node: {Node: Node, TunnelKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=",
SealingKey: "sk", Contract: compose.HandedContract, Capabilities: []string{"systemd", "package-manager"}}}, SealingKey: "sk", Contract: compose.HandedContract, Capabilities: []string{"systemd", "package-manager"}}},
Module: map[string]delivery.Module{}, Manifests: map[string][]byte{}, Module: map[string]delivery.Module{}, Manifests: map[string][]byte{},
Own: []compose.OwnSecretRow{{Node: Node, Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}, Own: []compose.OwnSecretRow{{Node: Node, Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}},
@@ -3,6 +3,8 @@ package frombustest
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
engcomp "github.com/novox/mesh-host/internal/compositions" engcomp "github.com/novox/mesh-host/internal/compositions"
"reflect" "reflect"
"slices" "slices"
@@ -313,7 +315,7 @@ func statesOf(t *testing.T, b *Bus) frombus.States {
put(frombus.PlacementRead, "placement."+node, raw, err) put(frombus.PlacementRead, "placement."+node, raw, err)
} }
for node, rec := range b.Record { for node, rec := range b.Record {
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec}) raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &rec})
put(frombus.HaveRead, node+".node", raw, err) put(frombus.HaveRead, node+".node", raw, err)
} }
for module, m := range b.Module { for module, m := range b.Module {
+2 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
engcomp "github.com/novox/mesh-host/internal/compositions" engcomp "github.com/novox/mesh-host/internal/compositions"
"strings" "strings"
"testing" "testing"
@@ -18,7 +19,7 @@ import (
func aNode(t *testing.T, manifests ...string) (*bus, map[string]string) { func aNode(t *testing.T, manifests ...string) (*bus, map[string]string) {
t.Helper() t.Helper()
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}} p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
b := &bus{records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd"}}}, b := &bus{records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd"}}},
modules: map[string]delivery.Module{}, manifests: map[string][]byte{}, builds: map[string]*delivery.BuilderBuild{}} modules: map[string]delivery.Module{}, manifests: map[string][]byte{}, builds: map[string]*delivery.BuilderBuild{}}
fps := map[string]string{} fps := map[string]string{}
for i, raw := range manifests { for i, raw := range manifests {
+6 -4
View File
@@ -12,6 +12,8 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"strings" "strings"
"time" "time"
@@ -84,11 +86,11 @@ func (r statesReader) Placements(context.Context) (map[string]delivery.Placement
return keys.All(), err return keys.All(), err
} }
func (r statesReader) Records(context.Context) (map[string]delivery.NodeRecord, error) { func (r statesReader) Records(context.Context) (map[string]nodeengine.NodeRecord, error) {
out := map[string]delivery.NodeRecord{} out := map[string]nodeengine.NodeRecord{}
err := r.each(HaveRead, func(k string, v []byte) { err := r.each(HaveRead, func(k string, v []byte) {
if node, _, isNode, err := delivery.ParseHaveKey(k); err == nil && isNode { if node, _, isNode, err := nodeengineclient.ParseHaveKey(k); err == nil && isNode {
if h, err := delivery.DecodeHaveNode(v); err == nil && h.Record != nil { if h, err := nodeengineclient.DecodeHaveNode(v); err == nil && h.Record != nil {
out[node] = *h.Record out[node] = *h.Record
} }
} }
+7 -7
View File
@@ -5,24 +5,24 @@
package judge package judge
import ( import (
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-sdk/go/delivery"
engcomp "github.com/novox/mesh-host/internal/compositions" engcomp "github.com/novox/mesh-host/internal/compositions"
) )
// The shapes of mesh-sdk's delivery package this package judges, by the names its code was written with. // The shapes of mesh-sdk's delivery package this package judges, by the names its code was written with.
type ( type (
Composed = engcomp.Composed Composed = engcomp.Composed
Judgement = delivery.Judgement Judgement = string
CandidateAnswer = nodeengine.CandidateAnswer CandidateAnswer = nodeengine.CandidateAnswer
ModuleDiffers = nodeengine.CandidateDiffers ModuleDiffers = nodeengine.CandidateDiffers
ResourceBodies = nodeengine.CandidateBodies ResourceBodies = nodeengine.CandidateBodies
) )
const ( const (
JudgedRecreates = delivery.JudgedRecreates JudgedRecreates = nodeengineclient.JudgedRecreates
JudgedMovesData = delivery.JudgedMovesData JudgedMovesData = nodeengineclient.JudgedMovesData
JudgedNeither = delivery.JudgedNeither JudgedNeither = "neither"
JudgedCannot = delivery.JudgedCannot JudgedCannot = nodeengineclient.JudgedCannot
JudgedUnchanged = delivery.JudgedUnchanged JudgedUnchanged = "unchanged"
) )
+4 -5
View File
@@ -4,10 +4,9 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/novox/mesh-host/internal/buckets"
"time"
) )
// Bus is the link open now as the session holds it. What it can do is asked of it by what it implements (HaveBus, // Bus is the link open now as the session holds it. What it can do is asked of it by what it implements (HaveBus,
@@ -87,9 +86,9 @@ func (b OverNATS) HaveBind(ctx context.Context) (HaveBucket, error) {
} }
js = bounded js = bounded
} }
kv, err := js.KeyValue(delivery.HaveBucket) kv, err := js.KeyValue(buckets.Have)
if err != nil { if err != nil {
return nil, fmt.Errorf("the have bucket %s: %w", delivery.HaveBucket, err) return nil, fmt.Errorf("the have bucket %s: %w", buckets.Have, err)
} }
return natsHaveBucket{kv}, nil return natsHaveBucket{kv}, nil
} }
+3 -4
View File
@@ -4,17 +4,16 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/novox/mesh-host/internal/buckets"
) )
// ---- the data items of the modules placed on this node, in the bus's own storage (novox/hq ADR 0577 clause 1) ------ // ---- the data items of the modules placed on this node, in the bus's own storage (novox/hq ADR 0577 clause 1) ------
// DataBucket is the node-engine's declared state data (delivery.EngineDataBucket), created by the grants part from the // DataBucket is the node-engine's declared state data (buckets.Data), created by the grants part from the
// engine's manifest as its states acts and health are: one key `<node>.<module>` per module placed on the node that // engine's manifest as its states acts and health are: one key `<node>.<module>` per module placed on the node that
// declares data items, written by that node's engine alone, read by that node's disk-load. // declares data items, written by that node's engine alone, read by that node's disk-load.
const DataBucket = delivery.EngineDataBucket const DataBucket = buckets.Data
// dataStore binds DataBucket over the link open now: bound, never created. // dataStore binds DataBucket over the link open now: bound, never created.
func (l *natsLink) dataStore() (nats.KeyValue, error) { func (l *natsLink) dataStore() (nats.KeyValue, error) {
+81 -93
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
sdkkeys "git.novox.be/novox/mesh-sdk/go/keys" sdkkeys "git.novox.be/novox/mesh-sdk/go/keys"
"github.com/novox/mesh-host/internal/compositions" "github.com/novox/mesh-host/internal/compositions"
@@ -12,8 +13,6 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/delivery"
) )
// ErrHaveAbsent is what HaveGet returns for a key the bucket does not hold. // ErrHaveAbsent is what HaveGet returns for a key the bucket does not hold.
@@ -40,14 +39,14 @@ type HaveBucket interface {
type haveState struct { type haveState struct {
mu sync.Mutex mu sync.Mutex
modules map[string]ModuleFact modules map[string]ModuleFact
health map[string]delivery.Health health map[string]string
// words is, by module, what the last look found of the resource that made its health failed or waits: the have says // words is, by module, what the last look found of the resource that made its health failed or waits: the have says
// it, so a failed health is never without words (issue 1496: "the node gave no words" hid a directory used as found). // it, so a failed health is never without words (issue 1496: "the node gave no words" hid a directory used as found).
words map[string]string words map[string]string
// looked says a look's health was taken: a running module it judges no resource of then has nothing that could be // looked says a look's health was taken: a running module it judges no resource of then has nothing that could be
// down, and is ok (issue 1945). // down, and is ok (issue 1945).
looked bool looked bool
node *delivery.HaveNode node *nodeengine.HaveByNodeNodeValue
// nodeComposed is the digest of the node's own composition it runs (ADR 0539 §9), kept beside the node's facts. // nodeComposed is the digest of the node's own composition it runs (ADR 0539 §9), kept beside the node's facts.
nodeComposed string nodeComposed string
// nodeSealed is, on a node composing its own, each value its own composition carries sealed to its key (ADR 0547 §3, // nodeSealed is, on a node composing its own, each value its own composition carries sealed to its key (ADR 0547 §3,
@@ -65,7 +64,7 @@ type haveState struct {
removed map[string]string removed map[string]string
// userList is what the bus node's engine last wrote of the bus's user list, by the module whose have says it (novox/hq // userList is what the bus node's engine last wrote of the bus's user list, by the module whose have says it (novox/hq
// ADR 0547 §8); kept apart from the module's fact, which every apply replaces. // ADR 0547 §8); kept apart from the module's fact, which every apply replaces.
userList map[string]delivery.UserListWritten userList map[string]nodeengine.UserListWritten
// userListNoKey is each module whose user list was said to have no have key to ride on, so it is said once. // userListNoKey is each module whose user list was said to have no have key to ride on, so it is said once.
userListNoKey map[string]bool userListNoKey map[string]bool
// unjudged is each module whose composition moved (an apply began, or the fact names another build) and that no look // unjudged is each module whose composition moved (an apply began, or the fact names another build) and that no look
@@ -76,8 +75,8 @@ type haveState struct {
// inMotion says a module's phase is one of a change still under way: what a look finds of it then judges nothing of the // inMotion says a module's phase is one of a change still under way: what a look finds of it then judges nothing of the
// composition being applied (a container stopped to be replaced reads unhealthy), so its health is not yet judged. // composition being applied (a container stopped to be replaced reads unhealthy), so its health is not yet judged.
func inMotion(phase string) bool { func inMotion(phase string) bool {
switch delivery.Phase(phase) { switch string(phase) {
case delivery.PhaseBackingUp, delivery.PhaseApplying, delivery.PhaseChecking: case nodeengineclient.PhaseBackingUp, nodeengineclient.PhaseApplying, nodeengineclient.PhaseChecking:
return true return true
} }
return false return false
@@ -85,15 +84,15 @@ func inMotion(phase string) bool {
// healthOf is the health a module's have carries: unknown (not yet judged) while its change is under way or until a look // healthOf is the health a module's have carries: unknown (not yet judged) while its change is under way or until a look
// after it, the last look's otherwise. The caller holds the lock. // after it, the last look's otherwise. The caller holds the lock.
func (s *haveState) healthOf(module string, f ModuleFact) delivery.Health { func (s *haveState) healthOf(module string, f ModuleFact) string {
if inMotion(f.Phase) { if inMotion(f.Phase) {
return delivery.HealthUnknown return nodeengineclient.HealthUnknown
} }
if got, known := s.health[module]; known { if got, known := s.health[module]; known {
return got return got
} }
if s.unjudged[module] { if s.unjudged[module] {
return delivery.HealthUnknown return nodeengineclient.HealthUnknown
} }
return nothingJudged(f, s.looked) return nothingJudged(f, s.looked)
} }
@@ -141,8 +140,8 @@ func (s *haveState) takeLook(h *Health) {
// wordsOf is what a have says of its health when the apply said nothing: the words of the resource that made it failed // wordsOf is what a have says of its health when the apply said nothing: the words of the resource that made it failed
// or waits, while it is so. The caller holds the lock. // or waits, while it is so. The caller holds the lock.
func (s *haveState) wordsOf(module string, health delivery.Health) string { func (s *haveState) wordsOf(module string, health string) string {
if health != delivery.HealthFailed && health != delivery.HealthWaits { if health != nodeengineclient.HealthFailed && health != nodeengineclient.HealthWaits {
return "" return ""
} }
return s.words[module] return s.words[module]
@@ -154,8 +153,8 @@ func (s *haveState) wordsOf(module string, health delivery.Health) string {
// module's health: it says nothing of how the module runs, waits for the operator's hand-over and keeps its own // module's health: it says nothing of how the module runs, waits for the operator's hand-over and keeps its own
// condition, raised from the same statement (issue 1496: on ace mesh-build-node's cache directory, there before the // condition, raised from the same statement (issue 1496: on ace mesh-build-node's cache directory, there before the
// mesh, made its have failed while its check answered healthy, and the assigner held its version on every node). // mesh, made its have failed while its check answered healthy, and the assigner held its version on every node).
func moduleHealths(h *Health) (map[string]delivery.Health, map[string]string) { func moduleHealths(h *Health) (map[string]string, map[string]string) {
out, words := map[string]delivery.Health{}, map[string]string{} out, words := map[string]string{}, map[string]string{}
if h == nil { if h == nil {
return out, words return out, words
} }
@@ -183,7 +182,7 @@ func moduleHealths(h *Health) (map[string]delivery.Health, map[string]string) {
} }
} }
for module, r := range rank { for module, r := range rank {
out[module] = [...]delivery.Health{delivery.HealthOK, delivery.HealthUnknown, delivery.HealthWaits, delivery.HealthFailed}[r] out[module] = [...]string{nodeengineclient.HealthOK, nodeengineclient.HealthUnknown, nodeengineclient.HealthWaits, nodeengineclient.HealthFailed}[r]
if r < 2 { if r < 2 {
delete(words, module) delete(words, module)
} }
@@ -206,21 +205,28 @@ func resourceWords(r ResourceHealth) string {
} }
// nodeFacts is the node's own facts of a report, each as the JSON the report carries it in. // nodeFacts is the node's own facts of a report, each as the JSON the report carries it in.
func nodeFacts(r Report) delivery.HaveNode { func nodeFacts(r Report) nodeengine.HaveByNodeNodeValue {
n := delivery.HaveNode{V: sdkkeys.Version} n := nodeengine.HaveByNodeNodeValue{V: sdkkeys.Version}
carry := func(into *json.RawMessage, empty bool, v any) { carry := func(into *[]map[string]any, empty bool, v any) {
if empty { if empty {
return return
} }
if raw, err := json.Marshal(v); err == nil { raw, err := json.Marshal(v)
*into = raw if err != nil {
return
}
var list []map[string]any
if json.Unmarshal(raw, &list) == nil {
*into = list
} }
} }
carry(&n.Windows, len(r.Windows) == 0, r.Windows) carry(&n.Windows, len(r.Windows) == 0, r.Windows)
carry(&n.Busy, len(r.Busy) == 0, r.Busy) carry(&n.Busy, len(r.Busy) == 0, r.Busy)
carry(&n.Strays, len(r.Strays) == 0, r.Strays) carry(&n.Strays, len(r.Strays) == 0, r.Strays)
carry(&n.Filters, len(r.Filters) == 0, r.Filters) carry(&n.Filters, len(r.Filters) == 0, r.Filters)
carry(&n.Profile, len(r.Profile) == 0, r.Profile) if len(r.Profile) > 0 {
n.Profile = r.Profile
}
return n return n
} }
@@ -296,7 +302,7 @@ func (q *Session) noteApply(r Report) bool {
// nodeFactsWithRecord is the node's own facts of a report with its record (ADR 0555 §3): a report that says nothing of // nodeFactsWithRecord is the node's own facts of a report with its record (ADR 0555 §3): a report that says nothing of
// the machine leaves the record last made standing, and one without a health statement keeps the contract last stated. // the machine leaves the record last made standing, and one without a health statement keeps the contract last stated.
func (q *Session) nodeFactsWithRecord(r Report, before *delivery.HaveNode) *delivery.HaveNode { func (q *Session) nodeFactsWithRecord(r Report, before *nodeengine.HaveByNodeNodeValue) *nodeengine.HaveByNodeNodeValue {
n := nodeFacts(r) n := nodeFacts(r)
if q.Keys != nil { if q.Keys != nil {
keys := q.Keys() keys := q.Keys()
@@ -377,20 +383,20 @@ func (q *Session) writeHaves(ctx context.Context, bus Bus) {
sort.Strings(modules) sort.Strings(modules)
for _, module := range modules { for _, module := range modules {
f := s.modules[module] f := s.modules[module]
h := delivery.Have{V: sdkkeys.Version, FP: f.FP, Composed: f.Composed, Build: f.Build, h := nodeengine.HaveByNodeModuleValue{V: sdkkeys.Version, Fp: f.FP, Composed: f.Composed, Build: f.Build,
Phase: delivery.PhaseRunning, Health: delivery.HealthUnknown, Detail: f.Detail} Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthUnknown, Detail: f.Detail}
h.Kept = f.Kept h.Kept = f.Kept
if delivery.Phase(f.Phase) == delivery.PhaseWaitingYes { if string(f.Phase) == nodeengineclient.PhaseWaitingYes {
h.Judgement, h.Waits, h.BuildAlone = delivery.Judgement(f.Judgement), f.Waits, f.BuildAlone h.Judgement, h.Waits, h.BuildAlone = string(f.Judgement), f.Waits, f.BuildAlone
} }
switch { switch {
case f.Phase != "": case f.Phase != "":
h.Phase = delivery.Phase(f.Phase) h.Phase = string(f.Phase)
case f.Failed: case f.Failed:
h.Phase = delivery.PhaseFailed h.Phase = nodeengineclient.PhaseFailed
} }
h.SealedAt = f.Sealed h.SealedAt = f.Sealed
if h.Phase == delivery.PhaseFailed { if h.Phase == nodeengineclient.PhaseFailed {
h.PullWait = f.PullWait h.PullWait = f.PullWait
} }
h.Health = s.healthOf(module, f) h.Health = s.healthOf(module, f)
@@ -400,19 +406,19 @@ func (q *Session) writeHaves(ctx context.Context, bus Bus) {
if w, ok := s.userList[module]; ok { if w, ok := s.userList[module]; ok {
h.UserList = &w h.UserList = &w
} }
key, err := delivery.HaveKey(node, module) key, err := nodeengineclient.HaveKey(node, module)
if err != nil { if err != nil {
q.sayHave(fmt.Sprintf("module %q has no key in the have bucket: %v", module, err)) q.sayHave(fmt.Sprintf("module %q has no key in the have bucket: %v", module, err))
continue continue
} }
raw, err := delivery.EncodeHave(h) raw, err := nodeengineclient.EncodeHave(h)
if err != nil { if err != nil {
q.sayHave(fmt.Sprintf("cannot encode %s: %v", key, err)) q.sayHave(fmt.Sprintf("cannot encode %s: %v", key, err))
continue continue
} }
if !q.putHave(write, bucket, key, raw, func(current []byte) bool { if !q.putHave(write, bucket, key, raw, func(current []byte) bool {
c, err := delivery.DecodeHave(current) c, err := nodeengineclient.DecodeHave(current)
return err == nil && delivery.SameHave(c, h) return err == nil && nodeengineclient.SameHave(c, h)
}) { }) {
return return
} }
@@ -428,19 +434,19 @@ func (q *Session) writeHaves(ctx context.Context, bus Bus) {
return return
} }
if s.node != nil { if s.node != nil {
key, err := delivery.HaveNodeKey(node) key, err := nodeengineclient.HaveNodeKey(node)
if err == nil { if err == nil {
var raw []byte var raw []byte
facts := *s.node facts := *s.node
facts.Composed = s.nodeComposed facts.Composed = s.nodeComposed
facts.SealedAt = s.nodeSealed facts.SealedAt = s.nodeSealed
facts.Candidates = interimAnswers(s.nodeCandidates) facts.Candidates = s.nodeCandidates
facts.Composes, facts.ComposesWhy = s.nodeComposes, s.nodeComposesWhy facts.Composes, facts.ComposesWhy = s.nodeComposes, s.nodeComposesWhy
if raw, err = delivery.EncodeHaveNode(facts); err == nil { if raw, err = nodeengineclient.EncodeHaveNode(facts); err == nil {
want := facts want := facts
if !q.putHave(write, bucket, key, raw, func(current []byte) bool { if !q.putHave(write, bucket, key, raw, func(current []byte) bool {
c, err := delivery.DecodeHaveNode(current) c, err := nodeengineclient.DecodeHaveNode(current)
return err == nil && delivery.SameHaveNode(c, want) return err == nil && nodeengineclient.SameHaveNode(c, want)
}) { }) {
return return
} }
@@ -503,15 +509,15 @@ func (q *Session) writeRemoved(ctx context.Context, b HaveBucket, node string) b
} }
sort.Strings(gone) sort.Strings(gone)
for _, module := range gone { for _, module := range gone {
key, err := delivery.HaveKey(node, module) key, err := nodeengineclient.HaveKey(node, module)
if err != nil { if err != nil {
q.sayHave(fmt.Sprintf("module %q has no key in the have bucket: %v", module, err)) q.sayHave(fmt.Sprintf("module %q has no key in the have bucket: %v", module, err))
delete(s.removed, module) delete(s.removed, module)
continue continue
} }
h := delivery.Have{V: sdkkeys.Version, Build: s.removed[module], Phase: delivery.PhaseRemoved, h := nodeengine.HaveByNodeModuleValue{V: sdkkeys.Version, Build: s.removed[module], Phase: nodeengineclient.PhaseRemoved,
Health: delivery.HealthUnknown, Detail: "the applied declaration no longer holds the module"} Health: nodeengineclient.HealthUnknown, Detail: "the applied declaration no longer holds the module"}
raw, err := delivery.EncodeHave(h) raw, err := nodeengineclient.EncodeHave(h)
if err != nil { if err != nil {
q.sayHave(fmt.Sprintf("cannot encode %s: %v", key, err)) q.sayHave(fmt.Sprintf("cannot encode %s: %v", key, err))
delete(s.removed, module) delete(s.removed, module)
@@ -531,7 +537,7 @@ func (q *Session) writeRemoved(ctx context.Context, b HaveBucket, node string) b
q.haveFailed(err) q.haveFailed(err)
return false return false
} }
if c, err := delivery.DecodeHave(current); err == nil && c.Phase == delivery.PhaseRemoved { if c, err := nodeengineclient.DecodeHave(current); err == nil && c.Phase == nodeengineclient.PhaseRemoved {
delete(s.removed, module) delete(s.removed, module)
continue continue
} }
@@ -565,7 +571,7 @@ func (q *Session) writeUserListsAlone(ctx context.Context, b HaveBucket, node st
} }
sort.Strings(alone) sort.Strings(alone)
for _, module := range alone { for _, module := range alone {
key, err := delivery.HaveKey(node, module) key, err := nodeengineclient.HaveKey(node, module)
if err != nil { if err != nil {
q.sayHave(fmt.Sprintf("module %q has no key in the have bucket: %v", module, err)) q.sayHave(fmt.Sprintf("module %q has no key in the have bucket: %v", module, err))
continue continue
@@ -592,7 +598,7 @@ func (q *Session) writeUserListsAlone(ctx context.Context, b HaveBucket, node st
return false return false
} }
delete(s.userListNoKey, module) delete(s.userListNoKey, module)
h, err := delivery.DecodeHave(current) h, err := nodeengineclient.DecodeHave(current)
if err != nil { if err != nil {
q.sayHave(fmt.Sprintf("the bus's user list is not written in %s: its value cannot be read: %v", key, err)) q.sayHave(fmt.Sprintf("the bus's user list is not written in %s: its value cannot be read: %v", key, err))
continue continue
@@ -600,10 +606,10 @@ func (q *Session) writeUserListsAlone(ctx context.Context, b HaveBucket, node st
w := s.userList[module] w := s.userList[module]
next := h next := h
next.UserList = &w next.UserList = &w
if delivery.SameHave(h, next) { if nodeengineclient.SameHave(h, next) {
continue continue
} }
raw, err := delivery.EncodeHave(next) raw, err := nodeengineclient.EncodeHave(next)
if err != nil { if err != nil {
q.sayHave(fmt.Sprintf("cannot encode %s: %v", key, err)) q.sayHave(fmt.Sprintf("cannot encode %s: %v", key, err))
continue continue
@@ -685,12 +691,12 @@ func (q *Session) NoteModule(ctx context.Context, f ModuleFact) {
// NoteUserList takes what the bus node's engine wrote of the bus's user list, and writes it in the bus module's have // NoteUserList takes what the bus node's engine wrote of the bus's user list, and writes it in the bus module's have
// where it differs from the bucket's (novox/hq ADR 0547 §8). A module this engine has said no have of yet carries it // where it differs from the bucket's (novox/hq ADR 0547 §8). A module this engine has said no have of yet carries it
// from its first have on. // from its first have on.
func (q *Session) NoteUserList(ctx context.Context, module string, w delivery.UserListWritten) { func (q *Session) NoteUserList(ctx context.Context, module string, w nodeengine.UserListWritten) {
q.init() q.init()
s := &q.haves s := &q.haves
s.mu.Lock() s.mu.Lock()
if s.userList == nil { if s.userList == nil {
s.userList = map[string]delivery.UserListWritten{} s.userList = map[string]nodeengine.UserListWritten{}
} }
s.userList[module] = w s.userList[module] = w
s.mu.Unlock() s.mu.Unlock()
@@ -732,7 +738,7 @@ func (q *Session) NoteNode(ctx context.Context, composed string, sealed composit
s.nodeComposed = composed s.nodeComposed = composed
s.nodeSealed = sealed s.nodeSealed = sealed
if s.node == nil { if s.node == nil {
n := delivery.HaveNode{V: sdkkeys.Version} n := nodeengine.HaveByNodeNodeValue{V: sdkkeys.Version}
s.node = &n s.node = &n
} }
s.mu.Unlock() s.mu.Unlock()
@@ -765,7 +771,7 @@ func (q *Session) NoteCandidates(ctx context.Context, answers map[string]nodeeng
q.writeHaves(ctx, bus) q.writeHaves(ctx, bus)
} }
// NoteComposes says, in the node's own key, whose compositions the node applies (delivery.ComposesOwn) and why: written // NoteComposes says, in the node's own key, whose compositions the node applies (nodeengineclient.ComposesOwn) and why: written
// at each start of its agent, where every have is read (issue 1126). // at each start of its agent, where every have is read (issue 1126).
func (q *Session) NoteComposes(ctx context.Context, composes, why string) { func (q *Session) NoteComposes(ctx context.Context, composes, why string) {
q.init() q.init()
@@ -786,49 +792,49 @@ func (q *Session) NoteComposes(ctx context.Context, composes, why string) {
// ReadNodeHave is the node's own key as the bucket holds it, and whether it holds one: what the node's own composition // ReadNodeHave is the node's own key as the bucket holds it, and whether it holds one: what the node's own composition
// it last ran is and carries sealed, read after a restart (ADR 0547 §3). // it last ran is and carries sealed, read after a restart (ADR 0547 §3).
func (q *Session) ReadNodeHave(ctx context.Context) (delivery.HaveNode, bool) { func (q *Session) ReadNodeHave(ctx context.Context) (nodeengine.HaveByNodeNodeValue, bool) {
n, ok, _ := q.ReadNodeHaveErr(ctx) n, ok, _ := q.ReadNodeHaveErr(ctx)
return n, ok return n, ok
} }
// ReadNodeHaveErr is ReadNodeHave telling the key absent (false, nil) from one that could not be read (false, the // ReadNodeHaveErr is ReadNodeHave telling the key absent (false, nil) from one that could not be read (false, the
// error): a bus that did not answer is never kept as a node that ran nothing (issue 1126, 2026-10-28). // error): a bus that did not answer is never kept as a node that ran nothing (issue 1126, 2026-10-28).
func (q *Session) ReadNodeHaveErr(ctx context.Context) (delivery.HaveNode, bool, error) { func (q *Session) ReadNodeHaveErr(ctx context.Context) (nodeengine.HaveByNodeNodeValue, bool, error) {
q.init() q.init()
q.mu.Lock() q.mu.Lock()
bus := q.bus bus := q.bus
q.mu.Unlock() q.mu.Unlock()
hb, ok := bus.(HaveBus) hb, ok := bus.(HaveBus)
if !ok || bus == nil { if !ok || bus == nil {
return delivery.HaveNode{}, false, ErrNotLinkedHaves return nodeengine.HaveByNodeNodeValue{}, false, ErrNotLinkedHaves
} }
key, err := delivery.HaveNodeKey(q.Membership.Node) key, err := nodeengineclient.HaveNodeKey(q.Membership.Node)
if err != nil { if err != nil {
return delivery.HaveNode{}, false, err return nodeengine.HaveByNodeNodeValue{}, false, err
} }
read, cancel := context.WithTimeout(ctx, q.timeout()) read, cancel := context.WithTimeout(ctx, q.timeout())
defer cancel() defer cancel()
bucket, err := hb.HaveBind(read) bucket, err := hb.HaveBind(read)
if err != nil { if err != nil {
return delivery.HaveNode{}, false, err return nodeengine.HaveByNodeNodeValue{}, false, err
} }
value, _, err := bucket.Get(read, key) value, _, err := bucket.Get(read, key)
if errors.Is(err, ErrHaveAbsent) { if errors.Is(err, ErrHaveAbsent) {
return delivery.HaveNode{}, false, nil return nodeengine.HaveByNodeNodeValue{}, false, nil
} }
if err != nil { if err != nil {
return delivery.HaveNode{}, false, err return nodeengine.HaveByNodeNodeValue{}, false, err
} }
n, err := delivery.DecodeHaveNode(value) n, err := nodeengineclient.DecodeHaveNode(value)
if err != nil { if err != nil {
return delivery.HaveNode{}, false, nil // a key no engine can read names nothing it runs return nodeengine.HaveByNodeNodeValue{}, false, nil // a key no engine can read names nothing it runs
} }
return n, true, nil return n, true, nil
} }
// ReadHave is the have this node-engine last wrote for a module, and whether the bucket holds one. A bucket that cannot // ReadHave is the have this node-engine last wrote for a module, and whether the bucket holds one. A bucket that cannot
// be read answers as none: the agent then compares with the declaration it kept, and applies only what differs. // be read answers as none: the agent then compares with the declaration it kept, and applies only what differs.
func (q *Session) ReadHave(ctx context.Context, module string) (delivery.Have, bool) { func (q *Session) ReadHave(ctx context.Context, module string) (nodeengine.HaveByNodeModuleValue, bool) {
h, ok, _ := q.ReadHaveErr(ctx, module) h, ok, _ := q.ReadHaveErr(ctx, module)
return h, ok return h, ok
} }
@@ -838,62 +844,44 @@ var ErrNotLinkedHaves = errors.New("no link to the bus that binds the have bucke
// ReadHaveErr is ReadHave telling a key absent (false, nil) from one that could not be read (false, the error): a bus // ReadHaveErr is ReadHave telling a key absent (false, nil) from one that could not be read (false, the error): a bus
// that does not answer for a moment is not a module that never ran (issue 1126). // that does not answer for a moment is not a module that never ran (issue 1126).
func (q *Session) ReadHaveErr(ctx context.Context, module string) (delivery.Have, bool, error) { func (q *Session) ReadHaveErr(ctx context.Context, module string) (nodeengine.HaveByNodeModuleValue, bool, error) {
q.init() q.init()
q.mu.Lock() q.mu.Lock()
bus := q.bus bus := q.bus
q.mu.Unlock() q.mu.Unlock()
hb, ok := bus.(HaveBus) hb, ok := bus.(HaveBus)
if !ok || bus == nil { if !ok || bus == nil {
return delivery.Have{}, false, ErrNotLinkedHaves return nodeengine.HaveByNodeModuleValue{}, false, ErrNotLinkedHaves
} }
key, err := delivery.HaveKey(q.Membership.Node, module) key, err := nodeengineclient.HaveKey(q.Membership.Node, module)
if err != nil { if err != nil {
return delivery.Have{}, false, err return nodeengine.HaveByNodeModuleValue{}, false, err
} }
read, cancel := context.WithTimeout(ctx, q.timeout()) read, cancel := context.WithTimeout(ctx, q.timeout())
defer cancel() defer cancel()
bucket, err := hb.HaveBind(read) bucket, err := hb.HaveBind(read)
if err != nil { if err != nil {
return delivery.Have{}, false, err return nodeengine.HaveByNodeModuleValue{}, false, err
} }
value, _, err := bucket.Get(read, key) value, _, err := bucket.Get(read, key)
if errors.Is(err, ErrHaveAbsent) { if errors.Is(err, ErrHaveAbsent) {
return delivery.Have{}, false, nil return nodeengine.HaveByNodeModuleValue{}, false, nil
} }
if err != nil { if err != nil {
return delivery.Have{}, false, err return nodeengine.HaveByNodeModuleValue{}, false, err
} }
h, err := delivery.DecodeHave(value) h, err := nodeengineclient.DecodeHave(value)
if err != nil { if err != nil {
return delivery.Have{}, false, nil // a have no engine can read names nothing it runs return nodeengine.HaveByNodeModuleValue{}, false, nil // a have no engine can read names nothing it runs
} }
return h, true, nil return h, true, nil
} }
// nothingJudged is the health of a module a look judged no resource of: ok when it was looked at and the module applied // nothingJudged is the health of a module a look judged no resource of: ok when it was looked at and the module applied
// and runs (nothing of it could be down), unknown otherwise. // and runs (nothing of it could be down), unknown otherwise.
func nothingJudged(f ModuleFact, looked bool) delivery.Health { func nothingJudged(f ModuleFact, looked bool) string {
if looked && !f.Failed && (f.Phase == "" || f.Phase == string(delivery.PhaseRunning)) { if looked && !f.Failed && (f.Phase == "" || f.Phase == string(nodeengineclient.PhaseRunning)) {
return delivery.HealthOK return nodeengineclient.HealthOK
} }
return delivery.HealthUnknown return nodeengineclient.HealthUnknown
}
// interimAnswers is the node's candidate answers in the form mesh-sdk's HaveNode still holds them, until the node-engine's
// have is written through the seat's contract types (the Have group of the go/delivery rewrite): the two shapes share
// their wire names, so the JSON carries one into the other.
func interimAnswers(in map[string]nodeengine.CandidateAnswer) map[string]delivery.CandidateAnswer {
if in == nil {
return nil
}
raw, err := json.Marshal(in)
if err != nil {
return nil
}
var out map[string]delivery.CandidateAnswer
if json.Unmarshal(raw, &out) != nil {
return nil
}
return out
} }
+10 -10
View File
@@ -4,12 +4,12 @@ package link
import ( import (
"context" "context"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-host/internal/buckets"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/nats-io/nats.go"
) )
// 1132/AC8: against a real bus with the bucket node-engine_have, a node-engine that applies a declaration of two // 1132/AC8: against a real bus with the bucket node-engine_have, a node-engine that applies a declaration of two
@@ -21,9 +21,9 @@ import (
func TestNatsHavesAreWrittenOnceAndOnlyOnChange(t *testing.T) { func TestNatsHavesAreWrittenOnceAndOnlyOnChange(t *testing.T) {
conn, js := aBus(t) conn, js := aBus(t)
const node = "haveok" const node = "haveok"
kv, err := js.KeyValue(delivery.HaveBucket) kv, err := js.KeyValue(buckets.Have)
if err != nil { if err != nil {
if kv, err = js.CreateKeyValue(&nats.KeyValueConfig{Bucket: delivery.HaveBucket, History: delivery.HaveHistory}); err != nil { if kv, err = js.CreateKeyValue(&nats.KeyValueConfig{Bucket: buckets.Have, History: buckets.HaveHistory}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
@@ -66,17 +66,17 @@ func TestNatsHavesAreWrittenOnceAndOnlyOnChange(t *testing.T) {
first := revisions() first := revisions()
a, _ := kv.Get(node + ".a") a, _ := kv.Get(node + ".a")
haveA, err := delivery.DecodeHave(a.Value()) haveA, err := nodeengineclient.DecodeHave(a.Value())
if err != nil || haveA.Phase != delivery.PhaseRunning || haveA.Build != "b-1" || haveA.Health != delivery.HealthOK { if err != nil || haveA.Phase != nodeengineclient.PhaseRunning || haveA.Build != "b-1" || haveA.Health != nodeengineclient.HealthOK {
t.Fatalf("a holds %+v (%v)", haveA, err) t.Fatalf("a holds %+v (%v)", haveA, err)
} }
b, _ := kv.Get(node + ".b") b, _ := kv.Get(node + ".b")
haveB, err := delivery.DecodeHave(b.Value()) haveB, err := nodeengineclient.DecodeHave(b.Value())
if err != nil || haveB.Phase != delivery.PhaseFailed || haveB.Detail == "" { if err != nil || haveB.Phase != nodeengineclient.PhaseFailed || haveB.Detail == "" {
t.Fatalf("b holds %+v (%v)", haveB, err) t.Fatalf("b holds %+v (%v)", haveB, err)
} }
n, _ := kv.Get(node + ".node") n, _ := kv.Get(node + ".node")
if hn, err := delivery.DecodeHaveNode(n.Value()); err != nil || len(hn.Windows) == 0 { if hn, err := nodeengineclient.DecodeHaveNode(n.Value()); err != nil || len(hn.Windows) == 0 {
t.Fatalf("the node key holds %+v (%v)", hn, err) t.Fatalf("the node key holds %+v (%v)", hn, err)
} }
+77 -72
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
sdkkeys "git.novox.be/novox/mesh-sdk/go/keys" sdkkeys "git.novox.be/novox/mesh-sdk/go/keys"
"github.com/novox/mesh-host/internal/compositions" "github.com/novox/mesh-host/internal/compositions"
@@ -15,7 +16,6 @@ import (
"time" "time"
"git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
) )
@@ -104,11 +104,11 @@ func (l *haveLink) Set(_ context.Context, key string, value []byte, revision uin
return nil return nil
} }
func (l *haveLink) have(t *testing.T, key string) delivery.Have { func (l *haveLink) have(t *testing.T, key string) nodeengine.HaveByNodeModuleValue {
t.Helper() t.Helper()
l.hmu.Lock() l.hmu.Lock()
defer l.hmu.Unlock() defer l.hmu.Unlock()
h, err := delivery.DecodeHave(l.kv[key].value) h, err := nodeengineclient.DecodeHave(l.kv[key].value)
if err != nil { if err != nil {
t.Fatalf("%s holds no have: %v", key, err) t.Fatalf("%s holds no have: %v", key, err)
} }
@@ -121,9 +121,9 @@ func (l *haveLink) setCount() int {
return len(l.sets) return len(l.sets)
} }
func (l *haveLink) put(t *testing.T, key string, h delivery.Have) { func (l *haveLink) put(t *testing.T, key string, h nodeengine.HaveByNodeModuleValue) {
t.Helper() t.Helper()
raw, err := delivery.EncodeHave(h) raw, err := nodeengineclient.EncodeHave(h)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -165,11 +165,11 @@ func TestAnAppliedModuleIsWrittenRunningAndAFailedOneFailed(t *testing.T) {
q, _ := aHaveQueue(l) q, _ := aHaveQueue(l)
q.SayFacts(context.Background(), appliedModules()) q.SayFacts(context.Background(), appliedModules())
a, b := l.have(t, "n1.a"), l.have(t, "n1.b") a, b := l.have(t, "n1.a"), l.have(t, "n1.b")
if a.Phase != delivery.PhaseRunning || a.Build != "b-1" || a.Health != delivery.HealthOK { if a.Phase != nodeengineclient.PhaseRunning || a.Build != "b-1" || a.Health != nodeengineclient.HealthOK {
t.Errorf("a: %+v", a) t.Errorf("a: %+v", a)
} }
if b.Phase != delivery.PhaseFailed || b.Build != "b-2" || !strings.Contains(b.Detail, "no space") || if b.Phase != nodeengineclient.PhaseFailed || b.Build != "b-2" || !strings.Contains(b.Detail, "no space") ||
b.Health != delivery.HealthFailed { b.Health != nodeengineclient.HealthFailed {
t.Errorf("b: %+v", b) t.Errorf("b: %+v", b)
} }
if l.kv["n1.a"].rev != 1 { if l.kv["n1.a"].rev != 1 {
@@ -180,14 +180,14 @@ func TestAnAppliedModuleIsWrittenRunningAndAFailedOneFailed(t *testing.T) {
// 1132/AC5: a have equal to the bucket's value is not written; a different one is written over the last revision. // 1132/AC5: a have equal to the bucket's value is not written; a different one is written over the last revision.
func TestAHaveIsWrittenOnlyWhenItDiffers(t *testing.T) { func TestAHaveIsWrittenOnlyWhenItDiffers(t *testing.T) {
l := newHaveLink() l := newHaveLink()
l.put(t, "n1.a", delivery.Have{V: sdkkeys.Version, Build: "b-1", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) l.put(t, "n1.a", nodeengine.HaveByNodeModuleValue{V: sdkkeys.Version, Build: "b-1", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
l.put(t, "n1.b", delivery.Have{V: sdkkeys.Version, Build: "b-0", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) l.put(t, "n1.b", nodeengine.HaveByNodeModuleValue{V: sdkkeys.Version, Build: "b-0", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
q, _ := aHaveQueue(l) q, _ := aHaveQueue(l)
q.SayFacts(context.Background(), appliedModules()) q.SayFacts(context.Background(), appliedModules())
if l.kv["n1.a"].rev != 1 { if l.kv["n1.a"].rev != 1 {
t.Errorf("a equal to the bucket was written again, to revision %d", l.kv["n1.a"].rev) t.Errorf("a equal to the bucket was written again, to revision %d", l.kv["n1.a"].rev)
} }
if l.kv["n1.b"].rev != 2 || l.have(t, "n1.b").Phase != delivery.PhaseFailed { if l.kv["n1.b"].rev != 2 || l.have(t, "n1.b").Phase != nodeengineclient.PhaseFailed {
t.Errorf("b was not updated over its last revision: %+v at %d", l.have(t, "n1.b"), l.kv["n1.b"].rev) t.Errorf("b was not updated over its last revision: %+v at %d", l.have(t, "n1.b"), l.kv["n1.b"].rev)
} }
before := l.setCount() before := l.setCount()
@@ -205,7 +205,7 @@ func TestAModulesHealthFollowsTheLook(t *testing.T) {
{Module: "c", Build: "b"}, {Module: "d", Build: "b"}, {Module: "e", Build: "b"}}} {Module: "c", Build: "b"}, {Module: "d", Build: "b"}, {Module: "e", Build: "b"}}}
q.SayFacts(context.Background(), rep) q.SayFacts(context.Background(), rep)
for _, m := range []string{"a", "b", "c", "d", "e"} { for _, m := range []string{"a", "b", "c", "d", "e"} {
if h := l.have(t, "n1."+m).Health; h != delivery.HealthUnknown { if h := l.have(t, "n1."+m).Health; h != nodeengineclient.HealthUnknown {
t.Errorf("%s with no look is %s, want unknown", m, h) t.Errorf("%s with no look is %s, want unknown", m, h)
} }
} }
@@ -220,8 +220,8 @@ func TestAModulesHealthFollowsTheLook(t *testing.T) {
if !q.SayHealth(context.Background(), *h) { if !q.SayHealth(context.Background(), *h) {
t.Fatal("not said") t.Fatal("not said")
} }
for m, want := range map[string]delivery.Health{"a": delivery.HealthWaits, "b": delivery.HealthFailed, for m, want := range map[string]string{"a": nodeengineclient.HealthWaits, "b": nodeengineclient.HealthFailed,
"c": delivery.HealthWaits, "d": delivery.HealthUnknown, "e": delivery.HealthOK} { "c": nodeengineclient.HealthWaits, "d": nodeengineclient.HealthUnknown, "e": nodeengineclient.HealthOK} {
if got := l.have(t, "n1."+m).Health; got != want { if got := l.have(t, "n1."+m).Health; got != want {
t.Errorf("%s is %s, want %s", m, got, want) t.Errorf("%s is %s, want %s", m, got, want)
} }
@@ -239,7 +239,7 @@ func TestAModulesHealthFollowsTheLook(t *testing.T) {
} }
h2.Resources = []ResourceHealth{{Module: "a", Resource: "a.server", State: StateHealthy}} h2.Resources = []ResourceHealth{{Module: "a", Resource: "a.server", State: StateHealthy}}
q.SayHealth(context.Background(), h2) q.SayHealth(context.Background(), h2)
if got := l.have(t, "n1.a"); got.Health != delivery.HealthOK || got.Detail != "" { if got := l.have(t, "n1.a"); got.Health != nodeengineclient.HealthOK || got.Detail != "" {
t.Errorf("a after a healthy look is %s, saying %q", got.Health, got.Detail) t.Errorf("a after a healthy look is %s, saying %q", got.Health, got.Detail)
} }
} }
@@ -255,14 +255,19 @@ func TestTheNodeKeyCarriesTheNodesFacts(t *testing.T) {
rep.Filters = []Filter{{Where: "inet mesh", Owner: "mesh"}} rep.Filters = []Filter{{Where: "inet mesh", Owner: "mesh"}}
rep.Profile = map[string]any{"cores": 8} rep.Profile = map[string]any{"cores": 8}
q.SayFacts(context.Background(), rep) q.SayFacts(context.Background(), rep)
got, err := delivery.DecodeHaveNode(l.kv["n1.node"].value) got, err := nodeengineclient.DecodeHaveNode(l.kv["n1.node"].value)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
enc := func(v any) json.RawMessage { b, _ := json.Marshal(v); return b } enc := func(v any) []map[string]any {
want := delivery.HaveNode{V: sdkkeys.Version, Windows: enc(rep.Windows), Busy: enc(rep.Busy), Strays: enc(rep.Strays), var out []map[string]any
Filters: enc(rep.Filters), Profile: enc(rep.Profile)} b, _ := json.Marshal(v)
if !delivery.SameHaveNode(got, want) { _ = json.Unmarshal(b, &out)
return out
}
want := nodeengine.HaveByNodeNodeValue{V: sdkkeys.Version, Windows: enc(rep.Windows), Busy: enc(rep.Busy), Strays: enc(rep.Strays),
Filters: enc(rep.Filters), Profile: rep.Profile}
if !nodeengineclient.SameHaveNode(got, want) {
t.Errorf("the node key holds %+v, want %+v", got, want) t.Errorf("the node key holds %+v, want %+v", got, want)
} }
@@ -279,14 +284,14 @@ func TestTheNodeKeyCarriesTheNodesFacts(t *testing.T) {
rep.Outward = []string{"eth0"} rep.Outward = []string{"eth0"}
rep.Health = &Health{Contract: NodeKeyContract} rep.Health = &Health{Contract: NodeKeyContract}
q.SayFacts(context.Background(), rep) q.SayFacts(context.Background(), rep)
if got, err = delivery.DecodeHaveNode(l.kv["n1.node"].value); err != nil || got.Record == nil { if got, err = nodeengineclient.DecodeHaveNode(l.kv["n1.node"].value); err != nil || got.Record == nil {
t.Fatalf("the node key holds no record: %+v, %v", got, err) t.Fatalf("the node key holds no record: %+v, %v", got, err)
} }
wantRecord := delivery.NodeRecord{V: sdkkeys.Version, Node: "n1", TunnelKey: "tk", SealingKey: "sk", ServingKey: "pk", wantRecord := nodeengine.NodeRecord{V: sdkkeys.Version, Node: "n1", TunnelKey: "tk", SealingKey: "sk", ServingKey: "pk",
Certificate: "cert", Trusts: keys.Trusts, Capabilities: []string{"apt", "bus-state-calls", "ledger-calls", "systemd"}, OutwardLinks: []string{"eth0"}, NetworksSaid: true, Certificate: "cert", Trusts: keys.Trusts, Capabilities: []string{"apt", "bus-state-calls", "ledger-calls", "systemd"}, OutwardLinks: []string{"eth0"}, NetworksSaid: true,
Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}}, Contract: NodeKeyContract, Networks: []nodeengine.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}}, Contract: NodeKeyContract,
ManifestReads: catalogue.ManifestReads()} // what it reads of a manifest: its contract's (ADR 0614 §4) ManifestReads: catalogue.ManifestReads()} // what it reads of a manifest: its contract's (ADR 0614 §4)
if !delivery.SameNodeRecord(*got.Record, wantRecord) { if !nodeengineclient.SameNodeRecord(*got.Record, wantRecord) {
t.Errorf("record = %+v, want %+v", *got.Record, wantRecord) t.Errorf("record = %+v, want %+v", *got.Record, wantRecord)
} }
// Among them, that it fills a seat's reach from the seat holder's listen (ADR 0632 §1). // Among them, that it fills a seat's reach from the seat holder's listen (ADR 0632 §1).
@@ -307,7 +312,7 @@ func TestTheNodeKeyCarriesTheNodesFacts(t *testing.T) {
} }
l.statedErr, l.stated = nil, map[string]string{} l.statedErr, l.stated = nil, map[string]string{}
q.SayFacts(context.Background(), rep) q.SayFacts(context.Background(), rep)
if got, _ = delivery.DecodeHaveNode(l.kv["n1.node"].value); l.kv["n1.node"].rev != at+1 || !slices.Equal(got.Record.Capabilities, []string{"apt", "systemd"}) { if got, _ = nodeengineclient.DecodeHaveNode(l.kv["n1.node"].value); l.kv["n1.node"].rev != at+1 || !slices.Equal(got.Record.Capabilities, []string{"apt", "systemd"}) {
t.Errorf("a runner stating no calls: revision %d (was %d), capabilities %v", l.kv["n1.node"].rev, at, got.Record.Capabilities) t.Errorf("a runner stating no calls: revision %d (was %d), capabilities %v", l.kv["n1.node"].rev, at, got.Record.Capabilities)
} }
at++ at++
@@ -316,13 +321,13 @@ func TestTheNodeKeyCarriesTheNodesFacts(t *testing.T) {
at++ at++
keys.Certificate = "cert-2" keys.Certificate = "cert-2"
q.SayFacts(context.Background(), rep) q.SayFacts(context.Background(), rep)
if got, _ = delivery.DecodeHaveNode(l.kv["n1.node"].value); l.kv["n1.node"].rev != at+1 || got.Record.Certificate != "cert-2" { if got, _ = nodeengineclient.DecodeHaveNode(l.kv["n1.node"].value); l.kv["n1.node"].rev != at+1 || got.Record.Certificate != "cert-2" {
t.Errorf("a new certificate: revision %d (was %d), record %+v", l.kv["n1.node"].rev, at, got.Record) t.Errorf("a new certificate: revision %d (was %d), record %+v", l.kv["n1.node"].rev, at, got.Record)
} }
bare := rep bare := rep
bare.Health = nil bare.Health = nil
q.SayFacts(context.Background(), bare) q.SayFacts(context.Background(), bare)
if got, _ = delivery.DecodeHaveNode(l.kv["n1.node"].value); got.Record.Contract != NodeKeyContract { if got, _ = nodeengineclient.DecodeHaveNode(l.kv["n1.node"].value); got.Record.Contract != NodeKeyContract {
t.Errorf("a report without a health statement dropped the contract: %+v", got.Record) t.Errorf("a report without a health statement dropped the contract: %+v", got.Record)
} }
} }
@@ -342,7 +347,7 @@ func TestAFailedHaveWriteIsSaidAndWrittenAgain(t *testing.T) {
l.failing = false l.failing = false
l.hmu.Unlock() l.hmu.Unlock()
q.SayFacts(context.Background(), appliedModules()) q.SayFacts(context.Background(), appliedModules())
if l.have(t, "n1.a").Phase != delivery.PhaseRunning { if l.have(t, "n1.a").Phase != nodeengineclient.PhaseRunning {
t.Error("the next apply did not write again") t.Error("the next apply did not write again")
} }
} }
@@ -407,7 +412,7 @@ func TestAFailedBindOrWriteEndsThePassAndReturnsInTime(t *testing.T) {
if l.setCount() == 0 { if l.setCount() == 0 {
t.Fatal("a bind that met no responders was not written again") t.Fatal("a bind that met no responders was not written again")
} }
if l.have(t, "n1.a").Phase != delivery.PhaseRunning { if l.have(t, "n1.a").Phase != nodeengineclient.PhaseRunning {
t.Error("the have written again does not say running") t.Error("the have written again does not say running")
} }
} }
@@ -426,13 +431,13 @@ func TestAModuleLeavingTheDeclarationIsWrittenRemoved(t *testing.T) {
revA, revB := l.kv["n1.a"].rev, l.kv["n1.b"].rev revA, revB := l.kv["n1.a"].rev, l.kv["n1.b"].rev
q.SayFacts(context.Background(), onlyA()) q.SayFacts(context.Background(), onlyA())
b := l.have(t, "n1.b") b := l.have(t, "n1.b")
if b.Phase != delivery.PhaseRemoved || b.Build != "b-2" { if b.Phase != nodeengineclient.PhaseRemoved || b.Build != "b-2" {
t.Errorf("b: %+v", b) t.Errorf("b: %+v", b)
} }
if l.kv["n1.b"].rev != revB+1 { if l.kv["n1.b"].rev != revB+1 {
t.Errorf("b was written at revision %d, want one over %d", l.kv["n1.b"].rev, revB) t.Errorf("b was written at revision %d, want one over %d", l.kv["n1.b"].rev, revB)
} }
if l.kv["n1.a"].rev != revA || l.have(t, "n1.a").Phase != delivery.PhaseRunning { if l.kv["n1.a"].rev != revA || l.have(t, "n1.a").Phase != nodeengineclient.PhaseRunning {
t.Errorf("a changed: %+v at %d", l.have(t, "n1.a"), l.kv["n1.a"].rev) t.Errorf("a changed: %+v at %d", l.have(t, "n1.a"), l.kv["n1.a"].rev)
} }
before := l.setCount() before := l.setCount()
@@ -449,11 +454,11 @@ func TestARemovedModuleAddedBackIsWrittenWithItsNewPhase(t *testing.T) {
q.SayFacts(context.Background(), appliedModules()) q.SayFacts(context.Background(), appliedModules())
q.SayFacts(context.Background(), onlyA()) q.SayFacts(context.Background(), onlyA())
q.SayFacts(context.Background(), appliedModules()) q.SayFacts(context.Background(), appliedModules())
if b := l.have(t, "n1.b"); b.Phase != delivery.PhaseFailed || b.Build != "b-2" { if b := l.have(t, "n1.b"); b.Phase != nodeengineclient.PhaseFailed || b.Build != "b-2" {
t.Errorf("b after it came back: %+v", b) t.Errorf("b after it came back: %+v", b)
} }
q.SayFacts(context.Background(), onlyA()) q.SayFacts(context.Background(), onlyA())
if b := l.have(t, "n1.b"); b.Phase != delivery.PhaseRemoved { if b := l.have(t, "n1.b"); b.Phase != nodeengineclient.PhaseRemoved {
t.Errorf("b removed again: %+v", b) t.Errorf("b removed again: %+v", b)
} }
} }
@@ -462,11 +467,11 @@ func TestARemovedModuleAddedBackIsWrittenWithItsNewPhase(t *testing.T) {
// bucket is not created again. // bucket is not created again.
func TestAModuleNeverWrittenIsNotWrittenRemoved(t *testing.T) { func TestAModuleNeverWrittenIsNotWrittenRemoved(t *testing.T) {
l := newHaveLink() l := newHaveLink()
l.put(t, "n1.c", delivery.Have{V: sdkkeys.Version, Build: "b-9", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) l.put(t, "n1.c", nodeengine.HaveByNodeModuleValue{V: sdkkeys.Version, Build: "b-9", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
q, _ := aHaveQueue(l) q, _ := aHaveQueue(l)
q.SayFacts(context.Background(), appliedModules()) q.SayFacts(context.Background(), appliedModules())
q.SayFacts(context.Background(), onlyA()) q.SayFacts(context.Background(), onlyA())
if c := l.have(t, "n1.c"); c.Phase != delivery.PhaseRunning || l.kv["n1.c"].rev != 1 { if c := l.have(t, "n1.c"); c.Phase != nodeengineclient.PhaseRunning || l.kv["n1.c"].rev != 1 {
t.Errorf("c, which this node-engine never wrote, changed: %+v at %d", c, l.kv["n1.c"].rev) t.Errorf("c, which this node-engine never wrote, changed: %+v at %d", c, l.kv["n1.c"].rev)
} }
// a key that vanished from the bucket is not created by the removal // a key that vanished from the bucket is not created by the removal
@@ -495,7 +500,7 @@ func TestARefusedRemovalIsWrittenByTheNextPass(t *testing.T) {
l.failing = false l.failing = false
l.hmu.Unlock() l.hmu.Unlock()
q.SayFacts(context.Background(), onlyA()) q.SayFacts(context.Background(), onlyA())
if b := l.have(t, "n1.b"); b.Phase != delivery.PhaseRemoved { if b := l.have(t, "n1.b"); b.Phase != nodeengineclient.PhaseRemoved {
t.Errorf("b: %+v", b) t.Errorf("b: %+v", b)
} }
} }
@@ -504,13 +509,13 @@ func TestARefusedRemovalIsWrittenByTheNextPass(t *testing.T) {
// before: one of them the new declaration lacks is written removed, one that is not in the bucket is not created. // before: one of them the new declaration lacks is written removed, one that is not in the bucket is not created.
func TestARestartedNodeEngineWritesTheRemovalFromTheReportsBefore(t *testing.T) { func TestARestartedNodeEngineWritesTheRemovalFromTheReportsBefore(t *testing.T) {
l := newHaveLink() l := newHaveLink()
l.put(t, "n1.a", delivery.Have{V: sdkkeys.Version, Build: "b-1", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) l.put(t, "n1.a", nodeengine.HaveByNodeModuleValue{V: sdkkeys.Version, Build: "b-1", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
l.put(t, "n1.b", delivery.Have{V: sdkkeys.Version, Build: "b-2", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) l.put(t, "n1.b", nodeengine.HaveByNodeModuleValue{V: sdkkeys.Version, Build: "b-2", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
q, _ := aHaveQueue(l) // fresh: nothing written by this process q, _ := aHaveQueue(l) // fresh: nothing written by this process
r := onlyA() r := onlyA()
r.Before = []string{"a", "b", "c"} r.Before = []string{"a", "b", "c"}
q.SayFacts(context.Background(), r) q.SayFacts(context.Background(), r)
if b := l.have(t, "n1.b"); b.Phase != delivery.PhaseRemoved { if b := l.have(t, "n1.b"); b.Phase != nodeengineclient.PhaseRemoved {
t.Errorf("b: %+v", b) t.Errorf("b: %+v", b)
} }
if _, there := l.kv["n1.c"]; there { if _, there := l.kv["n1.c"]; there {
@@ -563,24 +568,24 @@ func TestAnAppliedModulesHaveNamesTheFingerprintAndTheDigestAndFollowsItsHealth(
dig := "sha256:" + strings.Repeat("b", 64) dig := "sha256:" + strings.Repeat("b", 64)
q.NoteModule(context.Background(), ModuleFact{Module: "web", FP: fp, Composed: dig}) q.NoteModule(context.Background(), ModuleFact{Module: "web", FP: fp, Composed: dig})
h := l.have(t, "n1.web") h := l.have(t, "n1.web")
if h.FP != fp || h.Composed != dig || h.Phase != delivery.PhaseRunning || h.Health != delivery.HealthUnknown { if h.Fp != fp || h.Composed != dig || h.Phase != nodeengineclient.PhaseRunning || h.Health != nodeengineclient.HealthUnknown {
t.Fatalf("the have after the apply: %+v", h) t.Fatalf("the have after the apply: %+v", h)
} }
// Starting: it waits. Past its start window the check fails: failed. // Starting: it waits. Past its start window the check fails: failed.
q.SayHealth(context.Background(), *healthOf(map[string]string{"web": StateStarting})) q.SayHealth(context.Background(), *healthOf(map[string]string{"web": StateStarting}))
if got := l.have(t, "n1.web"); got.Health != delivery.HealthWaits || got.FP != fp || got.Composed != dig { if got := l.have(t, "n1.web"); got.Health != nodeengineclient.HealthWaits || got.Fp != fp || got.Composed != dig {
t.Fatalf("while it starts: %+v", got) t.Fatalf("while it starts: %+v", got)
} }
q.SayHealth(context.Background(), *healthOf(map[string]string{"web": StateUnhealthy})) q.SayHealth(context.Background(), *healthOf(map[string]string{"web": StateUnhealthy}))
if got := l.have(t, "n1.web"); got.Health != delivery.HealthFailed || got.FP != fp || got.Composed != dig { if got := l.have(t, "n1.web"); got.Health != nodeengineclient.HealthFailed || got.Fp != fp || got.Composed != dig {
t.Fatalf("a failing check after the start window: %+v", got) t.Fatalf("a failing check after the start window: %+v", got)
} }
// The bus's user list written alone rides on the module's have, and an apply after it keeps it (ADR 0547 §8). // The bus's user list written alone rides on the module's have, and an apply after it keeps it (ADR 0547 §8).
w := delivery.UserListWritten{Source: "n1.web." + fp, Revision: 4, Wanted: dig, Digest: dig, Users: 3, w := nodeengine.UserListWritten{Source: "n1.web." + fp, Revision: 4, Wanted: dig, Digest: dig, Users: 3,
At: time.Date(2026, 10, 25, 21, 0, 0, 0, time.UTC)} At: time.Date(2026, 10, 25, 21, 0, 0, 0, time.UTC)}
q.NoteUserList(context.Background(), "web", w) q.NoteUserList(context.Background(), "web", w)
if got := l.have(t, "n1.web"); got.UserList == nil || got.UserList.Revision != 4 || got.FP != fp { if got := l.have(t, "n1.web"); got.UserList == nil || got.UserList.Revision != 4 || got.Fp != fp {
t.Fatalf("the user list is not in the have: %+v", got) t.Fatalf("the user list is not in the have: %+v", got)
} }
q.NoteModule(context.Background(), ModuleFact{Module: "web", FP: fp, Composed: dig}) q.NoteModule(context.Background(), ModuleFact{Module: "web", FP: fp, Composed: dig})
@@ -589,24 +594,24 @@ func TestAnAppliedModulesHaveNamesTheFingerprintAndTheDigestAndFollowsItsHealth(
} }
// A failed apply waiting on a store's holder carries the pull it waits on in its have (ENG-9, read by node_haves). // A failed apply waiting on a store's holder carries the pull it waits on in its have (ENG-9, read by node_haves).
wait := &delivery.PullWait{Image: "reg/x@sha256:1", Seat: "container-image-registry", Holder: "distribution", wait := &nodeengine.PullWait{Image: "reg/x@sha256:1", Seat: "container-image-registry", Holder: "distribution",
Began: time.Date(2026, 11, 4, 14, 0, 0, 0, time.UTC), Retried: "distribution settled"} Began: time.Date(2026, 11, 4, 14, 0, 0, 0, time.UTC), Retried: "distribution settled"}
q.NoteModule(context.Background(), ModuleFact{Module: "web", FP: fp, Composed: dig, Failed: true, Detail: "the pull failed", PullWait: wait}) q.NoteModule(context.Background(), ModuleFact{Module: "web", FP: fp, Composed: dig, Failed: true, Detail: "the pull failed", PullWait: wait})
if got := l.have(t, "n1.web"); got.Phase != delivery.PhaseFailed || got.PullWait == nil || got.PullWait.Holder != "distribution" { if got := l.have(t, "n1.web"); got.Phase != nodeengineclient.PhaseFailed || got.PullWait == nil || got.PullWait.Holder != "distribution" {
t.Fatalf("the failed have does not say the pull it waits on: %+v", got) t.Fatalf("the failed have does not say the pull it waits on: %+v", got)
} }
// A module held for a failed backup says so and what it keeps running; removed is written once, over its key. // A module held for a failed backup says so and what it keeps running; removed is written once, over its key.
q.NoteModule(context.Background(), ModuleFact{Module: "web", FP: fp, Composed: dig, Phase: string(delivery.PhaseHeld), Detail: "the repository is locked"}) q.NoteModule(context.Background(), ModuleFact{Module: "web", FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseHeld), Detail: "the repository is locked"})
if got := l.have(t, "n1.web"); got.Phase != delivery.PhaseHeld || got.Detail == "" { if got := l.have(t, "n1.web"); got.Phase != nodeengineclient.PhaseHeld || got.Detail == "" {
t.Fatalf("held: %+v", got) t.Fatalf("held: %+v", got)
} }
q.NoteRemoved(context.Background(), "web", "") q.NoteRemoved(context.Background(), "web", "")
if got := l.have(t, "n1.web"); got.Phase != delivery.PhaseRemoved { if got := l.have(t, "n1.web"); got.Phase != nodeengineclient.PhaseRemoved {
t.Fatalf("removed: %+v", got) t.Fatalf("removed: %+v", got)
} }
q.NoteNode(context.Background(), dig, compositions.Seals{"core-box#sealed": "core"}) q.NoteNode(context.Background(), dig, compositions.Seals{"core-box#sealed": "core"})
if n, err := delivery.DecodeHaveNode(l.kv["n1.node"].value); err != nil || n.Composed != dig || len(n.SealedAt) != 1 || n.SealedAt["core-box#sealed"] != "core" { if n, err := nodeengineclient.DecodeHaveNode(l.kv["n1.node"].value); err != nil || n.Composed != dig || len(n.SealedAt) != 1 || n.SealedAt["core-box#sealed"] != "core" {
t.Fatalf("the node key, with its own composition's sealed values by slot: %+v %v", n, err) t.Fatalf("the node key, with its own composition's sealed values by slot: %+v %v", n, err)
} }
if n, ok := q.ReadNodeHave(context.Background()); !ok || n.Composed != dig || len(n.SealedAt) != 1 { if n, ok := q.ReadNodeHave(context.Background()); !ok || n.Composed != dig || len(n.SealedAt) != 1 {
@@ -615,11 +620,11 @@ func TestAnAppliedModulesHaveNamesTheFingerprintAndTheDigestAndFollowsItsHealth(
// The node's answers to placement candidates ride in the same key (ADR 0559 §4), and go when no group is answered. // The node's answers to placement candidates ride in the same key (ADR 0559 §4), and go when no group is answered.
answers := map[string]nodeengine.CandidateAnswer{"p-1": {Proposal: "p-1", Revision: 1, Verdict: nodeengine.CandidateAnswerVerdictSame, Same: []string{"web"}}} answers := map[string]nodeengine.CandidateAnswer{"p-1": {Proposal: "p-1", Revision: 1, Verdict: nodeengine.CandidateAnswerVerdictSame, Same: []string{"web"}}}
q.NoteCandidates(context.Background(), answers) q.NoteCandidates(context.Background(), answers)
if n, err := delivery.DecodeHaveNode(l.kv["n1.node"].value); err != nil || n.Candidates["p-1"].Proposal != "p-1" || n.Composed != dig { if n, err := nodeengineclient.DecodeHaveNode(l.kv["n1.node"].value); err != nil || n.Candidates["p-1"].Proposal != "p-1" || n.Composed != dig {
t.Fatalf("the node key with its answers: %+v %v", n, err) t.Fatalf("the node key with its answers: %+v %v", n, err)
} }
q.NoteCandidates(context.Background(), nil) q.NoteCandidates(context.Background(), nil)
if n, _ := delivery.DecodeHaveNode(l.kv["n1.node"].value); n.Candidates != nil { if n, _ := nodeengineclient.DecodeHaveNode(l.kv["n1.node"].value); n.Candidates != nil {
t.Fatalf("an answer left after its group went: %+v", n.Candidates) t.Fatalf("an answer left after its group went: %+v", n.Candidates)
} }
} }
@@ -633,16 +638,16 @@ func TestAModuleWithNothingToJudgeIsOkOnceLookedAt(t *testing.T) {
q, _ := aHaveQueue(l) q, _ := aHaveQueue(l)
q.SayFacts(context.Background(), Report{Modules: []ModuleFact{{Module: "mesh-host", Build: "b"}, {Module: "web", Build: "b"}, q.SayFacts(context.Background(), Report{Modules: []ModuleFact{{Module: "mesh-host", Build: "b"}, {Module: "web", Build: "b"},
{Module: "broken", Build: "b", Failed: true, Detail: "x"}}}) {Module: "broken", Build: "b", Failed: true, Detail: "x"}}})
if h := l.have(t, "n1.mesh-host").Health; h != delivery.HealthUnknown { if h := l.have(t, "n1.mesh-host").Health; h != nodeengineclient.HealthUnknown {
t.Fatalf("before any look: %s", h) t.Fatalf("before any look: %s", h)
} }
if !q.SayHealth(context.Background(), *healthOf(map[string]string{"web": StateHealthy})) { if !q.SayHealth(context.Background(), *healthOf(map[string]string{"web": StateHealthy})) {
t.Fatal("not said") t.Fatal("not said")
} }
if h := l.have(t, "n1.mesh-host").Health; h != delivery.HealthOK { if h := l.have(t, "n1.mesh-host").Health; h != nodeengineclient.HealthOK {
t.Fatalf("a running module with nothing to judge after a look: %s", h) t.Fatalf("a running module with nothing to judge after a look: %s", h)
} }
if h := l.have(t, "n1.broken").Health; h == delivery.HealthOK { if h := l.have(t, "n1.broken").Health; h == nodeengineclient.HealthOK {
t.Fatalf("a failed module was said ok") t.Fatalf("a failed module was said ok")
} }
} }
@@ -654,17 +659,17 @@ func TestAModuleSaidAgainOverAnOlderEnginesUnknownIsWrittenWithThisEnginesHealth
l := newHaveLink() l := newHaveLink()
fp := "src2:" + strings.Repeat("a", 64) fp := "src2:" + strings.Repeat("a", 64)
dig := "sha256:" + strings.Repeat("b", 64) dig := "sha256:" + strings.Repeat("b", 64)
l.put(t, "n1.xorg", delivery.Have{V: sdkkeys.Version, FP: fp, Composed: dig, Phase: delivery.PhaseRunning, l.put(t, "n1.xorg", nodeengine.HaveByNodeModuleValue{V: sdkkeys.Version, Fp: fp, Composed: dig, Phase: nodeengineclient.PhaseRunning,
Health: delivery.HealthUnknown}) Health: nodeengineclient.HealthUnknown})
q, _ := aHaveQueue(l) q, _ := aHaveQueue(l)
if !q.SayHealth(context.Background(), *healthOf(map[string]string{"web": StateHealthy})) { if !q.SayHealth(context.Background(), *healthOf(map[string]string{"web": StateHealthy})) {
t.Fatal("not said") t.Fatal("not said")
} }
if h := l.have(t, "n1.xorg"); h.Health != delivery.HealthUnknown { if h := l.have(t, "n1.xorg"); h.Health != nodeengineclient.HealthUnknown {
t.Fatalf("a module the session does not hold was written: %+v", h) t.Fatalf("a module the session does not hold was written: %+v", h)
} }
q.NoteModule(context.Background(), ModuleFact{Module: "xorg", FP: fp, Composed: dig}) q.NoteModule(context.Background(), ModuleFact{Module: "xorg", FP: fp, Composed: dig})
if h := l.have(t, "n1.xorg"); h.Health != delivery.HealthOK || h.FP != fp || h.Composed != dig || h.Phase != delivery.PhaseRunning { if h := l.have(t, "n1.xorg"); h.Health != nodeengineclient.HealthOK || h.Fp != fp || h.Composed != dig || h.Phase != nodeengineclient.PhaseRunning {
t.Fatalf("said again after a look: %+v", h) t.Fatalf("said again after a look: %+v", h)
} }
} }
@@ -679,26 +684,26 @@ func TestAHealthIsNotJudgedWhileAModuleIsApplied(t *testing.T) {
old := ModuleFact{Module: "ctl", FP: "src2:" + strings.Repeat("a", 64), Composed: "sha256:" + strings.Repeat("1", 64)} old := ModuleFact{Module: "ctl", FP: "src2:" + strings.Repeat("a", 64), Composed: "sha256:" + strings.Repeat("1", 64)}
q.NoteModule(ctx, old) q.NoteModule(ctx, old)
q.SayHealth(ctx, *healthOf(map[string]string{"ctl": StateUnhealthy})) q.SayHealth(ctx, *healthOf(map[string]string{"ctl": StateUnhealthy}))
if h := l.have(t, "n1.ctl"); h.Health != delivery.HealthFailed { if h := l.have(t, "n1.ctl"); h.Health != nodeengineclient.HealthFailed {
t.Fatalf("the running module's failed look was not said: %+v", h) t.Fatalf("the running module's failed look was not said: %+v", h)
} }
next := ModuleFact{Module: "ctl", FP: "src2:" + strings.Repeat("b", 64), Composed: "sha256:" + strings.Repeat("2", 64)} next := ModuleFact{Module: "ctl", FP: "src2:" + strings.Repeat("b", 64), Composed: "sha256:" + strings.Repeat("2", 64)}
applying := next applying := next
applying.Phase = string(delivery.PhaseApplying) applying.Phase = string(nodeengineclient.PhaseApplying)
q.NoteModule(ctx, applying) q.NoteModule(ctx, applying)
if h := l.have(t, "n1.ctl"); h.Phase != delivery.PhaseApplying || h.Health != delivery.HealthUnknown { if h := l.have(t, "n1.ctl"); h.Phase != nodeengineclient.PhaseApplying || h.Health != nodeengineclient.HealthUnknown {
t.Fatalf("applying carried the last look's health: %+v", h) t.Fatalf("applying carried the last look's health: %+v", h)
} }
q.SayHealth(ctx, *healthOf(map[string]string{"ctl": StateUnhealthy})) q.SayHealth(ctx, *healthOf(map[string]string{"ctl": StateUnhealthy}))
if h := l.have(t, "n1.ctl"); h.Health != delivery.HealthUnknown { if h := l.have(t, "n1.ctl"); h.Health != nodeengineclient.HealthUnknown {
t.Fatalf("a look during the apply judged it: %+v", h) t.Fatalf("a look during the apply judged it: %+v", h)
} }
q.NoteModule(ctx, next) q.NoteModule(ctx, next)
if h := l.have(t, "n1.ctl"); h.Phase != delivery.PhaseRunning || h.Health != delivery.HealthUnknown { if h := l.have(t, "n1.ctl"); h.Phase != nodeengineclient.PhaseRunning || h.Health != nodeengineclient.HealthUnknown {
t.Fatalf("running before a look after the apply: %+v", h) t.Fatalf("running before a look after the apply: %+v", h)
} }
q.SayHealth(ctx, *healthOf(map[string]string{"ctl": StateHealthy})) q.SayHealth(ctx, *healthOf(map[string]string{"ctl": StateHealthy}))
if h := l.have(t, "n1.ctl"); h.Health != delivery.HealthOK { if h := l.have(t, "n1.ctl"); h.Health != nodeengineclient.HealthOK {
t.Fatalf("the look after the apply was not taken: %+v", h) t.Fatalf("the look after the apply was not taken: %+v", h)
} }
} }
@@ -711,7 +716,7 @@ func TestAHealthIsNotCarriedOverToAnotherComposition(t *testing.T) {
q.NoteModule(ctx, ModuleFact{Module: "ctl", FP: "src2:" + strings.Repeat("a", 64)}) q.NoteModule(ctx, ModuleFact{Module: "ctl", FP: "src2:" + strings.Repeat("a", 64)})
q.SayHealth(ctx, *healthOf(map[string]string{"ctl": StateUnhealthy})) q.SayHealth(ctx, *healthOf(map[string]string{"ctl": StateUnhealthy}))
q.NoteModule(ctx, ModuleFact{Module: "ctl", FP: "src2:" + strings.Repeat("b", 64)}) q.NoteModule(ctx, ModuleFact{Module: "ctl", FP: "src2:" + strings.Repeat("b", 64)})
if h := l.have(t, "n1.ctl"); h.Health != delivery.HealthUnknown { if h := l.have(t, "n1.ctl"); h.Health != nodeengineclient.HealthUnknown {
t.Fatalf("the old composition's look judged the new one: %+v", h) t.Fatalf("the old composition's look judged the new one: %+v", h)
} }
} }
@@ -724,11 +729,11 @@ func TestADeclarationReportMovesAHealthAndARemovalForgetsIt(t *testing.T) {
ctx := context.Background() ctx := context.Background()
q.SayFacts(ctx, Report{Applied: []string{"a.x"}, Modules: []ModuleFact{{Module: "a", Build: "b-1"}}, q.SayFacts(ctx, Report{Applied: []string{"a.x"}, Modules: []ModuleFact{{Module: "a", Build: "b-1"}},
Health: healthOf(map[string]string{"a": StateUnhealthy})}) Health: healthOf(map[string]string{"a": StateUnhealthy})})
if h := l.have(t, "n1.a"); h.Health != delivery.HealthFailed { if h := l.have(t, "n1.a"); h.Health != nodeengineclient.HealthFailed {
t.Fatalf("the failed look was not said: %+v", h) t.Fatalf("the failed look was not said: %+v", h)
} }
q.SayFacts(ctx, Report{Applied: []string{"a.x"}, Modules: []ModuleFact{{Module: "a", Build: "b-1", FP: "src2:" + strings.Repeat("c", 64)}}}) q.SayFacts(ctx, Report{Applied: []string{"a.x"}, Modules: []ModuleFact{{Module: "a", Build: "b-1", FP: "src2:" + strings.Repeat("c", 64)}}})
if h := l.have(t, "n1.a"); h.Health != delivery.HealthUnknown { if h := l.have(t, "n1.a"); h.Health != nodeengineclient.HealthUnknown {
t.Fatalf("the old composition's look judged the new one: %+v", h) t.Fatalf("the old composition's look judged the new one: %+v", h)
} }
select { select {
@@ -750,18 +755,18 @@ func TestADeclarationReportMovesAHealthAndARemovalForgetsIt(t *testing.T) {
// key that does not exist is not made up and is said once; a writer that drops the list again is written over. // key that does not exist is not made up and is said once; a writer that drops the list again is written over.
func TestAUserListReachesTheHaveOfAModuleNoApplyHereSays(t *testing.T) { func TestAUserListReachesTheHaveOfAModuleNoApplyHereSays(t *testing.T) {
l := newHaveLink() l := newHaveLink()
older := delivery.Have{V: sdkkeys.Version, Build: "build-1", Phase: delivery.PhaseRunning, Health: delivery.HealthOK} older := nodeengine.HaveByNodeModuleValue{V: sdkkeys.Version, Build: "build-1", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}
l.put(t, "n1.nats", older) l.put(t, "n1.nats", older)
q, said := aHaveQueue(l) q, said := aHaveQueue(l)
dig := "sha256:" + strings.Repeat("c", 64) dig := "sha256:" + strings.Repeat("c", 64)
w := delivery.UserListWritten{Source: "n1.nats", Revision: 7, Wanted: dig, Digest: dig, Users: 58, w := nodeengine.UserListWritten{Source: "n1.nats", Revision: 7, Wanted: dig, Digest: dig, Users: 58,
At: time.Date(2026, 10, 26, 12, 26, 51, 0, time.UTC)} At: time.Date(2026, 10, 26, 12, 26, 51, 0, time.UTC)}
q.NoteUserList(context.Background(), "nats", w) q.NoteUserList(context.Background(), "nats", w)
got := l.have(t, "n1.nats") got := l.have(t, "n1.nats")
if got.UserList == nil || got.UserList.Users != 58 || got.UserList.Digest != dig { if got.UserList == nil || got.UserList.Users != 58 || got.UserList.Digest != dig {
t.Fatalf("the user list is not in the have: %+v", got) t.Fatalf("the user list is not in the have: %+v", got)
} }
if got.Build != "build-1" || got.Phase != delivery.PhaseRunning || got.Health != delivery.HealthOK { if got.Build != "build-1" || got.Phase != nodeengineclient.PhaseRunning || got.Health != nodeengineclient.HealthOK {
t.Fatalf("the merge lost the key's own fields: %+v", got) t.Fatalf("the merge lost the key's own fields: %+v", got)
} }
rev := l.kv["n1.nats"].rev rev := l.kv["n1.nats"].rev
@@ -794,7 +799,7 @@ func TestAUserListReachesTheHaveOfAModuleNoApplyHereSays(t *testing.T) {
} }
fp := "src2:" + strings.Repeat("a", 64) fp := "src2:" + strings.Repeat("a", 64)
q.NoteModule(context.Background(), ModuleFact{Module: "other", FP: fp, Composed: dig}) q.NoteModule(context.Background(), ModuleFact{Module: "other", FP: fp, Composed: dig})
if got := l.have(t, "n1.other"); got.UserList == nil || got.FP != fp { if got := l.have(t, "n1.other"); got.UserList == nil || got.Fp != fp {
t.Fatalf("the first have of the module lost the list: %+v", got) t.Fatalf("the first have of the module lost the list: %+v", got)
} }
} }
+4 -5
View File
@@ -3,12 +3,11 @@ package link
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"slices" "slices"
"strings" "strings"
"testing" "testing"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/delivery"
) )
// A health statement is said on the link open now, as the machine's own word on its own subject, and // A health statement is said on the link open now, as the machine's own word on its own subject, and
@@ -60,7 +59,7 @@ func TestAHealthStatementIsSaidOnTheLinkOpenNow(t *testing.T) {
since := time.Date(2026, 10, 29, 20, 0, 0, 0, time.UTC) since := time.Date(2026, 10, 29, 20, 0, 0, 0, time.UTC)
look := func(streak int, writer string) Health { look := func(streak int, writer string) Health {
return Health{Contract: 7, At: time.Now(), Network: &NetworkHealth{State: StateUnhealthy, Since: since, Parts: []NetworkPart{ return Health{Contract: 7, At: time.Now(), Network: &NetworkHealth{State: StateUnhealthy, Since: since, Parts: []NetworkPart{
{Part: "resolv-conf", State: StateUnhealthy, Writer: writer, Owner: "dnsmasq", Since: since, Streak: streak}}}, {Part: "resolv-conf", State: StateUnhealthy, Writer: writer, Owner: "dnsmasq", Since: since, Streak: int64(streak)}}},
Units: &UnitsHealth{State: "degraded", Failed: []FailedUnit{{Unit: "cups.service", Scope: "system", Since: since}}}, Units: &UnitsHealth{State: "degraded", Failed: []FailedUnit{{Unit: "cups.service", Scope: "system", Since: since}}},
Resources: []ResourceHealth{ Resources: []ResourceHealth{
{Module: "brother", Resource: "brother.scanner", Kind: KindAccount, Account: "jochen", State: StateUnhealthy, {Module: "brother", Resource: "brother.scanner", Kind: KindAccount, Account: "jochen", State: StateUnhealthy,
@@ -121,12 +120,12 @@ func TestAHealthStatementIsSaidOnTheLinkOpenNow(t *testing.T) {
t.Fatalf("step %d: wrote %v (want %v), %v", i, wrote, step.wrote, err) t.Fatalf("step %d: wrote %v (want %v), %v", i, wrote, step.wrote, err)
} }
} }
got, err := delivery.DecodeNodeHealth(store.values["anchor"]) got, err := nodeengineclient.DecodeHealth(store.values["anchor"])
if err != nil || got.Node != "anchor" || got.Network.Parts[0].Writer != "openvpn" || !got.Peers[0].Seen || got.At.IsZero() { if err != nil || got.Node != "anchor" || got.Network.Parts[0].Writer != "openvpn" || !got.Peers[0].Seen || got.At.IsZero() {
t.Fatalf("the key holds %+v, %v", got, err) t.Fatalf("the key holds %+v, %v", got, err)
} }
// The key's bytes are mesh-sdk's layout: decoded and encoded again by it, the same bytes. // The key's bytes are mesh-sdk's layout: decoded and encoded again by it, the same bytes.
if again, err := delivery.EncodeNodeHealth(got); err != nil || string(again) != string(store.values["anchor"]) { if again, err := nodeengineclient.EncodeHealth(got); err != nil || string(again) != string(store.values["anchor"]) {
t.Fatalf("the key's bytes are not the sdk's layout: %s\n%s (%v)", store.values["anchor"], again, err) t.Fatalf("the key's bytes are not the sdk's layout: %s\n%s (%v)", store.values["anchor"], again, err)
} }
if store.puts != 3 { if store.puts != 3 {
+11 -12
View File
@@ -1,11 +1,10 @@
package link package link
import ( import (
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-sdk/go/health"
"github.com/novox/mesh-host/internal/compositions" "github.com/novox/mesh-host/internal/compositions"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
) )
// The wire formats shared with the control plane, which defines them separately because this // The wire formats shared with the control plane, which defines them separately because this
@@ -304,10 +303,10 @@ const ReasonUsedAsFound = "used as found:"
// The machine's networking and its service managers in a statement are the node's health report's parts (mesh-sdk // The machine's networking and its service managers in a statement are the node's health report's parts (mesh-sdk
// delivery, issue 774): one layout for the message, the report and the watchman that reads it. // delivery, issue 774): one layout for the message, the report and the watchman that reads it.
type ( type (
NetworkHealth = delivery.NetworkHealth NetworkHealth = nodeengine.NetworkHealth
NetworkPart = delivery.NetworkPart NetworkPart = nodeengine.NetworkPart
UnitsHealth = delivery.UnitsHealth UnitsHealth = nodeengine.UnitsHealth
FailedUnit = delivery.FailedUnit FailedUnit = nodeengine.FailedUnit
) )
// The states a long-running resource is said in (ADR 0240 §4). // The states a long-running resource is said in (ADR 0240 §4).
@@ -640,12 +639,12 @@ type ModuleFact struct {
// Judgement and Waits are, in phase waiting-yes, the judgement of the move and the composition waiting (ADR 0547 §3). // Judgement and Waits are, in phase waiting-yes, the judgement of the move and the composition waiting (ADR 0547 §3).
Judgement string Judgement string
Waits string Waits string
// BuildAlone says, in phase waiting-yes, the move changes the build alone (ADR 0601 §1, delivery.Have.BuildAlone). // BuildAlone says, in phase waiting-yes, the move changes the build alone (ADR 0601 §1, nodeengine.HaveByNodeModuleValue.BuildAlone).
BuildAlone bool BuildAlone bool
// Kept is what the node keeps of the module as it runs, for a manifest that does not say it yet (delivery.Have.Kept). // Kept is what the node keeps of the module as it runs, for a manifest that does not say it yet (nodeengine.HaveByNodeModuleValue.Kept).
Kept []string Kept []string
// Sealed is the values the composition it names carries sealed to this node's key, by slot (delivery.Have.SealedAt). // Sealed is the values the composition it names carries sealed to this node's key, by slot (nodeengine.HaveByNodeModuleValue.SealedAt).
Sealed compositions.Seals Sealed compositions.Seals
// PullWait is, on a failed fact, the pull from a store its apply waits on (delivery.Have.PullWait, issue 1966 ENG-9). // PullWait is, on a failed fact, the pull from a store its apply waits on (nodeengine.HaveByNodeModuleValue.PullWait, issue 1966 ENG-9).
PullWait *delivery.PullWait PullWait *nodeengine.PullWait
} }
+16 -13
View File
@@ -4,13 +4,16 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-sdk/go/keys"
"github.com/novox/mesh-host/internal/buckets"
"slices" "slices"
"sort" "sort"
"strings" "strings"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
) )
@@ -30,17 +33,17 @@ import (
// - node-account-fallback: Fallback, the engine's fall back to its old login during a rotation (ADR 0583 §6). // - node-account-fallback: Fallback, the engine's fall back to its old login during a rotation (ADR 0583 §6).
// //
// The report's layout is mesh-sdk's (delivery.NodeHealth): the bucket, its version and its parts. // The report's layout is mesh-sdk's (nodeengine.HealthByNodeValue): the bucket, its version and its parts.
const ( const (
HealthBucket = delivery.NodeHealthBucket HealthBucket = buckets.Health
NodeHealthVersion = delivery.NodeHealthVersion NodeHealthVersion = keys.Version
) )
type ( type (
NodeHealth = delivery.NodeHealth NodeHealth = nodeengine.HealthByNodeValue
PeerSeen = delivery.PeerSeen PeerSeen = nodeengine.PeerSeen
NodeAccount = delivery.NodeAccount NodeAccount = nodeengine.NodeAccount
NodeDirectory = delivery.NodeDirectory NodeDirectory = nodeengine.NodeDirectory
) )
// ReasonReloginNeeded starts an account's reason when its session needs a new login (accounts.ReasonRelogin). // ReasonReloginNeeded starts an account's reason when its session needs a new login (accounts.ReasonRelogin).
@@ -83,7 +86,7 @@ const TowardHub = "hub"
// streaks, each unhealthy part naming the machines it points at (machines), its machine units, and each account and // streaks, each unhealthy part naming the machines it points at (machines), its machine units, and each account and
// directory of its resources that the six alerts read. // directory of its resources that the six alerts read.
func NodeHealthOf(node string, h Health, peers []PeerSeen, machines Machines) NodeHealth { func NodeHealthOf(node string, h Health, peers []PeerSeen, machines Machines) NodeHealth {
out := NodeHealth{V: NodeHealthVersion, Node: node, Units: h.Units, Contract: h.Contract} out := NodeHealth{V: NodeHealthVersion, Node: node, Units: h.Units, Contract: int64(h.Contract)}
if len(peers) > 0 { if len(peers) > 0 {
out.Peers = append([]PeerSeen(nil), peers...) out.Peers = append([]PeerSeen(nil), peers...)
sort.Slice(out.Peers, func(i, k int) bool { return out.Peers[i].Key < out.Peers[k].Key }) sort.Slice(out.Peers, func(i, k int) bool { return out.Peers[i].Key < out.Peers[k].Key })
@@ -108,7 +111,7 @@ func NodeHealthOf(node string, h Health, peers []PeerSeen, machines Machines) No
} }
if r.Kind == KindAccount && r.Root == declaration.RootNever { if r.Kind == KindAccount && r.Root == declaration.RootNever {
// The account declared never to become root without a person, judged (ADR 0266): what DA and D-root read. // The account declared never to become root without a person, judged (ADR 0266): what DA and D-root read.
out.Root = append(out.Root, delivery.RootVerdict{Account: r.Account, Module: r.Module, Resource: r.Resource, out.Root = append(out.Root, nodeengine.RootAccountVerdict{Account: r.Account, Module: r.Module, Resource: r.Resource,
State: r.State, Reason: r.Reason, Since: r.Since}) State: r.State, Reason: r.Reason, Since: r.Since})
} }
if r.Kind == KindDirectory && strings.HasPrefix(r.Reason, ReasonUsedAsFound) { if r.Kind == KindDirectory && strings.HasPrefix(r.Reason, ReasonUsedAsFound) {
@@ -191,16 +194,16 @@ func writeNodeHealth(ctx context.Context, store HealthStore, last **NodeHealth,
return false, fmt.Errorf("the health report of %s cannot be read: %w", h.Node, err) return false, fmt.Errorf("the health report of %s cannot be read: %w", h.Node, err)
} }
if raw != nil { if raw != nil {
if was, err := delivery.DecodeNodeHealth(raw); err == nil { if was, err := nodeengineclient.DecodeHealth(raw); err == nil {
*last = &was *last = &was
} }
} }
} }
if *last != nil && delivery.SameNodeHealth(**last, h) { if *last != nil && nodeengineclient.SameHealth(**last, h) {
return false, nil return false, nil
} }
h.At = now().UTC() h.At = now().UTC()
raw, err := delivery.EncodeNodeHealth(h) raw, err := nodeengineclient.EncodeHealth(h)
if err != nil { if err != nil {
return false, err return false, err
} }
+8 -6
View File
@@ -2,6 +2,7 @@ package link
import ( import (
"encoding/json" "encoding/json"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
sdkkeys "git.novox.be/novox/mesh-sdk/go/keys" sdkkeys "git.novox.be/novox/mesh-sdk/go/keys"
"slices" "slices"
"sort" "sort"
@@ -9,11 +10,10 @@ import (
"time" "time"
"git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
) )
// The node's record (novox/hq ADR 0555 §3, issue 1126): what the machine observes of itself, written in its own have key // The node's record (novox/hq ADR 0555 §3, issue 1126): what the machine observes of itself, written in its own have key
// `<node>.node` (delivery.HaveNode.Record) with its facts, so every node and the parts that compose read it from the bus // `<node>.node` (nodeengine.HaveByNodeNodeValue.Record) with its facts, so every node and the parts that compose read it from the bus
// and hear its change as the key's. Each value is the one the controller took from this node's enrolment and reports // and hear its change as the key's. Each value is the one the controller took from this node's enrolment and reports
// until now (its profile's present capabilities and networks, its outward links, its health statement's contract, and // until now (its profile's present capabilities and networks, its outward links, its health statement's contract, and
// the public halves of the keys it enrolled with), read here from the same sources, never from the controller. // the public halves of the keys it enrolled with), read here from the same sources, never from the controller.
@@ -44,7 +44,7 @@ var readsStatedAt = time.Now().UTC()
// recordOf is the node's record from one facts report and its keys; nil for a report that carries no profile (one that // recordOf is the node's record from one facts report and its keys; nil for a report that carries no profile (one that
// says nothing of the machine, such as a rekey), so the record last written stands. // says nothing of the machine, such as a rekey), so the record last written stands.
func recordOf(node string, r Report, keys RecordKeys) *delivery.NodeRecord { func recordOf(node string, r Report, keys RecordKeys) *nodeengine.NodeRecord {
if r.Profile == nil { if r.Profile == nil {
return nil return nil
} }
@@ -61,7 +61,7 @@ func recordOf(node string, r Report, keys RecordKeys) *delivery.NodeRecord {
if err != nil || json.Unmarshal(raw, &p) != nil { if err != nil || json.Unmarshal(raw, &p) != nil {
return nil return nil
} }
rec := &delivery.NodeRecord{V: sdkkeys.Version, Node: node, TunnelKey: keys.TunnelKey, SealingKey: keys.SealingKey, rec := &nodeengine.NodeRecord{V: sdkkeys.Version, Node: node, TunnelKey: keys.TunnelKey, SealingKey: keys.SealingKey,
ServingKey: keys.ServingKey, Certificate: keys.Certificate, Authority: keys.Authority, Trusts: keys.Trusts, ServingKey: keys.ServingKey, Certificate: keys.Certificate, Authority: keys.Authority, Trusts: keys.Trusts,
ManifestReads: manifestReads()} ManifestReads: manifestReads()}
for _, c := range p.Capabilities { for _, c := range p.Capabilities {
@@ -77,13 +77,15 @@ func recordOf(node string, r Report, keys RecordKeys) *delivery.NodeRecord {
sort.Strings(rec.Capabilities) sort.Strings(rec.Capabilities)
if p.Machine != nil { if p.Machine != nil {
rec.NetworksSaid = true rec.NetworksSaid = true
rec.Networks = p.Machine.Networks for _, n := range p.Machine.Networks {
rec.Networks = append(rec.Networks, nodeengine.AttachedNetwork{Link: n.Link, Network: n.Network})
}
} }
if len(r.Outward) > 0 { if len(r.Outward) > 0 {
rec.OutwardLinks = append([]string(nil), r.Outward...) rec.OutwardLinks = append([]string(nil), r.Outward...)
} }
if r.Health != nil { if r.Health != nil {
rec.Contract = r.Health.Contract rec.Contract = int64(r.Health.Contract)
} }
return rec return rec
} }
+5 -4
View File
@@ -3,6 +3,7 @@ package link
import ( import (
"context" "context"
"fmt" "fmt"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"sync" "sync"
"time" "time"
@@ -35,9 +36,9 @@ type Rotator struct {
failures int failures int
// fellBack is the key's `since` a fallback was made for: the same rotation is not tried again. // fellBack is the key's `since` a fallback was made for: the same rotation is not tried again.
fellBackSince string fellBackSince string
// fallen is the fall back the node's health report carries (delivery.LoginFallback), from which the watchman raises // fallen is the fall back the node's health report carries (nodeengine.LoginFallback), from which the watchman raises
// node-account-fallback (ADR 0583 §6), until the vault's key of this node says anything new. // node-account-fallback (ADR 0583 §6), until the vault's key of this node says anything new.
fallen *delivery.LoginFallback fallen *nodeengine.LoginFallback
switchedSince string switchedSince string
} }
@@ -162,13 +163,13 @@ func (r *Rotator) AuthFailed(login string) {
if r.Now != nil { if r.Now != nil {
now = r.Now now = r.Now
} }
r.fallen = &delivery.LoginFallback{Node: r.Logins.node, From: login, Why: text, At: now().UTC()} r.fallen = &nodeengine.LoginFallback{Node: r.Logins.node, From: login, Why: text, At: now().UTC()}
r.old, r.failures = "", 0 r.old, r.failures = "", 0
r.fellBackSince = r.switchedSince r.fellBackSince = r.switchedSince
} }
// Fallback is the fall back the node's health report carries now, nil when there is none. // Fallback is the fall back the node's health report carries now, nil when there is none.
func (r *Rotator) Fallback() *delivery.LoginFallback { func (r *Rotator) Fallback() *nodeengine.LoginFallback {
if r == nil { if r == nil {
return nil return nil
} }
+3 -1
View File
@@ -3,6 +3,8 @@ package link
import ( import (
"context" "context"
"errors" "errors"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"slices" "slices"
"strings" "strings"
"testing" "testing"
@@ -140,7 +142,7 @@ func TestTheOldValueGoesAtRotationDoneAbandonSwitchesBackAndFailuresFallBack(t *
t.Fatalf("after the bound: %s, fall back %+v", cur, fb) t.Fatalf("after the bound: %s, fall back %+v", cur, fb)
} }
// The health report carries it, from which the watchman raises node-account-fallback. // The health report carries it, from which the watchman raises node-account-fallback.
if _, err := delivery.EncodeNodeHealth(delivery.NodeHealth{Node: "ace", Fallback: fb}); err != nil { if _, err := nodeengineclient.EncodeHealth(nodeengine.HealthByNodeValue{Node: "ace", Fallback: fb}); err != nil {
t.Errorf("the fall back is no health report's: %v", err) t.Errorf("the fall back is no health report's: %v", err)
} }
steps := len(f.steps) steps := len(f.steps)
+1 -2
View File
@@ -12,7 +12,6 @@ import (
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
stdio "git.novox.be/novox/mesh-sdk/go" stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health" "git.novox.be/novox/mesh-sdk/go/health"
) )
@@ -23,7 +22,7 @@ import (
// receives, orders or reconciles a whole declaration. // receives, orders or reconciles a whole declaration.
type Session struct { type Session struct {
// Fallback is the engine's fall back to its old login its health report carries now (Rotator.Fallback); nil says none. // Fallback is the engine's fall back to its old login its health report carries now (Rotator.Fallback); nil says none.
Fallback func() *delivery.LoginFallback Fallback func() *nodeengine.LoginFallback
// linkedUser is the bus user the link open now authenticated as, guarded by mu (WhenLinkedAs). // linkedUser is the bus user the link open now authenticated as, guarded by mu (WhenLinkedAs).
linkedUser string linkedUser string
// Membership is the node and the mesh's key a controller's ask is verified with. // Membership is the node and the mesh's key a controller's ask is verified with.
+1 -1
View File
@@ -46,7 +46,7 @@ func TestDrillAResolverFileRewrittenInAThrowawayMachine(t *testing.T) {
Network: &link.NetworkHealth{State: st.State, Since: st.Since.UTC(), Parts: []link.NetworkPart{}}} Network: &link.NetworkHealth{State: st.State, Since: st.Since.UTC(), Parts: []link.NetworkPart{}}}
for _, p := range st.Parts { for _, p := range st.Parts {
h.Network.Parts = append(h.Network.Parts, link.NetworkPart{Part: p.Part, State: p.State, Reason: p.Reason, h.Network.Parts = append(h.Network.Parts, link.NetworkPart{Part: p.Part, State: p.State, Reason: p.Reason,
Said: p.Said, Writer: p.Writer, Owner: p.Owner, Toward: p.Toward, Since: p.Since.UTC(), Streak: p.Streak}) Said: p.Said, Writer: p.Writer, Owner: p.Owner, Toward: p.Toward, Since: p.Since.UTC(), Streak: int64(p.Streak)})
} }
said = append(said, h) said = append(said, h)
raw, _ := json.Marshal(st) raw, _ := json.Marshal(st)
+3 -3
View File
@@ -227,7 +227,7 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask nodeengi
case laidErr != nil && baseErr != nil: case laidErr != nil && baseErr != nil:
// The node composes none of its own without the candidate either: the candidate changes nothing of that. // The node composes none of its own without the candidate either: the candidate changes nothing of that.
a.Verdict, a.Modules = nec.Composed, []nodeengine.Module{} a.Verdict, a.Modules = nec.Composed, []nodeengine.Module{}
a.Own = nodeengine.Module{Module: delivery.HaveNodeWord, State: nec.ModuleHeld, Words: laidErr.Error()} a.Own = nodeengine.Module{Module: nec.NodeWord, State: nec.ModuleHeld, Words: laidErr.Error()}
a.Took = c.now().Sub(began).Milliseconds() a.Took = c.now().Sub(began).Milliseconds()
return a return a
case laidErr != nil: case laidErr != nil:
@@ -244,7 +244,7 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask nodeengi
one := func(module string) (nodeengine.Module, bool) { one := func(module string) (nodeengine.Module, bool) {
m := nodeengine.Module{Module: module} m := nodeengine.Module{Module: module}
if module == "" { if module == "" {
m.Module = delivery.HaveNodeWord m.Module = nec.NodeWord
} }
if why, held := laidHeld[module]; held { if why, held := laidHeld[module]; held {
m.State, m.Words = nec.ModuleRefused, why m.State, m.Words = nec.ModuleRefused, why
@@ -295,7 +295,7 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask nodeengi
if own, ok := one(""); ok { if own, ok := one(""); ok {
a.Own = own a.Own = own
} else { } else {
a.Own = nodeengine.Module{Module: delivery.HaveNodeWord, State: nec.ModuleHeld, a.Own = nodeengine.Module{Module: nec.NodeWord, State: nec.ModuleHeld,
Words: "the node composes no composition of its own"} Words: "the node composes no composition of its own"}
} }
refused = refused || a.Own.State == nec.ModuleRefused refused = refused || a.Own.State == nec.ModuleRefused
+6 -5
View File
@@ -3,6 +3,7 @@ package selfcompose
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"github.com/novox/mesh-host/internal/buckets"
"strings" "strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
@@ -83,7 +84,7 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) {
} }
place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"},
Modules: []delivery.PlacedModule{{Module: "lemurs"}}}) Modules: []delivery.PlacedModule{{Module: "lemurs"}}})
have, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) have, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}})
mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"})
deadline := time.Now().Add(5 * time.Second) deadline := time.Now().Add(5 * time.Second)
watching := func() int { b.mu.Lock(); defer b.mu.Unlock(); return len(b.watches) } watching := func() int { b.mu.Lock(); defer b.mu.Unlock(); return len(b.watches) }
@@ -91,9 +92,9 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) {
time.Sleep(5 * time.Millisecond) time.Sleep(5 * time.Millisecond)
} }
b.put(t, delivery.PlacementBucket, "placement.n1", place) b.put(t, delivery.PlacementBucket, "placement.n1", place)
b.put(t, delivery.HaveBucket, "n1.node", have) b.put(t, buckets.Have, "n1.node", have)
b.put(t, delivery.ModulesBucket, "lemurs", mod) b.put(t, delivery.ModulesBucket, "lemurs", mod)
for _, bucket := range []string{delivery.PlacementCandidatesBucket, delivery.PlacementBucket, delivery.HaveBucket, delivery.ModulesBucket, ResolvedBucket} { for _, bucket := range []string{delivery.PlacementCandidatesBucket, delivery.PlacementBucket, buckets.Have, delivery.ModulesBucket, ResolvedBucket} {
b.send(bucket, agent.Update{Caught: true}) b.send(bucket, agent.Update{Caught: true})
} }
for !s.ready.Load() && time.Now().Before(deadline) { for !s.ready.Load() && time.Now().Before(deadline) {
@@ -106,7 +107,7 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) {
} }
if a.Verdict != nec.Composed || len(a.Modules) != 1 || a.Modules[0].Module != "lemurs" || if a.Verdict != nec.Composed || len(a.Modules) != 1 || a.Modules[0].Module != "lemurs" ||
a.Modules[0].State != nec.ModuleComposed || a.Modules[0].Fp != fpNew || !a.Modules[0].Changed || a.Modules[0].State != nec.ModuleComposed || a.Modules[0].Fp != fpNew || !a.Modules[0].Changed ||
a.Own.Module != delivery.HaveNodeWord || a.Took > nec.AnswerBound.Milliseconds() { a.Own.Module != nec.NodeWord || a.Took > nec.AnswerBound.Milliseconds() {
t.Errorf("an accepted candidate answered %+v", a) t.Errorf("an accepted candidate answered %+v", a)
} }
// Issue 774: the input it sends the sandbox says how it composed the node (the builds), so the sandbox composes it // Issue 774: the input it sends the sandbox says how it composed the node (the builds), so the sandbox composes it
@@ -181,7 +182,7 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) {
s := NewShadow(env, nil) s := NewShadow(env, nil)
s.c.placements["n1"] = delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, s.c.placements["n1"] = delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"},
Modules: []delivery.PlacedModule{{Module: "lemurs"}}} Modules: []delivery.PlacedModule{{Module: "lemurs"}}}
s.c.records["n1"] = delivery.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}} s.c.records["n1"] = nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}
s.c.modules["lemurs"] = delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"} s.c.modules["lemurs"] = delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}
s.c.modules["other"] = delivery.Module{FP2: fp, Manifest: digestOther, Repository: "novox/x", Commit: "c"} s.c.modules["other"] = delivery.Module{FP2: fp, Manifest: digestOther, Repository: "novox/x", Commit: "c"}
s.ready.Store(true) s.ready.Store(true)
+6 -3
View File
@@ -2,6 +2,9 @@ package selfcompose
import ( import (
"context" "context"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"github.com/novox/mesh-host/internal/buckets"
engcomp "github.com/novox/mesh-host/internal/compositions" engcomp "github.com/novox/mesh-host/internal/compositions"
"slices" "slices"
"strings" "strings"
@@ -85,18 +88,18 @@ func TestTheNodePublishesItsCompositions(t *testing.T) {
} }
place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"},
Modules: []delivery.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}}}) Modules: []delivery.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}}})
have, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}})
mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"})
omod, _ := delivery.EncodeModule(delivery.Module{FP2: ofp, Manifest: odigest, Repository: "novox/o", Commit: "c"}) omod, _ := delivery.EncodeModule(delivery.Module{FP2: ofp, Manifest: odigest, Repository: "novox/o", Commit: "c"})
want, _ := delivery.EncodeWant(delivery.Want{FP: fp, Self: true}) want, _ := delivery.EncodeWant(delivery.Want{FP: fp, Self: true})
nodeWant, _ := delivery.EncodeNodeWant(delivery.NodeWant{Self: true}) nodeWant, _ := delivery.EncodeNodeWant(delivery.NodeWant{Self: true})
b.put(t, delivery.PlacementBucket, "placement.n1", place) b.put(t, delivery.PlacementBucket, "placement.n1", place)
b.put(t, delivery.HaveBucket, "n1.node", have) b.put(t, buckets.Have, "n1.node", have)
b.put(t, delivery.ModulesBucket, "lemurs", mod) b.put(t, delivery.ModulesBucket, "lemurs", mod)
b.put(t, delivery.ModulesBucket, "oldie", omod) b.put(t, delivery.ModulesBucket, "oldie", omod)
b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", want) b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", want)
b.put(t, delivery.AssignerWantsBucket, "want.n1.node", nodeWant) b.put(t, delivery.AssignerWantsBucket, "want.n1.node", nodeWant)
for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, delivery.HaveBucket, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} { for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, buckets.Have, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} {
b.send(bucket, agent.Update{Caught: true}) b.send(bucket, agent.Update{Caught: true})
} }
for u := next(t, out); !u.Caught; u = next(t, out) { for u := next(t, out); !u.Caught; u = next(t, out) {
+20 -8
View File
@@ -4,13 +4,15 @@ import (
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"github.com/novox/mesh-host/internal/buckets"
engcomp "github.com/novox/mesh-host/internal/compositions" engcomp "github.com/novox/mesh-host/internal/compositions"
"sort" "sort"
"strings" "strings"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose"
"github.com/novox/mesh-host/internal/compose/frombus" "github.com/novox/mesh-host/internal/compose/frombus"
) )
@@ -62,7 +64,7 @@ func (c *Composer) publishData(ctx context.Context) {
items, unplaced, held, err := dataItemsOf(ctx, itemsReader{reader{c: c}}, c.env.Mesh, scope) items, unplaced, held, err := dataItemsOf(ctx, itemsReader{reader{c: c}}, c.env.Mesh, scope)
if err != nil { if err != nil {
c.sayData("the data items of this node's modules cannot be worked out, and what the engine's state " + c.sayData("the data items of this node's modules cannot be worked out, and what the engine's state " +
delivery.EngineDataBucket + " holds stands; " + c.oweData() + ": " + err.Error()) buckets.Data + " holds stands; " + c.oweData() + ": " + err.Error())
return return
} }
// A module held for a refused manifest declares nothing this pass can read: it is named, its other modules' items // A module held for a refused manifest declares nothing this pass can read: it is named, its other modules' items
@@ -83,7 +85,7 @@ func (c *Composer) publishData(ctx context.Context) {
c.mu.Unlock() c.mu.Unlock()
wrote, removed, err := c.writeData(ctx, items, digests, held) wrote, removed, err := c.writeData(ctx, items, digests, held)
if err != nil { if err != nil {
c.sayData("the data items of this node's modules are not all kept in " + delivery.EngineDataBucket + c.sayData("the data items of this node's modules are not all kept in " + buckets.Data +
"; " + c.oweData() + ": " + err.Error()) "; " + c.oweData() + ": " + err.Error())
return return
} }
@@ -93,7 +95,7 @@ func (c *Composer) publishData(ctx context.Context) {
c.sayData("") c.sayData("")
if wrote+removed > 0 { if wrote+removed > 0 {
c.env.Say(fmt.Sprintf("the data items of this node's modules: %d key(s) of %s written, %d deleted", c.env.Say(fmt.Sprintf("the data items of this node's modules: %d key(s) of %s written, %d deleted",
wrote, delivery.EngineDataBucket, removed)) wrote, buckets.Data, removed))
} }
} }
@@ -145,7 +147,7 @@ func (c *Composer) digestsByModule() map[string]string {
func (c *Composer) writeData(ctx context.Context, items map[string][]catalogue.PlacedDataItem, digests map[string]string, held map[string]string) (wrote, removed int, err error) { func (c *Composer) writeData(ctx context.Context, items map[string][]catalogue.PlacedDataItem, digests map[string]string, held map[string]string) (wrote, removed int, err error) {
store := c.env.Data store := c.env.Data
if c.dataKept == nil { if c.dataKept == nil {
pattern, err := delivery.EngineDataNodeKeys(c.env.Node) pattern, err := nodeengineclient.DataNodeKeys(c.env.Node)
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
@@ -176,7 +178,7 @@ func (c *Composer) writeData(ctx context.Context, items map[string][]catalogue.P
if len(items[module]) == 0 { if len(items[module]) == 0 {
continue continue
} }
key, err := delivery.EngineDataKey(c.env.Node, module) key, err := nodeengineclient.DataKey(c.env.Node, module)
if err != nil { if err != nil {
failed = append(failed, err.Error()) failed = append(failed, err.Error())
continue continue
@@ -185,7 +187,7 @@ func (c *Composer) writeData(ctx context.Context, items map[string][]catalogue.P
if !ok { if !ok {
digest = digests[""] // run inside the node's own composition digest = digests[""] // run inside the node's own composition
} }
raw, err := delivery.EncodeEngineData(delivery.EngineData{Node: c.env.Node, Module: module, Digest: digest, Items: items[module]}) raw, err := nodeengineclient.EncodeData(nodeengine.DataByNodeModuleValue{Node: c.env.Node, Module: module, Digest: digest, Items: placedItems(items[module])})
if err != nil { if err != nil {
failed = append(failed, err.Error()) failed = append(failed, err.Error())
continue continue
@@ -232,9 +234,19 @@ func (c *Composer) writeData(ctx context.Context, items map[string][]catalogue.P
// heldKey is whether key is the key of a module held for a refused manifest, which stands as it was. // heldKey is whether key is the key of a module held for a refused manifest, which stands as it was.
func heldKey(node, key string, held map[string]string) bool { func heldKey(node, key string, held map[string]string) bool {
for module := range held { for module := range held {
if k, err := delivery.EngineDataKey(node, module); err == nil && k == key { if k, err := nodeengineclient.DataKey(node, module); err == nil && k == key {
return true return true
} }
} }
return false return false
} }
// placedItems are a module's data items as the state data carries them.
func placedItems(in []catalogue.PlacedDataItem) []nodeengine.PlacedDataItem {
out := make([]nodeengine.PlacedDataItem, 0, len(in))
for _, i := range in {
out = append(out, nodeengine.PlacedDataItem{Module: i.Module, Item: i.ID, Class: i.Class, Path: i.Path,
CoveredBy: i.CoveredBy, Protection: i.Protection, Measure: i.Measure})
}
return out
}
+13 -10
View File
@@ -3,6 +3,9 @@ package selfcompose
import ( import (
"context" "context"
"errors" "errors"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"github.com/novox/mesh-host/internal/buckets"
engcomp "github.com/novox/mesh-host/internal/compositions" engcomp "github.com/novox/mesh-host/internal/compositions"
"slices" "slices"
"strings" "strings"
@@ -86,7 +89,7 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) {
var said []string var said []string
env := Env{Node: "n1", Data: store, Say: func(s string) { said = append(said, s) }} env := Env{Node: "n1", Data: store, Say: func(s string) { said = append(said, s) }}
// The engine's grant lets it read its own node's keys, as the listing at a run's first pass does, and no other's. // The engine's grant lets it read its own node's keys, as the listing at a run's first pass does, and no other's.
if !grantAllows(t, "n1", delivery.EngineDataBucket, "n1.*") || grantAllows(t, "n1", delivery.EngineDataBucket, "n2.*") { if !grantAllows(t, "n1", buckets.Data, "n1.*") || grantAllows(t, "n1", buckets.Data, "n2.*") {
t.Error("the node's grant reads other keys of the engine's state data than its own") t.Error("the node's grant reads other keys of the engine's state data than its own")
} }
c := New(env) c := New(env)
@@ -105,15 +108,15 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) {
deletes []string deletes []string
asked int asked int
// want is each key's items and digest after the step. // want is each key's items and digest after the step.
want map[string]delivery.EngineData want map[string]nodeengine.DataByNodeModuleValue
}{ }{
{name: "the run's first pass writes each module's items and deletes what left", due: true, {name: "the run's first pass writes each module's items and deletes what left", due: true,
items: map[string][]catalogue.PlacedDataItem{"web": {item("web", "db", "/srv/web/db")}, "oldie": {item("oldie", "x", "/srv/oldie")}, "bare": nil}, items: map[string][]catalogue.PlacedDataItem{"web": {item("web", "db", "/srv/web/db")}, "oldie": {item("oldie", "x", "/srv/oldie")}, "bare": nil},
digests: map[string]string{webKey: "sha256:w1", nodeKey: "sha256:n1"}, digests: map[string]string{webKey: "sha256:w1", nodeKey: "sha256:n1"},
puts: []string{"n1.oldie", "n1.web"}, deletes: []string{"n1.gone"}, asked: 1, puts: []string{"n1.oldie", "n1.web"}, deletes: []string{"n1.gone"}, asked: 1,
want: map[string]delivery.EngineData{ want: map[string]nodeengine.DataByNodeModuleValue{
"n1.web": {Module: "web", Digest: "sha256:w1", Items: []catalogue.PlacedDataItem{item("web", "db", "/srv/web/db")}}, "n1.web": {Module: "web", Digest: "sha256:w1", Items: placedItems([]catalogue.PlacedDataItem{item("web", "db", "/srv/web/db")})},
"n1.oldie": {Module: "oldie", Digest: "sha256:n1", Items: []catalogue.PlacedDataItem{item("oldie", "x", "/srv/oldie")}}}}, "n1.oldie": {Module: "oldie", Digest: "sha256:n1", Items: placedItems([]catalogue.PlacedDataItem{item("oldie", "x", "/srv/oldie")})}}},
{name: "a pass that handed nothing new reads nothing", due: false, asked: 1}, {name: "a pass that handed nothing new reads nothing", due: false, asked: 1},
{name: "a pass that changes no module's items writes nothing", due: true, {name: "a pass that changes no module's items writes nothing", due: true,
items: map[string][]catalogue.PlacedDataItem{"web": {item("web", "db", "/srv/web/db")}, "oldie": {item("oldie", "x", "/srv/oldie")}}, items: map[string][]catalogue.PlacedDataItem{"web": {item("web", "db", "/srv/web/db")}, "oldie": {item("oldie", "x", "/srv/oldie")}},
@@ -126,8 +129,8 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) {
{name: "a module whose composition changed its items is written again", due: false, {name: "a module whose composition changed its items is written again", due: false,
items: map[string][]catalogue.PlacedDataItem{"web": {item("web", "db", "/srv/web/db2")}, "oldie": {item("oldie", "x", "/srv/oldie")}}, items: map[string][]catalogue.PlacedDataItem{"web": {item("web", "db", "/srv/web/db2")}, "oldie": {item("oldie", "x", "/srv/oldie")}},
digests: map[string]string{webKey: "sha256:w2", nodeKey: "sha256:n1"}, puts: []string{"n1.web"}, asked: 5, digests: map[string]string{webKey: "sha256:w2", nodeKey: "sha256:n1"}, puts: []string{"n1.web"}, asked: 5,
want: map[string]delivery.EngineData{ want: map[string]nodeengine.DataByNodeModuleValue{
"n1.web": {Module: "web", Digest: "sha256:w2", Items: []catalogue.PlacedDataItem{item("web", "db", "/srv/web/db2")}}}}, "n1.web": {Module: "web", Digest: "sha256:w2", Items: placedItems([]catalogue.PlacedDataItem{item("web", "db", "/srv/web/db2")})}}},
{name: "a module that left the node is deleted", due: true, {name: "a module that left the node is deleted", due: true,
items: map[string][]catalogue.PlacedDataItem{"oldie": {item("oldie", "x", "/srv/oldie")}}, items: map[string][]catalogue.PlacedDataItem{"oldie": {item("oldie", "x", "/srv/oldie")}},
digests: map[string]string{nodeKey: "sha256:n1"}, deletes: []string{"n1.web"}, asked: 6}, digests: map[string]string{nodeKey: "sha256:n1"}, deletes: []string{"n1.web"}, asked: 6},
@@ -136,8 +139,8 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) {
items: map[string][]catalogue.PlacedDataItem{"web": {item("web", "db", "/srv/web/db")}}, items: map[string][]catalogue.PlacedDataItem{"web": {item("web", "db", "/srv/web/db")}},
held: map[string]string{"oldie": "the manifest of oldie is refused: a bar item is no icon"}, held: map[string]string{"oldie": "the manifest of oldie is refused: a bar item is no icon"},
digests: map[string]string{webKey: "sha256:w3", nodeKey: "sha256:n1"}, puts: []string{"n1.web"}, asked: 7, digests: map[string]string{webKey: "sha256:w3", nodeKey: "sha256:n1"}, puts: []string{"n1.web"}, asked: 7,
want: map[string]delivery.EngineData{ want: map[string]nodeengine.DataByNodeModuleValue{
"n1.web": {Module: "web", Digest: "sha256:w3", Items: []catalogue.PlacedDataItem{item("web", "db", "/srv/web/db")}}}}, "n1.web": {Module: "web", Digest: "sha256:w3", Items: placedItems([]catalogue.PlacedDataItem{item("web", "db", "/srv/web/db")})}}},
} { } {
t.Run(step.name, func(t *testing.T) { t.Run(step.name, func(t *testing.T) {
store.mu.Lock() store.mu.Lock()
@@ -159,7 +162,7 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) {
t.Errorf("owed a pass: %v after %s; want after %s", c.owed.Load(), wait, step.owed) t.Errorf("owed a pass: %v after %s; want after %s", c.owed.Load(), wait, step.owed)
} }
for key, want := range step.want { for key, want := range step.want {
got, err := delivery.DecodeEngineData(store.kv[key]) got, err := nodeengineclient.DecodeData(store.kv[key])
if err != nil || got.Node != "n1" || got.Module != want.Module || got.Digest != want.Digest || if err != nil || got.Node != "n1" || got.Module != want.Module || got.Digest != want.Digest ||
!slices.Equal(got.Items, want.Items) { !slices.Equal(got.Items, want.Items) {
t.Errorf("%s holds %+v (%v), want %+v", key, got, err, want) t.Errorf("%s holds %+v (%v), want %+v", key, got, err, want)
-1
View File
@@ -10,7 +10,6 @@ import (
engcomp "github.com/novox/mesh-host/internal/compositions" engcomp "github.com/novox/mesh-host/internal/compositions"
"sort" "sort"
"time" "time"
) )
// **The packages this node's compositions declare, kept by its engine** (novox/hq ADR 0565 §6, §15; issue 1953). The // **The packages this node's compositions declare, kept by its engine** (novox/hq ADR 0565 §6, §15; issue 1953). The
+19 -16
View File
@@ -19,6 +19,9 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"github.com/novox/mesh-host/internal/buckets"
"github.com/novox/mesh-host/internal/compositions" "github.com/novox/mesh-host/internal/compositions"
engcomp "github.com/novox/mesh-host/internal/compositions" engcomp "github.com/novox/mesh-host/internal/compositions"
"maps" "maps"
@@ -76,7 +79,7 @@ type Env struct {
Unseal func(sealed string) ([]byte, error) Unseal func(sealed string) ([]byte, error)
// Held is the sealed values a module's have says it runs, kept by the agent on the bus: what runs after a restart, // Held is the sealed values a module's have says it runs, kept by the agent on the bus: what runs after a restart,
// before the agent applied anything, and after the switch once the kept declaration is old. By slot // before the agent applied anything, and after the switch once the kept declaration is old. By slot
// (delivery.Have.SealedAt); legacy is the list a have written before slots holds (delivery.Have.Sealed), reused // (nodeengine.HaveByNodeModuleValue.SealedAt); legacy is the list a have written before slots holds (nodeengine.HaveByNodeModuleValue.Sealed), reused
// only within its own module. // only within its own module.
// An error is a have the bus did not answer for, never one absent: the pass is then tried again (BusFailed). // An error is a have the bus did not answer for, never one absent: the pass is then tried again (BusFailed).
Held func(module string) (slots compositions.Seals, legacy []string, err error) Held func(module string) (slots compositions.Seals, legacy []string, err error)
@@ -131,7 +134,7 @@ type Composer struct {
// placementKeys is the seat's state placement key by key in either form, each node's placement assembled from it // placementKeys is the seat's state placement key by key in either form, each node's placement assembled from it
// (delivery.PlacementKeys; research 072 row 37): a write of the holder's under way is never composed half. // (delivery.PlacementKeys; research 072 row 37): a write of the holder's under way is never composed half.
placementKeys *delivery.PlacementKeys placementKeys *delivery.PlacementKeys
records map[string]delivery.NodeRecord records map[string]nodeengine.NodeRecord
// inOwn is, by module, whether this node's have of it names no fingerprint of the new form (mesh-wireguard, a module // inOwn is, by module, whether this node's have of it names no fingerprint of the new form (mesh-wireguard, a module
// at a build of the old form): the node runs it inside its own composition, and composes it there again (issue 1126). // at a build of the old form): the node runs it inside its own composition, and composes it there again (issue 1126).
inOwn map[string]bool inOwn map[string]bool
@@ -298,7 +301,7 @@ func New(env Env) *Composer {
env.Say = func(string) {} env.Say = func(string) {}
} }
return &Composer{env: env, wants: map[string]delivery.Want{}, placements: map[string]delivery.Placement{}, placementKeys: delivery.NewPlacementKeys(), return &Composer{env: env, wants: map[string]delivery.Want{}, placements: map[string]delivery.Placement{}, placementKeys: delivery.NewPlacementKeys(),
records: map[string]delivery.NodeRecord{}, inOwn: map[string]bool{}, modules: map[string]delivery.Module{}, values: map[string]map[string]any{}, records: map[string]nodeengine.NodeRecord{}, inOwn: map[string]bool{}, modules: map[string]delivery.Module{}, values: map[string]map[string]any{},
manifests: map[string][]byte{}, archives: map[string][]byte{}, builds: map[string]delivery.BuilderBuild{}, handed: map[string]string{}, sealedOwn: map[string]ownSealed{}, manifests: map[string][]byte{}, archives: map[string][]byte{}, builds: map[string]delivery.BuilderBuild{}, handed: map[string]string{}, sealedOwn: map[string]ownSealed{},
pkgs: map[string]compositions.Composed{}, asked: make(chan chan Pass, 8)} pkgs: map[string]compositions.Composed{}, asked: make(chan chan Pass, 8)}
} }
@@ -357,8 +360,8 @@ func (c *Composer) inputs() []input {
// Every node's record, and this node's own haves (what it runs inside its own composition): two watches, each // Every node's record, and this node's own haves (what it runs inside its own composition): two watches, each
// within the node's grant (buscompose: readGrants of the have bucket at `*.node`, NodeKeysGrants at `<node>.>`). // within the node's grant (buscompose: readGrants of the have bucket at `*.node`, NodeKeysGrants at `<node>.>`).
// A watch of every key is refused by the bus (issue 1126, 2026-10-27 09:21: the fold's `>` stopped every agent). // A watch of every key is refused by the bus (issue 1126, 2026-10-27 09:21: the fold's `>` stopped every agent).
{delivery.HaveBucket, "*." + delivery.HaveNodeWord, nil, (*Composer).takeRecordChanged}, {buckets.Have, "*." + nodeengineclient.NodeWord, nil, (*Composer).takeRecordChanged},
{delivery.HaveBucket, node + ".>", nil, (*Composer).takeModuleHave}, {buckets.Have, node + ".>", nil, (*Composer).takeModuleHave},
{delivery.ModulesBucket, ">", (*Composer).takeModule, nil}, {delivery.ModulesBucket, ">", (*Composer).takeModule, nil},
{ResolvedBucket, node + ".>", (*Composer).takeValue, nil}, {ResolvedBucket, node + ".>", (*Composer).takeValue, nil},
// The builder's builds: a pass that held a module for a build not yet done is made again when it lands (issue // The builder's builds: a pass that held a module for a build not yet done is made again when it lands (issue
@@ -690,13 +693,13 @@ func (c *Composer) takePlacement(u agent.Update) {
} }
func (c *Composer) takeRecord(u agent.Update) { func (c *Composer) takeRecord(u agent.Update) {
node, _, isNode, err := delivery.ParseHaveKey(u.Key) node, _, isNode, err := nodeengineclient.ParseHaveKey(u.Key)
if err != nil || !isNode { if err != nil || !isNode {
return return
} }
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
h, err := delivery.DecodeHaveNode(u.Value) h, err := nodeengineclient.DecodeHaveNode(u.Value)
if u.Deleted || err != nil || h.Record == nil { if u.Deleted || err != nil || h.Record == nil {
delete(c.records, node) delete(c.records, node)
return return
@@ -715,13 +718,13 @@ func (c *Composer) takeRecordChanged(u agent.Update) bool {
// id; mesh-wireguard: none). Read from the haves, which outlast a restart, never from what the agent has applied // id; mesh-wireguard: none). Read from the haves, which outlast a restart, never from what the agent has applied
// this run: a fresh agent composes before it applies anything (review of the fold, round 1). // this run: a fresh agent composes before it applies anything (review of the fold, round 1).
func (c *Composer) takeModuleHave(u agent.Update) bool { func (c *Composer) takeModuleHave(u agent.Update) bool {
node, module, isNode, err := delivery.ParseHaveKey(u.Key) node, module, isNode, err := nodeengineclient.ParseHaveKey(u.Key)
if err != nil || isNode || node != c.env.Node { if err != nil || isNode || node != c.env.Node {
return false return false
} }
in := false in := false
if !u.Deleted { if !u.Deleted {
if h, err := delivery.DecodeHave(u.Value); err == nil { if h, err := nodeengineclient.DecodeHave(u.Value); err == nil {
// A have that names nothing it runs, failed (the node could not compose a module it never ran: NotComposed), is // A have that names nothing it runs, failed (the node could not compose a module it never ran: NotComposed), is
// no module run inside the node's own composition: taken as one, it was composed from its manifest past its // no module run inside the node's own composition: taken as one, it was composed from its manifest past its
// build (issue 1126, review round 1 of the builds). // build (issue 1126, review round 1 of the builds).
@@ -729,9 +732,9 @@ func (c *Composer) takeModuleHave(u agent.Update) bool {
// mesh-host #189, novox's postgres 2026-10-31): taken as one, it was composed from its manifest and refused // mesh-host #189, novox's postgres 2026-10-31): taken as one, it was composed from its manifest and refused
// for its secret reader before the start backed it up again. Any other have naming nothing keeps the rule // for its secret reader before the start backed it up again. Any other have naming nothing keeps the rule
// (waiting for a yes inside the node's own stays in). // (waiting for a yes inside the node's own stays in).
interrupted := h.Phase == delivery.PhaseHeld && h.Detail == agent.BackupInterrupted interrupted := h.Phase == nodeengineclient.PhaseHeld && h.Detail == agent.BackupInterrupted
runsNothing := (h.Phase == delivery.PhaseFailed || interrupted) && h.FP == "" && h.Composed == "" runsNothing := (h.Phase == nodeengineclient.PhaseFailed || interrupted) && h.Fp == "" && h.Composed == ""
in = h.Phase != delivery.PhaseRemoved && !runsNothing && !strings.HasPrefix(h.FP, delivery.SourcePrefix2) in = h.Phase != nodeengineclient.PhaseRemoved && !runsNothing && !strings.HasPrefix(h.Fp, delivery.SourcePrefix2)
} }
} }
c.mu.Lock() c.mu.Lock()
@@ -749,7 +752,7 @@ func (c *Composer) takeModuleHave(u agent.Update) bool {
// inOwnOf is, of the modules p places, those the node runs inside its own composition (c.inOwn), sorted; c.mu held. // inOwnOf is, of the modules p places, those the node runs inside its own composition (c.inOwn), sorted; c.mu held.
// Machines is, from the placements this composer holds, each node by its address on the private network, and the hub's // Machines is, from the placements this composer holds, each node by its address on the private network, and the hub's
// node: what names the machine a failing network part points at (delivery.NetworkPart.Targets, issue 774). Empty // node: what names the machine a failing network part points at (nodeengine.NetworkPart.Targets, issue 774). Empty
// before any placement is read. // before any placement is read.
func (c *Composer) Machines() (byAddress map[string]string, hub string) { func (c *Composer) Machines() (byAddress map[string]string, hub string) {
c.mu.Lock() c.mu.Lock()
@@ -1079,7 +1082,7 @@ func (c *Composer) refused(why string, held map[string]string) {
c.refusal = said c.refusal = said
c.held = heldModules(held) c.held = heldModules(held)
if why != "" { if why != "" {
c.held = append(c.held, delivery.HaveNodeWord) c.held = append(c.held, nodeengineclient.NodeWord)
} }
c.mu.Unlock() c.mu.Unlock()
if !same && c.env.Refused != nil { if !same && c.env.Refused != nil {
@@ -1374,10 +1377,10 @@ func (r reader) Placements(context.Context) (map[string]delivery.Placement, erro
return out, nil return out, nil
} }
func (r reader) Records(context.Context) (map[string]delivery.NodeRecord, error) { func (r reader) Records(context.Context) (map[string]nodeengine.NodeRecord, error) {
r.c.mu.Lock() r.c.mu.Lock()
defer r.c.mu.Unlock() defer r.c.mu.Unlock()
out := make(map[string]delivery.NodeRecord, len(r.c.records)) out := make(map[string]nodeengine.NodeRecord, len(r.c.records))
for k, v := range r.c.records { for k, v := range r.c.records {
out[k] = v out[k] = v
} }
+41 -39
View File
@@ -8,7 +8,9 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"github.com/novox/mesh-host/internal/buckets"
"github.com/novox/mesh-host/internal/compositions" "github.com/novox/mesh-host/internal/compositions"
"os" "os"
"reflect" "reflect"
@@ -189,19 +191,19 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
omod, _ := delivery.EncodeModule(delivery.Module{FP2: delivery.SourcePrefix2 + strings.Repeat("c", 64), Manifest: odigest, Repository: "novox/o", Commit: "c"}) omod, _ := delivery.EncodeModule(delivery.Module{FP2: delivery.SourcePrefix2 + strings.Repeat("c", 64), Manifest: odigest, Repository: "novox/o", Commit: "c"})
place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"},
Modules: []delivery.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}}}) Modules: []delivery.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}}})
have, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}})
mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"})
want, _ := delivery.EncodeWant(delivery.Want{FP: fp, Self: true}) want, _ := delivery.EncodeWant(delivery.Want{FP: fp, Self: true})
nodeWant, _ := delivery.EncodeNodeWant(delivery.NodeWant{Self: true}) nodeWant, _ := delivery.EncodeNodeWant(delivery.NodeWant{Self: true})
b.put(t, delivery.PlacementBucket, "placement.n1", place) b.put(t, delivery.PlacementBucket, "placement.n1", place)
b.put(t, delivery.HaveBucket, "n1.node", have) b.put(t, buckets.Have, "n1.node", have)
oldHave, _ := delivery.EncodeHave(delivery.Have{Build: "build-1", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) oldHave, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Build: "build-1", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
b.put(t, delivery.HaveBucket, "n1.oldie", oldHave) b.put(t, buckets.Have, "n1.oldie", oldHave)
b.put(t, delivery.ModulesBucket, "lemurs", mod) b.put(t, delivery.ModulesBucket, "lemurs", mod)
b.put(t, delivery.ModulesBucket, "oldie", omod) b.put(t, delivery.ModulesBucket, "oldie", omod)
b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", want) b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", want)
b.put(t, delivery.AssignerWantsBucket, "want.n1.node", nodeWant) b.put(t, delivery.AssignerWantsBucket, "want.n1.node", nodeWant)
for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, delivery.HaveBucket, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} { for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, buckets.Have, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} {
b.send(bucket, agent.Update{Caught: true}) b.send(bucket, agent.Update{Caught: true})
} }
got := map[string]bool{} got := map[string]bool{}
@@ -696,10 +698,10 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
bkey, _ := delivery.BuilderBuildKey("tool", next2) bkey, _ := delivery.BuilderBuildKey("tool", next2)
tb.kv[delivery.BuilderBuildsBucket+"/"+bkey] = building tb.kv[delivery.BuilderBuildsBucket+"/"+bkey] = building
tb.put(t, delivery.PlacementBucket, "placement.n1", tplace) tb.put(t, delivery.PlacementBucket, "placement.n1", tplace)
tb.put(t, delivery.HaveBucket, "n1.node", have) tb.put(t, buckets.Have, "n1.node", have)
tb.put(t, delivery.ModulesBucket, "tool", tmod) tb.put(t, delivery.ModulesBucket, "tool", tmod)
tb.put(t, delivery.AssignerWantsBucket, "want.n1.tool", twant) tb.put(t, delivery.AssignerWantsBucket, "want.n1.tool", twant)
for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, delivery.HaveBucket, delivery.ModulesBucket, for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, buckets.Have, delivery.ModulesBucket,
ResolvedBucket, delivery.BuilderBuildsBucket} { ResolvedBucket, delivery.BuilderBuildsBucket} {
tb.send(bucket, agent.Update{Caught: true}) tb.send(bucket, agent.Update{Caught: true})
} }
@@ -1049,7 +1051,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
} }
// What n1 runs: lemurs and its own composition, as its composer composes them from the trunk. // What n1 runs: lemurs and its own composition, as its composer composes them from the trunk.
running, _, _, err := frombus.Compose(ctx, fixedReader{placements: map[string]delivery.Placement{"n1": trunk}, running, _, _, err := frombus.Compose(ctx, fixedReader{placements: map[string]delivery.Placement{"n1": trunk},
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}, records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}},
modules: mods, manifests: map[string][]byte{digest: []byte(lemurs), kdigest: []byte(keeper)}}, frombus.Mesh{}, frombus.Scope{Node: "n1"}, map[string]string{"lemurs": fp}) modules: mods, manifests: map[string][]byte{digest: []byte(lemurs), kdigest: []byte(keeper)}}, frombus.Mesh{}, frombus.Scope{Node: "n1"}, map[string]string{"lemurs": fp})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -1085,10 +1087,10 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
time.Sleep(30 * time.Millisecond) time.Sleep(30 * time.Millisecond)
sb.missing(delivery.PlacementCandidatesBucket, false) sb.missing(delivery.PlacementCandidatesBucket, false)
sb.made(6) sb.made(6)
rec, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) rec, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}})
tp, _ := delivery.EncodePlacement(trunk) tp, _ := delivery.EncodePlacement(trunk)
sb.put(delivery.PlacementBucket, "placement.n1", tp, 1) sb.put(delivery.PlacementBucket, "placement.n1", tp, 1)
sb.put(delivery.HaveBucket, "n1.node", rec, 1) sb.put(buckets.Have, "n1.node", rec, 1)
for name, m := range mods { for name, m := range mods {
raw, _ := delivery.EncodeModule(m) raw, _ := delivery.EncodeModule(m)
sb.put(delivery.ModulesBucket, name, raw, 1) sb.put(delivery.ModulesBucket, name, raw, 1)
@@ -1201,7 +1203,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
} }
} }
// The node's own key changing (its answers written) composes nothing again; a group's key deleted drops its answer. // The node's own key changing (its answers written) composes nothing again; a group's key deleted drops its answer.
sb.put(delivery.HaveBucket, "n1.node", rec, 2) sb.put(buckets.Have, "n1.node", rec, 2)
select { select {
case a := <-answered: case a := <-answered:
t.Errorf("answers handed again for nothing changed: %+v", a) t.Errorf("answers handed again for nothing changed: %+v", a)
@@ -1232,13 +1234,13 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
} }
} }
lemursHave := func(detail string, rev uint64) { lemursHave := func(detail string, rev uint64) {
raw, err := delivery.EncodeHave(delivery.Have{FP: fp, Composed: lemursDigest, Phase: delivery.PhaseRunning, raw, err := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fp, Composed: lemursDigest, Phase: nodeengineclient.PhaseRunning,
Health: delivery.HealthFailed, Detail: detail}) Health: nodeengineclient.HealthFailed, Detail: detail})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
hk, _ := delivery.HaveKey("n1", "lemurs") hk, _ := nodeengineclient.HaveKey("n1", "lemurs")
sb.put(delivery.HaveBucket, hk, raw, rev) sb.put(buckets.Have, hk, raw, rev)
} }
lemursHave("its tool lemurs_health: no link to the bus is open", 10) lemursHave("its tool lemurs_health: no link to the bus is open", 10)
drain() drain()
@@ -1273,9 +1275,9 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
t.Errorf("a new revision's answer said %q", got) t.Errorf("a new revision's answer said %q", got)
} }
settled = answering() settled = answering()
hk, _ := delivery.HaveKey("n1", "lemurs") hk, _ := nodeengineclient.HaveKey("n1", "lemurs")
moved, _ := delivery.EncodeHave(delivery.Have{FP: fp, Composed: "sha256:" + strings.Repeat("e", 64), Phase: delivery.PhaseRunning}) moved, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fp, Composed: "sha256:" + strings.Repeat("e", 64), Phase: nodeengineclient.PhaseRunning})
sb.put(delivery.HaveBucket, hk, moved, 13) sb.put(buckets.Have, hk, moved, 13)
drain() drain()
if got := answering(); got != settled+len(answers) { if got := answering(); got != settled+len(answers) {
t.Errorf("what runs moved, and %d answer(s) were composed again for %d group(s)", got-settled, len(answers)) t.Errorf("what runs moved, and %d answer(s) were composed again for %d group(s)", got-settled, len(answers))
@@ -1350,7 +1352,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
go func() { _ = nsh.Run(nctx) }() go func() { _ = nsh.Run(nctx) }()
nb.made(6) nb.made(6)
nb.put(delivery.PlacementBucket, "placement.n1", tp, 1) nb.put(delivery.PlacementBucket, "placement.n1", tp, 1)
nb.put(delivery.HaveBucket, "n1.node", rec, 1) nb.put(buckets.Have, "n1.node", rec, 1)
for name, m := range mods { for name, m := range mods {
raw, _ := delivery.EncodeModule(m) raw, _ := delivery.EncodeModule(m)
nb.put(delivery.ModulesBucket, name, raw, 1) nb.put(delivery.ModulesBucket, name, raw, 1)
@@ -1362,7 +1364,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
oldRaw, _ := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Proposal: "older", Placement: &trunk}) oldRaw, _ := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Proposal: "older", Placement: &trunk})
oldKey, _ := delivery.PlacementCandidateKey("older", "n1") oldKey, _ := delivery.PlacementCandidateKey("older", "n1")
nb.put(delivery.PlacementCandidatesBucket, oldKey, oldRaw, 2) nb.put(delivery.PlacementCandidatesBucket, oldKey, oldRaw, 2)
for _, bucket := range []string{delivery.PlacementCandidatesBucket, delivery.PlacementBucket, delivery.HaveBucket, delivery.ModulesBucket, delivery.BuilderBuildsBucket} { for _, bucket := range []string{delivery.PlacementCandidatesBucket, delivery.PlacementBucket, buckets.Have, delivery.ModulesBucket, delivery.BuilderBuildsBucket} {
nb.send(bucket, agent.Update{Caught: true}) nb.send(bucket, agent.Update{Caught: true})
} }
select { select {
@@ -1508,7 +1510,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
cc.manifests[digest] = []byte(lemurs) cc.manifests[digest] = []byte(lemurs)
cc.manifests[cdigest] = []byte(withCert) cc.manifests[cdigest] = []byte(withCert)
cc.modules = map[string]delivery.Module{"lemurs": {Manifest: digest}, "web": {Manifest: cdigest}} cc.modules = map[string]delivery.Module{"lemurs": {Manifest: digest}, "web": {Manifest: cdigest}}
cc.records["n1"] = delivery.NodeRecord{Node: "n1", ServingKey: base64.StdEncoding.EncodeToString(make([]byte, 32))} cc.records["n1"] = nodeengine.NodeRecord{Node: "n1", ServingKey: base64.StdEncoding.EncodeToString(make([]byte, 32))}
onlyLemurs := delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "lemurs"}}} onlyLemurs := delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "lemurs"}}}
cc.certifyIfWanted(context.Background(), reader{c: cc}, onlyLemurs) cc.certifyIfWanted(context.Background(), reader{c: cc}, onlyLemurs)
if certAsks != 0 { if certAsks != 0 {
@@ -1656,7 +1658,7 @@ func (b *patternBus) caught() {
// fixedReader is frombus's Reader over values held, for composing what a node runs. // fixedReader is frombus's Reader over values held, for composing what a node runs.
type fixedReader struct { type fixedReader struct {
placements map[string]delivery.Placement placements map[string]delivery.Placement
records map[string]delivery.NodeRecord records map[string]nodeengine.NodeRecord
modules map[string]delivery.Module modules map[string]delivery.Module
manifests map[string][]byte manifests map[string][]byte
} }
@@ -1664,7 +1666,7 @@ type fixedReader struct {
func (r fixedReader) Placements(context.Context) (map[string]delivery.Placement, error) { func (r fixedReader) Placements(context.Context) (map[string]delivery.Placement, error) {
return r.placements, nil return r.placements, nil
} }
func (r fixedReader) Records(context.Context) (map[string]delivery.NodeRecord, error) { func (r fixedReader) Records(context.Context) (map[string]nodeengine.NodeRecord, error) {
return r.records, nil return r.records, nil
} }
func (r fixedReader) Modules(context.Context) (map[string]delivery.Module, error) { func (r fixedReader) Modules(context.Context) (map[string]delivery.Module, error) {
@@ -1700,34 +1702,34 @@ func (r fixedReader) Served(context.Context, string, string, []string) (map[stri
func TestAHaveSaysWhetherItsModuleRunsInsideTheNodesOwn(t *testing.T) { func TestAHaveSaysWhetherItsModuleRunsInsideTheNodesOwn(t *testing.T) {
for _, row := range []struct { for _, row := range []struct {
name string name string
have delivery.Have have nodeengine.HaveByNodeModuleValue
in bool in bool
// heldToday says the shadow counts the module held today (issue 1126: a seed holding it too holds it as today). // heldToday says the shadow counts the module held today (issue 1126: a seed holding it too holds it as today).
heldToday bool heldToday bool
}{ }{
{"failed, naming nothing it runs", delivery.Have{Phase: delivery.PhaseFailed, Health: delivery.HealthFailed, {"failed, naming nothing it runs", nodeengine.HaveByNodeModuleValue{Phase: nodeengineclient.PhaseFailed, Health: nodeengineclient.HealthFailed,
Detail: "the node cannot compose it, and keeps running what it ran: no build"}, false, true}, Detail: "the node cannot compose it, and keeps running what it ran: no build"}, false, true},
{"failed at a build fingerprint", delivery.Have{FP: "build-0123abcd", Composed: "sha256:" + strings.Repeat("c", 64), Phase: delivery.PhaseFailed, {"failed at a build fingerprint", nodeengine.HaveByNodeModuleValue{Fp: "build-0123abcd", Composed: "sha256:" + strings.Repeat("c", 64), Phase: nodeengineclient.PhaseFailed,
Health: delivery.HealthFailed, Detail: "the node cannot compose it, and keeps running what it ran: no build"}, true, true}, Health: nodeengineclient.HealthFailed, Detail: "the node cannot compose it, and keeps running what it ran: no build"}, true, true},
// The live have of novox's de-spiegel (2026-10-28): a build of the old form, named by `build`, and no `fp`. // The live have of novox's de-spiegel (2026-10-28): a build of the old form, named by `build`, and no `fp`.
{"running a build of the old form", delivery.Have{Build: "build-1790368817085312877", Phase: delivery.PhaseRunning, Health: delivery.HealthOK}, true, true}, {"running a build of the old form", nodeengine.HaveByNodeModuleValue{Build: "build-1790368817085312877", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}, true, true},
{"running a build of the new form", delivery.Have{FP: delivery.SourcePrefix2 + strings.Repeat("a", 64), Composed: "sha256:" + strings.Repeat("c", 64), {"running a build of the new form", nodeengine.HaveByNodeModuleValue{Fp: delivery.SourcePrefix2 + strings.Repeat("a", 64), Composed: "sha256:" + strings.Repeat("c", 64),
Phase: delivery.PhaseRunning, Health: delivery.HealthOK}, false, false}, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}, false, false},
{"running with no fingerprint (mesh-wireguard)", delivery.Have{Phase: delivery.PhaseRunning, Health: delivery.HealthOK}, true, false}, {"running with no fingerprint (mesh-wireguard)", nodeengine.HaveByNodeModuleValue{Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}, true, false},
{"held today", delivery.Have{FP: "build-0123abcd", Composed: "sha256:" + strings.Repeat("c", 64), Phase: delivery.PhaseHeld, {"held today", nodeengine.HaveByNodeModuleValue{Fp: "build-0123abcd", Composed: "sha256:" + strings.Repeat("c", 64), Phase: nodeengineclient.PhaseHeld,
Health: delivery.HealthWaits, Detail: "held: a required config value is unset"}, true, true}, Health: nodeengineclient.HealthWaits, Detail: "held: a required config value is unset"}, true, true},
// novox's postgres (2026-10-31), its have written before mesh-host #189: held by a backup the node agent's stop // novox's postgres (2026-10-31), its have written before mesh-host #189: held by a backup the node agent's stop
// interrupted and naming nothing; counted in, it was composed from its manifest and refused for its secret reader. // interrupted and naming nothing; counted in, it was composed from its manifest and refused for its secret reader.
{"held by an interrupted backup, naming nothing", delivery.Have{Phase: delivery.PhaseHeld, Health: delivery.HealthWaits, {"held by an interrupted backup, naming nothing", nodeengine.HaveByNodeModuleValue{Phase: nodeengineclient.PhaseHeld, Health: nodeengineclient.HealthWaits,
Detail: agent.BackupInterrupted}, false, true}, Detail: agent.BackupInterrupted}, false, true},
} { } {
t.Run(row.name, func(t *testing.T) { t.Run(row.name, func(t *testing.T) {
c := New(Env{Node: "n1"}) c := New(Env{Node: "n1"})
key, err := delivery.HaveKey("n1", "web") key, err := nodeengineclient.HaveKey("n1", "web")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
value, err := delivery.EncodeHave(row.have) value, err := nodeengineclient.EncodeHave(row.have)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -1783,16 +1785,16 @@ func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) {
} }
place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"},
Modules: []delivery.PlacedModule{{Module: "lemurs"}}}) Modules: []delivery.PlacedModule{{Module: "lemurs"}}})
have, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}})
mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"})
want, _ := delivery.EncodeWant(delivery.Want{FP: fp, Self: true}) want, _ := delivery.EncodeWant(delivery.Want{FP: fp, Self: true})
nodeWant, _ := delivery.EncodeNodeWant(delivery.NodeWant{Self: true}) nodeWant, _ := delivery.EncodeNodeWant(delivery.NodeWant{Self: true})
b.put(t, delivery.PlacementBucket, "placement.n1", place) b.put(t, delivery.PlacementBucket, "placement.n1", place)
b.put(t, delivery.HaveBucket, "n1.node", have) b.put(t, buckets.Have, "n1.node", have)
b.put(t, delivery.ModulesBucket, "lemurs", mod) b.put(t, delivery.ModulesBucket, "lemurs", mod)
b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", want) b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", want)
b.put(t, delivery.AssignerWantsBucket, "want.n1.node", nodeWant) b.put(t, delivery.AssignerWantsBucket, "want.n1.node", nodeWant)
for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, delivery.HaveBucket, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} { for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, buckets.Have, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} {
b.send(bucket, agent.Update{Caught: true}) b.send(bucket, agent.Update{Caught: true})
} }
handed := map[string]bool{} handed := map[string]bool{}
+12 -10
View File
@@ -5,7 +5,9 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"github.com/novox/mesh-host/internal/buckets"
engcomp "github.com/novox/mesh-host/internal/compositions" engcomp "github.com/novox/mesh-host/internal/compositions"
"sort" "sort"
"strings" "strings"
@@ -160,11 +162,11 @@ func (s *Shadow) Run(ctx context.Context) error {
ins := []input{ ins := []input{
{delivery.PlacementCandidatesBucket, ">", nil, nil}, {delivery.PlacementCandidatesBucket, ">", nil, nil},
{delivery.PlacementBucket, ">", (*Composer).takePlacement, nil}, {delivery.PlacementBucket, ">", (*Composer).takePlacement, nil},
{delivery.HaveBucket, "*." + delivery.HaveNodeWord, nil, nil}, {buckets.Have, "*." + nodeengineclient.NodeWord, nil, nil},
{delivery.ModulesBucket, ">", (*Composer).takeModule, nil}, {delivery.ModulesBucket, ">", (*Composer).takeModule, nil},
{ResolvedBucket, node + ".>", (*Composer).takeValue, nil}, {ResolvedBucket, node + ".>", (*Composer).takeValue, nil},
// What this node runs moved: an apply ends in its module's have. // What this node runs moved: an apply ends in its module's have.
{delivery.HaveBucket, node + ".>", nil, nil}, {buckets.Have, node + ".>", nil, nil},
} }
type tagged struct { type tagged struct {
in int in int
@@ -315,7 +317,7 @@ func (s *Shadow) takeCandidate(u agent.Update) bool {
// takeRecord takes a node's record, and says whether it changed: the node's own key changes with each answer written, // takeRecord takes a node's record, and says whether it changed: the node's own key changes with each answer written,
// which moves nothing composed. // which moves nothing composed.
func (s *Shadow) takeRecord(u agent.Update) bool { func (s *Shadow) takeRecord(u agent.Update) bool {
node, _, isNode, err := delivery.ParseHaveKey(u.Key) node, _, isNode, err := nodeengineclient.ParseHaveKey(u.Key)
if err != nil || !isNode { if err != nil || !isNode {
return false return false
} }
@@ -327,7 +329,7 @@ func (s *Shadow) takeRecord(u agent.Update) bool {
c.mu.Lock() c.mu.Lock()
after, has := c.records[node] after, has := c.records[node]
c.mu.Unlock() c.mu.Unlock()
return had != has || (has && !delivery.SameNodeRecord(before, after)) return had != has || (has && !nodeengineclient.SameNodeRecord(before, after))
} }
// How long the shadow waits before it says it is not ready, and how long a proposal's answer runs before it is said slow. // How long the shadow waits before it says it is not ready, and how long a proposal's answer runs before it is said slow.
@@ -600,14 +602,14 @@ func (s *Shadow) heldToday() map[string]string {
// takeRan keeps the digest one of this node's module haves says runs, and says whether it changed: a have whose health // takeRan keeps the digest one of this node's module haves says runs, and says whether it changed: a have whose health
// or words alone moved runs the same. // or words alone moved runs the same.
func (s *Shadow) takeRan(u agent.Update) bool { func (s *Shadow) takeRan(u agent.Update) bool {
node, module, isNode, err := delivery.ParseHaveKey(u.Key) node, module, isNode, err := nodeengineclient.ParseHaveKey(u.Key)
if err != nil || isNode || node != s.c.env.Node { if err != nil || isNode || node != s.c.env.Node {
return false return false
} }
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
before, had := s.ran[module] before, had := s.ran[module]
h, err := delivery.DecodeHave(u.Value) h, err := nodeengineclient.DecodeHave(u.Value)
if s.heldNow == nil { if s.heldNow == nil {
s.heldNow = map[string]string{} s.heldNow = map[string]string{}
} }
@@ -615,18 +617,18 @@ func (s *Shadow) takeRan(u agent.Update) bool {
s.heldNow[module] = "" s.heldNow[module] = ""
if !u.Deleted && err == nil { if !u.Deleted && err == nil {
switch { switch {
case h.Phase == delivery.PhaseHeld: case h.Phase == nodeengineclient.PhaseHeld:
s.heldNow[module] = "held" s.heldNow[module] = "held"
case h.Phase == delivery.PhaseFailed: case h.Phase == nodeengineclient.PhaseFailed:
s.heldNow[module] = "failed" s.heldNow[module] = "failed"
case h.Phase != delivery.PhaseRemoved && h.FP == "" && h.Build != "": case h.Phase != nodeengineclient.PhaseRemoved && h.Fp == "" && h.Build != "":
// A build of the old form: its have names a `build` and no `fp`, which no registered build of the trunk // A build of the old form: its have names a `build` and no `fp`, which no registered build of the trunk
// names, so the controller keeps it as it runs (issue 1126: novox's de-spiegel at build-…); a seed keeps it so. // names, so the controller keeps it as it runs (issue 1126: novox's de-spiegel at build-…); a seed keeps it so.
s.heldNow[module] = "runs a build of the old form" s.heldNow[module] = "runs a build of the old form"
} }
} }
holdMoved := wasHeld != s.heldNow[module] // a hold's start or end changes the answer too holdMoved := wasHeld != s.heldNow[module] // a hold's start or end changes the answer too
if u.Deleted || err != nil || h.Composed == "" || h.Phase == delivery.PhaseRemoved { if u.Deleted || err != nil || h.Composed == "" || h.Phase == nodeengineclient.PhaseRemoved {
delete(s.ran, module) delete(s.ran, module)
return had || holdMoved return had || holdMoved
} }
+5 -4
View File
@@ -31,6 +31,7 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
engcomp "github.com/novox/mesh-host/internal/compositions" engcomp "github.com/novox/mesh-host/internal/compositions"
"regexp" "regexp"
"sort" "sort"
@@ -49,7 +50,7 @@ const ResourceID = "bus-users"
// Written is what the follower last did: the source it took the list from, the list's digest there, the file's digest // Written is what the follower last did: the source it took the list from, the list's digest there, the file's digest
// on the machine after the write, how many users the list holds, when, and why a write failed. // on the machine after the write, how many users the list holds, when, and why a write failed.
type Written = delivery.UserListWritten type Written = nodeengine.UserListWritten
// Env is everything the follower needs of the node. // Env is everything the follower needs of the node.
type Env struct { type Env struct {
@@ -336,13 +337,13 @@ func (f *Follower) settleGrants(ctx context.Context, again bool) {
} }
moved := file.module != f.cur.module || pathOf(file.resource) != pathOf(f.cur.resource) moved := file.module != f.cur.module || pathOf(file.resource) != pathOf(f.cur.resource)
f.cur = file f.cur = file
if !again && !moved && f.state.Source != "" && f.state.Revision == rev && f.written { if !again && !moved && f.state.Source != "" && f.state.Revision == int64(rev) && f.written {
f.mu.Unlock() f.mu.Unlock()
return return
} }
f.mu.Unlock() f.mu.Unlock()
w := Written{Source: delivery.GrantsUserListSource, Revision: rev, Wanted: g.Digest, At: f.env.Now().UTC(), w := Written{Source: delivery.GrantsUserListSource, Revision: int64(rev), Wanted: g.Digest, At: f.env.Now().UTC(),
Follows: delivery.FollowGrants, Key: f.key(), Unwatched: f.unwatchedNow()} Follows: delivery.FollowGrants, Key: f.key(), Unwatched: f.unwatchedNow()}
var err error var err error
var changed bool var changed bool
@@ -370,7 +371,7 @@ func (f *Follower) settleGrants(ctx context.Context, again bool) {
} }
} }
if err == nil { if err == nil {
w.Users = Users(body) w.Users = int64(Users(body))
if err = f.guard(resource, body); err != nil { if err = f.guard(resource, body); err != nil {
if disk, derr := f.env.OnDisk(resource); derr == nil && disk != nil { if disk, derr := f.env.OnDisk(resource); derr == nil && disk != nil {
w.Digest = Digest(disk) w.Digest = Digest(disk)
+1 -1
View File
@@ -109,7 +109,7 @@ func TestTheBusNodeWritesTheGrantsPartsUserListAlone(t *testing.T) {
failWrite bool failWrite bool
onDisk string onDisk string
wantWrites []string // contents written, in order wantWrites []string // contents written, in order
wantUsers int wantUsers int64
wantErr string wantErr string
wantRefused bool wantRefused bool
grants []gput grants []gput