ADR 0593 §6: a composition read from composed-<node> is a record never applied, and the engine applies only what it composes itself, so no composition carries a signature and nothing is verified. The agent's trusted signers, its signed-composition test and the unused signing helper of the integration bed go, so mesh-sdk can drop SignComposed, VerifyComposed and Composed.Signature. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
174 lines
6.8 KiB
Go
174 lines
6.8 KiB
Go
package selfcompose
|
|
|
|
import (
|
|
"context"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
|
|
"github.com/novox/mesh-host/internal/agent"
|
|
)
|
|
|
|
// fakeComposed is a node's composition state: its keys, and every write and deletion made.
|
|
type fakeComposed struct {
|
|
mu sync.Mutex
|
|
kv map[string]ComposedEntry
|
|
rev uint64
|
|
puts []string
|
|
deletes []string
|
|
}
|
|
|
|
func (f *fakeComposed) Entries(context.Context) (map[string]ComposedEntry, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := map[string]ComposedEntry{}
|
|
for k, v := range f.kv {
|
|
out[k] = v
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeComposed) Put(_ context.Context, key string, value []byte) (uint64, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.rev++
|
|
f.kv[key] = ComposedEntry{Value: value, Revision: f.rev}
|
|
f.puts = append(f.puts, key)
|
|
return f.rev, nil
|
|
}
|
|
|
|
func (f *fakeComposed) Delete(_ context.Context, key string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
delete(f.kv, key)
|
|
f.deletes = append(f.deletes, key)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeComposed) written() []string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return slices.Clone(f.puts)
|
|
}
|
|
|
|
// novox/hq ADR 0593 §6: the node publishes each composition it makes in its composition state, a record never applied:
|
|
// the wanted module and the node's own at the first pass; nothing again for a pass that changes nothing; a done build
|
|
// of a module placed here that no want names, at its trunk's build, written once as its own key (before any want moves);
|
|
// the same build landing again writes nothing. And a module's keys are kept while among the five it wrote last or named
|
|
// by a want, and deleted otherwise.
|
|
func TestTheNodePublishesItsCompositions(t *testing.T) {
|
|
b := newFakeBus()
|
|
fp := delivery.SourcePrefix2 + strings.Repeat("a", 64)
|
|
ofp := delivery.SourcePrefix2 + strings.Repeat("c", 64)
|
|
digest := delivery.ManifestDigest([]byte(lemurs))
|
|
oldie := `{"module":"oldie","version":"1","capabilities":["systemd"],"resources":[{"id":"service","type":"service","unit":"oldie.service","state":"running","boot":"enabled"}]}`
|
|
odigest := delivery.ManifestDigest([]byte(oldie))
|
|
for d, json := range map[string]string{digest: lemurs, odigest: oldie} {
|
|
man, _ := delivery.EncodeManifest(delivery.Manifest{Module: map[string]string{digest: "lemurs", odigest: "oldie"}[d], JSON: json})
|
|
key, _ := delivery.ManifestsKey(d)
|
|
b.kv[delivery.ManifestsBucket+"/"+key] = man
|
|
}
|
|
state := &fakeComposed{kv: map[string]ComposedEntry{}}
|
|
env := b.env()
|
|
env.ComposedState = state
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
c := New(env)
|
|
out, err := c.Watch(ctx, "n1.>")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"},
|
|
Modules: []delivery.PlacedModule{{Module: "lemurs"}, {Module: "oldie"}}})
|
|
have, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}})
|
|
mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"})
|
|
omod, _ := delivery.EncodeModule(delivery.Module{FP2: ofp, Manifest: odigest, Repository: "novox/o", Commit: "c"})
|
|
want, _ := delivery.EncodeWant(delivery.Want{FP: fp, Self: true})
|
|
nodeWant, _ := delivery.EncodeNodeWant(delivery.NodeWant{Self: true})
|
|
b.put(t, delivery.PlacementBucket, "placement.n1", place)
|
|
b.put(t, delivery.HaveBucket, "n1.node", have)
|
|
b.put(t, delivery.ModulesBucket, "lemurs", mod)
|
|
b.put(t, delivery.ModulesBucket, "oldie", omod)
|
|
b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", want)
|
|
b.put(t, delivery.AssignerWantsBucket, "want.n1.node", nodeWant)
|
|
for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, delivery.HaveBucket, delivery.ModulesBucket, ResolvedBucket, delivery.BuilderBuildsBucket} {
|
|
b.send(bucket, agent.Update{Caught: true})
|
|
}
|
|
for u := next(t, out); !u.Caught; u = next(t, out) {
|
|
}
|
|
lemursKey, _ := delivery.ComposedKey("n1", "lemurs", fp)
|
|
oldieKey, _ := delivery.ComposedKey("n1", "oldie", ofp)
|
|
waitFor := func(what string, ok func([]string) bool) []string {
|
|
t.Helper()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if got := state.written(); ok(got) {
|
|
return got
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
b.mu.Lock()
|
|
t.Logf("said: %q", b.said)
|
|
b.mu.Unlock()
|
|
t.Fatalf("%s: written %v", what, state.written())
|
|
return nil
|
|
}
|
|
first := waitFor("the first pass", func(got []string) bool { return len(got) >= 2 })
|
|
if !slices.Equal(first, []string{lemursKey, "n1.node"}) {
|
|
t.Errorf("the first pass wrote %v, want the wanted module's key and the node's own", first)
|
|
}
|
|
if raw := state.kv[lemursKey].Value; raw != nil {
|
|
comp, err := delivery.DecodeComposed(raw)
|
|
if err != nil || comp.Module != "lemurs" || comp.FP != fp || len(comp.Resources) == 0 {
|
|
t.Errorf("the published composition of lemurs: %+v, %v", comp, err)
|
|
}
|
|
}
|
|
// A pass that changes nothing writes nothing.
|
|
setFor(t, &askedWithin, 0)
|
|
if _, err := c.ComposeNow(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
if got := state.written(); len(got) != 2 {
|
|
t.Errorf("a pass that changed nothing wrote %v", got[2:])
|
|
}
|
|
// oldie's trunk build is done: its key at that build is written once, though no want names it.
|
|
build, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: odigest})
|
|
bkey, _ := delivery.BuilderBuildKey("oldie", ofp)
|
|
b.mu.Lock()
|
|
b.kv[delivery.BuilderBuildsBucket+"/"+bkey] = build
|
|
b.mu.Unlock()
|
|
b.put(t, delivery.BuilderBuildsBucket, bkey, build)
|
|
got := waitFor("oldie's done build", func(got []string) bool { return len(got) >= 3 })
|
|
if got[2] != oldieKey {
|
|
t.Errorf("the done build of oldie wrote %v, want %s", got[2:], oldieKey)
|
|
}
|
|
b.put(t, delivery.BuilderBuildsBucket, bkey, build)
|
|
time.Sleep(100 * time.Millisecond)
|
|
if got := state.written(); len(got) != 3 {
|
|
t.Errorf("the same build landing again wrote %v", got[3:])
|
|
}
|
|
|
|
// What is kept: the five keys of a module written last, and one a want names; the rest is deleted.
|
|
keep := New(b.env())
|
|
keep.pubKept = map[string]ComposedEntry{}
|
|
var keys []string
|
|
for i, ch := range "123456" {
|
|
key, _ := delivery.ComposedKey("n1", "lemurs", delivery.SourcePrefix2+strings.Repeat(string(ch), 64))
|
|
keys = append(keys, key)
|
|
keep.pubKept[key] = ComposedEntry{Revision: uint64(10 + i)}
|
|
}
|
|
keep.pubKept["n1.node"] = ComposedEntry{Revision: 1}
|
|
if past := keep.pastKeeping(); !slices.Equal(past, []string{keys[0]}) {
|
|
t.Errorf("past keeping: %v, want the oldest of six, %s", past, keys[0])
|
|
}
|
|
keep.wants = map[string]delivery.Want{"lemurs": {FP: delivery.SourcePrefix2 + strings.Repeat("1", 64)}}
|
|
if past := keep.pastKeeping(); len(past) != 0 {
|
|
t.Errorf("past keeping with a want naming the oldest: %v, want none", past)
|
|
}
|
|
}
|