Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
421e6b0771 | ||
|
|
8e32583089 | ||
|
|
6019c6d514 | ||
|
|
054754482c | ||
|
|
f7129fdb41 | ||
|
|
f7abd05981 | ||
|
|
514cf23aef | ||
|
|
95b509e0d2 | ||
|
|
83e135f101 | ||
|
|
30b86ea602 | ||
|
|
32a99144f6 | ||
|
|
3544d644ba | ||
|
|
a0ce37fb2c | ||
|
|
a24a1d72ad | ||
|
|
02c7f8e052 | ||
|
|
53b0e8d318 | ||
|
|
c777fbae8f | ||
|
|
98719a1bd5 | ||
|
|
72bcf20870 | ||
|
|
b47668421f | ||
|
|
2239fb253d | ||
|
|
7da4deb3f1 | ||
|
|
b461b005e9 | ||
|
|
75e3a26336 | ||
|
|
5b455cecfe | ||
|
|
faa2005538 | ||
|
|
6ea23b6731 | ||
|
|
db7cd273f0 | ||
|
|
12c80d7c2e | ||
|
|
649ed21d55 | ||
|
|
23cbf9294a | ||
|
|
03b7fcd7bb | ||
|
|
7b8f5e7805 |
+13
-2
@@ -156,6 +156,17 @@ type options struct {
|
||||
file string
|
||||
// out is where what a command says goes. Stdout, and a buffer under test.
|
||||
out io.Writer
|
||||
// runner is what the node agent's applies run commands through (issue 774, ADR 0594): nil is the machine's own,
|
||||
// apply.ExecRunner; a test gives one that records what an apply asked of the container runtime.
|
||||
runner apply.Runner
|
||||
}
|
||||
|
||||
// run is the runner the node agent's applies use: the options' own, else the machine's.
|
||||
func (o options) run() apply.Runner {
|
||||
if o.runner != nil {
|
||||
return o.runner
|
||||
}
|
||||
return apply.ExecRunner
|
||||
}
|
||||
|
||||
// parseArgs takes the subcommand first, then its flags.
|
||||
@@ -1637,11 +1648,11 @@ func applyModuleHeld(ctx context.Context, opts options, declared *declaration.De
|
||||
func(known store.State) (apply.Report, store.State, error) {
|
||||
if scope == nil {
|
||||
return apply.ApplyMindingWindows(ctx, built, declared, known, store.OriginDeclared,
|
||||
apply.ExecRunner, announceOr(say), sealOpener(opts.state), apply.KeepIn(filepath.Dir(opts.state)),
|
||||
opts.run(), announceOr(say), sealOpener(opts.state), apply.KeepIn(filepath.Dir(opts.state)),
|
||||
apply.WindowsIn(filepath.Dir(opts.state)))
|
||||
}
|
||||
return apply.ApplyModule(ctx, built, declared, *scope, known, store.OriginDeclared,
|
||||
apply.ExecRunner, announceOr(say), sealOpener(opts.state), apply.KeepIn(filepath.Dir(opts.state)),
|
||||
opts.run(), announceOr(say), sealOpener(opts.state), apply.KeepIn(filepath.Dir(opts.state)),
|
||||
apply.WindowsIn(filepath.Dir(opts.state)))
|
||||
}, announceOr(say))
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ func (h sessionHaves) Write(ctx context.Context, x agent.Have) {
|
||||
}
|
||||
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,
|
||||
Sealed: x.Sealed})
|
||||
BuildAlone: x.BuildAlone, Kept: x.Kept, Sealed: x.Sealed})
|
||||
}
|
||||
|
||||
func (h sessionHaves) Removed(ctx context.Context, module string) {
|
||||
@@ -508,7 +508,13 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
// What this engine applied, kept on the machine and read at the start, so what runs is known before the node
|
||||
// composes its own (issue 774).
|
||||
Applied: appliedStoreFor(opts.state),
|
||||
Inactive: inactiveIn(&liveness.Exec{Run: apply.ExecRunner, System: builtFor}, heldBy(apply.WindowsIn(filepath.Dir(opts.state)))),
|
||||
Inactive: inactiveIn(&liveness.Exec{Run: liveness.Runner(opts.run()), System: builtFor}, heldBy(apply.WindowsIn(filepath.Dir(opts.state)))),
|
||||
Held: func() map[string]string {
|
||||
if c := composerNow.Load(); c != nil {
|
||||
return c.LastHeld()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Revisions: func(ctx context.Context, key string) ([]agent.Update, error) {
|
||||
got, err := session.KVHistory(ctx, composedBucket, key)
|
||||
if err != nil {
|
||||
@@ -606,6 +612,12 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
now.NotComposedEach(run, why, held) // a module held alone marks that module, never the others (issue 1126)
|
||||
}
|
||||
},
|
||||
// And what it keeps of a module as it runs, for a manifest that does not say it yet: on the module's have (ADR 0601 §4).
|
||||
kept: func(kept map[string][]string) {
|
||||
if now := own.get(); now != nil && run.Err() == nil {
|
||||
now.KeptNow(run, kept)
|
||||
}
|
||||
},
|
||||
// And the vault's authority beside the controller's, and this node's certificate asked of the vault when a
|
||||
// module here asks for one (ADR 0550 §2).
|
||||
vaultAuthority: trust.VaultAuthority,
|
||||
@@ -616,7 +628,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
// And its answer to every placement candidate, placed or not, applying nothing (novox/hq ADR 0559 §4): written into
|
||||
// its own key, `<node>.node`, under candidates.
|
||||
shadowHooks := hooks
|
||||
shadowHooks.refused, shadowHooks.certify = nil, nil
|
||||
shadowHooks.refused, shadowHooks.kept, shadowHooks.certify = nil, nil, nil
|
||||
shadowEnv := composerEnv(session, node, say, shadowHooks)
|
||||
shadow := selfcompose.NewShadow(shadowEnv,
|
||||
func(ctx context.Context, answers map[string]delivery.CandidateAnswer) {
|
||||
@@ -630,7 +642,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
composer := selfComposer(session, node, hooks, say)
|
||||
machinesNow.Store(composer) // the machines a failing network part points at, by the placements (issue 774)
|
||||
composerNow.Store(composer) // what the verb compose asks a pass of, this run's alone
|
||||
env.Composed, env.SelfComposed, env.Policy = composer, true, composer.Policy
|
||||
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")
|
||||
ag = agent.New(env)
|
||||
@@ -958,6 +970,7 @@ type composerHooks struct {
|
||||
running func() []delivery.Composed
|
||||
unseal func(string) ([]byte, error)
|
||||
refused func(why string, held map[string]string)
|
||||
kept func(kept map[string][]string)
|
||||
held func(module string) (delivery.Seals, []string, error)
|
||||
// vaultAuthority and certify are selfcompose.Env's VaultAuthority and Certify.
|
||||
vaultAuthority func() string
|
||||
@@ -1011,6 +1024,6 @@ func composerEnv(session *link.Session, node string, say func(string), hooks com
|
||||
Hear: session.Hear,
|
||||
// And a moved module's templates archive, in the artifact store, as every archive is fetched (issue 1126).
|
||||
Fetch: apply.Fetch,
|
||||
Running: hooks.running, Unseal: hooks.unseal, Refused: hooks.refused, Held: hooks.held,
|
||||
Running: hooks.running, Unseal: hooks.unseal, Refused: hooks.refused, Held: hooks.held, Kept: hooks.kept,
|
||||
VaultAuthority: hooks.vaultAuthority, Certify: hooks.certify}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -14,13 +15,17 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/novox/mesh-host/internal/agent"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/nodegrant"
|
||||
"github.com/novox/mesh-host/internal/selfcompose"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/system"
|
||||
)
|
||||
|
||||
// The node agent whole, against a real bus with JetStream started in the test: the wants bucket, the node's composition
|
||||
@@ -619,3 +624,253 @@ func mustComposedKey(t *testing.T, module, fp string) string {
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// recordingRuntime is a container runtime that keeps its containers in memory and records every command an apply asks
|
||||
// of it (issue 774): what the bus server's container met is read from it, never from a real runtime.
|
||||
type recordingRuntime struct {
|
||||
mu sync.Mutex
|
||||
containers map[string]string // name: the spec label it was made with
|
||||
asked []string
|
||||
}
|
||||
|
||||
func (r *recordingRuntime) run(_ context.Context, name string, args ...string) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.asked = append(r.asked, name+" "+strings.Join(args, " "))
|
||||
if name != "docker" || len(args) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "27.0\n", nil
|
||||
case "container":
|
||||
if len(args) > 1 && args[1] == "inspect" {
|
||||
spec, ok := r.containers[args[len(args)-1]]
|
||||
if !ok {
|
||||
return "", errors.New("no such container")
|
||||
}
|
||||
return "true\t" + spec + "\n", nil
|
||||
}
|
||||
case "image":
|
||||
if len(args) > 1 && args[1] == "inspect" {
|
||||
return "2026-01-01T00:00:00Z\n", nil
|
||||
}
|
||||
case "rm":
|
||||
delete(r.containers, args[len(args)-1])
|
||||
case "rename":
|
||||
if spec, ok := r.containers[args[1]]; ok {
|
||||
delete(r.containers, args[1])
|
||||
r.containers[args[2]] = spec
|
||||
}
|
||||
case "run", "create":
|
||||
var made, spec string
|
||||
for i, a := range args {
|
||||
if a == "--name" && i+1 < len(args) {
|
||||
made = args[i+1]
|
||||
}
|
||||
if a == "--label" && i+1 < len(args) && strings.HasPrefix(args[i+1], "mesh-host.spec=") {
|
||||
spec = strings.TrimPrefix(args[i+1], "mesh-host.spec=")
|
||||
}
|
||||
}
|
||||
r.containers[made] = spec
|
||||
return "made\n", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// moved is each command that removed, made, stopped, renamed or restarted the container named.
|
||||
func (r *recordingRuntime) moved(container string) []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var out []string
|
||||
for _, a := range r.asked {
|
||||
for _, verb := range []string{"rm ", "run ", "create ", "stop ", "restart ", "kill ", "rename ", "start "} {
|
||||
for _, prefix := range []string{"docker ", "docker container "} {
|
||||
if strings.HasPrefix(a, prefix+verb) && strings.Contains(a, container) {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ADR 0599 §6 and §4 (issue 774): the bus server the node's own composition made while the bus module was folded is taken
|
||||
// over by the bus module's first want, the node agent started whole against a real bus: it composes the bus module, writes
|
||||
// its changed file and records the server as the bus module's, and asks the container runtime to remove, make, stop,
|
||||
// rename or restart nothing, since the build does not change the server. A later want whose build changes the server
|
||||
// replaces it once, as any module's, once the want carries the yes (§2: the assigner gives it from the release's Approve).
|
||||
// ADR 0601 §2: the bus module keeps data (its streams), so before each of those applies the node agent asks its node's
|
||||
// backup seat to back it up, as for every module that keeps data, and applies only once the backup says it is done.
|
||||
func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *testing.T) {
|
||||
b := newItBed(t)
|
||||
runtime := &recordingRuntime{containers: map[string]string{}}
|
||||
b.opts.runner = runtime.run
|
||||
const server = "mesh-broker-it"
|
||||
conf := filepath.Join(b.dir, "nats.conf")
|
||||
image := "registry.example/nats@sha256:" + strings.Repeat("a", 64)
|
||||
|
||||
// What the node's own composition made of the folded bus: the server and its file, recorded as the node's own.
|
||||
built, err := system.For(builtFor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
folded, err := declaration.Parse([]byte(`{"declaration":1,"resources":[` +
|
||||
`{"id":"nats.server-conf","type":"file","path":"` + conf + `","mode":"0644","content":"listen: old\n"},` +
|
||||
`{"id":"nats.server","type":"container","name":"` + server + `","image":"` + image + `"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, known, err := apply.ApplyModule(context.Background(), built, folded, apply.Scope{Module: store.NodeModule}, store.State{},
|
||||
store.OriginDeclared, runtime.run, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Save(b.opts.state, known); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if moved := runtime.moved(server); len(moved) != 1 {
|
||||
t.Fatalf("the folded server was not made once: %v", moved)
|
||||
}
|
||||
made := runtime.containers[server]
|
||||
runtime.mu.Lock()
|
||||
runtime.asked = nil
|
||||
runtime.mu.Unlock()
|
||||
|
||||
kvOf := func(bucket string) nats.KeyValue {
|
||||
kv, err := b.js.CreateKeyValue(&nats.KeyValueConfig{Bucket: bucket, History: 5})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return kv
|
||||
}
|
||||
placement, modules, manifests := kvOf(delivery.PlacementBucket), kvOf(delivery.ModulesBucket), kvOf(delivery.ManifestsBucket)
|
||||
kvOf(selfcompose.ResolvedBucket)
|
||||
builds := kvOf(delivery.BuilderBuildsBucket)
|
||||
kvOf(delivery.PlacementCandidatesBucket)
|
||||
kvOf(link.PackagesBucket)
|
||||
// The bus module at a build, done: its manifest written and the indexer's module naming it at fp.
|
||||
publish := func(fp, env string) {
|
||||
man := `{"module":"nats","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],` +
|
||||
`"data":{"own":[{"id":"jetstream","path":"` + b.dir + `","class":"valuable","why":"the bus's streams"}]},"resources":[` +
|
||||
`{"id":"server-conf","type":"file","path":"` + conf + `","mode":"0644","content":"listen: new\n"},` +
|
||||
`{"id":"server","type":"container","name":"` + server + `","image":"` + image + `"` + env + `}]}`
|
||||
digest := delivery.ManifestDigest([]byte(man))
|
||||
mraw, _ := delivery.EncodeManifest(delivery.Manifest{Module: "nats", JSON: man})
|
||||
mkey, _ := delivery.ManifestsKey(digest)
|
||||
modv, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/mesh-catalog", Commit: strings.Repeat("3", 40)})
|
||||
if _, err := manifests.Put(mkey, mraw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bkey, _ := delivery.BuilderBuildKey("nats", fp)
|
||||
done, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: digest})
|
||||
if _, err := builds.Put(bkey, done); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := modules.Put("nats", modv); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
rec, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: itNode,
|
||||
Capabilities: []string{"service-manager", "package-manager", "container-runtime"}}})
|
||||
hkey, _ := delivery.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})
|
||||
for key, value := range map[string][]byte{hkey: rec, natsHave: oldHave} {
|
||||
if _, err := b.haves.Put(key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
busWant := func(w delivery.Want) {
|
||||
raw, err := delivery.EncodeWant(w)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, _ := delivery.WantKey(itNode, "nats")
|
||||
if _, err := b.wants.Put(key, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// The node's backup seat: its holder says the bus module has something to back up, starts a backup on now, and says
|
||||
// when it ended on its event; each ask of now notes what the bus's file held then, so the order is seen.
|
||||
var backups []string
|
||||
var backupsMu sync.Mutex
|
||||
if _, err := b.conn.Subscribe(link.SeatToolSubject("node-backup", "backed-up", itNode), func(m *nats.Msg) {
|
||||
_ = m.Respond([]byte(`{"result":[{"module":"nats","runs":1}]}`))
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.conn.Subscribe(link.SeatToolSubject("node-backup", "now", itNode), func(m *nats.Msg) {
|
||||
got, _ := os.ReadFile(conf)
|
||||
backupsMu.Lock()
|
||||
backups = append(backups, string(got))
|
||||
backupsMu.Unlock()
|
||||
// The node agent hears the backup's end before it asks (backupNode), so the event follows the answer at once.
|
||||
_ = m.Respond([]byte(`{"result":{"started":true}}`))
|
||||
_ = b.conn.Publish(backedUpEvent, backupEvent(itNode, "nats", true, time.Now(), ""))
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backedUp := func() []string {
|
||||
backupsMu.Lock()
|
||||
defer backupsMu.Unlock()
|
||||
return append([]string(nil), backups...)
|
||||
}
|
||||
first := fpIt("e")
|
||||
publish(first, "")
|
||||
busWant(delivery.Want{FP: first, Self: true})
|
||||
stop := b.start()
|
||||
defer stop()
|
||||
for key, value := range places {
|
||||
if _, err := placement.Put(key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
})
|
||||
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)
|
||||
}
|
||||
if got := backedUp(); len(got) != 1 || got[0] != "listen: old\n" {
|
||||
t.Fatalf("the bus module's first apply was not backed up first: the backups saw %q", got)
|
||||
}
|
||||
state, err := store.Load(b.opts.state)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec, ok := state.Find("nats.server"); !ok || rec.Module != "nats" {
|
||||
t.Errorf("the server is recorded %+v, not as the bus module's", rec)
|
||||
}
|
||||
|
||||
// A build that changes the server: replaced once, as any module's, with the yes the assigner writes from the release's
|
||||
// Approve for a move the node judged to recreate it (ADR 0599 §2, §4).
|
||||
next := fpIt("f")
|
||||
publish(next, `,"env":{"CHANGED":"1"}`)
|
||||
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 != "" {
|
||||
busWant(delivery.Want{FP: next, Self: true, Yes: h.Waits})
|
||||
}
|
||||
return h.FP == next && h.Phase == delivery.PhaseRunning
|
||||
})
|
||||
var runs int
|
||||
for _, a := range runtime.moved(server) {
|
||||
if strings.HasPrefix(a, "docker run ") && strings.Contains(a, " --name "+server+" ") {
|
||||
runs++
|
||||
}
|
||||
}
|
||||
if got := backedUp(); len(got) != 2 || got[1] != "listen: new\n" {
|
||||
t.Errorf("the replacing apply of the bus module was not backed up first: the backups saw %q", got)
|
||||
}
|
||||
if runs != 1 || runtime.containers[server] == made {
|
||||
t.Errorf("the build that changes the server made it %d times, its spec still the folded one %v: %v", runs,
|
||||
runtime.containers[server] == made, runtime.moved(server))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ module github.com/novox/mesh-host
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
+69
-1
@@ -17,6 +17,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -58,6 +59,11 @@ 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 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.
|
||||
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
|
||||
// its own slot alone (issue 1126, C1).
|
||||
@@ -130,6 +136,9 @@ type Env struct {
|
||||
// Revisions are a composition key's revisions the bucket keeps, oldest first; nil reads none. The composition a want
|
||||
// names is taken from them when a newer one replaced it under the same key (ADR 0539 clause 1).
|
||||
Revisions func(ctx context.Context, key string) ([]Update, error)
|
||||
// Held is what the node's composer held in its last pass, each module with why, as the verb composition shows it
|
||||
// for the node's own (issue 774); nil says nothing.
|
||||
Held func() map[string]string
|
||||
// Settled is told a module's apply or removal that succeeded, once what runs holds it (Everything), outside the
|
||||
// agent's lock: the declaration the node applies now names what that composition names, its filled files among
|
||||
// them. The node-engine's filling tells the provisioner's client then to fill again what it refused (mesh-host
|
||||
@@ -153,6 +162,11 @@ type Env struct {
|
||||
// move of a module of policy record that recreates a container or moves its data waits for a person's yes in its
|
||||
// want (ADR 0547 §3). Nil judges no move.
|
||||
Policy func(ctx context.Context, module string) string
|
||||
// ComposedToday is the digest of a module composed at the build fp with today's settings and secrets, as the node would
|
||||
// compose it now, and whether it could be composed: a move to another build whose composition of the build it runs is
|
||||
// what runs changes the build alone (novox/hq ADR 0601 §1), and the have says so. Nil: no move is said to change the
|
||||
// build alone, and each is asked of a person.
|
||||
ComposedToday func(ctx context.Context, module, fp string) (string, bool)
|
||||
// HoldsData says whether a module's manifest declares data (catalogue.Manifest.DataItems), and whether its manifest
|
||||
// could be read: what a composition's Data is, for what runs of a module from the kept declaration, which carries no
|
||||
// Data of its own. A composition says Data, so without it a data module from the kept declaration is judged to move
|
||||
@@ -198,6 +212,8 @@ type Agent struct {
|
||||
// keptData is, for each module of the kept declaration whose manifest could be read, whether it declares data
|
||||
// (Env.HoldsData): the Data of what runs of it from the kept declaration.
|
||||
keptData map[string]bool
|
||||
// kept is, by module, what the composer's last pass keeps of it as it runs (KeptNow, ADR 0601 §4). Under mu.
|
||||
kept map[string][]string
|
||||
// attempt is the last apply of a module that failed or is held, with the revisions it was made from.
|
||||
attempt map[string]attempt
|
||||
checked map[string]bool
|
||||
@@ -572,6 +588,11 @@ func (a *Agent) NotComposedEach(ctx context.Context, why string, held map[string
|
||||
// said runs, its fingerprint, digest and sealed values, rather than a have naming nothing: what runs is not lost, and
|
||||
// the boxes the composer reuses after a restart stay (review of L2, rounds 2 and 3).
|
||||
func (a *Agent) writeHave(ctx context.Context, h Have) {
|
||||
if h.Module != NodeModule {
|
||||
a.mu.Lock()
|
||||
h.Kept = a.kept[h.Module]
|
||||
a.mu.Unlock()
|
||||
}
|
||||
if h.Composed == "" && h.Module != NodeModule {
|
||||
if last, ok := a.env.Haves.Read(ctx, h.Module); ok && last.Composed != "" {
|
||||
h.FP, h.Composed = last.FP, last.Composed
|
||||
@@ -588,6 +609,36 @@ func (a *Agent) writeHave(ctx context.Context, h Have) {
|
||||
a.env.Haves.Write(ctx, h)
|
||||
}
|
||||
|
||||
// KeptNow takes in what the composer's last pass keeps of each module as it runs because its manifest does not yet say it
|
||||
// (ADR 0601 §4), and writes again the have of each module whose words changed, so it carries them; a have waiting for a yes
|
||||
// takes them at its next write, which keeps what it waits for.
|
||||
func (a *Agent) KeptNow(ctx context.Context, kept map[string][]string) {
|
||||
a.mu.Lock()
|
||||
var changed []string
|
||||
for m := range a.kept {
|
||||
if _, still := kept[m]; !still {
|
||||
changed = append(changed, m)
|
||||
}
|
||||
}
|
||||
for m, words := range kept {
|
||||
if !slices.Equal(a.kept[m], words) {
|
||||
changed = append(changed, m)
|
||||
}
|
||||
}
|
||||
a.kept = map[string][]string{}
|
||||
for m, words := range kept {
|
||||
a.kept[m] = slices.Clone(words)
|
||||
}
|
||||
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) {
|
||||
h.Module = m
|
||||
a.writeHave(ctx, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) sealedOf(ctx context.Context, module, fp, composed string) delivery.Seals {
|
||||
a.mu.Lock()
|
||||
if c, ok := a.applied[module]; ok && c.Err == nil && c.Dig == composed {
|
||||
@@ -1649,10 +1700,27 @@ func (a *Agent) waitsForYes(ctx context.Context, module string, w wantNow, comp
|
||||
prev := a.running[module]
|
||||
a.mu.Unlock()
|
||||
a.writeHave(ctx, Have{Module: module, FP: prev.FP, Composed: prev.Composed, Phase: string(delivery.PhaseWaitingYes),
|
||||
Detail: j.Words, Judgement: string(j.Judgement), Waits: comp.Dig})
|
||||
Detail: j.Words, Judgement: string(j.Judgement), Waits: comp.Dig, BuildAlone: a.buildAlone(ctx, module, prev, w.Want.FP)})
|
||||
return true
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
if a.env.ComposedToday == nil || prev.FP == "" || prev.Composed == "" || prev.FP == want {
|
||||
return false
|
||||
}
|
||||
dig, ok := a.env.ComposedToday(ctx, module, prev.FP)
|
||||
return ok && dig == prev.Composed
|
||||
}
|
||||
|
||||
// runningComposition is what the module runs now: the composition last applied, else its resources in the declaration
|
||||
// the node kept from before the switch. Called with a.mu held.
|
||||
//
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -1288,6 +1289,17 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
|
||||
}
|
||||
return catalogue.PolicyRoll
|
||||
}
|
||||
// ADR 0601 §1: what runs composed today. rec's build composed again is what it runs, so its move changes the build alone;
|
||||
// db's is not (a setting changed too).
|
||||
b.env.ComposedToday = func(_ context.Context, m, fp string) (string, bool) {
|
||||
switch m {
|
||||
case "rec":
|
||||
return "sha256:" + strings.Repeat("8", 64), fp == fpOf("d")
|
||||
case "db":
|
||||
return "sha256:" + strings.Repeat("6", 64), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
// The bucket's revisions of each key, as the verb composition reads them (issue 774).
|
||||
var historyMu sync.Mutex
|
||||
history := map[string][]Update{}
|
||||
@@ -1301,6 +1313,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
|
||||
defer historyMu.Unlock()
|
||||
history[key] = append(history[key], Update{Key: key, Value: raw, Revision: uint64(len(history[key]) + 1)})
|
||||
}
|
||||
b.env.Held = func() map[string]string { return map[string]string{"mesh-build-go": "its binding is unset"} }
|
||||
stop := b.run()
|
||||
defer stop()
|
||||
unsigned := func(module, fp string, rs ...json.RawMessage) (string, []byte, string) {
|
||||
@@ -1329,6 +1342,15 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
|
||||
want("web", fp, "")
|
||||
eventually(t, "the node's own composition was not applied", func() bool { return len(b.appliedList()) == 1 })
|
||||
eventually(t, "the have does not name the composed digest", func() bool { return b.haves.last("web").Composed == dig })
|
||||
// ADR 0601 §4: what the composer keeps of web as it runs is carried on its have, and goes once it keeps none.
|
||||
b.ag.KeptNow(context.Background(), map[string][]string{"web": {"web runs the tool runner's process here"}})
|
||||
if h := b.haves.last("web"); len(h.Kept) != 1 || h.Composed != dig {
|
||||
t.Errorf("web's have does not carry what is kept as it runs: %+v", h)
|
||||
}
|
||||
b.ag.KeptNow(context.Background(), nil)
|
||||
if h := b.haves.last("web"); len(h.Kept) != 0 || h.Composed != dig {
|
||||
t.Errorf("web's have still carries what was kept: %+v", h)
|
||||
}
|
||||
// The same composition handed again is no move.
|
||||
b.comps.put(ck, craw)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
@@ -1452,6 +1474,9 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
|
||||
h := b.haves.last("db")
|
||||
return h.Phase == string(delivery.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)
|
||||
}
|
||||
// The verb composition says why it waits: the revision that waits, what runs, the judgement, and with diff the
|
||||
// container it recreates; the revision that runs, asked by its digest, is no move (issue 774).
|
||||
keep(dk, draw)
|
||||
@@ -1470,6 +1495,16 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
|
||||
if _, err := b.ag.Composition(context.Background(), "db", "sha256:"+strings.Repeat("0", 64), false); err == nil {
|
||||
t.Error("a digest no revision has was answered")
|
||||
}
|
||||
// Issue 774 (ace, 2026-11-04 01:29): the node's own revision shows its own fields, the provisioner client's account
|
||||
// and the adoption among them, and what the composer's last pass held.
|
||||
ownKey, ownRaw, ownDig := encodedAs(t, delivery.Composed{Node: node, Own: delivery.NodeOwn{Provisioner: "mesh-provisioner-client",
|
||||
Adoption: &declaration.Adoption{Taken: []string{"web"}}}}, func() string { k, _ := delivery.ComposedNodeKey(node); return k }())
|
||||
keep(ownKey, ownRaw)
|
||||
if view, err := b.ag.Composition(context.Background(), NodeModule, ownDig, false); err != nil || view.Own == nil ||
|
||||
view.Own.Provisioner != "mesh-provisioner-client" || view.Own.Adoption == nil || view.Own.Adoption.Taken[0] != "web" ||
|
||||
view.Own.Held["mesh-build-go"] == "" {
|
||||
t.Errorf("the node's own composition reads %+v %+v (%v)", view, view.Own, err)
|
||||
}
|
||||
// novox/hq ADR 0593 §6: the engine publishes each composition with its plan in its composition state; the verb reads
|
||||
// that revision by the digest its have and want name, its resources' (the plan left out of the digest).
|
||||
planned, _ := delivery.DecodeComposed(draw)
|
||||
@@ -1508,7 +1543,8 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
|
||||
for _, row := range []struct {
|
||||
module, fp, composed, waits string
|
||||
sealed delivery.Seals
|
||||
}{{"db", fp, "", ddig2, nil}, {"rec", recFP, recDig, rdig, delivery.Seals{"rec.key#sealed": `{"box":"rec"}`}}} {
|
||||
alone bool
|
||||
}{{"db", fp, "", ddig2, nil, false}, {"rec", recFP, recDig, rdig, delivery.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
|
||||
@@ -1518,6 +1554,9 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
|
||||
(row.sealed != nil && !maps.Equal(h.Sealed, row.sealed)) {
|
||||
t.Errorf("%s's have waiting for a yes does not keep what runs: %+v", row.module, h)
|
||||
}
|
||||
if h.BuildAlone != row.alone {
|
||||
t.Errorf("%s's have waiting for a yes says build alone %v, want %v", row.module, h.BuildAlone, row.alone)
|
||||
}
|
||||
}
|
||||
if got := b.appliedList(); len(got) != applied {
|
||||
t.Fatalf("a move waiting for a yes was applied: %v", got)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"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"
|
||||
)
|
||||
@@ -32,6 +33,26 @@ type CompositionView struct {
|
||||
Judged JudgedView `json:"judged"`
|
||||
// Diff is every resource that differs from what runs, when asked.
|
||||
Diff []judge.ResourceChange `json:"diff,omitempty"`
|
||||
// Own is, of the node's own composition (module node), its own fields beside what runs names (issue 774: ace's
|
||||
// own composition dropped the provisioner client's account, and nothing read could show it).
|
||||
Own *OwnView `json:"own,omitempty"`
|
||||
}
|
||||
|
||||
// OwnView is the node's own fields of a composition (delivery.NodeOwn) beside what runs of them, and what the node's
|
||||
// composer held in its last pass.
|
||||
type OwnView struct {
|
||||
// Provisioner is the account the provisioner's client runs as, as the revision read names it; RunsProvisioner as
|
||||
// what runs names it. Empty: the declaration names none, and every filled write is refused.
|
||||
Provisioner string `json:"provisioner_account"`
|
||||
RunsProvisioner string `json:"runs_provisioner_account,omitempty"`
|
||||
// Adoption is the revision's adoption: the node adopted, and which of its modules are taken; nil for a converged node.
|
||||
Adoption *declaration.Adoption `json:"adoption,omitempty"`
|
||||
// Identities, Groups and Libraries count what the revision names of each.
|
||||
Identities int `json:"identities,omitempty"`
|
||||
Groups int `json:"groups,omitempty"`
|
||||
Libraries int `json:"libraries,omitempty"`
|
||||
// Held is each module the node's composer held in its last pass, with why.
|
||||
Held map[string]string `json:"held,omitempty"`
|
||||
}
|
||||
|
||||
// WantView is a want as the verb shows it.
|
||||
@@ -171,6 +192,17 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo
|
||||
}
|
||||
view.Runs = rv
|
||||
}
|
||||
if module == NodeModule {
|
||||
o := read.C.Own
|
||||
view.Own = &OwnView{Provisioner: o.Provisioner, Adoption: o.Adoption, Identities: len(o.Identities), Groups: len(o.Groups),
|
||||
Libraries: len(o.Libraries)}
|
||||
if running != nil {
|
||||
view.Own.RunsProvisioner = running.Own.Provisioner
|
||||
}
|
||||
if a.env.Held != nil {
|
||||
view.Own.Held = a.env.Held()
|
||||
}
|
||||
}
|
||||
j := judge.JudgeMove(running, read.C)
|
||||
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) {
|
||||
|
||||
@@ -536,7 +536,7 @@ func witnessOf(r *declaration.Process) string {
|
||||
if r.Witness != "" {
|
||||
return r.Witness
|
||||
}
|
||||
return witness.Default(r.Name)
|
||||
return witness.Default(r.ID, r.Name)
|
||||
}
|
||||
|
||||
// digestWritten is the bundle digest a process's record says was placed: the first half of what it
|
||||
|
||||
@@ -185,7 +185,7 @@ func (r Rewritten) declares(id string) bool {
|
||||
// container runtime's configuration whole — and neither is anything the operator installs later.
|
||||
var genesisTakes = map[string]bool{
|
||||
RegistryModule: true, ControlPlaneModule: true, BuilderModule: true,
|
||||
"postgres": true, BusModule: true, "mesh-vault": true, "mesh-catalog": true,
|
||||
"postgres": true, BusModule: true, "mesh-vault": true,
|
||||
}
|
||||
|
||||
// takeIfAdopted takes one of genesis's own modules on an adopted node, once it is assigned and
|
||||
|
||||
@@ -64,7 +64,6 @@ const (
|
||||
StepStore Step = "store"
|
||||
StepBroker Step = "broker"
|
||||
StepVault Step = "vault"
|
||||
StepCatalogue Step = "catalogue"
|
||||
StepNetwork Step = "network"
|
||||
StepFilter Step = "filter"
|
||||
StepExtras Step = "extras"
|
||||
@@ -89,7 +88,7 @@ var Steps = []Step{
|
||||
// Phase two. The twelve above make a mesh that RUNS; these make one that WORKS — able to
|
||||
// build, to say what it holds, on its network, filtering. They used to be things somebody
|
||||
// typed afterwards, which is how they went missing without anything complaining.
|
||||
StepBase, StepStore, StepBroker, StepVault, StepCatalogue, StepNetwork, StepFilter, StepExtras, StepExport,
|
||||
StepBase, StepStore, StepBroker, StepVault, StepNetwork, StepFilter, StepExtras, StepExport,
|
||||
}
|
||||
|
||||
// Error is a failure, named by the step it happened in.
|
||||
@@ -750,12 +749,6 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro
|
||||
return result, failed(StepVault, err)
|
||||
}
|
||||
|
||||
// ---- 15. catalogue --------------------------------------------------------------------
|
||||
say("catalogue — the module graph: what is held, what a change reaches, what to rebuild")
|
||||
if err := InstallFromCatalogue(ctx, o, permanentControl, "mesh-catalog", say); err != nil {
|
||||
return result, failed(StepCatalogue, err)
|
||||
}
|
||||
|
||||
// ---- 16. network ----------------------------------------------------------------------
|
||||
say("network — the private network, and this machine's name on it")
|
||||
if err := PlaceOnTheNetwork(ctx, o, permanentControl, rewritten.BrokerAddress, say); err != nil {
|
||||
|
||||
@@ -13,14 +13,24 @@ import (
|
||||
// stopped genesis at step 17: "this mesh holds no copy of it. Build mesh-tools-go first".
|
||||
|
||||
// genesisModules is every catalogue module genesis installs on its own, read from the steps that
|
||||
// install them: the registry, the build agent, the store, the bus, the vault, the catalogue and the
|
||||
// install them: the registry, the build agent, the store, the bus, the vault and the
|
||||
// filter (extras are the operator's choice and come after the base too, but are not genesis's own).
|
||||
var genesisModules = []string{RegistryModule, BuilderModule, "postgres", BusModule, "mesh-vault", "mesh-catalog", "nftables"}
|
||||
var genesisModules = []string{RegistryModule, BuilderModule, "postgres", BusModule, "mesh-vault", "nftables"}
|
||||
|
||||
// Every genesis module's bundles are compiled in a toolchain the base step builds, read from the real
|
||||
// catalogue's manifests: a module that gains a language genesis does not build fails here, not at a
|
||||
// genesis.
|
||||
func TestTheBaseStepBuildsEveryToolchainAGenesisModuleIsCompiledIn(t *testing.T) {
|
||||
// ADR 0604 section 7: the catalog is the indexer's seat now, so genesis has no catalogue step and installs or takes no
|
||||
// mesh-catalog module.
|
||||
for _, step := range Steps {
|
||||
if step == "catalogue" {
|
||||
t.Fatal("genesis still has a catalogue step")
|
||||
}
|
||||
}
|
||||
if genesisTakes["mesh-catalog"] {
|
||||
t.Fatal("an adopted genesis still takes mesh-catalog")
|
||||
}
|
||||
built := map[string]bool{}
|
||||
for _, tc := range genesisToolchains {
|
||||
built[tc.Language] = true
|
||||
|
||||
@@ -63,6 +63,15 @@ func CompositionsIn(in Input, node string, fps map[string]string, mode Mode) (ou
|
||||
return compositions(in, node, fps, mode, false)
|
||||
}
|
||||
|
||||
// Folds says whether a module the node runs inside its own composition (inOwn, Node.InOwn) is folded there at the
|
||||
// fingerprint fp it is composed at: only at no fingerprint of the new form. At one (src2:) it has a composition of its
|
||||
// own, composed from its build, so every reader of a build (frombus) takes the build in exactly where this says it is
|
||||
// not folded (issue 1585: nats on novox, folded by the reader and composed at src2: here, refused for its server
|
||||
// artifact no build had published).
|
||||
func Folds(inOwn bool, fp string) bool {
|
||||
return inOwn && !strings.HasPrefix(fp, delivery.SourcePrefix2)
|
||||
}
|
||||
|
||||
func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]delivery.Composed, skipped, stood []string, err error) {
|
||||
reading := in
|
||||
reading.Nodes = append([]Node(nil), in.Nodes...)
|
||||
@@ -84,7 +93,7 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
|
||||
// The private network's module is built by nobody: no fingerprint of the new form ever names it.
|
||||
inOwn[overlay.Name] = true
|
||||
folds := func(module string) bool {
|
||||
return module != "" && inOwn[module] && !strings.HasPrefix(fps[module], delivery.SourcePrefix2)
|
||||
return module != "" && Folds(inOwn[module], fps[module])
|
||||
}
|
||||
if !found {
|
||||
return nil, nil, nil, fmt.Errorf("compose: the mesh has no node %q", node)
|
||||
@@ -316,7 +325,6 @@ func RunsAnyOf(runs *delivery.Composed, module string) bool {
|
||||
// nothing of has no Loads: the runtime dropped it, and the composition reads its loads again from the module's build
|
||||
// (issue 1126, review: shanks had already applied the drop of systemd-resolved). Nil when running holds no runtime process.
|
||||
func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle {
|
||||
runtimeID := catalogue.RuntimeModule + "." + resolution.RuntimeProcessID()
|
||||
type resource struct {
|
||||
ID string `json:"id"`
|
||||
Module string `json:"module"`
|
||||
@@ -339,7 +347,8 @@ func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle {
|
||||
owner, _ = declaration.ModuleGuess(r.ID)
|
||||
}
|
||||
switch {
|
||||
case r.Type == "process" && r.ID == runtimeID && owner == catalogue.RuntimeModule:
|
||||
case r.Type == "process" && owner != "" && r.ID == owner+"."+resolution.RuntimeProcessID():
|
||||
// The tool runner's process, by its id alone: `<its module>.runtime` (resolutionRuntimeProcess).
|
||||
env = r.Env
|
||||
case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+resolution.BundleID("")):
|
||||
name := strings.TrimPrefix(r.ID, owner+"."+resolution.BundleID(""))
|
||||
|
||||
@@ -104,7 +104,7 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
|
||||
// indexer's commit has no bundles (shanks's runtime lost the folded systemd-resolved, novox's the folded nats), and a
|
||||
// held module's new build's bundles are not delivered.
|
||||
dg := func(c byte) string { return "sha256:" + strings.Repeat(string(c), 64) }
|
||||
runtime := catalogue.Manifest{Module: catalogue.RuntimeModule, Version: "1",
|
||||
runtime := catalogue.Manifest{Module: "node-tools", Version: "1", ToolRunner: true,
|
||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/node-tools/broker"}},
|
||||
Bundles: []catalogue.Bundle{{Name: "runtime", Source: catalogue.ArtifactStoreScheme + "node-tools/runtime/blobs/" + dg('a'),
|
||||
Digest: dg('a'), Language: "go", Binary: "node-tools"}}}
|
||||
@@ -125,7 +125,7 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
|
||||
if dropped {
|
||||
env = `"MESH_TOOL_MODULES":""` // the drop already applied (shanks, 2026-10-29)
|
||||
}
|
||||
return delivery.Composed{Node: "anchor", Module: catalogue.RuntimeModule, Resources: []json.RawMessage{json.RawMessage(
|
||||
return delivery.Composed{Node: "anchor", Module: "node-tools", Resources: []json.RawMessage{json.RawMessage(
|
||||
`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{` + env + `}}`)}}
|
||||
}
|
||||
lampRuns := `{"id":"lamp.bundle-tools","type":"archive","source":"` + catalogue.ArtifactStoreScheme + `lamp/tools/blobs/` + dg('b') +
|
||||
@@ -155,13 +155,13 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
|
||||
}, map[string]string{"lamp": fpFor('1')}, false},
|
||||
} {
|
||||
tools := aMesh(t)
|
||||
tools.Catalogue[catalogue.RuntimeModule], tools.Catalogue["artifacts"], tools.Catalogue["lamp"] = runtime, store, lamp
|
||||
tools.Catalogue["node-tools"], tools.Catalogue["artifacts"], tools.Catalogue["lamp"] = runtime, store, lamp
|
||||
anchor := nodeOf(t, &tools, "anchor")
|
||||
anchor.Assigned = append(anchor.Assigned, "artifacts", catalogue.RuntimeModule, "lamp")
|
||||
tools.BusMinted = []string{"anchor." + catalogue.RuntimeModule}
|
||||
anchor.Assigned = append(anchor.Assigned, "artifacts", "node-tools", "lamp")
|
||||
tools.BusMinted = []string{"anchor." + "node-tools"}
|
||||
anchor.SealingKey = keyOf(t, "anchor")
|
||||
anchor.RunsTools = ToolsRunning([]delivery.Composed{runtimeRuns(row.dropped), row.set(anchor)})
|
||||
row.fps[overlay.Name], row.fps["artifacts"], row.fps[catalogue.RuntimeModule] = fpFor('a'), fpFor('5'), fpFor('e')
|
||||
row.fps[overlay.Name], row.fps["artifacts"], row.fps["node-tools"] = fpFor('a'), fpFor('5'), fpFor('e')
|
||||
out, _, _, err := CompositionsIn(tools, "anchor", row.fps, Foreseeing)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", row.name, err)
|
||||
@@ -188,7 +188,7 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
|
||||
// 1943/AC1: the upgrade policies and sources a composition's value may name are the catalogue's own words.
|
||||
func TestTheComposedPoliciesAreTheCatalogues(t *testing.T) {
|
||||
for _, p := range []string{catalogue.PolicyRoll, catalogue.PolicyTogether, catalogue.PolicyRecord} {
|
||||
for _, from := range []string{catalogue.FromPerson, catalogue.FromModule, catalogue.FromBus, catalogue.FromData, catalogue.FromDefault} {
|
||||
for _, from := range []string{catalogue.FromPerson, catalogue.FromModule, "the bus", catalogue.FromData, catalogue.FromDefault} { // "the bus": an older reader's word, still read
|
||||
v := delivery.ComposedDigest{Digest: "sha256:" + strings.Repeat("a", 64), Judgement: delivery.JudgedNeither, Policy: p, PolicyFrom: from}
|
||||
if _, err := delivery.EncodeComposedDigest(v); err != nil {
|
||||
t.Errorf("%s from %s: %v", p, from, err)
|
||||
|
||||
@@ -386,13 +386,13 @@ type CoreBoxWanted struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// coreModulesOf is every module of the plan on the core's list whose own secrets this machine's declaration carries, with their
|
||||
// coreModulesOf is every module of the plan whose manifest says its own secrets are the core's credentials, whose own secrets this machine's declaration carries, with their
|
||||
// plain own secret names, sorted: one handed to the provisioner has its secrets from the vault through the client, and one issued
|
||||
// outside the mesh is never the vault's to seal here.
|
||||
func coreModulesOf(modules []catalogue.Manifest, handed func(catalogue.Manifest) bool) map[string][]string {
|
||||
out := map[string][]string{}
|
||||
for _, m := range modules {
|
||||
if !resolution.CarriesVaultSealed(m.Module) || (handed != nil && handed(m)) {
|
||||
if !resolution.CarriesVaultSealed(m) || (handed != nil && handed(m)) {
|
||||
continue
|
||||
}
|
||||
var names []string
|
||||
|
||||
@@ -295,7 +295,7 @@ func aValidKey(t *testing.T, name string) string { return keyOf(t, name) }
|
||||
func TestACoreBoxTheInputHoldsNoneOfIsReturnedToKeepAndNothingIsWritten(t *testing.T) {
|
||||
mk := func() Input {
|
||||
in := aMesh(t)
|
||||
in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1",
|
||||
in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1", CoreCredentials: true,
|
||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/nats/broker", Taken: catalogue.TakenAtStart}}}
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Assigned = append(laptop.Assigned, "nats")
|
||||
|
||||
@@ -404,11 +404,11 @@ func firstReason(err error, failed map[string]string) error {
|
||||
return fmt.Errorf("%s; its stand-in is then refused: %w", strings.Join(named, "; "), err)
|
||||
}
|
||||
|
||||
// blames says the composition with m's stub (again) makes m the culprit of err: it composes, or fails otherwise. The
|
||||
// blames says the composition with m's stub (again) makes m the culprit of err, m the tool runner (runner) or not: it composes, or fails otherwise. The
|
||||
// runtime's stub is never a runtime (no build is taken in, so it carries no bundle) and always fails otherwise: it is
|
||||
// the culprit only where its stub composes (issue 774, 2026-10-30 19:30: node-tools held for any error naming it).
|
||||
func blames(m string, again, err error) bool {
|
||||
if m == catalogue.RuntimeModule {
|
||||
func blames(runner bool, again, err error) bool {
|
||||
if runner {
|
||||
// Or where its stub fails only for the modules of their own account it would run (issue 774): they are held
|
||||
// after it, each for its own words, and the node composes the rest.
|
||||
var none *resolution.NoRuntimeToRunError
|
||||
@@ -470,7 +470,8 @@ func culprit(in compose.Input, node string, fps map[string]string, mode compose.
|
||||
}
|
||||
changes := func(m, leave string) bool {
|
||||
_, _, _, again := compose.CompositionsIn(stubbed(in, node, m, leave), node, fps, mode)
|
||||
return blames(m, again, err)
|
||||
man, known := manifestOn(in, node, m)
|
||||
return blames(known && catalogue.IsToolRunner(man), again, err)
|
||||
}
|
||||
for _, m := range order {
|
||||
if _, done := failed[m]; done {
|
||||
|
||||
@@ -191,6 +191,28 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
!strings.Contains(held["spiegel"], "spiegel has not been built for this mesh") {
|
||||
t.Errorf("composed spiegel without what runs: %v %v, want spiegel held alone", err, held)
|
||||
}
|
||||
// Issue 1585 (nats on novox, met twice): wanted at a build of the new form, a module in InOwn is no longer folded
|
||||
// (compose.Folds), so its build is taken in and it composes with its image, never from the indexer's manifest as
|
||||
// "not built for this mesh".
|
||||
{
|
||||
bb := mk("lemurs", overlay.Name, "spiegel")
|
||||
bp := bb.placements["n1"]
|
||||
bp.Decided.Endpoint = "n1.example:51820"
|
||||
bb.placements["n1"] = bp
|
||||
sp := bb.modules["spiegel"]
|
||||
sp.FP2 = fp("3")
|
||||
bb.modules["spiegel"] = sp
|
||||
image := "registry.example:5100/spiegel/site@sha256:" + strings.Repeat("7", 64)
|
||||
bb.builds["spiegel "+sp.FP2] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: sp.Manifest,
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: "site", Kind: "upstream", Reference: image, Digest: "sha256:" + strings.Repeat("7", 64)}}}
|
||||
read, err := ComposingInput(ctx, bb, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}},
|
||||
map[string]string{"lemurs": fp("e"), "spiegel": sp.FP2})
|
||||
raw, _ := json.Marshal(read.Catalogue["spiegel"])
|
||||
if err != nil || read.ComposedHow.Held["spiegel"] != "" || !strings.Contains(string(raw), strings.Repeat("7", 64)) {
|
||||
t.Errorf("spiegel in InOwn wanted at %s: %v, held %v, read as %s; want its build's image", sp.FP2, err,
|
||||
read.ComposedHow, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// (C) Issue 1126, C5: what the others read of a module carried as it runs is what runs, never its stub's: the filter
|
||||
// opens the machine port its running container publishes (de-spiegel's 20005 on novox, not its software's 8080; a
|
||||
|
||||
@@ -98,9 +98,10 @@ type SignersReader interface {
|
||||
|
||||
// HandedReader is a Reader that also says, of the composing node's modules, which the vault judged handed to the
|
||||
// provisioner when it answered OwnSecrets (the vault's `own`, its `handed`: catalogue.AssignmentHands of the plan in
|
||||
// each module's assignment). The node's composition then hands exactly those (compose.Node.Handed, issue 774), so the
|
||||
// vault and the node never judge one module two ways. Nil, or a Reader without it (a vault older than the field),
|
||||
// composes by the manifest and the machine, as before.
|
||||
// each module's assignment). Never the rule: the node hands by the manifest, its build and the machine
|
||||
// (resolution.Handing, issue 1966), and the plans it composes are what the vault reads next; the vault's word is said
|
||||
// where the two disagree (resolution.VaultDisagrees). Nil, or a Reader without it (a vault older than the field), says
|
||||
// nothing.
|
||||
type HandedReader interface {
|
||||
Handed(ctx context.Context, node string) map[string]bool
|
||||
}
|
||||
@@ -154,6 +155,9 @@ type Scope struct {
|
||||
// key), so a differing resource shows a value sealed again as such (delivery.CompareComposedWith). Nil shows them
|
||||
// as differing.
|
||||
SameValue func(a, b string) (same, known bool)
|
||||
// Kept is told, in words, each module composed as what runs shows because its manifest does not yet say it (the tool
|
||||
// runner, the core's credentials; ADR 0601 §4), so the node says it. Nil: said nowhere.
|
||||
Kept func(module, words string)
|
||||
}
|
||||
|
||||
// InOwn is, of the modules p places on its node, those whose resources the node's own composition it runs
|
||||
@@ -229,7 +233,17 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
if err != nil {
|
||||
return compose.Input{}, err
|
||||
}
|
||||
return b.finish(ctx, r, r.Build, mesh, s, b.in, refuse, wanted)
|
||||
in, err := b.finish(ctx, r, r.Build, mesh, s, b.in, refuse, wanted)
|
||||
if err == nil {
|
||||
runningProvisioner(&in, s.Node, s.Running)
|
||||
// What runs is kept while a manifest does not yet say what shared code no longer infers (ADR 0601 §4).
|
||||
for _, k := range keepWhatRuns(&in, s.Node, s.Running) {
|
||||
if s.Kept != nil {
|
||||
s.Kept(k.Module, k.Words)
|
||||
}
|
||||
}
|
||||
}
|
||||
return in, err
|
||||
}
|
||||
|
||||
// base is the part of a node's input that the fingerprints it composes its modules at leave alone (issue 1954): read once
|
||||
@@ -571,6 +585,58 @@ func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]s
|
||||
return out, skipped, held, err
|
||||
}
|
||||
|
||||
// ComposingInput is the input the scope's node composes itself from at the builds fps, as Compose reads it (the same read,
|
||||
// the refusals it holds), with how Compose composes from it (compose.Input.ComposedHow): what a node-engine sends a
|
||||
// check's sandbox, so the sandbox composes the node through ComposeSent, the path Compose takes (issue 774). Read no
|
||||
// credential: the sandbox composes no pair value.
|
||||
func ComposingInput(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (compose.Input, error) {
|
||||
held := map[string]string{}
|
||||
in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, fps)
|
||||
if err != nil {
|
||||
return compose.Input{}, err
|
||||
}
|
||||
c := &compose.ComposedHow{Node: s.Node, Builds: maps.Clone(fps), Held: held}
|
||||
for _, run := range s.Running {
|
||||
if _, isHeld := held[run.Module]; isHeld && run.Module != "" {
|
||||
c.Running = append(c.Running, run)
|
||||
}
|
||||
}
|
||||
in.ComposedHow = c
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// ComposeSent composes node from an input ComposingInput read, through the path Compose takes (composeEach, the read's holds
|
||||
// first): what a check's sandbox runs. False when the input says no way of composing node (an input from a node-engine
|
||||
// before ComposedHow): the caller cannot compose it as the node does.
|
||||
func ComposeSent(in compose.Input, node string) (map[string]delivery.Composed, bool, error) {
|
||||
c := in.ComposedHow
|
||||
if c == nil || c.Node != node {
|
||||
return nil, false, nil
|
||||
}
|
||||
held := maps.Clone(c.Held)
|
||||
if held == nil {
|
||||
held = map[string]string{}
|
||||
}
|
||||
out, _, _, err := composeEach(in, node, c.Builds, false, c.Running, held, nil)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
// A module the sandbox held that the node-engine held neither in the read nor composing without the change is the change
|
||||
// refusing it: the node does not compose, said in that module's words, never passed with the rest composed (issue 774,
|
||||
// review of the sandbox input).
|
||||
var refused []string
|
||||
for _, m := range sortedKeys(held) {
|
||||
_, was := c.Held[m]
|
||||
if _, before := c.HeldBefore[m]; !was && !before {
|
||||
refused = append(refused, m+": "+held[m])
|
||||
}
|
||||
}
|
||||
if len(refused) > 0 {
|
||||
return nil, true, fmt.Errorf("on %s the change refuses %s", node, strings.Join(refused, "; "))
|
||||
}
|
||||
return out, true, nil
|
||||
}
|
||||
|
||||
// Plans is the provisioning plan of each module handed to the provisioner on the scope's node, composed from the bus
|
||||
// alone (novox/hq ADR 0570 clause 2): the controller's handedPlans over the same input (mesh-control
|
||||
// cmd/mesh-controller/compose_sdk.go), its rendering in the Foreseeing mode with the mesh's generators. A plan names
|
||||
@@ -812,7 +878,7 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m
|
||||
keptAt := maps.Clone(s.KeptAt)
|
||||
for _, pm := range p.Modules {
|
||||
if m, ok := modules[pm.Module]; ok && m.FP2 != "" && wanted[pm.Module] != "" && wanted[pm.Module] != m.FP2 &&
|
||||
keptAt[pm.Module] == "" && !slices.Contains(s.InOwn, pm.Module) {
|
||||
keptAt[pm.Module] == "" && !compose.Folds(slices.Contains(s.InOwn, pm.Module), wanted[pm.Module]) {
|
||||
if keptAt == nil {
|
||||
keptAt = map[string]string{}
|
||||
}
|
||||
@@ -827,9 +893,10 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m
|
||||
}
|
||||
for _, pm := range p.Modules {
|
||||
m, ok := modules[pm.Module]
|
||||
if !ok || m.FP2 == "" || keptAt[pm.Module] != "" || slices.Contains(s.InOwn, pm.Module) {
|
||||
// A module it runs inside its own composition is composed there as before: no build of the new form is what
|
||||
// it runs (Scope.InOwn).
|
||||
if !ok || m.FP2 == "" || keptAt[pm.Module] != "" || compose.Folds(slices.Contains(s.InOwn, pm.Module), wanted[pm.Module]) {
|
||||
// A module it runs inside its own composition is composed there as before while it is folded: no build of the
|
||||
// new form is what it runs (Scope.InOwn). Wanted at a build of the new form it is no longer folded (compose.Folds,
|
||||
// the composition's own rule), so its build is taken in (issue 1585).
|
||||
continue
|
||||
}
|
||||
if _, refused := in.Refused[pm.Module]; refused {
|
||||
@@ -1166,3 +1233,38 @@ func withoutHeldData(in compose.Input, node string) compose.Input {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// runningProvisioner names, on node, the account its provisioner client runs as now (compose.Node.RunningProvisioner): the
|
||||
// account its own composition as it runs names, else the user of the running process of the module that runs the client.
|
||||
func runningProvisioner(in *compose.Input, node string, running []delivery.Composed) {
|
||||
account := ""
|
||||
for _, c := range running {
|
||||
if c.Module == "" && c.Own.Provisioner != "" {
|
||||
account = c.Own.Provisioner
|
||||
}
|
||||
}
|
||||
for _, c := range running {
|
||||
if account != "" {
|
||||
break
|
||||
}
|
||||
man, ok := in.Catalogue[c.Module]
|
||||
if c.Module == "" || !ok || !man.ClaimsSeat(compose.NodeProvisionerSeat) {
|
||||
continue
|
||||
}
|
||||
for _, raw := range c.Resources {
|
||||
var r struct {
|
||||
Type string `json:"type"`
|
||||
User string `json:"user"`
|
||||
}
|
||||
if json.Unmarshal(raw, &r) == nil && r.Type == "process" && r.User != "" && r.User != "root" {
|
||||
account = r.User
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range in.Nodes {
|
||||
if in.Nodes[i].Name == node {
|
||||
in.Nodes[i].RunningProvisioner = account
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@ func TestInput(t *testing.T) {
|
||||
t.Run("a held module's first reason leads", aHeldModulesFirstReasonLeads)
|
||||
t.Run("a held module of its own account never refuses the node", aHeldModuleOfItsOwnAccountNeverRefusesTheNode)
|
||||
t.Run("another node not resolved is named", otherNodeNotResolvedIsNamed)
|
||||
t.Run("what runs is kept while a manifest does not say it", whatRunsIsKeptWhileAManifestDoesNotSayIt)
|
||||
ctx := context.Background()
|
||||
xterm, err := os.ReadFile("testdata/catalogue-835bf22b-xterm.module.json")
|
||||
if err != nil {
|
||||
@@ -867,3 +868,34 @@ func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ADR 0601 §4: the new node agent meets the tool runner's older manifest (no tool-runner) while the node runs its runtime
|
||||
// process, and the provisioner's older manifest (no core-credentials) while its own secret runs as the vault's box: each is
|
||||
// composed as what runs shows, said, by what runs and never by a name; a manifest that says it, or nothing running so, marks
|
||||
// nothing.
|
||||
func whatRunsIsKeptWhileAManifestDoesNotSayIt(t *testing.T) {
|
||||
running := []delivery.Composed{
|
||||
{Node: "a", Module: "runner-x", Resources: []json.RawMessage{json.RawMessage(`{"id":"runner-x.runtime","type":"process"}`)}},
|
||||
{Node: "a", Module: "prov-y", Resources: []json.RawMessage{json.RawMessage(`{"id":"prov-y.need-broker","type":"file","vault-sealed":{"box":"b"}}`)}},
|
||||
{Node: "a", Module: "app", Resources: []json.RawMessage{json.RawMessage(`{"id":"app.need-broker","type":"file"}`)}},
|
||||
}
|
||||
input := func(runnerSays bool) compose.Input {
|
||||
return compose.Input{Nodes: []compose.Node{{Name: "a", Assigned: []string{"app", "prov-y", "runner-x"}}},
|
||||
Catalogue: map[string]catalogue.Manifest{"runner-x": {Module: "runner-x", ToolRunner: runnerSays}, "prov-y": {Module: "prov-y"},
|
||||
"app": {Module: "app"}}}
|
||||
}
|
||||
in := input(false)
|
||||
said := keepWhatRuns(&in, "a", running)
|
||||
if !in.Catalogue["runner-x"].ToolRunner || !in.Catalogue["prov-y"].CoreCredentials || in.Catalogue["app"].ToolRunner ||
|
||||
in.Catalogue["app"].CoreCredentials || len(said) != 2 || said[0].Module != "prov-y" || said[1].Module != "runner-x" {
|
||||
t.Errorf("what runs was not kept: %+v, said %q", in.Catalogue, said)
|
||||
}
|
||||
in = input(true)
|
||||
if said := keepWhatRuns(&in, "a", running[:1]); len(said) != 0 {
|
||||
t.Errorf("a manifest that says it is the tool runner was marked again: %q", said)
|
||||
}
|
||||
in = input(false)
|
||||
if said := keepWhatRuns(&in, "a", nil); len(said) != 0 || in.Catalogue["runner-x"].ToolRunner {
|
||||
t.Errorf("nothing running marked %q", said)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package frombustest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -204,6 +206,92 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
|
||||
if fetched-archives != 1 {
|
||||
t.Errorf("restic's archive was read %d times over two compositions, want once", fetched-archives)
|
||||
}
|
||||
|
||||
// Issue 774 (ace, 2026-11-04 01:29): the provisioner client held as its stub (its build not done, so the node keeps
|
||||
// running what it runs) keeps the account the node's own composition names, and restic stays handed to it with its
|
||||
// plan; without it every filled write was refused and no module was handed.
|
||||
if b, err = Provisioned(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fps, ownKey := FPs(), ""
|
||||
ownKey, _ = delivery.ComposedNodeKey(Node)
|
||||
resticKey, _ := delivery.ComposedKey(Node, "restic", fps["restic"])
|
||||
before, _, _, err := frombus.Compose(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}, fps)
|
||||
account := before[ownKey].Own.Provisioner
|
||||
if err != nil || account == "" || len(before[resticKey].Filled) == 0 {
|
||||
t.Fatalf("the node composes with its client: account %q, restic's filled files %v: %v", account, before[resticKey].Filled, err)
|
||||
}
|
||||
var running []delivery.Composed
|
||||
for _, c := range before {
|
||||
running = append(running, c)
|
||||
}
|
||||
for _, row := range []struct {
|
||||
own, placed bool // the account read from the running own composition, else the client's running process; the client placed
|
||||
userless bool // the client placed and not held, its process naming no user: it composes no account
|
||||
// filledHeld holds every module with a filled file (restic, its build not done) and not the client: the node still
|
||||
// names the account (issue 774, ace 2026-11-04: its build modules and ssh-client held, no filled file, no account)
|
||||
filledHeld bool
|
||||
}{{true, true, false, false}, {false, true, false, false}, {true, false, false, false}, {false, false, false, false},
|
||||
{true, true, true, false}, {true, true, false, true}} {
|
||||
own := row.own
|
||||
runs := running
|
||||
if !own { // what runs names no account of its own: the client's running process says it
|
||||
runs = nil
|
||||
for _, c := range running {
|
||||
if c.Module == "" {
|
||||
c.Own.Provisioner = ""
|
||||
}
|
||||
runs = append(runs, c)
|
||||
}
|
||||
}
|
||||
bb, err := Provisioned()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := account
|
||||
if row.userless {
|
||||
// placed and not held, composing no account of its own: no stale account is kept from what runs
|
||||
mod := bb.Module["mesh-provisioner-client"]
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(bb.Manifests[mod.Manifest], &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range m["resources"].([]any) {
|
||||
delete(r.(map[string]any), "user")
|
||||
}
|
||||
raw, _ := json.Marshal(m)
|
||||
mod.Manifest = delivery.ManifestDigest(raw)
|
||||
bb.Module["mesh-provisioner-client"], bb.Manifests[mod.Manifest] = mod, raw
|
||||
built := *bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]]
|
||||
built.Manifest = mod.Manifest
|
||||
bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]] = &built
|
||||
want = ""
|
||||
} else if row.filledHeld {
|
||||
bb.Builds["restic "+fps["restic"]] = &delivery.BuilderBuild{State: delivery.BuildBuilding}
|
||||
} else if row.placed {
|
||||
// held: its build not done while it runs
|
||||
bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]] = &delivery.BuilderBuild{State: delivery.BuildBuilding}
|
||||
} else {
|
||||
// taken out of the node's placement while what runs still names it: the account goes with it, never kept stale
|
||||
p := bb.Placement[Node]
|
||||
p.Modules = slices.DeleteFunc(slices.Clone(p.Modules), func(m delivery.PlacedModule) bool { return m.Module == "mesh-provisioner-client" })
|
||||
bb.Placement[Node] = p
|
||||
want = ""
|
||||
}
|
||||
after, _, held, err := frombus.Compose(context.Background(), bb, frombus.Mesh{}, frombus.Scope{Node: Node, Running: runs}, fps)
|
||||
if _, isHeld := held["mesh-provisioner-client"]; err != nil || isHeld != (row.placed && !row.userless && !row.filledHeld) {
|
||||
t.Fatalf("%+v: held %v: %v", row, held, err)
|
||||
}
|
||||
if got := after[ownKey].Own.Provisioner; got != want {
|
||||
t.Errorf("%+v: the node's own names %q, want %q", row, got, want)
|
||||
}
|
||||
if _, resticHeld := held["restic"]; row.filledHeld && !resticHeld {
|
||||
t.Errorf("%+v: restic held %v", row, resticHeld)
|
||||
}
|
||||
if row.placed && !row.userless && !row.filledHeld && !reflect.DeepEqual(after[resticKey].Filled, before[resticKey].Filled) {
|
||||
t.Errorf("%+v: restic's filled files %v, want %v", row, after[resticKey].Filled, before[resticKey].Filled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// statesOf is the bus b as a module's read states hold it.
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
@@ -100,12 +99,12 @@ func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) {
|
||||
{"lamp", nil, true},
|
||||
{"lamp", errors.New("other"), true},
|
||||
{"lamp", errors.New("lamp refuses"), false},
|
||||
{catalogue.RuntimeModule, nil, true},
|
||||
{catalogue.RuntimeModule, errors.New("node-tools is assigned to n1 and its build produced 0 bundle(s)"), false},
|
||||
{catalogue.RuntimeModule, fmt.Errorf("compose: %w", &resolution.NoRuntimeToRunError{Held: true}), true},
|
||||
{catalogue.RuntimeModule, &resolution.NoRuntimeToRunError{}, false},
|
||||
{"node-tools", nil, true},
|
||||
{"node-tools", errors.New("node-tools is assigned to n1 and its build produced 0 bundle(s)"), false},
|
||||
{"node-tools", fmt.Errorf("compose: %w", &resolution.NoRuntimeToRunError{Held: true}), true},
|
||||
{"node-tools", &resolution.NoRuntimeToRunError{}, false},
|
||||
} {
|
||||
if got := blames(row.module, row.again, err); got != row.blamed {
|
||||
if got := blames(row.module == "node-tools", row.again, err); got != row.blamed {
|
||||
t.Errorf("%s with %v: blamed %v, want %v", row.module, row.again, got, row.blamed)
|
||||
}
|
||||
}
|
||||
@@ -117,13 +116,13 @@ func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) {
|
||||
// another build than the trunk's is composed from the build its want names.
|
||||
func aRunningModuleWaitsForItsBuild(t *testing.T) {
|
||||
dg := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
|
||||
tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` +
|
||||
tools := `{"module":"node-tools","version":"1","tool-runner":true,"own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` +
|
||||
`"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}`
|
||||
lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}`
|
||||
store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` +
|
||||
`"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}`
|
||||
old, next := "src2:"+strings.Repeat("a", 64), "src2:"+strings.Repeat("b", 64)
|
||||
running := []delivery.Composed{{Node: "n1", Module: catalogue.RuntimeModule, FP: old, Resources: []json.RawMessage{
|
||||
running := []delivery.Composed{{Node: "n1", Module: "node-tools", FP: old, Resources: []json.RawMessage{
|
||||
json.RawMessage(`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{"MESH_TOOL_MODULES":""}}`),
|
||||
json.RawMessage(`{"id":"node-tools.bundle-runtime","module":"node-tools","type":"archive","source":"artifact-store://node-tools/runtime/blobs/` +
|
||||
dg("1") + `","digest":"` + dg("1") + `","path":"` + resolution.BundlePath("node-tools", "runtime") + `"}`)}}}
|
||||
@@ -207,7 +206,7 @@ func aHeldModulesFirstReasonLeads(t *testing.T) {
|
||||
// secrets: the node composes every other module, and the held one keeps the words it was first held for.
|
||||
func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) {
|
||||
dg := "sha256:" + strings.Repeat("1", 64)
|
||||
tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` +
|
||||
tools := `{"module":"node-tools","version":"1","tool-runner":true,"own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` +
|
||||
`"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}`
|
||||
own := func(name string) string {
|
||||
return `{"module":"` + name + `","version":"1","runs-as":"` + name + `","secrets-owner":"` + name + `","tools":["status"],` +
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package frombus
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"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/compose"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
)
|
||||
|
||||
// **What runs is kept while a manifest does not yet say what shared code no longer infers** (novox/hq ADR 0601 §4). mesh-sdk
|
||||
// knows the tool runner and the modules whose own secrets are the core's credentials only by what their manifests say
|
||||
// (`tool-runner`, `core-credentials`), no longer by their names. A module placed on the composing node whose manifest (an
|
||||
// older build's) does not say so yet, while what the node runs of it shows it (the tool runner's process, `<module>.runtime`;
|
||||
// an own secret carried as the vault's box, the file's `vault-sealed`), is composed as what runs shows: its tool runner
|
||||
// process and its sealed secrets alike are kept, never composed away, and the node says so until the manifest says it.
|
||||
// Known by what runs, never by a module's name.
|
||||
|
||||
// keepWhatRuns marks, in in, each module placed on node that what runs shows is the tool runner or carries the core's
|
||||
// credentials, where its manifest does not say so; it returns, sorted by module, what it marked, in words. The tool runner is
|
||||
// marked only where no placed module's manifest says it is one.
|
||||
func keepWhatRuns(in *compose.Input, node string, running []delivery.Composed) []KeptAsRuns {
|
||||
placed := assignedOn(*in, node)
|
||||
if len(placed) == 0 || len(running) == 0 {
|
||||
return nil
|
||||
}
|
||||
runner, sealed := "", map[string]bool{}
|
||||
for _, run := range running {
|
||||
for _, raw := range run.Resources {
|
||||
var r struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if json.Unmarshal(raw, &r) != nil || r.ID == "" {
|
||||
continue
|
||||
}
|
||||
owner := run.Module
|
||||
if owner == "" {
|
||||
owner, _ = declaration.ModuleGuess(r.ID)
|
||||
}
|
||||
if owner == "" || !slices.Contains(placed, owner) {
|
||||
continue
|
||||
}
|
||||
if r.Type == "process" && r.ID == owner+"."+resolution.RuntimeProcessID() {
|
||||
runner = owner
|
||||
}
|
||||
if r.Type == "file" {
|
||||
var fields map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &fields) == nil {
|
||||
if _, ok := fields[catalogue.VaultSealedField]; ok {
|
||||
sealed[owner] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var said []KeptAsRuns
|
||||
saysRunner := false
|
||||
for _, m := range placed {
|
||||
if man, ok := manifestOn(*in, node, m); ok && catalogue.IsToolRunner(man) {
|
||||
saysRunner = true
|
||||
}
|
||||
}
|
||||
mark := func(module string, set func(*catalogue.Manifest), words string) {
|
||||
k := KeptAsRuns{Module: module, Words: words}
|
||||
for i := range in.Nodes {
|
||||
if in.Nodes[i].Name != node {
|
||||
continue
|
||||
}
|
||||
if man, ok := in.Nodes[i].Kept[module]; ok {
|
||||
set(&man)
|
||||
in.Nodes[i].Kept[module] = man
|
||||
said = append(said, k)
|
||||
return
|
||||
}
|
||||
}
|
||||
if man, ok := in.Catalogue[module]; ok {
|
||||
set(&man)
|
||||
in.Catalogue[module] = man
|
||||
said = append(said, k)
|
||||
}
|
||||
}
|
||||
if runner != "" && !saysRunner {
|
||||
mark(runner, func(m *catalogue.Manifest) { m.ToolRunner = true }, fmt.Sprintf("%s runs the tool runner's process here and its "+
|
||||
"manifest does not say it is the tool runner (tool-runner): it is composed as the tool runner it runs as, until its manifest says so", runner))
|
||||
}
|
||||
for _, m := range slices.Sorted(maps.Keys(sealed)) {
|
||||
if man, ok := manifestOn(*in, node, m); ok && !catalogue.CarriesCoreCredentials(man) {
|
||||
mark(m, func(m *catalogue.Manifest) { m.CoreCredentials = true }, fmt.Sprintf("%s carries its own secrets sealed by the vault here "+
|
||||
"and its manifest does not say they are the core's credentials (core-credentials): they stay sealed, until its manifest says so", m))
|
||||
}
|
||||
}
|
||||
sort.SliceStable(said, func(i, j int) bool { return said[i].Module < said[j].Module })
|
||||
return said
|
||||
}
|
||||
|
||||
// KeptAsRuns is one module composed as what runs shows, and why, in words (ADR 0601 §4): its have carries the words.
|
||||
type KeptAsRuns struct {
|
||||
Module, Words string
|
||||
}
|
||||
@@ -66,6 +66,7 @@ func PlansHeldTwice(ctx context.Context, r Reader, mesh Mesh, s Scope) (at, newe
|
||||
sc.into.Err = err
|
||||
continue
|
||||
}
|
||||
keepWhatRuns(&in, sc.scope.Node, sc.scope.Running) // what runs is kept, as Compose keeps it (ADR 0601 §4)
|
||||
sc.into.Plans, sc.into.Err = plansHeldOf(in, s.Node, held)
|
||||
mark("plans at " + sc.name)
|
||||
}
|
||||
|
||||
@@ -73,6 +73,13 @@ type Input struct {
|
||||
// issue 1126, C6). Used only while OperatorExport is nil.
|
||||
KeptRuns map[string]map[string]map[string]any
|
||||
|
||||
// ComposedHow is how the node-engine that read this input composes its node from it (frombus.Compose): the builds
|
||||
// it composes each module at, the modules it holds and what runs of each held one. A node-engine answers it with the
|
||||
// input it sends a check's sandbox (composer.SentInput), so the sandbox composes the node through the same path, a
|
||||
// module folded in the node's own composition kept as it runs rather than resolved from its manifest (issue 774, the
|
||||
// check of the bus want: novox's nats). Nil from every other reader and from a node-engine before it.
|
||||
ComposedHow *ComposedHow
|
||||
|
||||
// Issued is the credentials made for the consumers of each provider (inv.SecretsFrom): one row for each pair credential
|
||||
// that exists, by the provider node it is for. The grants a provider must create are read from these, so a credential not
|
||||
// made yet has no grant until the send that makes it. An assigner fills it from the facts snapshot's Grants of each
|
||||
@@ -152,8 +159,9 @@ type Node struct {
|
||||
// Assigned is the modules assigned to it, in the order the controller reads them (inv.Assigned).
|
||||
Assigned []string
|
||||
// Handed is, by module, whether the vault judged it handed to the provisioner when it answered this node's own
|
||||
// secrets (catalogue.AssignmentHands of the plan in its assignment): the node's own composition hands exactly those
|
||||
// (catalogue.Rendering.Assigned, issue 774). Nil where the vault said nothing of it, as for the controller.
|
||||
// secrets (catalogue.AssignmentHands of the plan in its assignment). Never the rule (resolution.Handing, issue 1966):
|
||||
// read only to say where the vault and this composition disagree (resolution.VaultDisagrees). Nil where the vault said
|
||||
// nothing of it, as for the controller.
|
||||
Handed map[string]bool
|
||||
// Capabilities are the ones present on it (inv.ProfileOf).
|
||||
Capabilities map[string]bool
|
||||
@@ -217,6 +225,11 @@ type Node struct {
|
||||
// so it is left to the node: Compositions composes it as a stub (Stub) and carries its resources as they run, each
|
||||
// after the resource it runs after (issue 1126). Nil composes every folded module from its manifest, as before.
|
||||
RunsOwn *delivery.Composed
|
||||
// RunningProvisioner is the account the provisioner's client runs as on this node now, read from what runs (its own
|
||||
// composition's provisioner account, else the user of the provisioner client's running process), empty when nothing
|
||||
// says it. A client held as its stub this pass composes no process, so the node keeps this account and its handing
|
||||
// (provisionerAccount; issue 774: ace, 2026-11-04 01:29, every filled write refused and postgres's consumers unhanded).
|
||||
RunningProvisioner string
|
||||
// Held is the modules refused alone on this node, each composed as what runs of it (Carried, in Kept; its Stub when
|
||||
// nothing of it runs) so the rest is composed (novox/hq issue 1126): none of a held module's own composition is in what
|
||||
// Compositions answers, so the node keeps what it last applied of it; one whose resources the node's own runs
|
||||
@@ -231,6 +244,22 @@ type Node struct {
|
||||
RunsTools map[string][]catalogue.Bundle
|
||||
}
|
||||
|
||||
// ComposedHow is how a node-engine composes its node from an Input (Input.ComposedHow).
|
||||
type ComposedHow struct {
|
||||
// Node is the node composed.
|
||||
Node string
|
||||
// Builds is the build each module is composed at, by module: a module the node runs folded in its own composition
|
||||
// at a build of the old form has none, and is composed there as it runs (Node.InOwn, Node.RunsOwn).
|
||||
Builds map[string]string
|
||||
// Held is each module the read held, with why: composed as what runs of it, and the rest without it.
|
||||
Held map[string]string
|
||||
// Running is what runs of each held module, its own composition, by which it is carried as it runs.
|
||||
Running []delivery.Composed
|
||||
// HeldBefore is each module the node holds composing without the change (the candidate's base), held by the read or
|
||||
// blamed later for a refusal: the change refuses none of them (issue 774, review round 1).
|
||||
HeldBefore map[string]string
|
||||
}
|
||||
|
||||
// Place is where a node is, as its placement says (inv.Overlay).
|
||||
type Place struct {
|
||||
Key string
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// What an input says of its held modules on the wire (issue 774, the group check of feat/l3-final 278aa5f0ccbe). A held
|
||||
// module stands in the input as its stub (Stub), and the stub's mark (catalogue.Manifest.Stubbed) is no field of a
|
||||
// manifest's JSON: an input a node-engine sent the check's sandbox lost it, so the sandbox read node-tools, held by its
|
||||
// manifest's verdict, as a tool runner with no bundle and refused every module of ace, g14 and shanks for "its build
|
||||
// produced 0 bundle(s)" instead of naming the verdict. So the input carries the mark itself, by module, for its catalogue
|
||||
// and for each node's kept manifests, and a reader marks each again. An input from a node-engine before the mark (no
|
||||
// Held) is read by its contract: a module Refused names stands in Catalogue as its stub.
|
||||
|
||||
// heldMarks are the modules an input holds as their stubs: those of its catalogue, and those each node keeps, by node.
|
||||
type heldMarks struct {
|
||||
Catalogue []string `json:",omitempty"`
|
||||
Kept map[string][]string `json:",omitempty"`
|
||||
}
|
||||
|
||||
// inputFields is Input without its JSON methods.
|
||||
type inputFields Input
|
||||
|
||||
// inputWire is an input as it crosses the bus: its fields, and the modules it holds as their stubs.
|
||||
type inputWire struct {
|
||||
inputFields
|
||||
Held *heldMarks `json:",omitempty"`
|
||||
}
|
||||
|
||||
// MarshalJSON writes the input with the modules it holds as their stubs named beside it (heldMarks).
|
||||
func (in Input) MarshalJSON() ([]byte, error) {
|
||||
w := inputWire{inputFields: inputFields(in)}
|
||||
marks := heldMarks{Catalogue: stubbedOf(in.Catalogue)}
|
||||
for _, n := range in.Nodes {
|
||||
if kept := stubbedOf(n.Kept); len(kept) > 0 {
|
||||
if marks.Kept == nil {
|
||||
marks.Kept = map[string][]string{}
|
||||
}
|
||||
marks.Kept[n.Name] = kept
|
||||
}
|
||||
}
|
||||
if len(marks.Catalogue) > 0 || len(marks.Kept) > 0 {
|
||||
w.Held = &marks
|
||||
}
|
||||
return json.Marshal(w)
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads an input and marks again each module it holds as its stub: those it names held, and, by the
|
||||
// input's contract, every module Refused names that its catalogue holds.
|
||||
func (in *Input) UnmarshalJSON(raw []byte) error {
|
||||
var w inputWire
|
||||
if err := json.Unmarshal(raw, &w); err != nil {
|
||||
return err
|
||||
}
|
||||
*in = Input(w.inputFields)
|
||||
held := map[string]bool{}
|
||||
for m := range in.Refused {
|
||||
held[m] = true
|
||||
}
|
||||
if w.Held != nil {
|
||||
for _, m := range w.Held.Catalogue {
|
||||
held[m] = true
|
||||
}
|
||||
}
|
||||
for m := range held {
|
||||
if man, ok := in.Catalogue[m]; ok {
|
||||
man.Stubbed = true
|
||||
in.Catalogue[m] = man
|
||||
}
|
||||
}
|
||||
if w.Held != nil {
|
||||
for i := range in.Nodes {
|
||||
for _, m := range w.Held.Kept[in.Nodes[i].Name] {
|
||||
if man, ok := in.Nodes[i].Kept[m]; ok {
|
||||
man.Stubbed = true
|
||||
in.Nodes[i].Kept[m] = man
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// stubbedOf is each module of manifests held as its stub, sorted.
|
||||
func stubbedOf(manifests map[string]catalogue.Manifest) []string {
|
||||
var out []string
|
||||
for name, m := range manifests {
|
||||
if m.Stubbed {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -115,6 +115,11 @@ func (d Declared) Body() ([]byte, error) {
|
||||
}
|
||||
if len(d.Filled) > 0 {
|
||||
envelope["filled_files"] = d.Filled
|
||||
}
|
||||
// The provisioner client's account is the node's, named whenever a client runs here, filled files or not: a node
|
||||
// whose every filled-file module is held this pass names none of its files, and still keeps them, so the client's
|
||||
// writes of them must be accepted (issue 774, ace 2026-11-04: its build modules and ssh-client held, no account).
|
||||
if d.Provisioner != "" {
|
||||
envelope["provisioner_account"] = d.Provisioner
|
||||
}
|
||||
// An empty declaration is deliberate: the node owns nothing the mesh put there (novox/hq issue 127), and the host refuses an
|
||||
@@ -202,9 +207,7 @@ func (c *composer) declaration(nodeName string, mode Mode) (Declared, error) {
|
||||
Filled: composed.Filled, LocalNetwork: resolution.LocalNetworkPorts(rules, with.LocalNetworks),
|
||||
CoreBoxesWanted: rendered.CoreBoxesWanted, Said: rendered.Said,
|
||||
Sequence: node.Sequence, Epoch: node.Epoch, Generation: node.Generation}
|
||||
if len(composed.Filled) > 0 {
|
||||
out.Provisioner = with.Provisioner
|
||||
}
|
||||
out.Provisioner = with.Provisioner
|
||||
// A handed module only pulled in by another keeps what requires it, for its value of its own in the assignments mirror (issue 765).
|
||||
for module := range composed.Plans {
|
||||
if by := resolution.RequiredBy(plan, module); by != "" {
|
||||
|
||||
@@ -168,7 +168,7 @@ func TestWhatASendWouldMakeIsNamedAsForeseenAndNeverInvented(t *testing.T) {
|
||||
in.Issued[0].ForProvider = ""
|
||||
in.Catalogue["assistant"] = anAssistant()
|
||||
in.Catalogue["web"] = aWebModule()
|
||||
in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1",
|
||||
in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1", CoreCredentials: true,
|
||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/nats/broker", Taken: catalogue.TakenAtStart}}}
|
||||
anchor := nodeOf(t, &in, "anchor")
|
||||
anchor.Assigned = append(anchor.Assigned, "assistant", "web", "nats")
|
||||
|
||||
@@ -74,6 +74,12 @@ func (c *composer) places(nodeName string, resolved Resolved) (Placed, error) {
|
||||
// not sent the field they would refuse.
|
||||
r.Hands = takesHanded(node)
|
||||
r.Provisioner = provisionerAccount(plan)
|
||||
if r.Provisioner == "" && provisionerHeld(plan) {
|
||||
// The provisioner's client is placed here and held this pass (its stub, or carried as it runs, composes no process
|
||||
// of its own): it keeps running as it runs, so the account it runs as stays named, and every module stays handed
|
||||
// to it. A client not held that composes no account, or none placed, names none: no stale account is kept.
|
||||
r.Provisioner = node.RunningProvisioner
|
||||
}
|
||||
r.Assigned = node.Handed
|
||||
r.ReadsHealth = node.Stated && node.Contract >= ReadinessContract
|
||||
r.JudgesRoot = node.Stated && node.Contract >= RootContract
|
||||
@@ -190,6 +196,21 @@ func provisionerAccount(plan catalogue.Resolution) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// provisionerHeld says the plan places a module claiming the provisioner client's seat on the machine that this pass
|
||||
// holds (its stand-in: Stub, or Carried as it runs), which composes none of the client's process.
|
||||
func provisionerHeld(plan catalogue.Resolution) bool {
|
||||
for _, m := range plan.Modules {
|
||||
if m.Stubbed && m.ClaimsSeat(NodeProvisionerSeat) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RunsTheProvisionerClient says a module runs its machine's provisioner client (runsTheProvisionerClient), for a reader
|
||||
// of what runs.
|
||||
func RunsTheProvisionerClient(m catalogue.Manifest) bool { return runsTheProvisionerClient(m) }
|
||||
|
||||
// runsTheProvisionerClient says a module runs its machine's provisioner client: it claims the seat and declares the own
|
||||
// secret that account is delivered in.
|
||||
func runsTheProvisionerClient(m catalogue.Manifest) bool {
|
||||
|
||||
@@ -102,7 +102,13 @@ func plainly(value any) string {
|
||||
// **An optional requirement left absent takes the default its placeholder names** (novox/hq ADR
|
||||
// 0330 §4), and one that names none is refused rather than rendered empty. The module check refuses
|
||||
// that already; this is the same rule where the file is made, for a manifest registered before it.
|
||||
func boundInto(resource map[string]any, known map[string]map[string]string, module string, absent map[string]bool) error {
|
||||
//
|
||||
// **A key the binding does not say is refused with why the node-engine fills the file** (issue 1966): filledHere
|
||||
// (notHandedWhy). What a provider's helper answers reaches only a handed module's files, which the provisioner's client
|
||||
// fills; a file the client should fill (handedFile) refused here is a handed module not handed, and says so, never only
|
||||
// the keys it lacks.
|
||||
func boundInto(resource map[string]any, known map[string]map[string]string, module string, absent map[string]bool,
|
||||
handedFile bool, filledHere string) error {
|
||||
if fmt.Sprint(resource["type"]) != "file" {
|
||||
return nil
|
||||
}
|
||||
@@ -135,10 +141,16 @@ func boundInto(resource map[string]any, known map[string]map[string]string, modu
|
||||
return token
|
||||
}
|
||||
value, said := values[key]
|
||||
if !said && handedFile {
|
||||
refused = fmt.Errorf(
|
||||
"handed module not handed: %s asks its %s binding for %q, which only the provisioner's client fills, "+
|
||||
"and %s (issue 1966)", module, provision, key, filledHere)
|
||||
return token
|
||||
}
|
||||
if !said {
|
||||
refused = fmt.Errorf(
|
||||
"%s asks its %s binding for %q, and what answers it says %s",
|
||||
module, provision, key, orNothing(namesOfKeys(values)))
|
||||
"%s asks its %s binding for %q, and what answers it says %s (%s)",
|
||||
module, provision, key, orNothing(namesOfKeys(values)), filledHere)
|
||||
return token
|
||||
}
|
||||
return value
|
||||
|
||||
@@ -2,14 +2,14 @@ package resolution
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// CarriesVaultSealed says whether module's own secrets are the core's credentials.
|
||||
func CarriesVaultSealed(module string) bool {
|
||||
return slices.Contains(mcat.CoreCredentialModules, module)
|
||||
// CarriesVaultSealed says whether a module's own secrets are the core's credentials, as its manifest says
|
||||
// (core-credentials): no list of modules is kept here.
|
||||
func CarriesVaultSealed(m mcat.Manifest) bool {
|
||||
return mcat.CarriesCoreCredentials(m)
|
||||
}
|
||||
|
||||
// vaultSealedOf is the vault's box for one of module's own secrets, if the rendering was given one.
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// The core's own credentials are what a module's manifest says (core-credentials, issue 774): no module is on a list.
|
||||
func TestTheCoresOwnCredentialsAreTheBusTheProvisionerAndItsClient(t *testing.T) {
|
||||
if want := []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}; !reflect.DeepEqual(mcat.CoreCredentialModules, want) {
|
||||
t.Fatalf("the core's modules are %v, want %v (mesh-vault's CoreModules is the same list)", mcat.CoreCredentialModules, want)
|
||||
}
|
||||
for _, m := range []string{"nats", "mesh-provisioner", "mesh-provisioner-client"} {
|
||||
if !CarriesVaultSealed(m) {
|
||||
t.Errorf("%s is not on the list", m)
|
||||
for raw, core := range map[string]bool{
|
||||
`{"module":"nats","version":"1","core-credentials":true}`: true,
|
||||
`{"module":"nats","version":"1"}`: false,
|
||||
`{"module":"postgres","version":"1"}`: false,
|
||||
} {
|
||||
m, err := mcat.Decode([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, m := range []string{"postgres", "mesh-vault", "mesh-controller", ""} {
|
||||
if CarriesVaultSealed(m) {
|
||||
t.Errorf("%s is on the list", m)
|
||||
if CarriesVaultSealed(m) != core {
|
||||
t.Errorf("%s: carries the core's credentials %v", raw, !core)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,6 +415,11 @@ func resolutionCompose(r mcat.Resolution, with mcat.Rendering, owner map[string]
|
||||
// family's member (ADR 0283), so the module reads its absence as not given.
|
||||
continue
|
||||
}
|
||||
if why := VaultDisagrees(with, m); sealed == "" && why != "" {
|
||||
// The vault left it out of what it answered this node, its plan handing it: never a secret to make.
|
||||
return nil, fmt.Errorf("handed module not handed: %s needs its own secret %q, which the vault answers "+
|
||||
"its provisioner's client alone, %s (issue 1966)", m.Module, name, why)
|
||||
}
|
||||
if sealed == "" {
|
||||
// Declared and not made. Refused rather than skipped: a module whose own
|
||||
// credential is silently absent starts, fails to authenticate, and the reason is
|
||||
@@ -912,7 +917,8 @@ func resolutionCompose(r mcat.Resolution, with mcat.Rendering, owner map[string]
|
||||
// **Substituted here, not on the machine.** A bound value is not secret — the mesh
|
||||
// holds it in the clear — so there is nothing for the host to be the only witness of,
|
||||
// and sending it already filled in means the host learns no new field.
|
||||
if err := boundInto(copied, known, m.Module, absent); err != nil {
|
||||
filledHere, handedFile := notHandedWhy(with, m, unsettled)
|
||||
if err := boundInto(copied, known, m.Module, absent, handedFile, filledHere); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// And what the module could not have written: the machine it turned out to be
|
||||
@@ -1046,7 +1052,7 @@ func resolutionCompose(r mcat.Resolution, with mcat.Rendering, owner map[string]
|
||||
return nil, err
|
||||
}
|
||||
if process != nil { // none for a held runtime's stand-in: what runs of it is carried
|
||||
owner[fmt.Sprint(process["id"])] = mcat.RuntimeModule
|
||||
owner[fmt.Sprint(process["id"])] = fmt.Sprint(process["name"]) // the tool runner's module (resolutionRuntimeProcess)
|
||||
out = append(out, process)
|
||||
}
|
||||
// And a runtime of its own for each module of its own account, after it (novox/hq ADR 0259 §8).
|
||||
@@ -1308,7 +1314,7 @@ func resolutionOwnSecretOwner(r mcat.Resolution, m mcat.Manifest, name string) s
|
||||
if o := m.OwnSecrets[name].Owner; o != "" {
|
||||
return o
|
||||
}
|
||||
if r.Account != "" && (m.Module == mcat.RuntimeModule || ToolsBusSecret(m, name)) {
|
||||
if r.Account != "" && (mcat.IsToolRunner(m) || ToolsBusSecret(m, name)) {
|
||||
return r.Account
|
||||
}
|
||||
return m.SecretsOwner
|
||||
|
||||
@@ -113,7 +113,7 @@ func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) {
|
||||
// The number this node was given for the forge — the one the machine it is about to run on
|
||||
// already publishes.
|
||||
out, err := Declaration(r, mcat.Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-broker"}},
|
||||
Needed: map[string]map[string]string{"node-tools": {"broker": "sealed-broker"}},
|
||||
Given: map[string]map[int]int{"gitea": {3000: 2999}},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -129,7 +129,7 @@ func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) {
|
||||
t.Fatalf("the forge is not published on the port this node gave it: %v", server["ports"])
|
||||
}
|
||||
// The forge's own code runs in the node's runtime (novox/hq ADR 0198), given its words there.
|
||||
runtime := fileNamed(out, mcat.RuntimeModule+"."+RuntimeProcessID())
|
||||
runtime := fileNamed(out, "node-tools"+"."+RuntimeProcessID())
|
||||
if runtime == nil {
|
||||
t.Fatalf("the node's runtime is not in the declaration: %v", ids(out))
|
||||
}
|
||||
@@ -168,7 +168,7 @@ func declaredGiteaSsh(t *testing.T, given map[int]int) map[string]any {
|
||||
givenPorts[k] = v
|
||||
}
|
||||
out, err := Declaration(r, mcat.Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-broker"}},
|
||||
Needed: map[string]map[string]string{"node-tools": {"broker": "sealed-broker"}},
|
||||
Ports: map[string]map[int]int{"gitea": givenPorts},
|
||||
Given: map[string]map[int]int{"gitea": given},
|
||||
})
|
||||
|
||||
@@ -96,22 +96,74 @@ func renderingHanding(with mcat.Rendering, m mcat.Manifest) bool {
|
||||
}
|
||||
|
||||
// Handing is whether a machine composed with this rendering hands the module to the provisioner, its files and
|
||||
// its own secrets (design 57 P6, V4): a moved module there, assigned or only pulled in by another (issue 765), on a
|
||||
// machine that can take it. The controller makes and seals none of such a module's secrets for that machine: its
|
||||
// client asks the vault.
|
||||
// its own secrets (design 57 P6, V4): a moved module there whose build kept what the client fills from
|
||||
// (catalogue.Handed, written by Resolve), assigned or only pulled in by another (issue 765), on a machine that can take
|
||||
// it (its node-engine writes a filled file on the client's word, and the client's account is named). The controller
|
||||
// makes and seals none of such a module's secrets for that machine: its client asks the vault.
|
||||
//
|
||||
// **Where the vault judged the module, its word is the rule** (Rendering.Assigned, issue 774): the vault leaves a
|
||||
// module's own secrets out of `own` exactly when the plan in its assignment hands it (AssignmentHands), so a node
|
||||
// composing itself hands exactly those, whatever its own reading of the manifest and the machine says. On 2026-10-31
|
||||
// postgres on novox was held: its plan handed its secrets, the vault answered none of them, and the node, judging by
|
||||
// the manifest and the machine, composed them itself and refused for want of `reader`. Two readers, one rule.
|
||||
// **The manifest, its build and the machine are the rule, never the plan** (issue 1966). The plan in a module's
|
||||
// assignment is what a node's last composition made of it; when the vault's reading of it (Rendering.Assigned) was the
|
||||
// rule, a module with no plan yet was judged not handed, composed the old way, refused there (its files ask the
|
||||
// provider's helper for what only the helper answers), and so never got the plan that would hand it: on 2026-11-04
|
||||
// ace's three build modules and ssh-client, and shanks's mesh-build-image and ssh-client. The vault's word follows the
|
||||
// plans this rule makes, one composition later, and is read only to say where the two disagree (VaultDisagrees).
|
||||
func Handing(with mcat.Rendering, m mcat.Manifest) bool {
|
||||
if hands, judged := with.Assigned[m.Module]; judged {
|
||||
return hands
|
||||
}
|
||||
return m.Moved && m.Handed != nil && with.Hands && with.Provisioner != ""
|
||||
}
|
||||
|
||||
// machineWhy is why a machine does not take a moved module's handed files, "" where it does.
|
||||
func machineWhy(with mcat.Rendering) string {
|
||||
switch {
|
||||
case !with.Hands:
|
||||
return "this machine's node-engine has not stated that it writes a filled file on the provisioner client's word"
|
||||
case with.Provisioner == "":
|
||||
return "this machine's declaration names no account the provisioner's client runs as"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// VaultDisagrees says why this composition does not hand a module the vault judged handed (Rendering.Assigned: the plan
|
||||
// in its assignment hands it, so the vault left its own secrets out of what it answered this node), "" where they agree
|
||||
// or the vault said nothing of it. Said where one of those secrets is then missing, as a handed module not handed, never
|
||||
// as a secret to make (issue 1966).
|
||||
func VaultDisagrees(with mcat.Rendering, m mcat.Manifest) string {
|
||||
if !with.Assigned[m.Module] || Handing(with, m) {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case !m.Moved:
|
||||
return "the vault judged it handed by its plan, and it is not moved"
|
||||
case m.Handed == nil:
|
||||
return "the vault judged it handed by its plan, and its build kept no templates"
|
||||
}
|
||||
return "the vault judged it handed by its plan, and " + machineWhy(with)
|
||||
}
|
||||
|
||||
// notHandedWhy says why a file of this module is composed here, filled by the node-engine, rather than by the
|
||||
// provisioner's client: the module is not moved, its build kept nothing to hand, the machine cannot take it, its build
|
||||
// kept no template for this file, or the file merges the layers' settings (begin). Said where a placeholder only a
|
||||
// handed file can name is refused (boundInto); handedFile is whether the module's manifest and build hand this file, so
|
||||
// that a refusal of it is a handed module not handed.
|
||||
func notHandedWhy(with mcat.Rendering, m mcat.Manifest, r map[string]any) (why string, handedFile bool) {
|
||||
id := fmt.Sprint(r["id"])
|
||||
switch {
|
||||
case !m.Moved:
|
||||
return m.Module + " is not moved, so its node-engine fills its files", false
|
||||
case m.Handed == nil:
|
||||
return m.Module + " is moved and its build kept no templates, so its node-engine fills its files", false
|
||||
}
|
||||
if _, kept := m.Handed.Templates[id]; !kept {
|
||||
return fmt.Sprintf("its build kept no template for %s, so its node-engine fills that file", id), false
|
||||
}
|
||||
if mergesFile(r) {
|
||||
return fmt.Sprintf("%s merges the layers' settings, which its node-engine does (issue 831)", id), false
|
||||
}
|
||||
if why := machineWhy(with); why != "" {
|
||||
return why, true
|
||||
}
|
||||
return fmt.Sprintf("its build kept a template for %s and this composition filled it itself", id), true
|
||||
}
|
||||
|
||||
// requiredByWords begins why a module only pulled in by another is on a machine (Resolution.Because): `required by
|
||||
// <module>`.
|
||||
const requiredByWords = "required by "
|
||||
|
||||
@@ -113,9 +113,10 @@ func TestAHandedModulesLocalCredentialsOfTheVaultAreNamedByLocalInItsPlan(t *tes
|
||||
|
||||
// **Issue 969: a key its provider serves only at run time.** A moved module's file may name `${bound:…}` of a
|
||||
// provision whose provider tells it at run time (ssh-client's known_host of the forge). Where the machine takes
|
||||
// handed files the client fills it and the module composes; where it does not the controller must fill it from what
|
||||
// the provider serves statically, cannot, and refuses it with the very words an unmarked module is refused with
|
||||
// (ADR 0349 §17: the old way, byte for byte).
|
||||
// handed files the client fills it and the module composes, whatever the vault judged of a plan not made yet (issue
|
||||
// 1966); where it does not the node-engine must fill it from what the provider serves statically, cannot, and refuses
|
||||
// it as a handed module not handed, saying why the machine does not take it (issue 1966), where an unmarked module is
|
||||
// refused with the keys the binding says and that it is not moved.
|
||||
func TestAHandedModuleNamingARunTimeBoundKeyComposesOnlyWhereItIsHanded(t *testing.T) {
|
||||
text := "Host ${bound:postgres-database:at}\n KnownHost ${bound:postgres-database:known_host}\n"
|
||||
moved := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`,
|
||||
@@ -130,17 +131,30 @@ func TestAHandedModuleNamingARunTimeBoundKeyComposesOnlyWhereItIsHanded(t *testi
|
||||
t.Fatalf("handed, the module is filled by the client and the controller names nothing: %v %s", c.Filled, got)
|
||||
}
|
||||
|
||||
for name, with := range map[string]mcat.Rendering{
|
||||
"an engine that does not take a filled file alone": {Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"},
|
||||
"no provisioner client on the machine": {Hands: true, ArtifactStore: "store:5000"},
|
||||
// Issue 1966: the vault judged it not handed (no plan yet); it is handed all the same.
|
||||
noPlan := handingTo("store:5000")
|
||||
noPlan.Assigned = map[string]bool{"bot": false}
|
||||
if c, err := Compose(withDatabase(t, moved, text, true), noPlan); err != nil || len(c.Filled) == 0 {
|
||||
t.Fatalf("with no plan yet, the module is not handed: %v", err)
|
||||
}
|
||||
|
||||
for name, with := range map[string]struct {
|
||||
with mcat.Rendering
|
||||
why string
|
||||
}{
|
||||
"an engine that does not take a filled file alone": {mcat.Rendering{Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"},
|
||||
"has not stated that it writes a filled file"},
|
||||
"no provisioner client on the machine": {mcat.Rendering{Hands: true, ArtifactStore: "store:5000"},
|
||||
"names no account the provisioner's client runs as"},
|
||||
} {
|
||||
_, err := Compose(withDatabase(t, moved, text, true), sealedForBot(with))
|
||||
if err == nil || !strings.Contains(err.Error(), `asks its postgres-database binding for "known_host"`) {
|
||||
t.Fatalf("%s: the key is not refused: %v", name, err)
|
||||
_, err := Compose(withDatabase(t, moved, text, true), sealedForBot(with.with))
|
||||
if err == nil || !strings.Contains(err.Error(), `handed module not handed: bot asks its postgres-database binding for "known_host"`) ||
|
||||
!strings.Contains(err.Error(), with.why) {
|
||||
t.Fatalf("%s: the key is not refused as a handed module not handed: %v", name, err)
|
||||
}
|
||||
_, before := Compose(withDatabase(t, unmoved, text, false), sealedForBot(with))
|
||||
if before == nil || before.Error() != err.Error() {
|
||||
t.Fatalf("%s: refused otherwise than the unmarked module: %v / %v", name, err, before)
|
||||
_, before := Compose(withDatabase(t, unmoved, text, false), sealedForBot(with.with))
|
||||
if before == nil || !strings.Contains(before.Error(), `bot asks its postgres-database binding for "known_host", and what answers it says "as", "at" or "from" (bot is not moved`) {
|
||||
t.Fatalf("%s: the unmarked module is refused %v", name, before)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -60,14 +61,25 @@ func TestAHandedModulesSecretsAreNamedAndNoValueIsSealed(t *testing.T) {
|
||||
if want := []mcat.PlanSecret{{Name: "token", Path: path}}; !reflect.DeepEqual(c.Plans["bot"].Secrets, want) {
|
||||
t.Fatalf("the plan names secrets %+v, want %+v", c.Plans["bot"].Secrets, want)
|
||||
}
|
||||
// Issue 774 (postgres on novox, 2026-10-31): where the vault judged it handed, its secrets are the vault's even on a
|
||||
// machine the node reads as not taking a filled file, and nothing is refused for a value the vault never answers.
|
||||
judged, err := Compose(r, mcat.Rendering{ArtifactStore: "store:5000", Assigned: map[string]bool{"bot": true}})
|
||||
// Issue 1966 (ace, 2026-11-04): the vault judged it not handed, its assignment carrying no plan yet; the manifest,
|
||||
// its build and the machine hand it all the same, so the plan that would hand it is made.
|
||||
withoutPlan := handingTo("store:5000")
|
||||
withoutPlan.Assigned = map[string]bool{"bot": false}
|
||||
first, err := Compose(r, withoutPlan)
|
||||
if err != nil {
|
||||
t.Fatalf("a module the vault judged handed is refused: %v", err)
|
||||
t.Fatalf("a module the vault judged not handed, with no plan yet, is refused: %v", err)
|
||||
}
|
||||
if want := []mcat.PlanSecret{{Name: "token", Path: path}}; !reflect.DeepEqual(judged.Plans["bot"].Secrets, want) {
|
||||
t.Errorf("judged handed by the vault, the plan names secrets %+v, want %+v", judged.Plans["bot"].Secrets, want)
|
||||
if want := []mcat.PlanSecret{{Name: "token", Path: path}}; !reflect.DeepEqual(first.Plans["bot"].Secrets, want) {
|
||||
t.Errorf("with no plan yet, the plan names secrets %+v, want %+v", first.Plans["bot"].Secrets, want)
|
||||
}
|
||||
// Issue 774 (postgres on novox, 2026-10-31), as issue 1966 leaves it: where the vault judged it handed and the machine
|
||||
// cannot take it, the vault answered none of its secrets, and the refusal says a handed module is not handed, never
|
||||
// a secret to make.
|
||||
_, err = Compose(r, mcat.Rendering{ArtifactStore: "store:5000", Assigned: map[string]bool{"bot": true}})
|
||||
var notMade *NotMadeError
|
||||
if err == nil || errors.As(err, ¬Made) || !strings.Contains(err.Error(), "handed module not handed: bot needs its own secret \"token\"") ||
|
||||
!strings.Contains(err.Error(), "has not stated that it writes a filled file") {
|
||||
t.Errorf("judged handed by the vault on a machine that cannot take it, the composition says %v", err)
|
||||
}
|
||||
// A process naming the token's file still restarts when its client writes it.
|
||||
for _, res := range c.Resources {
|
||||
|
||||
@@ -46,9 +46,9 @@ func twoMergeableFiles(app, sidecar []any) mcat.Manifest {
|
||||
// The indexer's judge's proofs, items 1 to 13 of its list.
|
||||
func TestTheJudgesProofsOfAComposition(t *testing.T) {
|
||||
runtimeWith := mcat.Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-credential"}, "dash": {"token": "sealed-token"}}}
|
||||
Needed: map[string]map[string]string{"node-tools": {"broker": "sealed-credential"}, "dash": {"token": "sealed-token"}}}
|
||||
processOf := func(out []map[string]any) map[string]any {
|
||||
return fileNamed(out, mcat.RuntimeModule+"."+RuntimeProcessID())
|
||||
return fileNamed(out, "node-tools"+"."+RuntimeProcessID())
|
||||
}
|
||||
|
||||
t.Run("1 a bundle's words resolve per machine into the runtime's tool env", func(t *testing.T) {
|
||||
|
||||
@@ -13,13 +13,13 @@ import (
|
||||
// place the operator-sealed secrets are kept — and no other module is told.
|
||||
func TestTheHolderOfNodeReadsIsToldWhereItsNodeKeepsASecret(t *testing.T) {
|
||||
with := mcat.Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-credential"}, "reads": {"broker": "own"}, "desk": {"broker": "own"}}}
|
||||
goRuntime := mcat.Manifest{Module: mcat.RuntimeModule, Version: "1",
|
||||
OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + mcat.RuntimeModule + "/broker"}},
|
||||
Needed: map[string]map[string]string{"node-tools": {"broker": "sealed-credential"}, "reads": {"broker": "own"}, "desk": {"broker": "own"}}}
|
||||
goRuntime := mcat.Manifest{Module: "node-tools", Version: "1", ToolRunner: true,
|
||||
OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + "node-tools" + "/broker"}},
|
||||
Build: &mcat.Build{Artifacts: []mcat.Artifact{{Name: "runtime", Kind: mcat.ArtifactBundle, Language: "go",
|
||||
System: "arch", From: "cmd/node-tools"}}}}
|
||||
goRuntime, err := goRuntime.Resolve([]mcat.Built{{Name: "runtime", Kind: mcat.ArtifactBundle,
|
||||
Reference: mcat.ArtifactStoreScheme + mcat.RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
Reference: mcat.ArtifactStoreScheme + "node-tools" + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func TestTheHolderOfNodeReadsIsToldWhereItsNodeKeepsASecret(t *testing.T) {
|
||||
t.Fatalf("the holder is not told its node's secret places: %v (%v)", err, given)
|
||||
}
|
||||
want := []string{"/etc/app/db.secret", "/home/ops/.config/app", "/var/lib/desk", "/var/lib/mesh-reads",
|
||||
"/var/lib/mesh-vault", "/var/lib/mesh/" + mcat.RuntimeModule}
|
||||
"/var/lib/mesh-vault", "/var/lib/mesh/" + "node-tools"}
|
||||
if strings.Join(places, " ") != strings.Join(want, " ") {
|
||||
t.Errorf("secret places:\n got %v\nwant %v", places, want)
|
||||
}
|
||||
|
||||
@@ -21,12 +21,8 @@ func BundlePath(module, bundle string) string { return mcat.BundleRoot + "/" + m
|
||||
// a node is sent exactly what it was sent before, bundles included, because a bundle nothing loads
|
||||
// is bytes nobody reads.
|
||||
func resolutionRuntimeHere(r mcat.Resolution) bool {
|
||||
for _, m := range r.Modules {
|
||||
if m.Module == mcat.RuntimeModule {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
_, here := mcat.ToolRunnerOf(r.Modules)
|
||||
return here
|
||||
}
|
||||
|
||||
// bundleArchives is one archive per tools bundle of a module — a bundle the runtime LOADS something
|
||||
@@ -59,13 +55,13 @@ func RuntimeProcessID() string { return "runtime" }
|
||||
// interpreterFor is how a bundle in a language is run: the program the host's unit starts, with the
|
||||
// bundle's entrypoint after it. The one thing the composer takes from a language, and said here
|
||||
// rather than in a manifest because the runtime's process is the mesh's to compose (to-be 38 WP3).
|
||||
func interpreterFor(language string) (string, error) {
|
||||
func interpreterFor(module, language string) (string, error) {
|
||||
switch language {
|
||||
case "typescript":
|
||||
return "node", nil
|
||||
}
|
||||
return "", fmt.Errorf(
|
||||
"%s is written in %q, and the mesh knows no interpreter to run a %q bundle with", mcat.RuntimeModule, language, language)
|
||||
"%s is written in %q, and the mesh knows no interpreter to run a %q bundle with", module, language, language)
|
||||
}
|
||||
|
||||
// runtimeProcess is the one process a machine runs the node's tool runtime as (novox/hq ADR 0175,
|
||||
@@ -80,7 +76,7 @@ func interpreterFor(language string) (string, error) {
|
||||
func resolutionRuntimeProcess(r mcat.Resolution, with mcat.Rendering) (map[string]any, error) {
|
||||
var runtime *mcat.Manifest
|
||||
for i := range r.Modules {
|
||||
if r.Modules[i].Module == mcat.RuntimeModule {
|
||||
if mcat.IsToolRunner(r.Modules[i]) {
|
||||
runtime = &r.Modules[i]
|
||||
}
|
||||
}
|
||||
@@ -93,7 +89,7 @@ func resolutionRuntimeProcess(r mcat.Resolution, with mcat.Rendering) (map[strin
|
||||
if len(runtime.Bundles) != 1 {
|
||||
return nil, fmt.Errorf(
|
||||
"%s is assigned to %s and its build produced %d bundle(s); the runtime is one bundle "+
|
||||
"the mesh runs, so the module declares exactly one (novox/hq to-be 38)", mcat.RuntimeModule, r.Node, len(runtime.Bundles))
|
||||
"the mesh runs, so the module declares exactly one (novox/hq to-be 38)", runtime.Module, r.Node, len(runtime.Bundles))
|
||||
}
|
||||
bundle := runtime.Bundles[0]
|
||||
// What runs it (novox/hq ADR 0193): a runtime compiled to a binary runs itself, from its own
|
||||
@@ -105,9 +101,9 @@ func resolutionRuntimeProcess(r mcat.Resolution, with mcat.Rendering) (map[strin
|
||||
if len(bundle.Entrypoints) != 1 {
|
||||
return nil, fmt.Errorf(
|
||||
"%s's bundle %q names %d entrypoint(s); the runtime is run from one, so the module "+
|
||||
"declares exactly one (novox/hq to-be 38)", mcat.RuntimeModule, bundle.Name, len(bundle.Entrypoints))
|
||||
"declares exactly one (novox/hq to-be 38)", runtime.Module, bundle.Name, len(bundle.Entrypoints))
|
||||
}
|
||||
interpreter, err := interpreterFor(bundle.Language)
|
||||
interpreter, err := interpreterFor(runtime.Module, bundle.Language)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -117,7 +113,7 @@ func resolutionRuntimeProcess(r mcat.Resolution, with mcat.Rendering) (map[strin
|
||||
if !declared {
|
||||
return nil, fmt.Errorf(
|
||||
"%s declares no own secret named broker, and the node's credential is delivered there: "+
|
||||
"a module that speaks on the bus declares \"own-secrets\": {\"broker\": <path>}", mcat.RuntimeModule,
|
||||
"a module that speaks on the bus declares \"own-secrets\": {\"broker\": <path>}", runtime.Module,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -163,7 +159,7 @@ func resolutionRuntimeProcess(r mcat.Resolution, with mcat.Rendering) (map[strin
|
||||
}
|
||||
}
|
||||
sort.Strings(served)
|
||||
restartOn = append(restartOn, mcat.RuntimeModule+"."+mcat.NeedID("broker"))
|
||||
restartOn = append(restartOn, runtime.Module+"."+mcat.NeedID("broker"))
|
||||
sort.Strings(restartOn)
|
||||
|
||||
env := map[string]string{mcat.RuntimeToolModules: strings.Join(served, ","), mcat.RuntimeBrokerFile: credential.Path}
|
||||
@@ -176,7 +172,7 @@ func resolutionRuntimeProcess(r mcat.Resolution, with mcat.Rendering) (map[strin
|
||||
env[mcat.RuntimeToolEnv] = string(body)
|
||||
}
|
||||
process := map[string]any{
|
||||
"id": mcat.RuntimeModule + "." + RuntimeProcessID(), "type": "process", "name": mcat.RuntimeModule,
|
||||
"id": runtime.Module + "." + RuntimeProcessID(), "type": "process", "name": runtime.Module,
|
||||
"source": bundle.Source, "digest": bundle.Digest,
|
||||
"run": run,
|
||||
"env": env,
|
||||
@@ -193,7 +189,7 @@ func resolutionRuntimeProcess(r mcat.Resolution, with mcat.Rendering) (map[strin
|
||||
}
|
||||
// Routed through the artifact store as this network reaches it now, like everything the mesh
|
||||
// built; refused with the same words when there is no store to route through.
|
||||
if err := artifactsInto(process, mcat.RuntimeModule, with); err != nil {
|
||||
if err := artifactsInto(process, runtime.Module, with); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return process, nil
|
||||
@@ -229,14 +225,14 @@ func resolutionOwnRuntimes(r mcat.Resolution, with mcat.Rendering) ([]map[string
|
||||
}
|
||||
var runtime *mcat.Manifest
|
||||
for i := range r.Modules {
|
||||
if r.Modules[i].Module == mcat.RuntimeModule {
|
||||
if mcat.IsToolRunner(r.Modules[i]) {
|
||||
runtime = &r.Modules[i]
|
||||
}
|
||||
}
|
||||
if runtime == nil || len(runtime.Bundles) != 1 || runtime.Bundles[0].Binary == "" {
|
||||
return nil, &NoRuntimeToRunError{Held: runtime != nil && runtime.Stubbed, said: fmt.Sprintf("%s runs as its own "+
|
||||
"account in a runtime of its own, and %s is not here to run it from: assign %s to %s first (novox/hq ADR 0259)",
|
||||
own[0].Module, mcat.RuntimeModule, mcat.RuntimeModule, r.Node)}
|
||||
"account in a runtime of its own, and the tool runner is not here to run it from: assign the tool runner to %s first "+
|
||||
"(novox/hq ADR 0259)", own[0].Module, r.Node)}
|
||||
}
|
||||
program := runtime.Bundles[0]
|
||||
var out []map[string]any
|
||||
@@ -314,7 +310,7 @@ func resolutionOwnRuntimes(r mcat.Resolution, with mcat.Rendering) ([]map[string
|
||||
if yielding != nil {
|
||||
process["yields"] = yielding
|
||||
}
|
||||
if err := artifactsInto(process, mcat.RuntimeModule, with); err != nil {
|
||||
if err := artifactsInto(process, runtime.Module, with); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, process)
|
||||
|
||||
@@ -9,12 +9,12 @@ import (
|
||||
// The runtime-health is composed onto <module>.own-runtime, in the words the node-engine takes; to an engine
|
||||
// that does not read health it is not sent, as for every resource.
|
||||
func TestARuntimeHealthIsComposedOntoTheModulesOwnRuntime(t *testing.T) {
|
||||
goRuntime := mcat.Manifest{Module: mcat.RuntimeModule, Version: "1",
|
||||
OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + mcat.RuntimeModule + "/broker"}},
|
||||
goRuntime := mcat.Manifest{Module: "node-tools", Version: "1", ToolRunner: true,
|
||||
OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + "node-tools" + "/broker"}},
|
||||
Build: &mcat.Build{Artifacts: []mcat.Artifact{{Name: "runtime", Kind: mcat.ArtifactBundle, Language: "go",
|
||||
System: "arch", From: "cmd/node-tools"}}}}
|
||||
goRuntime, err := goRuntime.Resolve([]mcat.Built{{Name: "runtime", Kind: mcat.ArtifactBundle,
|
||||
Reference: mcat.ArtifactStoreScheme + mcat.RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
Reference: mcat.ArtifactStoreScheme + "node-tools" + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func TestARuntimeHealthIsComposedOntoTheModulesOwnRuntime(t *testing.T) {
|
||||
"looks": 2.0}
|
||||
for _, reads := range []bool{true, false} {
|
||||
with := mcat.Rendering{ArtifactStore: "anchor.internal:5101", ReadsHealth: reads,
|
||||
Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed"}, "keeper": {"broker": "own"}}}
|
||||
Needed: map[string]map[string]string{"node-tools": {"broker": "sealed"}, "keeper": {"broker": "own"}}}
|
||||
out, err := Declaration(mcat.Resolution{Node: "anchor", Account: "ops", Modules: []mcat.Manifest{keeper, goRuntime}}, with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -46,7 +46,7 @@ func TestARuntimeHealthIsComposedOntoTheModulesOwnRuntime(t *testing.T) {
|
||||
h["timeout"] != "10s" || h["looks"] != 2 || h["grace"] != "1m0s" {
|
||||
t.Errorf("its own runtime's health: %v", own[mcat.HealthField])
|
||||
}
|
||||
if machine := fileNamed(out, mcat.RuntimeModule+"."+RuntimeProcessID()); machine[mcat.HealthField] != nil {
|
||||
if machine := fileNamed(out, "node-tools"+"."+RuntimeProcessID()); machine[mcat.HealthField] != nil {
|
||||
t.Errorf("the machine's runtime was given the module's health: %v", machine[mcat.HealthField])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,12 +31,12 @@ func aToolsModule(t *testing.T, name string, entrypoints ...string) mcat.Manifes
|
||||
// loaded, and its broker secret to receive the node's credential in.
|
||||
func theRuntime(t *testing.T) mcat.Manifest {
|
||||
t.Helper()
|
||||
m := mcat.Manifest{Module: mcat.RuntimeModule, Version: "1",
|
||||
OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + mcat.RuntimeModule + "/broker"}},
|
||||
m := mcat.Manifest{Module: "node-tools", Version: "1", ToolRunner: true,
|
||||
OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + "node-tools" + "/broker"}},
|
||||
Build: &mcat.Build{Artifacts: []mcat.Artifact{{Name: "runtime", Kind: mcat.ArtifactBundle, Language: "typescript",
|
||||
Entrypoints: []string{"src/main.js"}}}}}
|
||||
resolved, err := m.Resolve([]mcat.Built{{Name: "runtime", Kind: mcat.ArtifactBundle,
|
||||
Reference: mcat.ArtifactStoreScheme + mcat.RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
Reference: mcat.ArtifactStoreScheme + "node-tools" + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func theRuntime(t *testing.T) mcat.Manifest {
|
||||
|
||||
func TestEveryToolsBundleIsDeliveredWhereTheRuntimeRuns(t *testing.T) {
|
||||
store := mcat.Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-credential"}}}
|
||||
Needed: map[string]map[string]string{"node-tools": {"broker": "sealed-credential"}}}
|
||||
nftables := aToolsModule(t, "nftables", "tools/index.js")
|
||||
zsh := aToolsModule(t, "zsh", "tools/index.js", "tools/more.js")
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestEveryToolsBundleIsDeliveredWhereTheRuntimeRuns(t *testing.T) {
|
||||
t.Errorf("zsh's tools bundle was not delivered: %v", ids(out))
|
||||
}
|
||||
// The runtime's own bundle is run, not loaded: its process delivers it, not an archive.
|
||||
if fileNamed(out, mcat.RuntimeModule+"."+BundleID("runtime")) != nil {
|
||||
if fileNamed(out, "node-tools"+"."+BundleID("runtime")) != nil {
|
||||
t.Error("the runtime's own bundle was delivered as an archive beside its process")
|
||||
}
|
||||
})
|
||||
@@ -86,6 +86,18 @@ func TestEveryToolsBundleIsDeliveredWhereTheRuntimeRuns(t *testing.T) {
|
||||
t.Errorf("%s was delivered to a machine running no runtime to load it", id)
|
||||
}
|
||||
}
|
||||
// The tool runner is what says it is one, never a name (issue 774): node-tools saying nothing is no runner.
|
||||
silent := theRuntime(t)
|
||||
silent.ToolRunner = false
|
||||
out, err = Declaration(mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{nftables, zsh, silent}}, store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range ids(out) {
|
||||
if strings.Contains(id, BundleID("")) || strings.HasSuffix(id, "."+RuntimeProcessID()) {
|
||||
t.Errorf("%s was composed for a module that does not say it is the tool runner", id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -101,7 +113,7 @@ func ids(out []map[string]any) []string {
|
||||
// where its credential is, and who the operator is — restarted when any of that changes.
|
||||
func TestTheMachineRunsOneRuntimeLoadingEveryDeliveredBundle(t *testing.T) {
|
||||
with := mcat.Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-credential"}}}
|
||||
Needed: map[string]map[string]string{"node-tools": {"broker": "sealed-credential"}}}
|
||||
nftables := aToolsModule(t, "nftables", "tools/index.js")
|
||||
// A bundle carrying a daemon beside its tools says which files the runtime loads.
|
||||
showcase := mcat.Manifest{Module: "showcase", Version: "1", Tools: []string{"greet"},
|
||||
@@ -118,12 +130,12 @@ func TestTheMachineRunsOneRuntimeLoadingEveryDeliveredBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process := fileNamed(out, mcat.RuntimeModule+"."+RuntimeProcessID())
|
||||
process := fileNamed(out, "node-tools"+"."+RuntimeProcessID())
|
||||
if process == nil {
|
||||
t.Fatalf("no runtime process was composed: %v", ids(out))
|
||||
}
|
||||
if process["type"] != "process" || process["name"] != mcat.RuntimeModule || process["digest"] != bundleDigest ||
|
||||
process["source"] != "http://anchor.internal:5101/v2/"+mcat.RuntimeModule+"/runtime/blobs/"+bundleDigest {
|
||||
if process["type"] != "process" || process["name"] != "node-tools" || process["digest"] != bundleDigest ||
|
||||
process["source"] != "http://anchor.internal:5101/v2/"+"node-tools"+"/runtime/blobs/"+bundleDigest {
|
||||
t.Errorf("the runtime's process is %v", process)
|
||||
}
|
||||
if fmt.Sprint(process["run"]) != "[node src/main.js]" {
|
||||
@@ -134,7 +146,7 @@ func TestTheMachineRunsOneRuntimeLoadingEveryDeliveredBundle(t *testing.T) {
|
||||
"showcase="+mcat.BundleRoot+"/showcase/code/tools/index.js" {
|
||||
t.Errorf("the runtime is told to serve %q: every loaded file, by module, and nothing a bundle runs", env[mcat.RuntimeToolModules])
|
||||
}
|
||||
if env[mcat.RuntimeBrokerFile] != "/var/lib/mesh/"+mcat.RuntimeModule+"/broker" {
|
||||
if env[mcat.RuntimeBrokerFile] != "/var/lib/mesh/"+"node-tools"+"/broker" {
|
||||
t.Errorf("the runtime reads its credential at %q, not where the module's own secret is placed", env[mcat.RuntimeBrokerFile])
|
||||
}
|
||||
if env[mcat.RuntimeOperatorAccount] != "ops" || env[mcat.RuntimeOperatorHome] != "/home/ops" || process["user"] != "ops" {
|
||||
@@ -142,18 +154,18 @@ func TestTheMachineRunsOneRuntimeLoadingEveryDeliveredBundle(t *testing.T) {
|
||||
}
|
||||
// The credential the process reads belongs to the account it runs as, or it could not read it
|
||||
// (to-be 38 WP3); other modules' secrets are left as their manifests say.
|
||||
if credential := fileNamed(out, mcat.RuntimeModule+"."+mcat.NeedID("broker")); credential == nil || credential["owner"] != "ops" {
|
||||
if credential := fileNamed(out, "node-tools"+"."+mcat.NeedID("broker")); credential == nil || credential["owner"] != "ops" {
|
||||
t.Errorf("the runtime's credential is not the account's to read: %v", credential)
|
||||
}
|
||||
restarts := fmt.Sprint(process["restart-on"])
|
||||
for _, want := range []string{"nftables." + BundleID("tools"), "showcase." + BundleID("code"), mcat.RuntimeModule + "." + mcat.NeedID("broker")} {
|
||||
for _, want := range []string{"nftables." + BundleID("tools"), "showcase." + BundleID("code"), "node-tools" + "." + mcat.NeedID("broker")} {
|
||||
if !strings.Contains(restarts, want) {
|
||||
t.Errorf("the runtime is not restarted when %s changes: %s", want, restarts)
|
||||
}
|
||||
}
|
||||
// After every bundle and the credential, so both exist before it starts.
|
||||
names := ids(out)
|
||||
if names[len(names)-1] != mcat.RuntimeModule+"."+RuntimeProcessID() {
|
||||
if names[len(names)-1] != "node-tools"+"."+RuntimeProcessID() {
|
||||
t.Errorf("the runtime's process is not last: %v", names)
|
||||
}
|
||||
|
||||
@@ -162,7 +174,7 @@ func TestTheMachineRunsOneRuntimeLoadingEveryDeliveredBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process := fileNamed(out, mcat.RuntimeModule+"."+RuntimeProcessID())
|
||||
process := fileNamed(out, "node-tools"+"."+RuntimeProcessID())
|
||||
env := process["env"].(map[string]string)
|
||||
if _, set := env[mcat.RuntimeOperatorAccount]; set {
|
||||
t.Error("an operator account was named on a machine that has none")
|
||||
@@ -170,13 +182,13 @@ func TestTheMachineRunsOneRuntimeLoadingEveryDeliveredBundle(t *testing.T) {
|
||||
if _, set := process["user"]; set {
|
||||
t.Error("a user was set on a machine with no account")
|
||||
}
|
||||
if credential := fileNamed(out, mcat.RuntimeModule+"."+mcat.NeedID("broker")); credential == nil || credential["owner"] != nil {
|
||||
if credential := fileNamed(out, "node-tools"+"."+mcat.NeedID("broker")); credential == nil || credential["owner"] != nil {
|
||||
t.Errorf("the runtime's credential was given an owner on a machine with no account: %v", credential)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a runtime module built wrong is refused by name", func(t *testing.T) {
|
||||
two := mcat.Manifest{Module: mcat.RuntimeModule, Version: "1", OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/b"}},
|
||||
two := mcat.Manifest{Module: "node-tools", Version: "1", ToolRunner: true, OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/b"}},
|
||||
Build: &mcat.Build{Artifacts: []mcat.Artifact{{Name: "runtime", Kind: mcat.ArtifactBundle, Language: "typescript",
|
||||
Entrypoints: []string{"a.js", "b.js"}}}}}
|
||||
resolved, err := two.Resolve([]mcat.Built{{Name: "runtime", Kind: mcat.ArtifactBundle,
|
||||
@@ -195,7 +207,7 @@ func TestTheMachineRunsOneRuntimeLoadingEveryDeliveredBundle(t *testing.T) {
|
||||
// build from before launchers — so the move needs no flag day.
|
||||
func TestTheRuntimeStartsTheLauncherWhereTheBuildWroteOne(t *testing.T) {
|
||||
with := mcat.Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-credential"}}}
|
||||
Needed: map[string]map[string]string{"node-tools": {"broker": "sealed-credential"}}}
|
||||
launched := mcat.Manifest{Module: "dash", Version: "1", Tools: []string{"status"},
|
||||
Build: &mcat.Build{Artifacts: []mcat.Artifact{{Name: "tools", Kind: mcat.ArtifactBundle, Language: "typescript",
|
||||
Entrypoints: []string{"tools/index.js"}}}}}
|
||||
@@ -210,7 +222,7 @@ func TestTheRuntimeStartsTheLauncherWhereTheBuildWroteOne(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := fileNamed(out, mcat.RuntimeModule+"."+RuntimeProcessID())["env"].(map[string]string)
|
||||
env := fileNamed(out, "node-tools"+"."+RuntimeProcessID())["env"].(map[string]string)
|
||||
want := "dash=" + mcat.BundleRoot + "/dash/tools/tools/index.serve.mjs,nftables=" + mcat.BundleRoot + "/nftables/tools/tools/index.js"
|
||||
if env[mcat.RuntimeToolModules] != want {
|
||||
t.Errorf("the runtime is told %q, want %q", env[mcat.RuntimeToolModules], want)
|
||||
@@ -220,13 +232,13 @@ func TestTheRuntimeStartsTheLauncherWhereTheBuildWroteOne(t *testing.T) {
|
||||
// novox/hq ADR 0193: a runtime compiled to a binary runs itself from its own unpacked bundle.
|
||||
func TestARuntimeCompiledToABinaryRunsItself(t *testing.T) {
|
||||
with := mcat.Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-credential"}}}
|
||||
goRuntime := mcat.Manifest{Module: mcat.RuntimeModule, Version: "1",
|
||||
OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + mcat.RuntimeModule + "/broker"}},
|
||||
Needed: map[string]map[string]string{"node-tools": {"broker": "sealed-credential"}}}
|
||||
goRuntime := mcat.Manifest{Module: "node-tools", Version: "1", ToolRunner: true,
|
||||
OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + "node-tools" + "/broker"}},
|
||||
Build: &mcat.Build{Artifacts: []mcat.Artifact{{Name: "runtime", Kind: mcat.ArtifactBundle, Language: "go",
|
||||
System: "arch", From: "cmd/node-tools"}}}}
|
||||
goRuntime, err := goRuntime.Resolve([]mcat.Built{{Name: "runtime", Kind: mcat.ArtifactBundle,
|
||||
Reference: mcat.ArtifactStoreScheme + mcat.RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
Reference: mcat.ArtifactStoreScheme + "node-tools" + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -234,7 +246,7 @@ func TestARuntimeCompiledToABinaryRunsItself(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process := fileNamed(out, mcat.RuntimeModule+"."+RuntimeProcessID())
|
||||
process := fileNamed(out, "node-tools"+"."+RuntimeProcessID())
|
||||
if fmt.Sprint(process["run"]) != "[./node-tools]" {
|
||||
t.Errorf("a Go runtime is run as %v, want its own binary", process["run"])
|
||||
}
|
||||
@@ -249,13 +261,13 @@ func TestARuntimeCompiledToABinaryRunsItself(t *testing.T) {
|
||||
// which runs as the operator's account and is given none of its words.
|
||||
func TestAModuleOfItsOwnAccountIsServedByARuntimeOfItsOwn(t *testing.T) {
|
||||
with := mcat.Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-credential"}, "telegram": {"broker": "own"}}}
|
||||
goRuntime := mcat.Manifest{Module: mcat.RuntimeModule, Version: "1",
|
||||
OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + mcat.RuntimeModule + "/broker"}},
|
||||
Needed: map[string]map[string]string{"node-tools": {"broker": "sealed-credential"}, "telegram": {"broker": "own"}}}
|
||||
goRuntime := mcat.Manifest{Module: "node-tools", Version: "1", ToolRunner: true,
|
||||
OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + "node-tools" + "/broker"}},
|
||||
Build: &mcat.Build{Artifacts: []mcat.Artifact{{Name: "runtime", Kind: mcat.ArtifactBundle, Language: "go",
|
||||
System: "arch", From: "cmd/node-tools"}}}}
|
||||
goRuntime, err := goRuntime.Resolve([]mcat.Built{{Name: "runtime", Kind: mcat.ArtifactBundle,
|
||||
Reference: mcat.ArtifactStoreScheme + mcat.RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
Reference: mcat.ArtifactStoreScheme + "node-tools" + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -267,7 +279,7 @@ func TestAModuleOfItsOwnAccountIsServedByARuntimeOfItsOwn(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
machine := fileNamed(out, mcat.RuntimeModule+"."+RuntimeProcessID())
|
||||
machine := fileNamed(out, "node-tools"+"."+RuntimeProcessID())
|
||||
if served := machine["env"].(map[string]string)[mcat.RuntimeToolModules]; strings.Contains(served, "telegram") || !strings.Contains(served, "nftables") {
|
||||
t.Errorf("the machine's runtime serves %q", served)
|
||||
}
|
||||
@@ -342,13 +354,13 @@ func TestAModuleOfItsOwnAccountIsServedByARuntimeOfItsOwn(t *testing.T) {
|
||||
// as the account agents run as there — either would hand what it holds back to the accounts it is kept from.
|
||||
func TestAModuleOfItsOwnAccountIsRefusedTheOperatorsAndTheAgentsAccount(t *testing.T) {
|
||||
with := mcat.Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-credential"}, "telegram": {"broker": "own"}}}
|
||||
goRuntime := mcat.Manifest{Module: mcat.RuntimeModule, Version: "1",
|
||||
OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + mcat.RuntimeModule + "/broker"}},
|
||||
Needed: map[string]map[string]string{"node-tools": {"broker": "sealed-credential"}, "telegram": {"broker": "own"}}}
|
||||
goRuntime := mcat.Manifest{Module: "node-tools", Version: "1", ToolRunner: true,
|
||||
OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + "node-tools" + "/broker"}},
|
||||
Build: &mcat.Build{Artifacts: []mcat.Artifact{{Name: "runtime", Kind: mcat.ArtifactBundle, Language: "go",
|
||||
System: "arch", From: "cmd/node-tools"}}}}
|
||||
goRuntime, err := goRuntime.Resolve([]mcat.Built{{Name: "runtime", Kind: mcat.ArtifactBundle,
|
||||
Reference: mcat.ArtifactStoreScheme + mcat.RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
Reference: mcat.ArtifactStoreScheme + "node-tools" + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -368,16 +380,16 @@ func TestAModuleOfItsOwnAccountIsRefusedTheOperatorsAndTheAgentsAccount(t *testi
|
||||
// files it holds for its consumers, and what its words name — belongs to that account, never the operator's.
|
||||
func TestWhatAModuleOfItsOwnAccountReadsIsItsAccounts(t *testing.T) {
|
||||
with := mcat.Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-credential"}, "vault": {"broker": "own"}},
|
||||
Needed: map[string]map[string]string{"node-tools": {"broker": "sealed-credential"}, "vault": {"broker": "own"}},
|
||||
Kept: &mcat.KeptExport{Export: 1, OperatorKey: "k"},
|
||||
Grants: []mcat.Grant{{Provision: "secret", Consumer: "anchor", From: "telegram", Sealed: "sealed-to-anchor",
|
||||
Values: map[string]any{"name": "telegram"}}}}
|
||||
goRuntime := mcat.Manifest{Module: mcat.RuntimeModule, Version: "1",
|
||||
OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + mcat.RuntimeModule + "/broker"}},
|
||||
goRuntime := mcat.Manifest{Module: "node-tools", Version: "1", ToolRunner: true,
|
||||
OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + "node-tools" + "/broker"}},
|
||||
Build: &mcat.Build{Artifacts: []mcat.Artifact{{Name: "runtime", Kind: mcat.ArtifactBundle, Language: "go",
|
||||
System: "arch", From: "cmd/node-tools"}}}}
|
||||
goRuntime, err := goRuntime.Resolve([]mcat.Built{{Name: "runtime", Kind: mcat.ArtifactBundle,
|
||||
Reference: mcat.ArtifactStoreScheme + mcat.RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
Reference: mcat.ArtifactStoreScheme + "node-tools" + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -193,7 +193,8 @@ var defaultSeats = append([]mcat.Seat{
|
||||
// images and archives, by digest — which is why the provision is the artifact store and not an
|
||||
// image registry.
|
||||
{Name: "mesh-artifact-store", Scope: mcat.ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"},
|
||||
{Name: "mesh-catalog", Scope: mcat.ScopeMesh, Decision: "novox/hq ADR 0121"},
|
||||
// The catalog (novox/hq ADR 0604): the SDK's entry with its four verbs, so the copies of the table agree.
|
||||
sdkSeat(mcat.CatalogSeat),
|
||||
// The delivery's owner (novox/hq ADR 0239): one commit's journey from its pull request's head to every
|
||||
// machine, and a group of them, recorded and ordered by its holder, which asks the controller for each act.
|
||||
// What it says it says as its module (`mesh-delivery.transition`), through the runtime that launches it.
|
||||
@@ -649,3 +650,12 @@ func vaultIntake(first map[string]string) map[string]string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sdkSeat is the SDK's entry for a seat, taken whole, so this copy of the table cannot disagree with it.
|
||||
func sdkSeat(name string) mcat.Seat {
|
||||
seat, ok := mcat.SeatNamed(name)
|
||||
if !ok {
|
||||
panic("the SDK's seat table holds no seat " + name)
|
||||
}
|
||||
return seat
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -69,4 +70,23 @@ func TestTheHolderIsTheModuleNotTheMachine(t *testing.T) {
|
||||
if !held || holder.Module != "gitea" {
|
||||
t.Fatalf("the holder was not told apart from a neighbour: %+v", holder)
|
||||
}
|
||||
|
||||
// ADR 0604 section 1, F3: the seat table written here answers as the SDK's does for the catalog seat, its four
|
||||
// verbs included, so no two copies of the table disagree.
|
||||
want, ok := mcat.SeatNamed(mcat.CatalogSeat)
|
||||
if !ok {
|
||||
t.Fatal("the SDK holds no catalog seat")
|
||||
}
|
||||
var have *mcat.Seat
|
||||
for i := range defaultSeats {
|
||||
if defaultSeats[i].Name == mcat.CatalogSeat {
|
||||
have = &defaultSeats[i]
|
||||
}
|
||||
}
|
||||
if have == nil {
|
||||
t.Fatal("the table here holds no catalog seat")
|
||||
}
|
||||
if !reflect.DeepEqual(*have, want) {
|
||||
t.Errorf("the table here has %+v, the SDK's is %+v", *have, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/composer"
|
||||
@@ -149,6 +150,14 @@ func runCase(r Request, c Case, depth string) (CaseVerdict, error) {
|
||||
for name, m := range base.Catalogue {
|
||||
shelf[name] = m
|
||||
}
|
||||
// A module the input's node-engine holds as refused (compose.Input.Refused: its verdict, or bytes that do not decode) is
|
||||
// held here as there, and its read says why, whatever the case's own verdicts: the check then names the cause the node
|
||||
// holds it for, never a pass the node would not compose, nor a rule about its stub (issue 774, group check 278aa5f0ccbe).
|
||||
for name, why := range base.Refused {
|
||||
if read, carried := cv.Manifests[name]; carried && read.Accepted {
|
||||
cv.Manifests[name] = ManifestRead{Refused: "its node-engine holds it: " + why}
|
||||
}
|
||||
}
|
||||
in.Catalogue = shelf
|
||||
in.Nodes = slices.Clone(in.Nodes)
|
||||
in.Holdings = slices.Clone(in.Holdings)
|
||||
@@ -205,8 +214,25 @@ func machine(in compose.Input, node, depth string, declarations bool) Machine {
|
||||
}
|
||||
return Machine{Composes: true}
|
||||
}
|
||||
// Composed as the node-engine composes it, when its input says how (compose.Input.ComposedHow, issue 774): the same
|
||||
// path, at the same builds, a module folded in its own composition kept as it runs.
|
||||
if !declarations {
|
||||
if _, how, err := frombus.ComposeSent(in, node); how {
|
||||
if err != nil {
|
||||
return Machine{Reason: err.Error()}
|
||||
}
|
||||
return Machine{Composes: true}
|
||||
}
|
||||
}
|
||||
d, problems, err := compose.Compose(in, node)
|
||||
if err != nil {
|
||||
// An input from a node-engine before ComposedHow says nothing of what runs of the modules folded in the node's
|
||||
// own composition: the sandbox cannot compose them as the node does, so it does not judge the node, and names
|
||||
// it (ADR 0593 §9), never a failure of the change and never a silent pass.
|
||||
if folded := foldedUnsaid(in, node); len(folded) > 0 && in.ComposedHow == nil {
|
||||
return Machine{NotJudged: NotJudged + strings.Join(folded, ", ") +
|
||||
" (its own composition carries them as they run, and the sandbox cannot): " + err.Error()}
|
||||
}
|
||||
return Machine{Reason: err.Error()}
|
||||
}
|
||||
m := Machine{Composes: len(problems) == 0, Problems: problems, Refusals: d.Refusals()}
|
||||
@@ -219,6 +245,33 @@ func machine(in compose.Input, node, depth string, declarations bool) Machine {
|
||||
return m
|
||||
}
|
||||
|
||||
// NotJudged opens the words of a machine the sandbox does not judge (Machine.NotJudged, the contract's field, ADR 0593 §9):
|
||||
// its node-engine sent an input that does not say how it composes (compose.Input.ComposedHow), and the node runs modules
|
||||
// folded in its own composition. The machine is then neither composed nor refused; a reader decides by the field.
|
||||
const NotJudged = "not judged: its node-engine sends no running state for "
|
||||
|
||||
// foldedUnsaid is each module node runs folded in its own composition (Node.InOwn) whose manifest names a build: what
|
||||
// the node composes as it runs, and an input without ComposedHow cannot.
|
||||
func foldedUnsaid(in compose.Input, node string) []string {
|
||||
var out []string
|
||||
for _, n := range in.Nodes {
|
||||
if n.Name != node {
|
||||
continue
|
||||
}
|
||||
for m, ok := range n.InOwn {
|
||||
man, known := n.Kept[m]
|
||||
if !known {
|
||||
man, known = in.Catalogue[m]
|
||||
}
|
||||
if ok && known && compose.NamesBuild(man) {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// pathsOf is every resource of a declaration that names a path, with its owner, group and mode as declared, sorted by
|
||||
// path.
|
||||
func pathsOf(d compose.Declared) []Path {
|
||||
|
||||
@@ -108,6 +108,118 @@ func TestARequestItCannotRunIsRefused(t *testing.T) {
|
||||
t.Errorf("the sandbox composed n1 %+v; the bus reader declared %+v", got, want)
|
||||
}
|
||||
})
|
||||
// Issue 774 (the check of the bus want, the third of a kind: the sandbox's input differs from what the bus reader
|
||||
// composes): a node like the bus node, its bus module folded in its own composition at a build of the old form and no
|
||||
// build published for the module's server artifact, composes in the sandbox as its node-engine composes it, from the
|
||||
// input that says how (compose.Input.ComposedHow); from an older engine's input, which does not, it is named not
|
||||
// judged, never failed and never passed in silence.
|
||||
t.Run("a module folded in the node's own composition", func(t *testing.T) {
|
||||
bus := `{"module": "bus", "version": "1", "claims": [{"name": "node-files"}], ` +
|
||||
`"resources": [{"id": "server", "type": "container", "name": "bus-server", "artifact": "server"}], ` +
|
||||
`"build": {"artifacts": [{"name": "server", "kind": "upstream", "from": "nats:2.11@sha256:` + strings.Repeat("a", 64) + `"}]}}`
|
||||
man, err := catalogue.Decode([]byte(bus))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
node := compose.Node{Name: "a", Assigned: []string{"bus"}, InOwn: map[string]bool{"bus": true},
|
||||
RunsOwn: &delivery.Composed{Node: "a"}}
|
||||
in := compose.Input{Catalogue: map[string]catalogue.Manifest{"bus": man}, Nodes: []compose.Node{node}}
|
||||
request := func(in compose.Input) Request {
|
||||
return withInput(t, Request{V: Version, Machines: []string{"a"}, Depth: DepthCompose,
|
||||
Cases: []Case{{Name: "change", Manifests: map[string]Manifest{}}}}, in)
|
||||
}
|
||||
older, err := Run(request(in))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m := older.Cases[0].Machines["a"]; m.Composes || !strings.HasPrefix(m.NotJudged, NotJudged+"bus") || m.Reason != "" {
|
||||
t.Errorf("an older engine's input of a node with a folded module: %+v", m)
|
||||
}
|
||||
in.ComposedHow = &compose.ComposedHow{Node: "a", Builds: map[string]string{}}
|
||||
sent, err := Run(request(in))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m := sent.Cases[0].Machines["a"]; !m.Composes || m.Reason != "" {
|
||||
t.Errorf("the input that says how the node composes, its bus folded: %+v", m)
|
||||
}
|
||||
// A module the change refuses beside the folded one fails the node in its words, never passed with the rest
|
||||
// composed (review of the sandbox input); held already where the node-engine composed, it stays held as there.
|
||||
app, err := catalogue.Decode([]byte(`{"module": "app", "version": "1", ` +
|
||||
`"resources": [{"id": "env", "type": "file", "path": "/var/lib/app/env", "mode": "0600", "content": "N=${secret:repository}\n"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.Catalogue["app"] = app
|
||||
in.Nodes[0].Assigned = []string{"bus", "app"}
|
||||
refused, err := Run(request(in))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m := refused.Cases[0].Machines["a"]; m.Composes || !strings.Contains(m.Reason, "app: ") || !strings.Contains(m.Reason, "repository") {
|
||||
t.Errorf("a module the change refuses on a node composed as its node-engine does: %+v", m)
|
||||
}
|
||||
// Held composing without the change, blamed for a refusal beyond the read (issue 1960's unset names): no refusal of
|
||||
// the change either (review round 1).
|
||||
in.ComposedHow.HeldBefore = map[string]string{"app": "its route names are unset"}
|
||||
before, err := Run(request(in))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m := before.Cases[0].Machines["a"]; !m.Composes {
|
||||
t.Errorf("a module the node holds composing without the change: %+v", m)
|
||||
}
|
||||
in.ComposedHow.HeldBefore = nil
|
||||
in.ComposedHow.Held = map[string]string{"app": "held on the node"}
|
||||
held, err := Run(request(in))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m := held.Cases[0].Machines["a"]; !m.Composes {
|
||||
t.Errorf("a module the node-engine holds too: %+v", m)
|
||||
}
|
||||
})
|
||||
// Issue 774 (the group check of feat/l3-final 278aa5f0ccbe): a tool runner its node-engine holds by its manifest's
|
||||
// verdict is sent as its stub, and the input says it holds it: the sandbox composes the node with it held, never refuses
|
||||
// it for its stub's missing bundle. An input from an engine before the mark is read by Refused alone, the same way.
|
||||
t.Run("a refused tool runner the engine sends as its stub", func(t *testing.T) {
|
||||
raw := `{"module": "runner", "version": "1", "tool-runner": true, ` +
|
||||
`"build": {"artifacts": [{"name": "runtime", "kind": "bundle", "language": "go", "from": "cmd/runner", "binary": "runner"}]}}`
|
||||
man, err := catalogue.Decode([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := compose.Input{Catalogue: map[string]catalogue.Manifest{"runner": compose.Stub(man)},
|
||||
Refused: map[string]string{"runner": "the indexer's judge refused the manifest of runner"},
|
||||
Nodes: []compose.Node{{Name: "a", Assigned: []string{"runner"}}}}
|
||||
wire, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var back compose.Input
|
||||
if err := json.Unmarshal(wire, &back); err != nil || !back.Catalogue["runner"].Stubbed || !strings.Contains(string(wire), `"Held"`) {
|
||||
t.Fatalf("the input's held mark across the wire: %s, %v", wire, err)
|
||||
}
|
||||
older := strings.Replace(string(wire), `"Held":{"Catalogue":["runner"]}`, `"Unsaid":null`, 1)
|
||||
for name, sent := range map[string]string{"with the mark": string(wire), "from an engine before it": older} {
|
||||
var r Request
|
||||
r = Request{V: Version, Machines: []string{"a"}, Depth: DepthCompose,
|
||||
Cases: []Case{{Name: "change", Manifests: map[string]Manifest{"runner": {Raw: json.RawMessage(raw)}}}}}
|
||||
if err := json.Unmarshal([]byte(sent), &r.Input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err := Run(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m := v.Cases[0].Machines["a"]; !m.Composes || strings.Contains(m.Reason, "bundle") {
|
||||
t.Errorf("%s: the node with its tool runner held: %+v", name, m)
|
||||
}
|
||||
if read := v.Cases[0].Manifests["runner"]; read.Accepted || !strings.Contains(read.Refused, "the indexer's judge refused the manifest of runner") {
|
||||
t.Errorf("%s: the runner's read names not why its node-engine holds it: %+v", name, read)
|
||||
}
|
||||
}
|
||||
})
|
||||
v, err := Run(Request{V: Version, Cases: []Case{{Name: "only-read", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}}})
|
||||
if err != nil || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted || v.Cases[0].Machines != nil || v.SDK == "" {
|
||||
t.Fatalf("a request with no machines: %+v, %v", v, err)
|
||||
|
||||
@@ -374,8 +374,9 @@ func (q *Session) writeHaves(ctx context.Context, bus Bus) {
|
||||
f := s.modules[module]
|
||||
h := delivery.Have{V: delivery.Version, FP: f.FP, Composed: f.Composed, Build: f.Build,
|
||||
Phase: delivery.PhaseRunning, Health: delivery.HealthUnknown, Detail: f.Detail}
|
||||
h.Kept = f.Kept
|
||||
if delivery.Phase(f.Phase) == delivery.PhaseWaitingYes {
|
||||
h.Judgement, h.Waits = delivery.Judgement(f.Judgement), f.Waits
|
||||
h.Judgement, h.Waits, h.BuildAlone = delivery.Judgement(f.Judgement), f.Waits, f.BuildAlone
|
||||
}
|
||||
switch {
|
||||
case f.Phase != "":
|
||||
|
||||
@@ -270,7 +270,8 @@ func TestTheNodeKeyCarriesTheNodesFacts(t *testing.T) {
|
||||
}
|
||||
wantRecord := delivery.NodeRecord{V: delivery.Version, Node: "n1", TunnelKey: "tk", SealingKey: "sk", ServingKey: "pk",
|
||||
Certificate: "cert", Trusts: keys.Trusts, Capabilities: []string{"apt", "systemd"}, OutwardLinks: []string{"eth0"}, NetworksSaid: true,
|
||||
Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}}, Contract: NodeKeyContract}
|
||||
Networks: []catalogue.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) {
|
||||
t.Errorf("record = %+v, want %+v", *got.Record, wantRecord)
|
||||
}
|
||||
|
||||
@@ -639,6 +639,10 @@ 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 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 []string
|
||||
// Sealed is the values the composition it names carries sealed to this node's key, by slot (delivery.Have.SealedAt).
|
||||
Sealed delivery.Seals
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package link
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
@@ -27,6 +28,11 @@ type RecordKeys struct {
|
||||
Trusts []string
|
||||
}
|
||||
|
||||
// manifestReads is what this engine reads of a manifest (novox/hq ADR 0614 §4): the reader capabilities of the mesh-sdk it
|
||||
// is built with, stated in its record so the assigner moves a want only to an engine that reads its manifest. Computed
|
||||
// once: it is the build's, and never changes while it runs.
|
||||
var manifestReads = sync.OnceValue(catalogue.ManifestReads)
|
||||
|
||||
// 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 {
|
||||
@@ -47,7 +53,8 @@ func recordOf(node string, r Report, keys RecordKeys) *delivery.NodeRecord {
|
||||
return nil
|
||||
}
|
||||
rec := &delivery.NodeRecord{V: delivery.Version, Node: node, TunnelKey: keys.TunnelKey, SealingKey: keys.SealingKey,
|
||||
ServingKey: keys.ServingKey, Certificate: keys.Certificate, Authority: keys.Authority, Trusts: keys.Trusts}
|
||||
ServingKey: keys.ServingKey, Certificate: keys.Certificate, Authority: keys.Authority, Trusts: keys.Trusts,
|
||||
ManifestReads: manifestReads()}
|
||||
for _, c := range p.Capabilities {
|
||||
if c.Present && c.Name != "" {
|
||||
rec.Capabilities = append(rec.Capabilities, c.Name)
|
||||
|
||||
@@ -181,14 +181,19 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask delivery
|
||||
if err != nil {
|
||||
return unready(err.Error())
|
||||
}
|
||||
laidComps, laidHeld, laidErr := laid.composeShadow(ctx, running, touched, func(scope frombus.Scope) {
|
||||
laidComps, laidHeld, laidErr := laid.composeShadow(ctx, running, touched, func(scope frombus.Scope, fps map[string]string) {
|
||||
if !ask.Input {
|
||||
return
|
||||
}
|
||||
in, err := frombus.Input(ctx, reader{c: laid}, laid.env.Mesh, scope)
|
||||
// Read as the composition reads it, at the builds it composes at, with how it composes (compose.Input.ComposedHow),
|
||||
// so the sandbox composes this node through the same path (issue 774).
|
||||
in, err := frombus.ComposingInput(ctx, reader{c: laid}, laid.env.Mesh, scope, fps)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// What the node holds composing without the candidate, a module blamed for a refusal among them, is no refusal of
|
||||
// the change (review round 1).
|
||||
in.ComposedHow.HeldBefore = maps.Clone(baseHeld)
|
||||
// As the sandbox reads it (composer.SentInput, the one shape): this node's own modules as taken in from their
|
||||
// builds, every other module's manifest left to the cases.
|
||||
if body, err := json.Marshal(composer.SentInput(in, laid.env.Node)); err == nil {
|
||||
@@ -411,7 +416,7 @@ func (c *Composer) layCandidate(ctx context.Context, cand delivery.Candidate) (m
|
||||
// composeShadow composes this node from what c holds, as the shadow composes it (shadowBuilds): each module at the build
|
||||
// it runs, one placed and not run at the indexer's, and one the candidate laid (laid) at the candidate's. input, when
|
||||
// given, is handed the scope.
|
||||
func (c *Composer) composeShadow(ctx context.Context, running []delivery.Composed, laid map[string]bool, input func(frombus.Scope)) (map[string]delivery.Composed, map[string]string, error) {
|
||||
func (c *Composer) composeShadow(ctx context.Context, running []delivery.Composed, laid map[string]bool, input func(frombus.Scope, map[string]string)) (map[string]delivery.Composed, map[string]string, error) {
|
||||
node := c.env.Node
|
||||
c.mu.Lock()
|
||||
placement, placed := c.placements[node]
|
||||
@@ -444,7 +449,7 @@ func (c *Composer) composeShadow(ctx context.Context, running []delivery.Compose
|
||||
c.mu.Unlock()
|
||||
scope := frombus.Scope{Node: node, KeptAt: kept, InOwn: inOwn, Running: running, SameValue: c.sameValue}
|
||||
if input != nil {
|
||||
input(scope)
|
||||
input(scope, fps)
|
||||
}
|
||||
comps, _, held, err := frombus.Compose(ctx, reader{c: c}, c.env.Mesh, scope, fps)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package selfcompose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"github.com/novox/mesh-host/internal/agent"
|
||||
"github.com/novox/mesh-host/internal/compose"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0593 §7: the verb compose-candidate composes a check's candidate on this node in shadow. A candidate that
|
||||
@@ -105,6 +107,14 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) {
|
||||
a.Own.Module != delivery.HaveNodeWord || a.Took > delivery.ComposeAnswerBound.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
|
||||
// through the same path.
|
||||
sent := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: accepted, Input: true})
|
||||
var in compose.Input
|
||||
if err := json.Unmarshal(sent.Input, &in); err != nil || in.ComposedHow == nil || in.ComposedHow.Node != "n1" ||
|
||||
in.ComposedHow.Builds["lemurs"] != fpNew {
|
||||
t.Errorf("the input sent for the sandbox does not say how the node composes: %+v, %v", in.ComposedHow, err)
|
||||
}
|
||||
r := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: refusedKey})
|
||||
if r.Verdict != delivery.ComposeCandidateRefused || len(r.Modules) != 1 || r.Modules[0].State != delivery.ModuleRefusedState ||
|
||||
!strings.Contains(r.Modules[0].Words, "a field the judge refuses") {
|
||||
|
||||
@@ -78,6 +78,9 @@ type Env struct {
|
||||
// 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 delivery.Seals, legacy []string, err error)
|
||||
// Kept is told, after each pass that composed, what the node keeps of each module as it runs because its manifest does
|
||||
// not yet say it (ADR 0601 §4), by module, so each module's have carries it (agent.KeptNow). Nil tells nobody.
|
||||
Kept func(kept map[string][]string)
|
||||
// Fetch reads an archive at a URL in the artifact store: a moved module's templates archive, whose texts its built
|
||||
// manifest carries (issue 1126). Read once per digest. Nil reads none, and a moved module does not compose.
|
||||
Fetch func(ctx context.Context, url string) ([]byte, error)
|
||||
@@ -215,6 +218,13 @@ type Pass struct {
|
||||
Earlier bool `json:"earlier,omitempty"`
|
||||
}
|
||||
|
||||
// LastHeld is what the last pass held, each module with why; nil before a pass or when it held none.
|
||||
func (c *Composer) LastHeld() map[string]string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return maps.Clone(c.last.Held)
|
||||
}
|
||||
|
||||
// askedWithin is how recent a pass answers an ask of the verb compose without a new one: the first wait after a seat
|
||||
// that did not answer (laterAgain's first).
|
||||
var askedWithin = 5 * time.Second
|
||||
@@ -844,7 +854,18 @@ func (c *Composer) compose(ctx context.Context) []agent.Update {
|
||||
}
|
||||
// Its own certificate, asked of the vault when a module here asks for one and the record holds none it trusts.
|
||||
c.certifyIfWanted(ctx, r, own)
|
||||
scope := frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn, Running: running}
|
||||
// What it keeps of a module as it runs, for a manifest that does not say it yet (ADR 0601 §4): said, and carried on the
|
||||
// module's have, from which the watchman raises its condition.
|
||||
keptRuns := map[string][]string{}
|
||||
var keptMu sync.Mutex
|
||||
scope := frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn, Running: running, Kept: func(module, words string) {
|
||||
keptMu.Lock()
|
||||
defer keptMu.Unlock()
|
||||
if !slices.Contains(keptRuns[module], words) {
|
||||
keptRuns[module] = append(keptRuns[module], words)
|
||||
}
|
||||
c.sayOnceEach(words)
|
||||
}}
|
||||
c.mu.Lock()
|
||||
c.dataScope = frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn} // the data items are worked out as composed
|
||||
c.mu.Unlock()
|
||||
@@ -901,11 +922,23 @@ func (c *Composer) compose(ctx context.Context) []agent.Update {
|
||||
}
|
||||
c.paid()
|
||||
pass.Held = held
|
||||
// The provisioner client's account the node runs with, dropped by this pass, is said: every filled write is refused
|
||||
// under a declaration that names none (filling.JudgeAccount), and no module is handed to it (issue 774, ace,
|
||||
// 2026-11-04 01:29). A placed client keeps its account while it is held (compose.Node.RunningProvisioner).
|
||||
if was, now := ownProvisioner(running), ownProvisioner(mapValues(compositions)); was != "" && now == "" && nodeWanted {
|
||||
c.sayOnceEach(fmt.Sprintf("the node's own composition names no provisioner client's account, where what runs names %s: "+
|
||||
"once applied, every filled write is refused and no module is handed to the provisioner", was))
|
||||
}
|
||||
// A module refused alone is held: it keeps what it runs, its have says why, and every other module is applied (issue
|
||||
// 1126: one module's refusal froze the whole node).
|
||||
done := c.waiting("the agent marking each held module's have")
|
||||
c.refused("", held)
|
||||
done()
|
||||
if c.env.Kept != nil {
|
||||
keptMu.Lock()
|
||||
c.env.Kept(keptRuns)
|
||||
keptMu.Unlock()
|
||||
}
|
||||
if len(held) == 0 {
|
||||
c.sayOnce("")
|
||||
} else {
|
||||
@@ -1635,3 +1668,66 @@ func Transient(err error) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ComposedToday is the digest of module composed at the build fp with today's settings and secrets, as this node would
|
||||
// compose it now beside its other modules at their wanted builds, the boxes it runs kept as compose keeps them, and whether
|
||||
// it composed (novox/hq ADR 0601 §1): the node agent compares it with what runs, to say whether a move to another build
|
||||
// changes the build alone. A module held, a node refused, or no placement: false, and the move is asked of a person.
|
||||
func (c *Composer) ComposedToday(ctx context.Context, module, fp string) (string, bool) {
|
||||
c.mu.Lock()
|
||||
fps, kept := map[string]string{}, map[string]string{}
|
||||
for m, w := range c.wants {
|
||||
if m == agent.NodeModule {
|
||||
continue
|
||||
}
|
||||
fps[m] = w.FP
|
||||
if mm, ok := c.modules[m]; ok && mm.FP2 != w.FP {
|
||||
kept[m] = w.FP
|
||||
}
|
||||
}
|
||||
fps[module] = fp
|
||||
delete(kept, module)
|
||||
if mm, ok := c.modules[module]; !ok || mm.FP2 != fp {
|
||||
kept[module] = fp
|
||||
}
|
||||
own, placed := c.placements[c.env.Node]
|
||||
inOwn := c.inOwnOf(own)
|
||||
c.mu.Unlock()
|
||||
if !placed {
|
||||
return "", false
|
||||
}
|
||||
var running []delivery.Composed
|
||||
if c.env.Running != nil {
|
||||
running = c.env.Running()
|
||||
}
|
||||
scope := frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn, Running: running}
|
||||
comps, _, held, err := frombus.Compose(ctx, reader{c: c}, c.env.Mesh, scope, fps)
|
||||
if err != nil || held[module] != "" {
|
||||
return "", false
|
||||
}
|
||||
for _, comp := range comps {
|
||||
if comp.Module == module && comp.FP == fp {
|
||||
dig, err := delivery.ComposedDigestOf(c.keepRunning(comp, running))
|
||||
return dig, err == nil
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ownProvisioner is the provisioner client's account the node's own composition among cs names, or "".
|
||||
func ownProvisioner(cs []delivery.Composed) string {
|
||||
for _, c := range cs {
|
||||
if c.Module == "" {
|
||||
return c.Own.Provisioner
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mapValues(m map[string]delivery.Composed) []delivery.Composed {
|
||||
out := make([]delivery.Composed, 0, len(m))
|
||||
for _, k := range slices.Sorted(maps.Keys(m)) {
|
||||
out = append(out, m[k])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -268,6 +268,31 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
|
||||
b.mu.Unlock()
|
||||
t.Fatalf("handed %v, want %s and %s", got, lemursKey, nodeKey)
|
||||
}
|
||||
// ADR 0601 §1: the module composed today at the build it runs, as the agent compares it with what runs. The same inputs give
|
||||
// the digest handed; a changed input (here its manifest's service, as a changed setting would change a file) another; a
|
||||
// build that cannot be composed (no done build or manifest of it: held) gives none.
|
||||
c.mu.Lock()
|
||||
handedDig := c.handed[lemursKey]
|
||||
c.mu.Unlock()
|
||||
if dig, ok := c.ComposedToday(ctx, "lemurs", fp); !ok || dig != handedDig {
|
||||
t.Errorf("composed today at the build it runs: %s %v, want %s", dig, ok, handedDig)
|
||||
}
|
||||
if dig, ok := c.ComposedToday(ctx, "lemurs", delivery.SourcePrefix2+strings.Repeat("9", 64)); ok {
|
||||
t.Errorf("a build that cannot be composed was composed today: %s", dig)
|
||||
}
|
||||
chgJSON := strings.Replace(lemurs, `"state":"running"`, `"state":"stopped"`, 1)
|
||||
chgDigest := delivery.ManifestDigest([]byte(chgJSON))
|
||||
chgMan, _ := delivery.EncodeManifest(delivery.Manifest{Module: "lemurs", JSON: chgJSON})
|
||||
chgKey, _ := delivery.ManifestsKey(chgDigest)
|
||||
b.mu.Lock()
|
||||
b.kv[delivery.ManifestsBucket+"/"+chgKey] = chgMan
|
||||
b.mu.Unlock()
|
||||
chgMod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: chgDigest, Repository: "novox/x", Commit: "c"})
|
||||
c.takeModule(agent.Update{Key: "lemurs", Value: chgMod})
|
||||
if dig, ok := c.ComposedToday(ctx, "lemurs", fp); !ok || dig == handedDig {
|
||||
t.Errorf("composed today with a changed input: %s %v, want another digest than %s", dig, ok, handedDig)
|
||||
}
|
||||
c.takeModule(agent.Update{Key: "lemurs", Value: mod})
|
||||
|
||||
// Issue 1126, the switch made safe: a bus not answering for a moment (a reload of its accounts) is tried again and
|
||||
// refuses nothing; a pass whose run ended refuses nothing either.
|
||||
|
||||
@@ -9,6 +9,7 @@ package witness
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
)
|
||||
|
||||
@@ -33,15 +34,13 @@ const (
|
||||
GiveUp = 30 * time.Minute
|
||||
)
|
||||
|
||||
// NodeToolsService is the runtime's name on the services protocol, and its instance is this
|
||||
// machine's node name: what `$SRV.PING.<service>.<node>` asks, so only this machine's runtime can
|
||||
// answer (mesh-tools node-tools internal/runtime, announce.Service{Name: module, ID: node}).
|
||||
const NodeToolsService = "node-tools"
|
||||
|
||||
// Default is the witness a process is judged by when it names none: the core process the mesh composes on every
|
||||
// machine, by the name it composes it under, and nothing else.
|
||||
func Default(process string) string {
|
||||
if process == NodeToolsService {
|
||||
// Default is the witness a process is judged by when it names none: the tool runner's process, the core process the mesh
|
||||
// composes on every machine for the module whose manifest says it is the tool runner (catalogue.IsToolRunner), with the id
|
||||
// `<its module>.runtime` and named as its module (resolution's runtime process), and nothing else. It is pinged by that
|
||||
// name on this machine's instance, `$SRV.PING.<name>.<node>`, so only this machine's runtime answers; never known here by
|
||||
// a module's name (ADR 0601 §4).
|
||||
func Default(id, process string) string {
|
||||
if process != "" && id == process+"."+resolution.RuntimeProcessID() {
|
||||
return ByPing
|
||||
}
|
||||
return ByNone
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
// The runtime is asked by its own name and this machine's: only this machine's runtime can answer.
|
||||
func TestTheRuntimeIsAskedByItsNameAndThisMachines(t *testing.T) {
|
||||
if got := link.PingSubject(NodeToolsService, "anchor"); got != "$SRV.PING.node-tools.anchor" {
|
||||
if got := link.PingSubject("runner-x", "anchor"); got != "$SRV.PING.runner-x.anchor" {
|
||||
t.Fatalf("the host asks %s; the runtime answers $SRV.PING.node-tools.<node>", got)
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,12 @@ func TestTheRuntimeIsAskedByItsNameAndThisMachines(t *testing.T) {
|
||||
// What is judged by default is the mesh's core process on every machine, by the name it is composed under, and
|
||||
// nothing else: the controller ended (issue 1126), so its name is judged as any other.
|
||||
func TestOnlyTheCoreProcessIsJudgedByDefault(t *testing.T) {
|
||||
for name, want := range map[string]string{"mesh-controller": ByNone, "node-tools": ByPing, "greeter": ByNone} {
|
||||
if got := Default(name); got != want {
|
||||
t.Errorf("%s is judged by %q, want %q", name, got, want)
|
||||
// The tool runner's process by its id and name, whatever its module is called (ADR 0601 §4); a process named so by
|
||||
// another id, or another process, is not.
|
||||
for id, want := range map[string]string{"runner-x.runtime": ByPing, "node-tools.runtime": ByNone, "runner-x.server": ByNone,
|
||||
"mesh-controller.runtime": ByNone} {
|
||||
if got := Default(id, "runner-x"); got != want {
|
||||
t.Errorf("%s is judged by %q, want %q", id, got, want)
|
||||
}
|
||||
}
|
||||
if NodeToolsWithin != 60*time.Second || PingWithin != 5*time.Second {
|
||||
|
||||
@@ -75,6 +75,13 @@ func TestTheManifestConsumesTheBackupEvent(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ADR 0614 §4: the node-engine's own manifest uses only words every running engine reads (the baseline, what an engine
|
||||
// stating none is read as), so its new build is never held for a word only it reads.
|
||||
if needs, err := catalogue.ManifestNeeds(raw); err != nil || len(needs) == 0 {
|
||||
t.Fatalf("the manifest's needs: %v, %v", needs, err)
|
||||
} else if missing := catalogue.MissingManifestReads(needs, nil); len(missing) != 0 {
|
||||
t.Errorf("the manifest needs %v, which an engine stating nothing does not read: its new build would be held", missing)
|
||||
}
|
||||
// 774 (the alert table): the state health, one key per node, the node's own health report, kept until
|
||||
// written again (no TTL: a node that stops writing keeps its last word, which the watchman reads with its time).
|
||||
// ADR 0577 clause 1: and the state data, one key per module placed on the node that declares data items, kept until
|
||||
|
||||
Reference in New Issue
Block a user