Files
mesh-host/internal/selfcompose/selfcompose_test.go
T
jschoubben 1e38c42646
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: shanks: novox/mesh-catalog#505: checks/placement/go.mod names mesh-sdk 91413de33516, which is neither on mesh-sdk's trunk nor the g…
The node's composer asks the vault for the pair credentials its composition reads, as consumer and provider, sealed to its key (issue 1126, ADR 0550 §5)
2026-10-26 19:21:22 +01:00

238 lines
9.3 KiB
Go

package selfcompose
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/compose"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/agent"
)
const lemurs = `{"module":"lemurs","version":"1","capabilities":["systemd"],"resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`
// fakeBus is the buckets the composer watches, each a channel a test writes updates to, and the keys Get reads.
type fakeBus struct {
mu sync.Mutex
chans map[string]chan agent.Update
kv map[string][]byte // bucket/key
said []string
}
func newFakeBus() *fakeBus {
return &fakeBus{chans: map[string]chan agent.Update{}, kv: map[string][]byte{}}
}
func (b *fakeBus) ch(bucket string) chan agent.Update {
b.mu.Lock()
defer b.mu.Unlock()
if b.chans[bucket] == nil {
b.chans[bucket] = make(chan agent.Update, 16)
}
return b.chans[bucket]
}
func (b *fakeBus) env() Env {
return Env{Node: "n1",
Watch: func(_ context.Context, bucket, _ string) (<-chan agent.Update, error) { return b.ch(bucket), nil },
Get: func(_ context.Context, bucket, key string) ([]byte, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.kv[bucket+"/"+key], nil
},
Say: func(s string) { b.mu.Lock(); b.said = append(b.said, s); b.mu.Unlock() }}
}
func (b *fakeBus) put(t *testing.T, bucket, key string, value []byte) {
t.Helper()
b.ch(bucket) <- agent.Update{Key: key, Value: value}
}
func next(t *testing.T, out <-chan agent.Update) agent.Update {
t.Helper()
select {
case u := <-out:
return u
case <-time.After(5 * time.Second):
t.Fatal("no update from the composer")
return agent.Update{}
}
}
func quiet(t *testing.T, out <-chan agent.Update) {
t.Helper()
select {
case u := <-out:
t.Fatalf("the composer handed %s, and nothing changed", u.Key)
case <-time.After(50 * time.Millisecond):
}
}
// Issue 1126 (ADR 0547 §2): the node composes its wanted modules and its own composition from its inputs on the bus once
// every input was read, hands each, then Caught; an input that changes nothing composed hands nothing; a module no want
// names any more is taken back; a node that cannot compose says so once and hands nothing.
func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
b := newFakeBus()
fp := delivery.SourcePrefix2 + strings.Repeat("a", 64)
digest := delivery.ManifestDigest([]byte(lemurs))
man, _ := delivery.EncodeManifest(delivery.Manifest{Module: "lemurs", JSON: lemurs})
key, _ := delivery.ManifestsKey(digest)
b.kv[delivery.ManifestsBucket+"/"+key] = man
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
env := b.env()
// The vault's own: keeper's password sealed to n1's key, sealed again (other bytes) at every ask.
var asks, generation int
env.Own = func(context.Context, map[string]any) (json.RawMessage, error) {
b.mu.Lock()
defer b.mu.Unlock()
asks++
return json.RawMessage(fmt.Sprintf(`{"node":"n1","key":"sk","own":[{"module":"keeper","name":"password","origin":"made",`+
`"generation":%d,"sealed":{"ask":%d}}]}`, generation+1, asks)), nil
}
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"}}})
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"})
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.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} {
b.ch(bucket) <- agent.Update{Caught: true}
}
got := map[string]bool{}
for {
u := next(t, out)
if u.Caught {
break
}
c, err := delivery.DecodeComposed(u.Value)
if err != nil {
t.Fatalf("%s: %v", u.Key, err)
}
if c.Node != "n1" {
t.Errorf("%s composed for %q", u.Key, c.Node)
}
got[u.Key] = true
}
lemursKey, _ := delivery.ComposedKey("n1", "lemurs", fp)
nodeKey, _ := delivery.ComposedNodeKey("n1")
if !got[lemursKey] || !got[nodeKey] || len(got) != 2 {
b.mu.Lock()
t.Logf("said: %v", b.said)
b.mu.Unlock()
t.Fatalf("handed %v, want %s and %s", got, lemursKey, nodeKey)
}
// A config value of another module, and a value changing nothing composed, hand nothing.
b.put(t, ResolvedBucket, "n1.other.k", []byte(`{"value":1}`))
quiet(t, out)
// A want removed takes its composition back.
removed, _ := delivery.EncodeWant(delivery.Want{Removed: true})
b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", removed)
if u := next(t, out); u.Key != lemursKey || !u.Deleted {
t.Fatalf("a removed want handed %+v", u)
}
// A want of a module whose manifest cannot be read: said once, nothing handed, what was handed stands.
gone := delivery.SourcePrefix2 + strings.Repeat("b", 64)
missing, _ := delivery.EncodeWant(delivery.Want{FP: gone, Self: true})
b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", missing)
quiet(t, out)
b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", missing)
quiet(t, out)
b.mu.Lock()
n := 0
for _, s := range b.said {
if strings.Contains(s, "lemurs is wanted at") {
n++
}
}
b.mu.Unlock()
if n != 1 {
t.Errorf("the fault was said %d times, want once: %v", n, b.said)
}
// An own secret comes sealed from the vault into the composition; sealed again at the same generation it is the value
// first had, so nothing is handed again; a new generation is handed.
b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", want)
if u := next(t, out); u.Key != lemursKey {
t.Fatalf("lemurs wanted back handed %s", u.Key)
}
keeper := `{"module":"keeper","version":"1","own-secrets":{"password":"/var/lib/mesh/keeper/password"},` +
`"resources":[{"id":"state","type":"directory","path":"/var/lib/mesh/keeper","mode":"0700"}]}`
kdigest := delivery.ManifestDigest([]byte(keeper))
kman, _ := delivery.EncodeManifest(delivery.Manifest{Module: "keeper", JSON: keeper})
kkey, _ := delivery.ManifestsKey(kdigest)
b.mu.Lock()
b.kv[delivery.ManifestsBucket+"/"+kkey] = kman
b.mu.Unlock()
kmod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: kdigest, Repository: "novox/k", Commit: "c"})
both, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"},
Modules: []delivery.PlacedModule{{Module: "lemurs"}, {Module: "keeper"}}})
b.put(t, delivery.ModulesBucket, "keeper", kmod)
b.put(t, delivery.PlacementBucket, "placement.n1", both)
b.put(t, delivery.AssignerWantsBucket, "want.n1.keeper", want)
keeperKey, _ := delivery.ComposedKey("n1", "keeper", fp)
var handed string
for handed == "" {
if u := next(t, out); u.Key == keeperKey {
handed = string(u.Value)
}
}
if !strings.Contains(handed, `{\"ask\":`) {
t.Fatalf("keeper's composition does not carry the sealed value: %s", handed)
}
b.put(t, ResolvedBucket, "n1.other.k", []byte(`{"value":2}`))
quiet(t, out)
b.mu.Lock()
generation++
b.mu.Unlock()
b.put(t, ResolvedBucket, "n1.other.k", []byte(`{"value":3}`))
if u := next(t, out); u.Key != keeperKey {
t.Fatalf("a new generation handed %s, want keeper again", u.Key)
}
// The pair credentials asked (ADR 0550 §5): the keys go to the vault's own with their role, a consumer's comes back as
// a credential held, a provider's as the grant's copy, each sealed to n1's key, the value first had kept.
var sent map[string]any
pairEnv := b.env()
pairEnv.Own = func(_ context.Context, args map[string]any) (json.RawMessage, error) {
sent = args
return json.RawMessage(`{"node":"n1","key":"sk","credentials":[` +
`{"role":"consumer","provision":"pg","consumer":"n1","consumer_module":"web","provider":"n2","name":"pair/n2/pg/n1/web","generation":1,"sealed":{"c":1}},` +
`{"role":"provider","provision":"s3","consumer":"n3","consumer_module":"files","provider":"n1","name":"provision/n1/s3","generation":2,"sealed":{"p":1}}]}`), nil
}
pc := New(pairEnv)
consumer := []compose.CredentialKey{{Provision: "pg", Consumer: "n1", ConsumerModule: "web", Provider: "n2"}}
provider := []compose.CredentialKey{{Provision: "s3", Consumer: "n3", ConsumerModule: "files", Provider: "n1"}}
creds, issued, err := reader{c: pc}.Credentials(ctx, "n1", consumer, provider)
if err != nil || len(creds) != 1 || len(issued) != 1 {
t.Fatalf("credentials %+v issued %+v, %v", creds, issued, err)
}
if creds[0].State != compose.CredentialHeld || creds[0].Provider != "n2" || creds[0].Sealed != `{"c":1}` ||
issued[0].Consumer != "n3" || issued[0].ForProvider != `{"p":1}` {
t.Errorf("credentials %+v issued %+v", creds, issued)
}
if asked, _ := json.Marshal(sent["credentials"]); !strings.Contains(string(asked), `"role":"consumer"`) || !strings.Contains(string(asked), `"role":"provider"`) {
t.Errorf("the vault was asked %s", asked)
}
}