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 (
"context"
"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"
"strings"
"sync"
"testing"
"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.
@@ -44,10 +44,10 @@ func (b *owedBus) Set(_ context.Context, key string, value []byte, revision uint
return nil
}
func (b *owedBus) have(key string) (delivery.Have, bool) {
func (b *owedBus) have(key string) (nodeengine.HaveByNodeModuleValue, bool) {
b.mu.Lock()
defer b.mu.Unlock()
h, err := delivery.DecodeHave(b.kv[key])
h, err := nodeengineclient.DecodeHave(b.kv[key])
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)}
applying := fact
applying.Phase = string(delivery.PhaseApplying)
applying.Phase = string(nodeengineclient.PhaseApplying)
queue.NoteModule(ctx, applying)
go judgeWhatRuns(ctx, j, queue, func(string) {}, nil, nil)
// 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()}})
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)
}
queue.NoteModule(ctx, fact)
deadline := time.Now().Add(2 * time.Second) // well within one look (liveness.LookEvery, 15 s)
for {
h, _ := bus.have("n1.xorg")
if h.Phase == delivery.PhaseRunning && h.Health == delivery.HealthOK {
if h.Phase == nodeengineclient.PhaseRunning && h.Health == nodeengineclient.HealthOK {
return
}
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{}}
for _, p := range ns.Parts {
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
+7 -6
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"github.com/novox/mesh-host/internal/compositions"
"os"
@@ -86,7 +87,7 @@ func (h sessionHaves) Read(ctx context.Context, module string) (agent.Have, bool
if !ok {
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
}
@@ -104,24 +105,24 @@ func (h sessionHaves) ReadErr(ctx context.Context, module string) (agent.Have, b
if !ok || err != nil {
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
}
func (h sessionHaves) Write(ctx context.Context, x agent.Have) {
if x.Module == agent.NodeModule {
// 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)
}
return
}
phase := x.Phase
if phase == string(delivery.PhaseRunning) {
if phase == string(nodeengineclient.PhaseRunning) {
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})
}
@@ -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
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
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)
own.set(ag)
current.set(ag)
+29 -28
View File
@@ -8,6 +8,7 @@ import (
"encoding/json"
"errors"
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"
"os"
"path/filepath"
@@ -136,7 +137,7 @@ func newItBedAs(t *testing.T, perms nodegrant.Permissions) *itBed {
into *nats.KeyValue
history uint8
}{{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})
if err != nil {
t.Fatal(err)
@@ -200,13 +201,13 @@ func (b *itBed) file(module string) string {
return string(got)
}
func (b *itBed) have(module string) delivery.Have {
key, _ := delivery.HaveKey(itNode, module)
func (b *itBed) have(module string) nodeengine.HaveByNodeModuleValue {
key, _ := nec.HaveKey(itNode, module)
e, err := b.haves.Get(key)
if err != nil {
return delivery.Have{}
return nodeengine.HaveByNodeModuleValue{}
}
h, _ := delivery.DecodeHave(e.Value())
h, _ := nec.DecodeHave(e.Value())
return h
}
@@ -293,11 +294,11 @@ func h8once(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, place []
if settled == 0 {
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"} {
h := b.have("web")
h.Health, h.Detail = delivery.HealthFailed, detail
raw, err := delivery.EncodeHave(h)
h.Health, h.Detail = nec.HealthFailed, detail
raw, err := nec.EncodeHave(h)
if err != nil {
t.Fatal(err)
}
@@ -334,15 +335,15 @@ func h8once(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, place []
// composes its own.
func h8back(t *testing.T, b *itBed, placement, resolved, candidates nats.KeyValue, hkey string) {
t.Helper()
node := func() delivery.HaveNode {
node := func() nodeengine.HaveByNodeNodeValue {
e, err := b.haves.Get(hkey)
if err != nil {
return delivery.HaveNode{}
return nodeengine.HaveByNodeNodeValue{}
}
n, _ := delivery.DecodeHaveNode(e.Value())
n, _ := nec.DecodeHaveNode(e.Value())
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)
}
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()
said := strings.Join(b.said, "\n")
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)
}
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"))
}
}
@@ -422,12 +423,12 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) {
fp := fpIt("a")
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"}}})
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.
pkey, _ := delivery.PlacementNodeKey(itNode)
places, _ := delivery.PlacementValues(delivery.Placement{Node: itNode, Modules: []delivery.PlacedModule{{Module: "web"}}})
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.
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}"}}}`
@@ -436,9 +437,9 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) {
mlkey, _ := delivery.ManifestsKey(mdigest)
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"}}})
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")
hkey2, _ := delivery.HaveNodeKey("it2")
hkey2, _ := nec.HaveNodeKey("it2")
buildKey, _ := delivery.BuilderBuildKey("web", fp)
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")}}})
@@ -503,7 +504,7 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str
t.Helper()
within(t, 2*time.Second, "the have does not name the build", func() bool {
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
// 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, 2*time.Second, "the have does not name the revision", func() bool {
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
@@ -551,7 +552,7 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str
if err != nil {
return false
}
n, err := delivery.DecodeHaveNode(e.Value())
n, err := nec.DecodeHaveNode(e.Value())
wire, _ := json.Marshal(n.Candidates["feat-x"])
_ = json.Unmarshal(wire, &answer)
return err == nil && answer.Proposal == "feat-x" && n.Record != nil
@@ -787,13 +788,13 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test
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"}}})
hkey, _ := delivery.HaveNodeKey(itNode)
hkey, _ := nec.HaveNodeKey(itNode)
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.
natsHave, _ := delivery.HaveKey(itNode, "nats")
oldHave, _ := delivery.EncodeHave(delivery.Have{Build: "build-1792674595610176744", Phase: delivery.PhaseRunning, Health: delivery.HealthOK})
natsHave, _ := nec.HaveKey(itNode, "nats")
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} {
if _, err := b.haves.Put(key, value); err != nil {
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 {
h := b.have("nats")
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 {
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})
within(t, 10*time.Second, "the build that changes the server was not applied", func() bool {
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})
}
return h.FP == next && h.Phase == delivery.PhaseRunning
return h.Fp == next && h.Phase == nec.PhaseRunning
})
var runs int
for _, a := range runtime.moved(server) {
+5 -3
View File
@@ -7,6 +7,8 @@ import (
"encoding/json"
"errors"
"fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"os"
"path/filepath"
"strings"
@@ -193,7 +195,7 @@ func placedAllHave(ctx context.Context, session *link.Session, node string) (boo
if err != nil {
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)
})
}
@@ -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
// 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).
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 {
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
// 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"
}
}
+10 -8
View File
@@ -5,6 +5,8 @@ import (
"crypto/ed25519"
"encoding/json"
"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"
"path/filepath"
"sort"
@@ -120,24 +122,24 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) {
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
// 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}
oldForm := delivery.Have{Build: "build-1791404191702719760", Phase: delivery.PhaseRunning}
newForm := nodeengine.HaveByNodeModuleValue{Fp: delivery.SourcePrefix2 + strings.Repeat("a", 64), Phase: nodeengineclient.PhaseRunning}
oldForm := nodeengine.HaveByNodeModuleValue{Build: "build-1791404191702719760", Phase: nodeengineclient.PhaseRunning}
for _, row := range []struct {
name string
has map[string]delivery.Have
has map[string]nodeengine.HaveByNodeModuleValue
want bool
}{
{"every placed module has a have of the new form", map[string]delivery.Have{"zsh": newForm, "lemurs": newForm}, true},
{"one has none yet", map[string]delivery.Have{"zsh": newForm}, false},
{"one runs at a build of the old form", map[string]delivery.Have{"zsh": newForm, "lemurs": oldForm}, false},
{"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]nodeengine.HaveByNodeModuleValue{"zsh": newForm}, 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)
}
}
// 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}}}
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 {
return oldForm, true, nil
}
+64 -58
View File
@@ -17,6 +17,8 @@ import (
"context"
"encoding/json"
"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"
"slices"
"sort"
@@ -61,18 +63,18 @@ type Have struct {
// waits for a person's yes (ADR 0547 §3).
Judgement 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
// 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
// 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).
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
// ENG-9, delivery.Have.PullWait): read through the have, as node_haves shows it.
PullWait *delivery.PullWait
// ENG-9, nodeengine.HaveByNodeModuleValue.PullWait): read through the have, as node_haves shows it.
PullWait *nodeengine.PullWait
}
// Haves is the node's have bucket as the agent uses it.
@@ -220,7 +222,7 @@ type Agent struct {
mu sync.Mutex
wants map[string]wantNow // by module
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
// not the key's latest value, and what it holds is still held out of the others' applies.
applied map[string]compNow
@@ -303,7 +305,7 @@ func New(env Env) *Agent {
if env.Say == nil {
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{},
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),
@@ -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.
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
@@ -590,7 +592,7 @@ func (a *Agent) NotComposedEach(ctx context.Context, why string, held map[string
a.mu.Unlock()
back := mark.prior
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 != "" {
back.Phase = string(r.Phase)
}
@@ -603,9 +605,9 @@ func (a *Agent) NotComposedEach(ctx context.Context, why string, held map[string
}
if !marked {
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 == "" {
mark.prior.Phase = string(delivery.PhaseRunning)
mark.prior.Phase = string(nodeengineclient.PhaseRunning)
}
} 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
@@ -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
// composer's mark over it would hide that cause, and baseline would take the mark for what runs (review of the
// 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.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))
@@ -639,7 +641,7 @@ func (a *Agent) NotComposedEach(ctx context.Context, why string, held map[string
a.notComposed[module] = mark
a.mu.Unlock()
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()
slices.Sort(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
a.writeHave(ctx, h)
}
@@ -733,10 +735,10 @@ func (a *Agent) Running() []compositions.Composed {
var out []compositions.Composed
for _, m := range mods {
h := a.running[m]
if h.Phase == delivery.PhaseRemoved {
if h.Phase == nodeengineclient.PhaseRemoved {
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)
} else if c, ok := a.applied[m]; ok && c.Dig == h.Composed {
out = append(out, c.C)
@@ -890,10 +892,10 @@ func (a *Agent) others(except string) []compositions.Composed {
sort.Strings(mods)
for _, m := range mods {
h := a.running[m]
if h.Phase == delivery.PhaseRemoved {
if h.Phase == nodeengineclient.PhaseRemoved {
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)
} else if c, ok := a.applied[m]; ok && c.Dig == h.Composed {
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
// composition not applied, or not yet (issue 924, review of 031e106).
func haveRuns(h Have) bool {
switch delivery.Phase(h.Phase) {
case "", delivery.PhaseRunning, delivery.PhaseHeld:
switch string(h.Phase) {
case "", nodeengineclient.PhaseRunning, nodeengineclient.PhaseHeld:
return true
}
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] {
continue
}
if h, ok := a.running[m]; ok && h.Phase != delivery.PhaseRemoved {
if h, ok := a.running[m]; ok && h.Phase != nodeengineclient.PhaseRemoved {
continue
}
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)
case tried && att.Held:
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)
case !tried:
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()
defer a.mu.Unlock()
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) {
@@ -1370,7 +1372,7 @@ func (a *Agent) settleOne(ctx context.Context, module string) {
}
a.mu.Lock()
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]
same := tried && att.FP == w.Want.FP && att.Composed == w.Want.Composed
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.prior[module] = true
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
// 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.
@@ -1454,7 +1456,7 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com
a.mu.Unlock()
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
// 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:
@@ -1467,7 +1469,7 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com
a.mu.Unlock()
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
// 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).
@@ -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,
AnsweredRev: w.Rev}
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.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 = ""
}
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) {
return false
}
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.runsChanged(module + " was taken from its have")
// 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).
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 false
@@ -1529,10 +1531,10 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com
return false
}
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.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
}
@@ -1567,10 +1569,10 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo
a.mu.Unlock()
if !runs {
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 {
words := got.Words
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.mu.Unlock()
// 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})
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)
a.mu.Lock()
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
a.attempt[module] = attempt{FP: fp, Composed: dig, WantRev: w.Rev, CompRev: comp.Rev, Transient: true, Gen: gen}
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 {
wait := a.env.Backoff(try)
when = "in " + wait.String()
@@ -1622,7 +1624,7 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo
})
}
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.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)
a.mu.Unlock()
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})
if 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.attempt, module)
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.mu.Unlock()
a.keepApplied(module, comp)
if a.env.Settled != nil {
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) {
@@ -1668,7 +1670,7 @@ func (a *Agent) remove(ctx context.Context, module string, w wantNow) {
a.mu.Unlock()
return
}
if ran && r.Phase == delivery.PhaseRemoved {
if ran && r.Phase == nodeengineclient.PhaseRemoved {
a.mu.Unlock()
return
}
@@ -1685,7 +1687,7 @@ func (a *Agent) remove(ctx context.Context, module string, w wantNow) {
delete(a.attempt, module)
delete(a.pulls, module)
delete(a.had, module)
a.running[module] = delivery.Have{Phase: delivery.PhaseRemoved}
a.running[module] = nodeengine.HaveByNodeModuleValue{Phase: nodeengineclient.PhaseRemoved}
a.mu.Unlock()
if a.env.Applied != 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.mu.Lock()
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()
// 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).
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).
var sealed compositions.Seals
if h.Composed == r.Composed {
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
}
@@ -1799,12 +1801,12 @@ func (a *Agent) waitsForYes(ctx context.Context, module string, w wantNow, comp
case known:
j = judge.JudgeMove(&from, comp.C)
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:
j = judge.JudgeMove(nil, comp.C)
}
switch j.Judgement {
case delivery.JudgedRecreates, delivery.JudgedMovesData, delivery.JudgedCannot:
case nodeengineclient.JudgedRecreates, nodeengineclient.JudgedMovesData, nodeengineclient.JudgedCannot:
default:
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))
prev := a.running[module]
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)})
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
// 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.
func (a *Agent) buildAlone(ctx context.Context, module string, prev delivery.Have, want string) bool {
if prev.FP == "" && prev.Composed == "" {
func (a *Agent) buildAlone(ctx context.Context, module string, prev nodeengine.HaveByNodeModuleValue, want string) bool {
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.
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
}
dig, ok := a.env.ComposedToday(ctx, module, prev.FP)
dig, ok := a.env.ComposedToday(ctx, module, prev.Fp)
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
// 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]
if pw == nil {
return nil
}
return &delivery.PullWait{Image: pw.Image, Seat: pw.Seat, Holder: pw.Holder, Began: pw.Started, Retried: pw.last,
RetriedAt: pw.lastAt}
out := &nodeengine.PullWait{Image: pw.Image, Seat: pw.Seat, Holder: pw.Holder, Began: pw.Started, Retried: pw.last}
if !pw.lastAt.IsZero() {
at := pw.lastAt
out.RetriedAt = &at
}
return out
}
+55 -53
View File
@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"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"
"maps"
"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 have does not name both", func() bool {
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;
// ace, 2026-10-25 21:17).
@@ -337,7 +339,7 @@ func TestStartingOnWhatTheKeptDeclarationHoldsAppliesNothing(t *testing.T) {
stop := b.run()
defer stop()
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)
}
time.Sleep(50 * time.Millisecond)
@@ -357,7 +359,7 @@ func TestStartingOnItsOwnHaveAppliesNothing(t *testing.T) {
fp := fpOf("a")
ck, craw, dig := composition(t, "avahi", fp, false, file("avahi", "x", "1"))
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"}}
b.comps.before(ck, craw)
b.wants.before(wk, wraw)
@@ -372,7 +374,7 @@ func TestStartingOnItsOwnHaveAppliesNothing(t *testing.T) {
fp := fpOf("a")
ck, craw, dig := composition(t, "avahi", fp, false, file("avahi", "x", "1"))
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"}}
b.comps.before(ck, craw)
b.wants.before(wk, wraw)
@@ -380,7 +382,7 @@ func TestStartingOnItsOwnHaveAppliesNothing(t *testing.T) {
defer stop()
eventually(t, "the mark was not cleared", func() bool {
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 {
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 {
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)
}
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, "the have does not say failed", func() bool {
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.
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)
}
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"} {
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)
@@ -509,7 +511,7 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) {
eventually(t, "not applied", func() bool { return len(b.appliedList()) == 1 })
eventually(t, "the have does not say whom it waits on", func() bool {
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()
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, "the have does not say what triggered the retry", func() bool {
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()
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
// 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" ||
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)
}
// 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()
b.ag.Settled("distribution")
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 {
t.Fatalf("a running module's have still waits on %+v", w)
}
@@ -620,11 +622,11 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) {
b.ag.RetryTransient()
}
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()
defer b.haves.mu.Unlock()
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)
}
}
@@ -664,7 +666,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
})
eventually(t, "the have does not say held", func() bool {
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 {
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 {
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
@@ -713,7 +715,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
ib.comps.before(ck2, craw2)
iwk, iwraw := wantFor(t, "db", fp2, dig2, delivery.BackupAgain)
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{})
ib.env.Backup = func(ctx context.Context, _ string) Backup {
close(began)
@@ -723,7 +725,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
stopI := ib.run()
<-began
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)
}
@@ -733,24 +735,24 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
for _, row := range []struct {
detail, backup string
backups, applied int
phase delivery.Phase
phase string
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.
{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,
// 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.BackupSkip, 0, 1, delivery.PhaseRunning, false},
{"restic: the repository is locked", "", 0, 0, delivery.PhaseHeld, false},
{"restic: the repository is locked", delivery.BackupAgain, 1, 1, nodeengineclient.PhaseRunning, false},
{"restic: the repository is locked", delivery.BackupSkip, 0, 1, nodeengineclient.PhaseRunning, false},
{"restic: the repository is locked", "", 0, 0, nodeengineclient.PhaseHeld, false},
} {
r := newBed(t)
r.comps.before(ck1, craw1)
r.comps.before(ck2, craw2)
wk, wraw := wantFor(t, "db", fp2, dig2, row.backup)
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 {
held.FP, held.Composed = "", ""
}
@@ -800,7 +802,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
return raw
}
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"}}
stopR := r.run()
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, "not running after the answer", func() bool {
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()
n := len(r.backups)
@@ -959,9 +961,9 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) {
if got := a.Everything(); len(got) != 2 {
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"))
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})
got := a.Everything()
if len(got) != 2 {
@@ -998,7 +1000,7 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) {
webDig, _ := compositions.DigestOf(web)
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: "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.
firstCh := make(chan []compositions.Composed, 1)
comps := b.comps
@@ -1143,7 +1145,7 @@ func TestAFailedApplyIsAppliedAgainAfterARestartWhateverIsKept(t *testing.T) {
b.comps.before(ck, craw)
b.wants.before(wk, wraw)
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()
defer stop()
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},
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,
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,
told: "the haves were read at the start", data: 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
// 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)
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"}`}})
want("idle", idleFP, "")
eventually(t, "idle's want was not taken", func() bool {
@@ -1599,7 +1601,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
module, fp, composed, slot, sealed string
}{{"web", fp, dig, "web.pw#sealed", "1"}, {"idle", idleFP, idleDig, "idle.key#sealed", `{"box":"idle"}`}} {
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") {
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
// 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)
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"})
want("broken", brokenFP, "")
eventually(t, "unread's want was not taken", func() bool {
@@ -1626,7 +1628,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
if h := b.haves.last("unread"); h.Module != "" {
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)
}
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(), "")
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)
}
}
@@ -1650,14 +1652,14 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
// novox) is marked alone, naming its own words; web, which composes, keeps running, unmarked. Composed again, idle's
// have is what it runs.
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)
}
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)
}
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)
}
// A revision at the same build (a config value changed) is applied.
@@ -1682,7 +1684,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
b.comps.put(dk, drawMoved)
eventually(t, "db's revision did not wait for a yes", func() bool {
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 {
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, drawMoved)
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 ||
view.Runs == nil || view.Runs.Composed != ddig || view.Judged.Judgement != string(delivery.JudgedRecreates) ||
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(nodeengineclient.JudgedRecreates) ||
!view.Judged.WaitsForYes || len(view.Diff) != 1 || view.Diff[0].ID != "db.server" || view.Diff[0].Change != "changed" ||
len(view.Revisions) != 2 {
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)
eventually(t, "db's have stays waiting-yes after its composition went back to what runs", func() bool {
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 {
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
// what runs (fingerprint, digest, sealed values) rather than naming nothing (review of L2, round 3).
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"}`}})
rk, rraw, rdig := unsigned("rec", fpB, container("rec@sha256:"+strings.Repeat("3", 64)))
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}} {
eventually(t, row.module+"'s move did not wait for a yes", func() bool {
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)
if h.FP != row.fp || (row.composed != "" && h.Composed != row.composed) || h.Composed == "" ||
@@ -1775,8 +1777,8 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
want("rec", fpB, rdig)
eventually(t, "the yes did not apply the move", func() bool {
h, r := b.haves.last("db"), b.haves.last("rec")
return len(b.appliedList()) == applied+2 && h.Phase == string(delivery.PhaseRunning) && h.FP == fpB &&
r.Phase == string(delivery.PhaseRunning) && r.FP == fpB && r.Composed == rdig
return len(b.appliedList()) == applied+2 && h.Phase == string(nodeengineclient.PhaseRunning) && h.FP == fpB &&
r.Phase == string(nodeengineclient.PhaseRunning) && r.FP == fpB && r.Composed == rdig
})
}
@@ -1816,7 +1818,7 @@ func testAnEngineStartReconcilesAModuleFromTheMachinesFacts(t *testing.T) {
}
if c.held {
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 {
b.kept = map[string][]json.RawMessage{"web": rs}
@@ -1867,7 +1869,7 @@ func testReconcileAppliesARunningModuleAgainAndLeavesTheHeldAndTheUnwantedAlone(
stop := b.run()
defer stop()
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 {
t.Fatalf("applied at the start: %v", got)
}
@@ -1882,13 +1884,13 @@ func testReconcileAppliesARunningModuleAgainAndLeavesTheHeldAndTheUnwantedAlone(
if got := b.appliedList(); len(got) != 0 {
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)
}
b.ag.Reconcile("web")
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.failWith = "systemctl start web.service: exit status 1"
@@ -1896,7 +1898,7 @@ func testReconcileAppliesARunningModuleAgainAndLeavesTheHeldAndTheUnwantedAlone(
b.ag.Reconcile("web")
eventually(t, "the failed re-apply did not write the have failed with its words", func() bool {
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)
if got := b.appliedList(); len(got) != 2 {
+4 -4
View File
@@ -4,11 +4,11 @@ import (
"context"
"errors"
"fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
engcomp "github.com/novox/mesh-host/internal/compositions"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"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
phase := h.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}
}
@@ -224,7 +224,7 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo
running = &from
rv := &RunsView{From: source, Resources: len(from.Resources), FP: from.FP}
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
}
@@ -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}
if a.env.SelfComposed && a.env.Policy != nil && module != NodeModule && (!w.Present || w.Want.Yes != read.Dig) {
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
}
}
+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"
"errors"
"fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"github.com/novox/mesh-host/internal/compositions"
"maps"
@@ -13,7 +14,6 @@ import (
"strings"
"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/resolution"
)
@@ -81,7 +81,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
if len(failed) == 0 {
return refuse(err)
}
failed[delivery.HaveNodeWord] = err.Error()
failed[nodeengineclient.NodeWord] = err.Error()
composed, skipped, stood = nil, nil, nil
}
// The node's own composition is judged only where the node runs one: one that runs none applies it only once a want
@@ -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.
for _, m := range sortedKeys(failed) {
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
case module == "" || node != s.Node:
// The node's own, or a value this node holds for a module elsewhere (a grant it provides): said as the node's.
refusals[delivery.HaveNodeWord] = append(refusals[delivery.HaveNodeWord], x)
refusals[nodeengineclient.NodeWord] = append(refusals[nodeengineclient.NodeWord], x)
default:
refusals[module] = append(refusals[module], x)
}
+4 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
engcomp "github.com/novox/mesh-host/internal/compositions"
"os"
@@ -42,7 +43,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
}
b := &bus{placements: map[string]delivery.Placement{"n1": p},
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}},
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)}},
manifests: map[string][]byte{delivery.ManifestDigest(lemurs): lemurs},
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)
}
}
var wireAnswer delivery.CandidateAnswer // the form mesh-sdk's have still holds, until the Have group moves
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 {
if raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Candidates: map[string]nodeengine.CandidateAnswer{"g": a}}); err != nil {
t.Errorf("the answer is not one a have holds: %v (%s)", err, raw)
}
if lemursOK := slices.ContainsFunc(a.Added, func(m nodeengine.CandidateAdded) bool { return m.Module == "lemurs" }); !lemursOK {
@@ -594,7 +591,7 @@ func otherNodeNotResolvedIsNamed(t *testing.T) {
b := &bus{placements: map[string]delivery.Placement{
"n1": {Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Endpoint: "n1.example:51820"}, Modules: []delivery.PlacedModule{{Module: "cons"}, {Module: overlay.Name}}},
"n2": {Node: "n2", Decided: delivery.NodeDecided{Address: "10.77.0.2"}, Modules: []delivery.PlacedModule{{Module: "prov"}, {Module: overlay.Name}}}},
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t)}, "n2": {Node: "n2", TunnelKey: publicKey(t)}},
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)}},
manifests: map[string][]byte{delivery.ManifestDigest(prov): prov, delivery.ManifestDigest(cons): cons}}
given := map[string]*delivery.Placement{}
+18 -8
View File
@@ -35,6 +35,7 @@ import (
"encoding/json"
"errors"
"fmt"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
engcomp "github.com/novox/mesh-host/internal/compositions"
"maps"
"net/netip"
@@ -57,8 +58,8 @@ type Reader interface {
// Placements is every node's placement, by node (delivery.PlacementBucket).
Placements(ctx context.Context) (map[string]delivery.Placement, error)
// Records is every node's record, by node: HaveNode.Record of each node's own have key, `<node>.node` of
// delivery.HaveBucket (ADR 0555 §3).
Records(ctx context.Context) (map[string]delivery.NodeRecord, error)
// buckets.Have (ADR 0555 §3).
Records(ctx context.Context) (map[string]nodeengine.NodeRecord, error)
// Modules is every module the indexer holds, by module (delivery.ModulesBucket).
Modules(ctx context.Context) (map[string]delivery.Module, error)
// Manifest is the raw manifest of a digest (delivery.ManifestsBucket).
@@ -252,7 +253,7 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
type base struct {
in compose.Input
placements map[string]delivery.Placement
records map[string]delivery.NodeRecord
records map[string]nodeengine.NodeRecord
modules map[string]delivery.Module
// ownRefused is each module placed on the composing node whose manifest the indexer refused, with why, in the order
// read: every scope holds it.
@@ -987,15 +988,24 @@ func declaresOwnSecrets(in compose.Input, node string) bool {
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
// 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{
Name: rec.Node,
Place: compose.Place{Key: rec.TunnelKey},
SealingKey: rec.SealingKey,
OutwardLinks: rec.OutwardLinks, Networks: rec.Networks, NetworksSaid: rec.NetworksSaid,
ServingKey: rec.ServingKey, Certificate: rec.Certificate, Contract: rec.Contract, Stated: rec.Contract > 0,
OutwardLinks: rec.OutwardLinks, Networks: attachedOf(rec.Networks), NetworksSaid: rec.NetworksSaid,
ServingKey: rec.ServingKey, Certificate: rec.Certificate, Contract: int(rec.Contract), Stated: rec.Contract > 0,
}
placeNode(&n, p)
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
// 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{}
for node, rec := range records {
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.
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{}
for n := range placements {
seen[n] = true
+13 -14
View File
@@ -11,6 +11,7 @@ import (
"encoding/json"
"errors"
"fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"github.com/novox/mesh-host/internal/compositions"
"os"
@@ -31,7 +32,7 @@ type bus struct {
vaultAuthority string
handed map[string]bool
placements map[string]delivery.Placement
records map[string]delivery.NodeRecord
records map[string]nodeengine.NodeRecord
modules map[string]delivery.Module
manifests map[string][]byte
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) {
return b.placements, nil
}
func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority }
func (b *bus) Handed(context.Context, string) map[string]bool { return b.handed }
func (b *bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.records, nil }
func (b *bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.modules, nil }
func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority }
func (b *bus) Handed(context.Context, string) map[string]bool { return b.handed }
func (b *bus) Records(context.Context) (map[string]nodeengine.NodeRecord, error) {
return b.records, nil
}
func (b *bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.modules, nil }
func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) {
raw, ok := b.manifests[digest]
if !ok {
@@ -180,7 +183,7 @@ func TestInput(t *testing.T) {
{Module: "media", HasMesh: true, MeshValues: map[string]any{"name": "mesh"}, HasLayer: true,
Layer: map[string]any{"places": map[string]any{"data": "/srv/data"}}, Values: map[string]any{"lines": float64(1000)}}}},
},
records: map[string]delivery.NodeRecord{
records: map[string]nodeengine.NodeRecord{
"anchor": {Node: "anchor", TunnelKey: keys["anchor"], Capabilities: caps, Contract: 2},
"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})
}
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)},
"objects": {Manifest: delivery.ManifestDigest(objects)}, "files": {Manifest: delivery.ManifestDigest(files)}},
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) {
t.Errorf("%s: %+v", name, a)
}
var wireAnswer delivery.CandidateAnswer // the form mesh-sdk's have still holds, until the Have group moves
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 {
if raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Candidates: map[string]nodeengine.CandidateAnswer{"g": a}}); err != nil {
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)
for node, r := range records {
k, _ := delivery.HaveNodeKey(node)
k, _ := nodeengineclient.HaveNodeKey(node)
rec := r
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec})
raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &rec})
put(HaveRead, k, raw, err)
}
modules, _ := b.Modules(ctx)
@@ -12,6 +12,7 @@ import (
"embed"
"encoding/json"
"errors"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"strings"
"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.
type Bus struct {
Placement map[string]delivery.Placement
Record map[string]delivery.NodeRecord
Record map[string]nodeengine.NodeRecord
Module map[string]delivery.Module
Manifests map[string][]byte
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) {
return b.Placement, nil
}
func (b *Bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.Record, nil }
func (b *Bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.Module, nil }
func (b *Bus) VaultAuthority(context.Context) string { return "" }
func (b *Bus) Records(context.Context) (map[string]nodeengine.NodeRecord, error) {
return b.Record, nil
}
func (b *Bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.Module, nil }
func (b *Bus) VaultAuthority(context.Context) string { return "" }
func (b *Bus) Manifest(_ context.Context, digest string) ([]byte, error) {
raw, ok := b.Manifests[digest]
@@ -152,7 +155,7 @@ func Provisioned() (*Bus, error) {
Placement: map[string]delivery.Placement{Node: {Node: Node,
Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Account: "operator", AccountHome: "/home/operator"},
Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "postgres"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}}},
Record: map[string]delivery.NodeRecord{Node: {Node: Node, TunnelKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=",
Record: map[string]nodeengine.NodeRecord{Node: {Node: Node, TunnelKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=",
SealingKey: "sk", Contract: compose.HandedContract, Capabilities: []string{"systemd", "package-manager"}}},
Module: map[string]delivery.Module{}, Manifests: map[string][]byte{},
Own: []compose.OwnSecretRow{{Node: Node, Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}},
@@ -3,6 +3,8 @@ package frombustest
import (
"context"
"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"
"reflect"
"slices"
@@ -313,7 +315,7 @@ func statesOf(t *testing.T, b *Bus) frombus.States {
put(frombus.PlacementRead, "placement."+node, raw, err)
}
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)
}
for module, m := range b.Module {
+2 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
engcomp "github.com/novox/mesh-host/internal/compositions"
"strings"
"testing"
@@ -18,7 +19,7 @@ import (
func aNode(t *testing.T, manifests ...string) (*bus, map[string]string) {
t.Helper()
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
b := &bus{records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd"}}},
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{}}
fps := map[string]string{}
for i, raw := range manifests {
+6 -4
View File
@@ -12,6 +12,8 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"strings"
"time"
@@ -84,11 +86,11 @@ func (r statesReader) Placements(context.Context) (map[string]delivery.Placement
return keys.All(), err
}
func (r statesReader) Records(context.Context) (map[string]delivery.NodeRecord, error) {
out := map[string]delivery.NodeRecord{}
func (r statesReader) Records(context.Context) (map[string]nodeengine.NodeRecord, error) {
out := map[string]nodeengine.NodeRecord{}
err := r.each(HaveRead, func(k string, v []byte) {
if node, _, isNode, err := delivery.ParseHaveKey(k); err == nil && isNode {
if h, err := delivery.DecodeHaveNode(v); err == nil && h.Record != nil {
if node, _, isNode, err := nodeengineclient.ParseHaveKey(k); err == nil && isNode {
if h, err := nodeengineclient.DecodeHaveNode(v); err == nil && h.Record != nil {
out[node] = *h.Record
}
}
+7 -7
View File
@@ -5,24 +5,24 @@
package judge
import (
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
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"
)
// The shapes of mesh-sdk's delivery package this package judges, by the names its code was written with.
type (
Composed = engcomp.Composed
Judgement = delivery.Judgement
Judgement = string
CandidateAnswer = nodeengine.CandidateAnswer
ModuleDiffers = nodeengine.CandidateDiffers
ResourceBodies = nodeengine.CandidateBodies
)
const (
JudgedRecreates = delivery.JudgedRecreates
JudgedMovesData = delivery.JudgedMovesData
JudgedNeither = delivery.JudgedNeither
JudgedCannot = delivery.JudgedCannot
JudgedUnchanged = delivery.JudgedUnchanged
JudgedRecreates = nodeengineclient.JudgedRecreates
JudgedMovesData = nodeengineclient.JudgedMovesData
JudgedNeither = "neither"
JudgedCannot = nodeengineclient.JudgedCannot
JudgedUnchanged = "unchanged"
)
+4 -5
View File
@@ -4,10 +4,9 @@ import (
"context"
"errors"
"fmt"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"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,
@@ -87,9 +86,9 @@ func (b OverNATS) HaveBind(ctx context.Context) (HaveBucket, error) {
}
js = bounded
}
kv, err := js.KeyValue(delivery.HaveBucket)
kv, err := js.KeyValue(buckets.Have)
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
}
+3 -4
View File
@@ -4,17 +4,16 @@ import (
"context"
"errors"
"fmt"
"git.novox.be/novox/mesh-sdk/go/delivery"
"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) ------
// 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
// 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.
func (l *natsLink) dataStore() (nats.KeyValue, error) {
+81 -93
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
sdkkeys "git.novox.be/novox/mesh-sdk/go/keys"
"github.com/novox/mesh-host/internal/compositions"
@@ -12,8 +13,6 @@ import (
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// ErrHaveAbsent is what HaveGet returns for a key the bucket does not hold.
@@ -40,14 +39,14 @@ type HaveBucket interface {
type haveState struct {
mu sync.Mutex
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
// 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
// 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).
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 string
// 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
// 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.
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 map[string]bool
// 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
// composition being applied (a container stopped to be replaced reads unhealthy), so its health is not yet judged.
func inMotion(phase string) bool {
switch delivery.Phase(phase) {
case delivery.PhaseBackingUp, delivery.PhaseApplying, delivery.PhaseChecking:
switch string(phase) {
case nodeengineclient.PhaseBackingUp, nodeengineclient.PhaseApplying, nodeengineclient.PhaseChecking:
return true
}
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
// 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) {
return delivery.HealthUnknown
return nodeengineclient.HealthUnknown
}
if got, known := s.health[module]; known {
return got
}
if s.unjudged[module] {
return delivery.HealthUnknown
return nodeengineclient.HealthUnknown
}
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
// or waits, while it is so. The caller holds the lock.
func (s *haveState) wordsOf(module string, health delivery.Health) string {
if health != delivery.HealthFailed && health != delivery.HealthWaits {
func (s *haveState) wordsOf(module string, health string) string {
if health != nodeengineclient.HealthFailed && health != nodeengineclient.HealthWaits {
return ""
}
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
// 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).
func moduleHealths(h *Health) (map[string]delivery.Health, map[string]string) {
out, words := map[string]delivery.Health{}, map[string]string{}
func moduleHealths(h *Health) (map[string]string, map[string]string) {
out, words := map[string]string{}, map[string]string{}
if h == nil {
return out, words
}
@@ -183,7 +182,7 @@ func moduleHealths(h *Health) (map[string]delivery.Health, map[string]string) {
}
}
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 {
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.
func nodeFacts(r Report) delivery.HaveNode {
n := delivery.HaveNode{V: sdkkeys.Version}
carry := func(into *json.RawMessage, empty bool, v any) {
func nodeFacts(r Report) nodeengine.HaveByNodeNodeValue {
n := nodeengine.HaveByNodeNodeValue{V: sdkkeys.Version}
carry := func(into *[]map[string]any, empty bool, v any) {
if empty {
return
}
if raw, err := json.Marshal(v); err == nil {
*into = raw
raw, err := json.Marshal(v)
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.Busy, len(r.Busy) == 0, r.Busy)
carry(&n.Strays, len(r.Strays) == 0, r.Strays)
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
}
@@ -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
// 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)
if q.Keys != nil {
keys := q.Keys()
@@ -377,20 +383,20 @@ func (q *Session) writeHaves(ctx context.Context, bus Bus) {
sort.Strings(modules)
for _, module := range modules {
f := s.modules[module]
h := delivery.Have{V: sdkkeys.Version, FP: f.FP, Composed: f.Composed, Build: f.Build,
Phase: delivery.PhaseRunning, Health: delivery.HealthUnknown, Detail: f.Detail}
h := nodeengine.HaveByNodeModuleValue{V: sdkkeys.Version, Fp: f.FP, Composed: f.Composed, Build: f.Build,
Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthUnknown, Detail: f.Detail}
h.Kept = f.Kept
if delivery.Phase(f.Phase) == delivery.PhaseWaitingYes {
h.Judgement, h.Waits, h.BuildAlone = delivery.Judgement(f.Judgement), f.Waits, f.BuildAlone
if string(f.Phase) == nodeengineclient.PhaseWaitingYes {
h.Judgement, h.Waits, h.BuildAlone = string(f.Judgement), f.Waits, f.BuildAlone
}
switch {
case f.Phase != "":
h.Phase = delivery.Phase(f.Phase)
h.Phase = string(f.Phase)
case f.Failed:
h.Phase = delivery.PhaseFailed
h.Phase = nodeengineclient.PhaseFailed
}
h.SealedAt = f.Sealed
if h.Phase == delivery.PhaseFailed {
if h.Phase == nodeengineclient.PhaseFailed {
h.PullWait = f.PullWait
}
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 {
h.UserList = &w
}
key, err := delivery.HaveKey(node, module)
key, err := nodeengineclient.HaveKey(node, module)
if err != nil {
q.sayHave(fmt.Sprintf("module %q has no key in the have bucket: %v", module, err))
continue
}
raw, err := delivery.EncodeHave(h)
raw, err := nodeengineclient.EncodeHave(h)
if err != nil {
q.sayHave(fmt.Sprintf("cannot encode %s: %v", key, err))
continue
}
if !q.putHave(write, bucket, key, raw, func(current []byte) bool {
c, err := delivery.DecodeHave(current)
return err == nil && delivery.SameHave(c, h)
c, err := nodeengineclient.DecodeHave(current)
return err == nil && nodeengineclient.SameHave(c, h)
}) {
return
}
@@ -428,19 +434,19 @@ func (q *Session) writeHaves(ctx context.Context, bus Bus) {
return
}
if s.node != nil {
key, err := delivery.HaveNodeKey(node)
key, err := nodeengineclient.HaveNodeKey(node)
if err == nil {
var raw []byte
facts := *s.node
facts.Composed = s.nodeComposed
facts.SealedAt = s.nodeSealed
facts.Candidates = interimAnswers(s.nodeCandidates)
facts.Candidates = s.nodeCandidates
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
if !q.putHave(write, bucket, key, raw, func(current []byte) bool {
c, err := delivery.DecodeHaveNode(current)
return err == nil && delivery.SameHaveNode(c, want)
c, err := nodeengineclient.DecodeHaveNode(current)
return err == nil && nodeengineclient.SameHaveNode(c, want)
}) {
return
}
@@ -503,15 +509,15 @@ func (q *Session) writeRemoved(ctx context.Context, b HaveBucket, node string) b
}
sort.Strings(gone)
for _, module := range gone {
key, err := delivery.HaveKey(node, module)
key, err := nodeengineclient.HaveKey(node, module)
if err != nil {
q.sayHave(fmt.Sprintf("module %q has no key in the have bucket: %v", module, err))
delete(s.removed, module)
continue
}
h := delivery.Have{V: sdkkeys.Version, Build: s.removed[module], Phase: delivery.PhaseRemoved,
Health: delivery.HealthUnknown, Detail: "the applied declaration no longer holds the module"}
raw, err := delivery.EncodeHave(h)
h := nodeengine.HaveByNodeModuleValue{V: sdkkeys.Version, Build: s.removed[module], Phase: nodeengineclient.PhaseRemoved,
Health: nodeengineclient.HealthUnknown, Detail: "the applied declaration no longer holds the module"}
raw, err := nodeengineclient.EncodeHave(h)
if err != nil {
q.sayHave(fmt.Sprintf("cannot encode %s: %v", key, err))
delete(s.removed, module)
@@ -531,7 +537,7 @@ func (q *Session) writeRemoved(ctx context.Context, b HaveBucket, node string) b
q.haveFailed(err)
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)
continue
}
@@ -565,7 +571,7 @@ func (q *Session) writeUserListsAlone(ctx context.Context, b HaveBucket, node st
}
sort.Strings(alone)
for _, module := range alone {
key, err := delivery.HaveKey(node, module)
key, err := nodeengineclient.HaveKey(node, module)
if err != nil {
q.sayHave(fmt.Sprintf("module %q has no key in the have bucket: %v", module, err))
continue
@@ -592,7 +598,7 @@ func (q *Session) writeUserListsAlone(ctx context.Context, b HaveBucket, node st
return false
}
delete(s.userListNoKey, module)
h, err := delivery.DecodeHave(current)
h, err := nodeengineclient.DecodeHave(current)
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))
continue
@@ -600,10 +606,10 @@ func (q *Session) writeUserListsAlone(ctx context.Context, b HaveBucket, node st
w := s.userList[module]
next := h
next.UserList = &w
if delivery.SameHave(h, next) {
if nodeengineclient.SameHave(h, next) {
continue
}
raw, err := delivery.EncodeHave(next)
raw, err := nodeengineclient.EncodeHave(next)
if err != nil {
q.sayHave(fmt.Sprintf("cannot encode %s: %v", key, err))
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
// 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.
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()
s := &q.haves
s.mu.Lock()
if s.userList == nil {
s.userList = map[string]delivery.UserListWritten{}
s.userList = map[string]nodeengine.UserListWritten{}
}
s.userList[module] = w
s.mu.Unlock()
@@ -732,7 +738,7 @@ func (q *Session) NoteNode(ctx context.Context, composed string, sealed composit
s.nodeComposed = composed
s.nodeSealed = sealed
if s.node == nil {
n := delivery.HaveNode{V: sdkkeys.Version}
n := nodeengine.HaveByNodeNodeValue{V: sdkkeys.Version}
s.node = &n
}
s.mu.Unlock()
@@ -765,7 +771,7 @@ func (q *Session) NoteCandidates(ctx context.Context, answers map[string]nodeeng
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).
func (q *Session) NoteComposes(ctx context.Context, composes, why string) {
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
// 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)
return n, ok
}
// 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).
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.mu.Lock()
bus := q.bus
q.mu.Unlock()
hb, ok := bus.(HaveBus)
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 {
return delivery.HaveNode{}, false, err
return nodeengine.HaveByNodeNodeValue{}, false, err
}
read, cancel := context.WithTimeout(ctx, q.timeout())
defer cancel()
bucket, err := hb.HaveBind(read)
if err != nil {
return delivery.HaveNode{}, false, err
return nodeengine.HaveByNodeNodeValue{}, false, err
}
value, _, err := bucket.Get(read, key)
if errors.Is(err, ErrHaveAbsent) {
return delivery.HaveNode{}, false, nil
return nodeengine.HaveByNodeNodeValue{}, false, 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 {
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
}
// 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.
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)
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
// 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.mu.Lock()
bus := q.bus
q.mu.Unlock()
hb, ok := bus.(HaveBus)
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 {
return delivery.Have{}, false, err
return nodeengine.HaveByNodeModuleValue{}, false, err
}
read, cancel := context.WithTimeout(ctx, q.timeout())
defer cancel()
bucket, err := hb.HaveBind(read)
if err != nil {
return delivery.Have{}, false, err
return nodeengine.HaveByNodeModuleValue{}, false, err
}
value, _, err := bucket.Get(read, key)
if errors.Is(err, ErrHaveAbsent) {
return delivery.Have{}, false, nil
return nodeengine.HaveByNodeModuleValue{}, false, 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 {
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
}
// 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.
func nothingJudged(f ModuleFact, looked bool) delivery.Health {
if looked && !f.Failed && (f.Phase == "" || f.Phase == string(delivery.PhaseRunning)) {
return delivery.HealthOK
func nothingJudged(f ModuleFact, looked bool) string {
if looked && !f.Failed && (f.Phase == "" || f.Phase == string(nodeengineclient.PhaseRunning)) {
return nodeengineclient.HealthOK
}
return delivery.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
return nodeengineclient.HealthUnknown
}
+10 -10
View File
@@ -4,12 +4,12 @@ package link
import (
"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"
"testing"
"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
@@ -21,9 +21,9 @@ import (
func TestNatsHavesAreWrittenOnceAndOnlyOnChange(t *testing.T) {
conn, js := aBus(t)
const node = "haveok"
kv, err := js.KeyValue(delivery.HaveBucket)
kv, err := js.KeyValue(buckets.Have)
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)
}
}
@@ -66,17 +66,17 @@ func TestNatsHavesAreWrittenOnceAndOnlyOnChange(t *testing.T) {
first := revisions()
a, _ := kv.Get(node + ".a")
haveA, err := delivery.DecodeHave(a.Value())
if err != nil || haveA.Phase != delivery.PhaseRunning || haveA.Build != "b-1" || haveA.Health != delivery.HealthOK {
haveA, err := nodeengineclient.DecodeHave(a.Value())
if err != nil || haveA.Phase != nodeengineclient.PhaseRunning || haveA.Build != "b-1" || haveA.Health != nodeengineclient.HealthOK {
t.Fatalf("a holds %+v (%v)", haveA, err)
}
b, _ := kv.Get(node + ".b")
haveB, err := delivery.DecodeHave(b.Value())
if err != nil || haveB.Phase != delivery.PhaseFailed || haveB.Detail == "" {
haveB, err := nodeengineclient.DecodeHave(b.Value())
if err != nil || haveB.Phase != nodeengineclient.PhaseFailed || haveB.Detail == "" {
t.Fatalf("b holds %+v (%v)", haveB, err)
}
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)
}
+77 -72
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
sdkkeys "git.novox.be/novox/mesh-sdk/go/keys"
"github.com/novox/mesh-host/internal/compositions"
@@ -15,7 +16,6 @@ import (
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/nats-io/nats.go"
)
@@ -104,11 +104,11 @@ func (l *haveLink) Set(_ context.Context, key string, value []byte, revision uin
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()
l.hmu.Lock()
defer l.hmu.Unlock()
h, err := delivery.DecodeHave(l.kv[key].value)
h, err := nodeengineclient.DecodeHave(l.kv[key].value)
if err != nil {
t.Fatalf("%s holds no have: %v", key, err)
}
@@ -121,9 +121,9 @@ func (l *haveLink) setCount() int {
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()
raw, err := delivery.EncodeHave(h)
raw, err := nodeengineclient.EncodeHave(h)
if err != nil {
t.Fatal(err)
}
@@ -165,11 +165,11 @@ func TestAnAppliedModuleIsWrittenRunningAndAFailedOneFailed(t *testing.T) {
q, _ := aHaveQueue(l)
q.SayFacts(context.Background(), appliedModules())
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)
}
if b.Phase != delivery.PhaseFailed || b.Build != "b-2" || !strings.Contains(b.Detail, "no space") ||
b.Health != delivery.HealthFailed {
if b.Phase != nodeengineclient.PhaseFailed || b.Build != "b-2" || !strings.Contains(b.Detail, "no space") ||
b.Health != nodeengineclient.HealthFailed {
t.Errorf("b: %+v", b)
}
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.
func TestAHaveIsWrittenOnlyWhenItDiffers(t *testing.T) {
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.b", delivery.Have{V: sdkkeys.Version, Build: "b-0", 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", nodeengine.HaveByNodeModuleValue{V: sdkkeys.Version, Build: "b-0", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
q, _ := aHaveQueue(l)
q.SayFacts(context.Background(), appliedModules())
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)
}
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)
}
before := l.setCount()
@@ -205,7 +205,7 @@ func TestAModulesHealthFollowsTheLook(t *testing.T) {
{Module: "c", Build: "b"}, {Module: "d", Build: "b"}, {Module: "e", Build: "b"}}}
q.SayFacts(context.Background(), rep)
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)
}
}
@@ -220,8 +220,8 @@ func TestAModulesHealthFollowsTheLook(t *testing.T) {
if !q.SayHealth(context.Background(), *h) {
t.Fatal("not said")
}
for m, want := range map[string]delivery.Health{"a": delivery.HealthWaits, "b": delivery.HealthFailed,
"c": delivery.HealthWaits, "d": delivery.HealthUnknown, "e": delivery.HealthOK} {
for m, want := range map[string]string{"a": nodeengineclient.HealthWaits, "b": nodeengineclient.HealthFailed,
"c": nodeengineclient.HealthWaits, "d": nodeengineclient.HealthUnknown, "e": nodeengineclient.HealthOK} {
if got := l.have(t, "n1."+m).Health; 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}}
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)
}
}
@@ -255,14 +255,19 @@ func TestTheNodeKeyCarriesTheNodesFacts(t *testing.T) {
rep.Filters = []Filter{{Where: "inet mesh", Owner: "mesh"}}
rep.Profile = map[string]any{"cores": 8}
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 {
t.Fatal(err)
}
enc := func(v any) json.RawMessage { b, _ := json.Marshal(v); return b }
want := delivery.HaveNode{V: sdkkeys.Version, Windows: enc(rep.Windows), Busy: enc(rep.Busy), Strays: enc(rep.Strays),
Filters: enc(rep.Filters), Profile: enc(rep.Profile)}
if !delivery.SameHaveNode(got, want) {
enc := func(v any) []map[string]any {
var out []map[string]any
b, _ := json.Marshal(v)
_ = 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)
}
@@ -279,14 +284,14 @@ func TestTheNodeKeyCarriesTheNodesFacts(t *testing.T) {
rep.Outward = []string{"eth0"}
rep.Health = &Health{Contract: NodeKeyContract}
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)
}
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,
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)
if !delivery.SameNodeRecord(*got.Record, wantRecord) {
if !nodeengineclient.SameNodeRecord(*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).
@@ -307,7 +312,7 @@ func TestTheNodeKeyCarriesTheNodesFacts(t *testing.T) {
}
l.statedErr, l.stated = nil, map[string]string{}
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)
}
at++
@@ -316,13 +321,13 @@ func TestTheNodeKeyCarriesTheNodesFacts(t *testing.T) {
at++
keys.Certificate = "cert-2"
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)
}
bare := rep
bare.Health = nil
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)
}
}
@@ -342,7 +347,7 @@ func TestAFailedHaveWriteIsSaidAndWrittenAgain(t *testing.T) {
l.failing = false
l.hmu.Unlock()
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")
}
}
@@ -407,7 +412,7 @@ func TestAFailedBindOrWriteEndsThePassAndReturnsInTime(t *testing.T) {
if l.setCount() == 0 {
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")
}
}
@@ -426,13 +431,13 @@ func TestAModuleLeavingTheDeclarationIsWrittenRemoved(t *testing.T) {
revA, revB := l.kv["n1.a"].rev, l.kv["n1.b"].rev
q.SayFacts(context.Background(), onlyA())
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)
}
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)
}
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)
}
before := l.setCount()
@@ -449,11 +454,11 @@ func TestARemovedModuleAddedBackIsWrittenWithItsNewPhase(t *testing.T) {
q.SayFacts(context.Background(), appliedModules())
q.SayFacts(context.Background(), onlyA())
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)
}
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)
}
}
@@ -462,11 +467,11 @@ func TestARemovedModuleAddedBackIsWrittenWithItsNewPhase(t *testing.T) {
// bucket is not created again.
func TestAModuleNeverWrittenIsNotWrittenRemoved(t *testing.T) {
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.SayFacts(context.Background(), appliedModules())
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)
}
// 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.hmu.Unlock()
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)
}
}
@@ -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.
func TestARestartedNodeEngineWritesTheRemovalFromTheReportsBefore(t *testing.T) {
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.b", delivery.Have{V: sdkkeys.Version, Build: "b-2", 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", nodeengine.HaveByNodeModuleValue{V: sdkkeys.Version, Build: "b-2", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
q, _ := aHaveQueue(l) // fresh: nothing written by this process
r := onlyA()
r.Before = []string{"a", "b", "c"}
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)
}
if _, there := l.kv["n1.c"]; there {
@@ -563,24 +568,24 @@ func TestAnAppliedModulesHaveNamesTheFingerprintAndTheDigestAndFollowsItsHealth(
dig := "sha256:" + strings.Repeat("b", 64)
q.NoteModule(context.Background(), ModuleFact{Module: "web", FP: fp, Composed: dig})
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)
}
// Starting: it waits. Past its start window the check fails: failed.
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)
}
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)
}
// 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)}
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)
}
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).
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"}
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)
}
// 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"})
if got := l.have(t, "n1.web"); got.Phase != delivery.PhaseHeld || got.Detail == "" {
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 != nodeengineclient.PhaseHeld || got.Detail == "" {
t.Fatalf("held: %+v", got)
}
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)
}
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)
}
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.
answers := map[string]nodeengine.CandidateAnswer{"p-1": {Proposal: "p-1", Revision: 1, Verdict: nodeengine.CandidateAnswerVerdictSame, Same: []string{"web"}}}
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)
}
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)
}
}
@@ -633,16 +638,16 @@ func TestAModuleWithNothingToJudgeIsOkOnceLookedAt(t *testing.T) {
q, _ := aHaveQueue(l)
q.SayFacts(context.Background(), Report{Modules: []ModuleFact{{Module: "mesh-host", Build: "b"}, {Module: "web", Build: "b"},
{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)
}
if !q.SayHealth(context.Background(), *healthOf(map[string]string{"web": StateHealthy})) {
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)
}
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")
}
}
@@ -654,17 +659,17 @@ func TestAModuleSaidAgainOverAnOlderEnginesUnknownIsWrittenWithThisEnginesHealth
l := newHaveLink()
fp := "src2:" + strings.Repeat("a", 64)
dig := "sha256:" + strings.Repeat("b", 64)
l.put(t, "n1.xorg", delivery.Have{V: sdkkeys.Version, FP: fp, Composed: dig, Phase: delivery.PhaseRunning,
Health: delivery.HealthUnknown})
l.put(t, "n1.xorg", nodeengine.HaveByNodeModuleValue{V: sdkkeys.Version, Fp: fp, Composed: dig, Phase: nodeengineclient.PhaseRunning,
Health: nodeengineclient.HealthUnknown})
q, _ := aHaveQueue(l)
if !q.SayHealth(context.Background(), *healthOf(map[string]string{"web": StateHealthy})) {
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)
}
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)
}
}
@@ -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)}
q.NoteModule(ctx, old)
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)
}
next := ModuleFact{Module: "ctl", FP: "src2:" + strings.Repeat("b", 64), Composed: "sha256:" + strings.Repeat("2", 64)}
applying := next
applying.Phase = string(delivery.PhaseApplying)
applying.Phase = string(nodeengineclient.PhaseApplying)
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)
}
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)
}
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)
}
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)
}
}
@@ -711,7 +716,7 @@ func TestAHealthIsNotCarriedOverToAnotherComposition(t *testing.T) {
q.NoteModule(ctx, ModuleFact{Module: "ctl", FP: "src2:" + strings.Repeat("a", 64)})
q.SayHealth(ctx, *healthOf(map[string]string{"ctl": StateUnhealthy}))
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)
}
}
@@ -724,11 +729,11 @@ func TestADeclarationReportMovesAHealthAndARemovalForgetsIt(t *testing.T) {
ctx := context.Background()
q.SayFacts(ctx, Report{Applied: []string{"a.x"}, Modules: []ModuleFact{{Module: "a", Build: "b-1"}},
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)
}
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)
}
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.
func TestAUserListReachesTheHaveOfAModuleNoApplyHereSays(t *testing.T) {
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)
q, said := aHaveQueue(l)
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)}
q.NoteUserList(context.Background(), "nats", w)
got := l.have(t, "n1.nats")
if got.UserList == nil || got.UserList.Users != 58 || got.UserList.Digest != dig {
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)
}
rev := l.kv["n1.nats"].rev
@@ -794,7 +799,7 @@ func TestAUserListReachesTheHaveOfAModuleNoApplyHereSays(t *testing.T) {
}
fp := "src2:" + strings.Repeat("a", 64)
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)
}
}
+4 -5
View File
@@ -3,12 +3,11 @@ package link
import (
"context"
"encoding/json"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"slices"
"strings"
"testing"
"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
@@ -60,7 +59,7 @@ func TestAHealthStatementIsSaidOnTheLinkOpenNow(t *testing.T) {
since := time.Date(2026, 10, 29, 20, 0, 0, 0, time.UTC)
look := func(streak int, writer string) Health {
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}}},
Resources: []ResourceHealth{
{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)
}
}
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() {
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.
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)
}
if store.puts != 3 {
+11 -12
View File
@@ -1,11 +1,10 @@
package link
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"
"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
@@ -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
// delivery, issue 774): one layout for the message, the report and the watchman that reads it.
type (
NetworkHealth = delivery.NetworkHealth
NetworkPart = delivery.NetworkPart
UnitsHealth = delivery.UnitsHealth
FailedUnit = delivery.FailedUnit
NetworkHealth = nodeengine.NetworkHealth
NetworkPart = nodeengine.NetworkPart
UnitsHealth = nodeengine.UnitsHealth
FailedUnit = nodeengine.FailedUnit
)
// 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 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
// 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
// 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
// PullWait is, on a failed fact, the pull from a store its apply waits on (delivery.Have.PullWait, issue 1966 ENG-9).
PullWait *delivery.PullWait
// PullWait is, on a failed fact, the pull from a store its apply waits on (nodeengine.HaveByNodeModuleValue.PullWait, issue 1966 ENG-9).
PullWait *nodeengine.PullWait
}
+16 -13
View File
@@ -4,13 +4,16 @@ import (
"context"
"errors"
"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"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"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).
//
// 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 (
HealthBucket = delivery.NodeHealthBucket
NodeHealthVersion = delivery.NodeHealthVersion
HealthBucket = buckets.Health
NodeHealthVersion = keys.Version
)
type (
NodeHealth = delivery.NodeHealth
PeerSeen = delivery.PeerSeen
NodeAccount = delivery.NodeAccount
NodeDirectory = delivery.NodeDirectory
NodeHealth = nodeengine.HealthByNodeValue
PeerSeen = nodeengine.PeerSeen
NodeAccount = nodeengine.NodeAccount
NodeDirectory = nodeengine.NodeDirectory
)
// 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
// directory of its resources that the six alerts read.
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 {
out.Peers = append([]PeerSeen(nil), peers...)
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 {
// 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})
}
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)
}
if raw != nil {
if was, err := delivery.DecodeNodeHealth(raw); err == nil {
if was, err := nodeengineclient.DecodeHealth(raw); err == nil {
*last = &was
}
}
}
if *last != nil && delivery.SameNodeHealth(**last, h) {
if *last != nil && nodeengineclient.SameHealth(**last, h) {
return false, nil
}
h.At = now().UTC()
raw, err := delivery.EncodeNodeHealth(h)
raw, err := nodeengineclient.EncodeHealth(h)
if err != nil {
return false, err
}
+8 -6
View File
@@ -2,6 +2,7 @@ package link
import (
"encoding/json"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
sdkkeys "git.novox.be/novox/mesh-sdk/go/keys"
"slices"
"sort"
@@ -9,11 +10,10 @@ import (
"time"
"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
// `<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
// 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.
@@ -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
// 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 {
return nil
}
@@ -61,7 +61,7 @@ func recordOf(node string, r Report, keys RecordKeys) *delivery.NodeRecord {
if err != nil || json.Unmarshal(raw, &p) != 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,
ManifestReads: manifestReads()}
for _, c := range p.Capabilities {
@@ -77,13 +77,15 @@ func recordOf(node string, r Report, keys RecordKeys) *delivery.NodeRecord {
sort.Strings(rec.Capabilities)
if p.Machine != nil {
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 {
rec.OutwardLinks = append([]string(nil), r.Outward...)
}
if r.Health != nil {
rec.Contract = r.Health.Contract
rec.Contract = int64(r.Health.Contract)
}
return rec
}
+5 -4
View File
@@ -3,6 +3,7 @@ package link
import (
"context"
"fmt"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"sync"
"time"
@@ -35,9 +36,9 @@ type Rotator struct {
failures int
// fellBack is the key's `since` a fallback was made for: the same rotation is not tried again.
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.
fallen *delivery.LoginFallback
fallen *nodeengine.LoginFallback
switchedSince string
}
@@ -162,13 +163,13 @@ func (r *Rotator) AuthFailed(login string) {
if r.Now != nil {
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.fellBackSince = r.switchedSince
}
// 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 {
return nil
}
+3 -1
View File
@@ -3,6 +3,8 @@ package link
import (
"context"
"errors"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"slices"
"strings"
"testing"
@@ -140,7 +142,7 @@ func TestTheOldValueGoesAtRotationDoneAbandonSwitchesBackAndFailuresFallBack(t *
t.Fatalf("after the bound: %s, fall back %+v", cur, fb)
}
// 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)
}
steps := len(f.steps)
+1 -2
View File
@@ -12,7 +12,6 @@ import (
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
)
@@ -23,7 +22,7 @@ import (
// receives, orders or reconciles a whole declaration.
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 func() *delivery.LoginFallback
Fallback func() *nodeengine.LoginFallback
// linkedUser is the bus user the link open now authenticated as, guarded by mu (WhenLinkedAs).
linkedUser string
// 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{}}}
for _, p := range st.Parts {
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)
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:
// 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.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()
return a
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) {
m := nodeengine.Module{Module: module}
if module == "" {
m.Module = delivery.HaveNodeWord
m.Module = nec.NodeWord
}
if why, held := laidHeld[module]; held {
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 {
a.Own = own
} 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"}
}
refused = refused || a.Own.State == nec.ModuleRefused
+6 -5
View File
@@ -3,6 +3,7 @@ package selfcompose
import (
"context"
"encoding/json"
"github.com/novox/mesh-host/internal/buckets"
"strings"
"sync/atomic"
"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"},
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"})
deadline := time.Now().Add(5 * time.Second)
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)
}
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)
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})
}
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" ||
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)
}
// 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.c.placements["n1"] = delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"},
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["other"] = delivery.Module{FP2: fp, Manifest: digestOther, Repository: "novox/x", Commit: "c"}
s.ready.Store(true)
+6 -3
View File
@@ -2,6 +2,9 @@ package selfcompose
import (
"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"
"slices"
"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"},
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"})
omod, _ := delivery.EncodeModule(delivery.Module{FP2: ofp, Manifest: odigest, Repository: "novox/o", Commit: "c"})
want, _ := delivery.EncodeWant(delivery.Want{FP: fp, Self: true})
nodeWant, _ := delivery.EncodeNodeWant(delivery.NodeWant{Self: true})
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, "oldie", omod)
b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", want)
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})
}
for u := next(t, out); !u.Caught; u = next(t, out) {
+20 -8
View File
@@ -4,13 +4,15 @@ import (
"bytes"
"context"
"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"
"sort"
"strings"
"time"
"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/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)
if err != nil {
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
}
// 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()
wrote, removed, err := c.writeData(ctx, items, digests, held)
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())
return
}
@@ -93,7 +95,7 @@ func (c *Composer) publishData(ctx context.Context) {
c.sayData("")
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",
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) {
store := c.env.Data
if c.dataKept == nil {
pattern, err := delivery.EngineDataNodeKeys(c.env.Node)
pattern, err := nodeengineclient.DataNodeKeys(c.env.Node)
if err != nil {
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 {
continue
}
key, err := delivery.EngineDataKey(c.env.Node, module)
key, err := nodeengineclient.DataKey(c.env.Node, module)
if err != nil {
failed = append(failed, err.Error())
continue
@@ -185,7 +187,7 @@ func (c *Composer) writeData(ctx context.Context, items map[string][]catalogue.P
if !ok {
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 {
failed = append(failed, err.Error())
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.
func heldKey(node, key string, held map[string]string) bool {
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 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 (
"context"
"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"
"slices"
"strings"
@@ -86,7 +89,7 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) {
var said []string
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.
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")
}
c := New(env)
@@ -105,15 +108,15 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) {
deletes []string
asked int
// 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,
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"},
puts: []string{"n1.oldie", "n1.web"}, deletes: []string{"n1.gone"}, asked: 1,
want: map[string]delivery.EngineData{
"n1.web": {Module: "web", Digest: "sha256:w1", Items: []catalogue.PlacedDataItem{item("web", "db", "/srv/web/db")}},
"n1.oldie": {Module: "oldie", Digest: "sha256:n1", Items: []catalogue.PlacedDataItem{item("oldie", "x", "/srv/oldie")}}}},
want: map[string]nodeengine.DataByNodeModuleValue{
"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: 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 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")}},
@@ -126,8 +129,8 @@ func TestTheEngineKeepsEachPlacedModulesDataItems(t *testing.T) {
{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")}},
digests: map[string]string{webKey: "sha256:w2", nodeKey: "sha256:n1"}, puts: []string{"n1.web"}, asked: 5,
want: map[string]delivery.EngineData{
"n1.web": {Module: "web", Digest: "sha256:w2", Items: []catalogue.PlacedDataItem{item("web", "db", "/srv/web/db2")}}}},
want: map[string]nodeengine.DataByNodeModuleValue{
"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,
items: map[string][]catalogue.PlacedDataItem{"oldie": {item("oldie", "x", "/srv/oldie")}},
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")}},
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,
want: map[string]delivery.EngineData{
"n1.web": {Module: "web", Digest: "sha256:w3", Items: []catalogue.PlacedDataItem{item("web", "db", "/srv/web/db")}}}},
want: map[string]nodeengine.DataByNodeModuleValue{
"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) {
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)
}
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 ||
!slices.Equal(got.Items, want.Items) {
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"
"sort"
"time"
)
// **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"
"errors"
"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"
engcomp "github.com/novox/mesh-host/internal/compositions"
"maps"
@@ -76,7 +79,7 @@ type Env struct {
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,
// 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.
// 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)
@@ -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
// (delivery.PlacementKeys; research 072 row 37): a write of the holder's under way is never composed half.
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
// 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
@@ -298,7 +301,7 @@ func New(env Env) *Composer {
env.Say = func(string) {}
}
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{},
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
// 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).
{delivery.HaveBucket, "*." + delivery.HaveNodeWord, nil, (*Composer).takeRecordChanged},
{delivery.HaveBucket, node + ".>", nil, (*Composer).takeModuleHave},
{buckets.Have, "*." + nodeengineclient.NodeWord, nil, (*Composer).takeRecordChanged},
{buckets.Have, node + ".>", nil, (*Composer).takeModuleHave},
{delivery.ModulesBucket, ">", (*Composer).takeModule, 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
@@ -690,13 +693,13 @@ func (c *Composer) takePlacement(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 {
return
}
c.mu.Lock()
defer c.mu.Unlock()
h, err := delivery.DecodeHaveNode(u.Value)
h, err := nodeengineclient.DecodeHaveNode(u.Value)
if u.Deleted || err != nil || h.Record == nil {
delete(c.records, node)
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
// this run: a fresh agent composes before it applies anything (review of the fold, round 1).
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 {
return false
}
in := false
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
// 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).
@@ -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
// 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).
interrupted := h.Phase == delivery.PhaseHeld && h.Detail == agent.BackupInterrupted
runsNothing := (h.Phase == delivery.PhaseFailed || interrupted) && h.FP == "" && h.Composed == ""
in = h.Phase != delivery.PhaseRemoved && !runsNothing && !strings.HasPrefix(h.FP, delivery.SourcePrefix2)
interrupted := h.Phase == nodeengineclient.PhaseHeld && h.Detail == agent.BackupInterrupted
runsNothing := (h.Phase == nodeengineclient.PhaseFailed || interrupted) && h.Fp == "" && h.Composed == ""
in = h.Phase != nodeengineclient.PhaseRemoved && !runsNothing && !strings.HasPrefix(h.Fp, delivery.SourcePrefix2)
}
}
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.
// 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.
func (c *Composer) Machines() (byAddress map[string]string, hub string) {
c.mu.Lock()
@@ -1079,7 +1082,7 @@ func (c *Composer) refused(why string, held map[string]string) {
c.refusal = said
c.held = heldModules(held)
if why != "" {
c.held = append(c.held, delivery.HaveNodeWord)
c.held = append(c.held, nodeengineclient.NodeWord)
}
c.mu.Unlock()
if !same && c.env.Refused != nil {
@@ -1374,10 +1377,10 @@ func (r reader) Placements(context.Context) (map[string]delivery.Placement, erro
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()
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 {
out[k] = v
}
+41 -39
View File
@@ -8,7 +8,9 @@ import (
"encoding/json"
"errors"
"fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"github.com/novox/mesh-host/internal/buckets"
"github.com/novox/mesh-host/internal/compositions"
"os"
"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"})
place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"},
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"})
want, _ := delivery.EncodeWant(delivery.Want{FP: fp, Self: true})
nodeWant, _ := delivery.EncodeNodeWant(delivery.NodeWant{Self: true})
b.put(t, delivery.PlacementBucket, "placement.n1", place)
b.put(t, delivery.HaveBucket, "n1.node", have)
oldHave, _ := delivery.EncodeHave(delivery.Have{Build: "build-1", Phase: delivery.PhaseRunning, Health: delivery.HealthOK})
b.put(t, delivery.HaveBucket, "n1.oldie", oldHave)
b.put(t, buckets.Have, "n1.node", have)
oldHave, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Build: "build-1", Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
b.put(t, buckets.Have, "n1.oldie", oldHave)
b.put(t, delivery.ModulesBucket, "lemurs", mod)
b.put(t, delivery.ModulesBucket, "oldie", omod)
b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", want)
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})
}
got := map[string]bool{}
@@ -696,10 +698,10 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
bkey, _ := delivery.BuilderBuildKey("tool", next2)
tb.kv[delivery.BuilderBuildsBucket+"/"+bkey] = building
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.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} {
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.
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})
if err != nil {
t.Fatal(err)
@@ -1085,10 +1087,10 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
time.Sleep(30 * time.Millisecond)
sb.missing(delivery.PlacementCandidatesBucket, false)
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)
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 {
raw, _ := delivery.EncodeModule(m)
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.
sb.put(delivery.HaveBucket, "n1.node", rec, 2)
sb.put(buckets.Have, "n1.node", rec, 2)
select {
case a := <-answered:
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) {
raw, err := delivery.EncodeHave(delivery.Have{FP: fp, Composed: lemursDigest, Phase: delivery.PhaseRunning,
Health: delivery.HealthFailed, Detail: detail})
raw, err := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fp, Composed: lemursDigest, Phase: nodeengineclient.PhaseRunning,
Health: nodeengineclient.HealthFailed, Detail: detail})
if err != nil {
t.Fatal(err)
}
hk, _ := delivery.HaveKey("n1", "lemurs")
sb.put(delivery.HaveBucket, hk, raw, rev)
hk, _ := nodeengineclient.HaveKey("n1", "lemurs")
sb.put(buckets.Have, hk, raw, rev)
}
lemursHave("its tool lemurs_health: no link to the bus is open", 10)
drain()
@@ -1273,9 +1275,9 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
t.Errorf("a new revision's answer said %q", got)
}
settled = answering()
hk, _ := delivery.HaveKey("n1", "lemurs")
moved, _ := delivery.EncodeHave(delivery.Have{FP: fp, Composed: "sha256:" + strings.Repeat("e", 64), Phase: delivery.PhaseRunning})
sb.put(delivery.HaveBucket, hk, moved, 13)
hk, _ := nodeengineclient.HaveKey("n1", "lemurs")
moved, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fp, Composed: "sha256:" + strings.Repeat("e", 64), Phase: nodeengineclient.PhaseRunning})
sb.put(buckets.Have, hk, moved, 13)
drain()
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))
@@ -1350,7 +1352,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
go func() { _ = nsh.Run(nctx) }()
nb.made(6)
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 {
raw, _ := delivery.EncodeModule(m)
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})
oldKey, _ := delivery.PlacementCandidateKey("older", "n1")
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})
}
select {
@@ -1508,7 +1510,7 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
cc.manifests[digest] = []byte(lemurs)
cc.manifests[cdigest] = []byte(withCert)
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"}}}
cc.certifyIfWanted(context.Background(), reader{c: cc}, onlyLemurs)
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.
type fixedReader struct {
placements map[string]delivery.Placement
records map[string]delivery.NodeRecord
records map[string]nodeengine.NodeRecord
modules map[string]delivery.Module
manifests map[string][]byte
}
@@ -1664,7 +1666,7 @@ type fixedReader struct {
func (r fixedReader) Placements(context.Context) (map[string]delivery.Placement, error) {
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
}
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) {
for _, row := range []struct {
name string
have delivery.Have
have nodeengine.HaveByNodeModuleValue
in bool
// heldToday says the shadow counts the module held today (issue 1126: a seed holding it too holds it as today).
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},
{"failed at a build fingerprint", delivery.Have{FP: "build-0123abcd", Composed: "sha256:" + strings.Repeat("c", 64), Phase: delivery.PhaseFailed,
Health: delivery.HealthFailed, Detail: "the node cannot compose it, and keeps running what it ran: no build"}, true, true},
{"failed at a build fingerprint", nodeengine.HaveByNodeModuleValue{Fp: "build-0123abcd", Composed: "sha256:" + strings.Repeat("c", 64), Phase: nodeengineclient.PhaseFailed,
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`.
{"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 new form", delivery.Have{FP: delivery.SourcePrefix2 + strings.Repeat("a", 64), Composed: "sha256:" + strings.Repeat("c", 64),
Phase: delivery.PhaseRunning, Health: delivery.HealthOK}, false, false},
{"running with no fingerprint (mesh-wireguard)", delivery.Have{Phase: delivery.PhaseRunning, Health: delivery.HealthOK}, true, false},
{"held today", delivery.Have{FP: "build-0123abcd", Composed: "sha256:" + strings.Repeat("c", 64), Phase: delivery.PhaseHeld,
Health: delivery.HealthWaits, Detail: "held: a required config value is unset"}, 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", nodeengine.HaveByNodeModuleValue{Fp: delivery.SourcePrefix2 + strings.Repeat("a", 64), Composed: "sha256:" + strings.Repeat("c", 64),
Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}, false, false},
{"running with no fingerprint (mesh-wireguard)", nodeengine.HaveByNodeModuleValue{Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}, true, false},
{"held today", nodeengine.HaveByNodeModuleValue{Fp: "build-0123abcd", Composed: "sha256:" + strings.Repeat("c", 64), Phase: nodeengineclient.PhaseHeld,
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
// 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},
} {
t.Run(row.name, func(t *testing.T) {
c := New(Env{Node: "n1"})
key, err := delivery.HaveKey("n1", "web")
key, err := nodeengineclient.HaveKey("n1", "web")
if err != nil {
t.Fatal(err)
}
value, err := delivery.EncodeHave(row.have)
value, err := nodeengineclient.EncodeHave(row.have)
if err != nil {
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"},
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"})
want, _ := delivery.EncodeWant(delivery.Want{FP: fp, Self: true})
nodeWant, _ := delivery.EncodeNodeWant(delivery.NodeWant{Self: true})
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.AssignerWantsBucket, "want.n1.lemurs", want)
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})
}
handed := map[string]bool{}
+12 -10
View File
@@ -5,7 +5,9 @@ import (
"context"
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
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"
"sort"
"strings"
@@ -160,11 +162,11 @@ func (s *Shadow) Run(ctx context.Context) error {
ins := []input{
{delivery.PlacementCandidatesBucket, ">", nil, nil},
{delivery.PlacementBucket, ">", (*Composer).takePlacement, nil},
{delivery.HaveBucket, "*." + delivery.HaveNodeWord, nil, nil},
{buckets.Have, "*." + nodeengineclient.NodeWord, nil, nil},
{delivery.ModulesBucket, ">", (*Composer).takeModule, nil},
{ResolvedBucket, node + ".>", (*Composer).takeValue, nil},
// 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 {
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,
// which moves nothing composed.
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 {
return false
}
@@ -327,7 +329,7 @@ func (s *Shadow) takeRecord(u agent.Update) bool {
c.mu.Lock()
after, has := c.records[node]
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.
@@ -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
// or words alone moved runs the same.
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 {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
before, had := s.ran[module]
h, err := delivery.DecodeHave(u.Value)
h, err := nodeengineclient.DecodeHave(u.Value)
if s.heldNow == nil {
s.heldNow = map[string]string{}
}
@@ -615,18 +617,18 @@ func (s *Shadow) takeRan(u agent.Update) bool {
s.heldNow[module] = ""
if !u.Deleted && err == nil {
switch {
case h.Phase == delivery.PhaseHeld:
case h.Phase == nodeengineclient.PhaseHeld:
s.heldNow[module] = "held"
case h.Phase == delivery.PhaseFailed:
case h.Phase == nodeengineclient.PhaseFailed:
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
// 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"
}
}
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)
return had || holdMoved
}
+5 -4
View File
@@ -31,6 +31,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
engcomp "github.com/novox/mesh-host/internal/compositions"
"regexp"
"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
// 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.
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)
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()
return
}
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()}
var err error
var changed bool
@@ -370,7 +371,7 @@ func (f *Follower) settleGrants(ctx context.Context, again bool) {
}
}
if err == nil {
w.Users = Users(body)
w.Users = int64(Users(body))
if err = f.guard(resource, body); err != nil {
if disk, derr := f.env.OnDisk(resource); derr == nil && disk != nil {
w.Digest = Digest(disk)
+1 -1
View File
@@ -109,7 +109,7 @@ func TestTheBusNodeWritesTheGrantsPartsUserListAlone(t *testing.T) {
failWrite bool
onDisk string
wantWrites []string // contents written, in order
wantUsers int
wantUsers int64
wantErr string
wantRefused bool
grants []gput