307 lines
12 KiB
Go
307 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// memStore is the bus as mesh-provisioner reaches it, in memory: the states it keeps.
|
|
type memStore struct {
|
|
mu sync.Mutex
|
|
states map[string]map[string][]byte
|
|
puts int
|
|
}
|
|
|
|
func newStore() *memStore { return &memStore{states: map[string]map[string][]byte{}} }
|
|
|
|
func (m *memStore) Get(state, key string) (*Entry, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
v, ok := m.states[state][key]
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
return &Entry{Key: key, Value: append([]byte(nil), v...)}, nil
|
|
}
|
|
|
|
func (m *memStore) Put(state, key string, value any) error {
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.states[state] == nil {
|
|
m.states[state] = map[string][]byte{}
|
|
}
|
|
m.states[state][key] = raw
|
|
m.puts++
|
|
return nil
|
|
}
|
|
|
|
func (m *memStore) Delete(state, key string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
delete(m.states[state], key)
|
|
return nil
|
|
}
|
|
|
|
func (m *memStore) Keys(state string) ([]string, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
var keys []string
|
|
for k := range m.states[state] {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys, nil
|
|
}
|
|
|
|
// write is a client's write, as the bus lets it through for its own machine's keys.
|
|
func (m *memStore) write(t *testing.T, state, key, raw string) {
|
|
t.Helper()
|
|
if err := m.Put(state, key, json.RawMessage(raw)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// The registry answers every kind a moved module's template may name (ADR 0349 clause 2): config, secret, bound,
|
|
// collected and the placement namespaces, each with its domain and where a client finds it.
|
|
func TestTheRegistryAnswersEveryKindATemplateMayName(t *testing.T) {
|
|
var kinds []string
|
|
for _, a := range TheRegistry {
|
|
kinds = append(kinds, a.Kind)
|
|
if a.Domain == "" || a.Why == "" || !strings.HasPrefix(a.Decision, "novox/hq ADR ") {
|
|
t.Errorf("%s: an answer names its domain, why and its decision: %+v", a.Kind, a)
|
|
}
|
|
switch a.From {
|
|
case FromBucket:
|
|
if a.Bucket == "" || !strings.HasPrefix(a.Key, "<node>.") {
|
|
t.Errorf("%s: a value in a bucket names the bucket and a key with the node first: %+v", a.Kind, a)
|
|
}
|
|
if a.Ask != "" {
|
|
t.Errorf("%s: from a bucket, asked of nobody: %+v", a.Kind, a)
|
|
}
|
|
case FromAsk:
|
|
if a.Ask == "" || a.Bucket != "" {
|
|
t.Errorf("%s: a value asked for names its verb and no bucket: %+v", a.Kind, a)
|
|
}
|
|
default:
|
|
t.Errorf("%s: from %q", a.Kind, a.From)
|
|
}
|
|
}
|
|
want := []string{"config", "secret", "bound", "consumers", "collected", "shell", "environment", "contribution",
|
|
"dir", "access", "port", "seat", "machine", "backup"}
|
|
if !slices.Equal(kinds, want) {
|
|
t.Errorf("the registry's kinds are %v, want %v", kinds, want)
|
|
}
|
|
// The buckets a client is granted to read its node's keys of (mesh-controller internal/broker/provisioner.go):
|
|
// the config domain's resolved values, this module's own collections, and placement in each module's assignment,
|
|
// where the controller carries the provisioner's plan (issue 671): a client never reads the node's declaration.
|
|
for kind, bucket := range map[string]string{"config": "mesh-config_resolved", "collected": "mesh-provisioner_" + Collect,
|
|
"shell": "mesh-provisioner_" + Collect, "environment": "mesh-provisioner_" + Collect,
|
|
"contribution": "mesh-provisioner_" + Collect,
|
|
"dir": "mesh-controller_assignments", "access": "mesh-controller_assignments",
|
|
"port": "mesh-controller_assignments", "seat": "mesh-controller_assignments",
|
|
"machine": "mesh-controller_assignments", "backup": "mesh-controller_assignments"} {
|
|
i := slices.IndexFunc(TheRegistry, func(a Answer) bool { return a.Kind == kind })
|
|
if TheRegistry[i].Bucket != bucket {
|
|
t.Errorf("%s is found in %s, want %s", kind, TheRegistry[i].Bucket, bucket)
|
|
}
|
|
}
|
|
// A secret is never in a bucket (ADR 0201 §13): asked of the vault.
|
|
i := slices.IndexFunc(TheRegistry, func(a Answer) bool { return a.Kind == "secret" })
|
|
if a := TheRegistry[i]; a.From != FromAsk || a.Ask != "mesh-vault.get" {
|
|
t.Errorf("a secret is answered by %+v, want asked of mesh-vault.get", a)
|
|
}
|
|
// A consumer's values are in its provider's own bucket, one entry per consumer, keyed as the providers' helper
|
|
// writes it (mesh-sdk go/provider, issue 678): the consumer's machine first.
|
|
i = slices.IndexFunc(TheRegistry, func(a Answer) bool { return a.Kind == "bound" })
|
|
if a := TheRegistry[i]; a.From != FromBucket || a.Bucket != "<provider-module>_bound" ||
|
|
a.Key != "<node>.<module>.<provision>[.<local>]" {
|
|
t.Errorf("a bound value is answered by %+v, want the provider's <provider-module>_bound, "+
|
|
"<node>.<module>.<provision>[.<local>]", a)
|
|
}
|
|
// A provider's consumers' credentials are the vault's, asked by the provider machine's client (ADR 0373).
|
|
i = slices.IndexFunc(TheRegistry, func(a Answer) bool { return a.Kind == "consumers" })
|
|
if a := TheRegistry[i]; a.Domain != "mesh-vault" || a.From != FromAsk || a.Ask != "mesh-vault.get" ||
|
|
a.Decision != "novox/hq ADR 0373" {
|
|
t.Errorf("a provider's consumers are answered by %+v, want the vault, asked of mesh-vault.get (ADR 0373)", a)
|
|
}
|
|
}
|
|
|
|
// The registry is written whole at start, a pass that finds it equal writes nothing, a changed answer is written
|
|
// back, and a kind no domain answers any more is deleted.
|
|
func TestTheRegistryIsWrittenBackAndAQuietPassWritesNothing(t *testing.T) {
|
|
s := newStore()
|
|
written, deleted, err := Publish(s)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(written) != len(TheRegistry) || len(deleted) != 0 {
|
|
t.Fatalf("the first pass wrote %v and deleted %v", written, deleted)
|
|
}
|
|
for _, a := range TheRegistry {
|
|
e, _ := s.Get(Registry, a.Kind)
|
|
var got Answer
|
|
if e == nil || json.Unmarshal(e.Value, &got) != nil || got != a {
|
|
t.Errorf("the registry's %s holds %v, want %+v", a.Kind, e, a)
|
|
}
|
|
}
|
|
puts := s.puts
|
|
if written, deleted, err := Publish(s); err != nil || len(written)+len(deleted) != 0 || s.puts != puts {
|
|
t.Fatalf("a quiet pass wrote %v, deleted %v (%v)", written, deleted, err)
|
|
}
|
|
s.write(t, Registry, "config", `{"kind":"config","domain":"mesh-controller"}`)
|
|
s.write(t, Registry, "setting", `{"kind":"setting"}`)
|
|
written, deleted, err = Publish(s)
|
|
if err != nil || !slices.Equal(written, []string{"config"}) || !slices.Equal(deleted, []string{"setting"}) {
|
|
t.Fatalf("a changed and an unknown kind: wrote %v, deleted %v (%v)", written, deleted, err)
|
|
}
|
|
}
|
|
|
|
func clientWrites(t *testing.T) *memStore {
|
|
s := newStore()
|
|
if _, _, err := Publish(s); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.write(t, Needs, "g14.dunst.config.font-size", `{"at":"2026-10-14T20:00:00Z"}`)
|
|
s.write(t, Needs, "g14.dunst.secret.token", `{}`)
|
|
s.write(t, Needs, "shanks.dunst.config.font-size", `{}`)
|
|
s.write(t, Needs, "shanks.gitea.bound.database.user", `{}`)
|
|
s.write(t, Applied, "g14.dunst", `{"build":"b1","outcome":"applied"}`)
|
|
s.write(t, Applied, "shanks.dunst", `{"build":"b1","outcome":"failed","reason":"waits for config font-size"}`)
|
|
s.write(t, Collect, "g14.zsh-snippets.git", `{}`)
|
|
s.write(t, Collect, "g14.bar-blocks.disk-load", `{}`)
|
|
s.write(t, Collect, "g14.shell=3Azsh=3Anormal.mesh-cli", `{}`)
|
|
return s
|
|
}
|
|
|
|
func keysOf(r Read) []string {
|
|
var out []string
|
|
for _, row := range r.Rows {
|
|
out = append(out, row.Key)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Each bucket is read by its shape, its parts named, and narrowed by node, module and collection.
|
|
func TestEachBucketIsReadByItsShapeAndNarrowed(t *testing.T) {
|
|
s := clientWrites(t)
|
|
for _, c := range []struct {
|
|
q Query
|
|
want []string
|
|
}{
|
|
{Query{}, []string{"access", "backup", "bound", "collected", "config", "consumers", "contribution", "dir", "environment",
|
|
"machine", "port", "seat", "secret", "shell"}},
|
|
{Query{What: Needs}, []string{"g14.dunst.config.font-size", "g14.dunst.secret.token",
|
|
"shanks.dunst.config.font-size", "shanks.gitea.bound.database.user"}},
|
|
{Query{What: Needs, Node: "g14"}, []string{"g14.dunst.config.font-size", "g14.dunst.secret.token"}},
|
|
{Query{What: Needs, Module: "dunst", Node: "shanks"}, []string{"shanks.dunst.config.font-size"}},
|
|
{Query{What: Applied, Module: "dunst"}, []string{"g14.dunst", "shanks.dunst"}},
|
|
{Query{What: Applied, Node: "ace"}, nil},
|
|
{Query{What: Collect, Collection: "zsh-snippets"}, []string{"g14.zsh-snippets.git"}},
|
|
{Query{What: Collect, Module: "disk-load"}, []string{"g14.bar-blocks.disk-load"}},
|
|
// An old namespace's collection, by its name, not its key's token (design 57 L1).
|
|
{Query{What: Collect, Collection: "shell:zsh:normal"}, []string{"g14.shell=3Azsh=3Anormal.mesh-cli"}},
|
|
} {
|
|
r, err := Do(s, c.q)
|
|
if err != nil {
|
|
t.Fatalf("%+v: %v", c.q, err)
|
|
}
|
|
if got := keysOf(r); !slices.Equal(got, c.want) || r.Count != len(c.want) {
|
|
t.Errorf("%+v: read %v (%d), want %v", c.q, got, r.Count, c.want)
|
|
}
|
|
}
|
|
r, _ := Do(s, Query{What: Needs, Node: "shanks", Module: "gitea"})
|
|
if row := r.Rows[0]; row.Node != "shanks" || row.Module != "gitea" || row.Kind != "bound" || row.Name != "database.user" {
|
|
t.Errorf("a need's parts: %+v", row)
|
|
}
|
|
r, _ = Do(s, Query{What: Collect, Collection: "bar-blocks"})
|
|
if row := r.Rows[0]; row.Node != "g14" || row.Collection != "bar-blocks" || row.Module != "disk-load" {
|
|
t.Errorf("an item's parts: %+v", row)
|
|
}
|
|
r, _ = Do(s, Query{What: Applied, Node: "shanks"})
|
|
if string(r.Rows[0].Value) != `{"build":"b1","outcome":"failed","reason":"waits for config font-size"}` {
|
|
t.Errorf("what is applied is read as the client wrote it: %s", r.Rows[0].Value)
|
|
}
|
|
}
|
|
|
|
// A key out of its bucket's shape is said, never dropped quietly.
|
|
func TestAKeyOutOfItsBucketsShapeIsSaid(t *testing.T) {
|
|
s := clientWrites(t)
|
|
s.write(t, Applied, "g14.dunst.extra", `{}`)
|
|
r, err := Do(s, Query{What: Applied})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r.Count != 2 || !strings.Contains(r.Said, "g14.dunst.extra") || !strings.Contains(r.Said, "<node>.<module>") {
|
|
t.Errorf("an unshaped key: %d rows, said %q", r.Count, r.Said)
|
|
}
|
|
}
|
|
|
|
// The verb's arguments are read strictly, and a narrowing that means nothing is refused rather than ignored.
|
|
func TestTheVerbsArgumentsAreReadStrictly(t *testing.T) {
|
|
s := clientWrites(t)
|
|
run := Tools(s)[0].Run
|
|
if Tools(s)[0].Name != "mesh-provisioner.provisioning" {
|
|
t.Fatalf("the verb is served as %s", Tools(s)[0].Name)
|
|
}
|
|
for _, args := range []map[string]any{
|
|
{"what": "needs", "caller": "g14.dunst"},
|
|
{"node": 3},
|
|
{"what": "secrets"},
|
|
{"node": "g14"},
|
|
{"what": "needs", "collection": "zsh-snippets"},
|
|
} {
|
|
if _, err := run(args); err == nil {
|
|
t.Errorf("%v was taken", args)
|
|
}
|
|
}
|
|
got, err := run(map[string]any{"what": "applied", "node": " g14 "})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r := got.(Read); r.Count != 1 || r.Said != "1 in applied for node g14" {
|
|
t.Errorf("a read: %+v", r)
|
|
}
|
|
}
|
|
|
|
// The manifest declares the seat, its verb, the four buckets this code keeps, and an account of its own, so that
|
|
// its account alone (with each client for its machine's keys) writes them.
|
|
func TestTheManifestDeclaresWhatTheCodeKeeps(t *testing.T) {
|
|
raw, err := os.ReadFile("../../module.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var m struct {
|
|
Module string `json:"module"`
|
|
RunsAs string `json:"runs-as"`
|
|
Claims []struct {
|
|
Name string `json:"name"`
|
|
Scope string `json:"scope"`
|
|
Serves []string `json:"serves"`
|
|
} `json:"claims"`
|
|
State []string `json:"state"`
|
|
OwnSecrets map[string]string `json:"own-secrets"`
|
|
}
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if m.Module != "mesh-provisioner" || m.RunsAs != "mesh-provisioner" || m.OwnSecrets["broker"] == "" {
|
|
t.Errorf("an account of its own: module %q, runs-as %q, own secrets %v", m.Module, m.RunsAs, m.OwnSecrets)
|
|
}
|
|
if len(m.Claims) != 1 || m.Claims[0].Name != Seat || m.Claims[0].Scope != "mesh" ||
|
|
!slices.Equal(m.Claims[0].Serves, []string{Verb}) {
|
|
t.Errorf("the claim: %+v", m.Claims)
|
|
}
|
|
if want := []string{Registry, Needs, Applied, Collect}; !slices.Equal(m.State, want) {
|
|
t.Errorf("the state is %v, want %v", m.State, want)
|
|
}
|
|
}
|