Feature 57 (config and provisioning): modules declare config and provisions, their files rendered from templates; synced with main (issues of feature 57, 874)
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
# mesh-provisioner
|
||||
|
||||
Holds the `mesh-provisioner` seat once for the mesh: the provisioner's server (novox/hq ADR 0349 clause 3, design 57
|
||||
P4). Go, one binary (`cmd/mesh-provisioner`), served by a runtime of its own on its own bus account (`runs-as`), so
|
||||
that its account alone writes the registry. The provisioner client on each node (`node-provisioner`, design 57 P5)
|
||||
is the module `mesh-provisioner-client`.
|
||||
|
||||
## What it keeps
|
||||
|
||||
- `mesh-provisioner_registry`: which domain answers which kind of placeholder, keyed by the kind (`config`,
|
||||
`secret`, `bound` — the provider's own bucket, `<provider-module>_bound`, keyed
|
||||
`<node>.<module>.<provision>[.<local>]` (mesh-sdk `go/provider`) — `consumers` — a provider's consumers'
|
||||
credentials, asked of the vault by the provider machine's client (novox/hq ADR 0373) — `collected` — and `shell`, `environment` and `contribution`, read as collections of the same
|
||||
name (design 57 L1) — and the placement namespaces `dir`, `access`, `port`, `seat`, `machine`). Each
|
||||
answer names the owning module and where a client finds the values: a domain's bucket and its key shape
|
||||
(placement's is the controller's assignments mirror, whose value carries the provisioner's plan of a module handed
|
||||
to it), or a seat's verb asked on request and reply (a secret is never in a bucket). Compiled in
|
||||
`registry.go`, written at start and every hour, only where it differs; a kind no domain answers is deleted.
|
||||
- `mesh-provisioner_needs`: what each module on each node needs, `<node>.<module>.<kind>.<name>`.
|
||||
- `mesh-provisioner_applied`: what is applied where, `<node>.<module>`.
|
||||
- `mesh-provisioner_collect`: the items of every collection, `<node>.<collection>.<contributor>`, the collection
|
||||
with a character a key's token cannot hold written `=<hex>` (`shell=3Azsh=3Anormal` is `shell:zsh:normal`), and
|
||||
read back by its name.
|
||||
|
||||
The last three are written by each node's provisioner client for its own node's keys alone: the controller grants
|
||||
the client `<node>.>` of each and nothing wider (mesh-controller `internal/broker/provisioner.go`), and a provider
|
||||
reads the needs without writing them. This module writes only the registry.
|
||||
|
||||
## The verb
|
||||
|
||||
`mesh-provisioner.provisioning` with `what` `registry` (the default), `needs`, `applied` or `collect`, narrowed by
|
||||
`node` and `module` (for `collect`, the contributor) and, for `collect`, `collection`. Reads only. A key out of its
|
||||
bucket's shape is said in the answer, never dropped quietly.
|
||||
@@ -0,0 +1,95 @@
|
||||
// mesh-provisioner: the provisioner's server, holder of the mesh-provisioner seat once for the mesh (novox/hq ADR
|
||||
// 0349 clause 3, design 57 P4). A Go bundle served by a runtime of its own, on its own bus account (`runs-as`, ADR
|
||||
// 0259 §8), keeping four buckets: mesh-provisioner_registry (which domain answers which kind of placeholder, its
|
||||
// alone), and mesh-provisioner_needs, _applied and _collect, which each machine's provisioner client writes for its
|
||||
// own machine (the controller grants the client those keys alone, issue 670). stdout is the MCP channel; what this
|
||||
// module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Seat is the role this module holds, once for the mesh, and Verb its one verb.
|
||||
const (
|
||||
Seat = "mesh-provisioner"
|
||||
Verb = "provisioning"
|
||||
)
|
||||
|
||||
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
|
||||
// runtimeStore is the bus, through the runtime that launched this bundle.
|
||||
type runtimeStore struct{}
|
||||
|
||||
func (runtimeStore) Get(state, key string) (*Entry, error) {
|
||||
e, err := stdio.State(state).Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Entry{Key: e.Key, Value: e.Value, Revision: e.Revision}, nil
|
||||
}
|
||||
|
||||
func (runtimeStore) Put(state, key string, value any) error {
|
||||
_, err := stdio.State(state).Put(key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
func (runtimeStore) Delete(state, key string) error { return stdio.State(state).Delete(key) }
|
||||
|
||||
func (runtimeStore) Keys(state string) ([]string, error) { return stdio.State(state).Keys() }
|
||||
|
||||
func main() {
|
||||
go publishing(runtimeStore{})
|
||||
if err := stdio.Serve("", Tools(runtimeStore{})); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// publishing writes the registry once the runtime answers, and again every hour, so a registry emptied by hand or
|
||||
// a bucket made again is written back without a restart. The runtime answers only once Serve runs, so the first
|
||||
// pass is tried again until it lands.
|
||||
func publishing(s Store) {
|
||||
for wait := 2 * time.Second; ; {
|
||||
time.Sleep(wait)
|
||||
written, deleted, err := Publish(s)
|
||||
switch {
|
||||
case err != nil:
|
||||
logf("[mesh-provisioner] the registry is not written yet (%v); trying again in %s", err, wait)
|
||||
wait = min(wait*2, time.Minute)
|
||||
continue
|
||||
case len(written) > 0 || len(deleted) > 0:
|
||||
logf("[mesh-provisioner] the registry: wrote %v, deleted %v", written, deleted)
|
||||
}
|
||||
wait = time.Hour
|
||||
}
|
||||
}
|
||||
|
||||
// Tools is the seat's verb as the runtime serves it.
|
||||
func Tools(s Store) []stdio.Tool {
|
||||
return []stdio.Tool{{
|
||||
Name: Seat + "." + Verb,
|
||||
Description: "Read what the provisioner keeps (novox/hq ADR 0349 clause 3): the registry (which domain " +
|
||||
"answers which kind of placeholder, the default), the needs each module on each node recorded, what is " +
|
||||
"applied where, or the items of every collection, narrowed by node and module, and collect by " +
|
||||
"collection. Reads only: each node's provisioner client writes its own node's needs, applied and items.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"what": map[string]any{"type": "string", "enum": []string{Registry, Needs, Applied, Collect},
|
||||
"description": "registry when not given"},
|
||||
"node": map[string]any{"type": "string", "description": "a node"},
|
||||
"module": map[string]any{"type": "string", "description": "a module: for collect, a contributor"},
|
||||
"collection": map[string]any{"type": "string", "description": "for collect: a collection"},
|
||||
}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
q, err := queryOf(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Do(s, q)
|
||||
},
|
||||
}}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The verb `provisioning`: a read of what this module keeps, narrowed by node and module. It writes nothing:
|
||||
// the registry is this module's, and the needs, the applied and the items are each machine's client's, for its
|
||||
// own machine (ADR 0349 clause 3).
|
||||
|
||||
// Entry is one key's current value.
|
||||
type Entry struct {
|
||||
Key string
|
||||
Value json.RawMessage
|
||||
Revision uint64
|
||||
}
|
||||
|
||||
// Store is the bus as this module reaches it: the state it keeps.
|
||||
type Store interface {
|
||||
Get(state, key string) (*Entry, error)
|
||||
Put(state, key string, value any) error
|
||||
Delete(state, key string) error
|
||||
Keys(state string) ([]string, error)
|
||||
}
|
||||
|
||||
// Query is one call of the verb.
|
||||
type Query struct {
|
||||
What string
|
||||
Node string
|
||||
Module string
|
||||
Collection string
|
||||
}
|
||||
|
||||
// Row is one key of a bucket, its parts named, and its value as written.
|
||||
type Row struct {
|
||||
Key string `json:"key"`
|
||||
Node string `json:"node,omitempty"`
|
||||
Module string `json:"module,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Collection string `json:"collection,omitempty"`
|
||||
Value json.RawMessage `json:"value"`
|
||||
}
|
||||
|
||||
// Read is a read's answer: the bucket read, its rows, how many, and what narrowed them.
|
||||
type Read struct {
|
||||
What string `json:"what"`
|
||||
Rows []Row `json:"rows"`
|
||||
Count int `json:"count"`
|
||||
Said string `json:"said"`
|
||||
}
|
||||
|
||||
// shapes are the keys of each bucket, the node first, as the clients write them.
|
||||
var shapes = map[string]string{
|
||||
Needs: "<node>.<module>.<kind>.<name>",
|
||||
Applied: "<node>.<module>",
|
||||
Collect: "<node>.<collection>.<contributor>",
|
||||
}
|
||||
|
||||
// rowOf names a key's parts by its bucket's shape, or says the key is not in that shape.
|
||||
func rowOf(what, key string) (Row, bool) {
|
||||
r := Row{Key: key}
|
||||
switch what {
|
||||
case Registry:
|
||||
r.Kind = key
|
||||
return r, key != ""
|
||||
case Needs:
|
||||
parts := strings.SplitN(key, ".", 4)
|
||||
if len(parts) != 4 || slices.Contains(parts, "") {
|
||||
return r, false
|
||||
}
|
||||
r.Node, r.Module, r.Kind, r.Name = parts[0], parts[1], parts[2], parts[3]
|
||||
case Applied:
|
||||
parts := strings.Split(key, ".")
|
||||
if len(parts) != 2 || slices.Contains(parts, "") {
|
||||
return r, false
|
||||
}
|
||||
r.Node, r.Module = parts[0], parts[1]
|
||||
case Collect:
|
||||
parts := strings.SplitN(key, ".", 3)
|
||||
if len(parts) != 3 || slices.Contains(parts, "") {
|
||||
return r, false
|
||||
}
|
||||
// The contributor is the module whose item it is; the collection as its key's token writes it, a character a
|
||||
// token cannot hold written `=<hex>` (`shell=3Azsh=3Anormal` is `shell:zsh:normal`).
|
||||
collection, ok := fromKeyToken(parts[1])
|
||||
if !ok {
|
||||
return r, false
|
||||
}
|
||||
r.Node, r.Collection, r.Module = parts[0], collection, parts[2]
|
||||
default:
|
||||
return r, false
|
||||
}
|
||||
return r, true
|
||||
}
|
||||
|
||||
// Do answers a read.
|
||||
func Do(s Store, q Query) (Read, error) {
|
||||
what := q.What
|
||||
if what == "" {
|
||||
what = Registry
|
||||
}
|
||||
if _, ok := shapes[what]; !ok && what != Registry {
|
||||
return Read{}, fmt.Errorf("provisioning reads registry, needs, applied or collect, not %q", what)
|
||||
}
|
||||
if what == Registry && (q.Node != "" || q.Module != "" || q.Collection != "") {
|
||||
return Read{}, fmt.Errorf("the registry is the same for every node and module; it is not narrowed")
|
||||
}
|
||||
if q.Collection != "" && what != Collect {
|
||||
return Read{}, fmt.Errorf("collection narrows collect alone, not %s", what)
|
||||
}
|
||||
keys, err := s.Keys(what)
|
||||
if err != nil {
|
||||
return Read{}, fmt.Errorf("listing %s: %w", what, err)
|
||||
}
|
||||
out := Read{What: what, Rows: []Row{}}
|
||||
var unshaped []string
|
||||
for _, k := range keys {
|
||||
r, ok := rowOf(what, k)
|
||||
if !ok {
|
||||
unshaped = append(unshaped, k)
|
||||
continue
|
||||
}
|
||||
if (q.Node != "" && r.Node != q.Node) || (q.Module != "" && r.Module != q.Module) ||
|
||||
(q.Collection != "" && r.Collection != q.Collection) {
|
||||
continue
|
||||
}
|
||||
e, err := s.Get(what, k)
|
||||
if err != nil {
|
||||
return Read{}, fmt.Errorf("reading %s's %s: %w", what, k, err)
|
||||
}
|
||||
if e == nil {
|
||||
continue // deleted between the listing and the read
|
||||
}
|
||||
r.Value = e.Value
|
||||
out.Rows = append(out.Rows, r)
|
||||
}
|
||||
sort.Slice(out.Rows, func(i, j int) bool { return out.Rows[i].Key < out.Rows[j].Key })
|
||||
out.Count = len(out.Rows)
|
||||
out.Said = said(what, q, out.Count)
|
||||
if len(unshaped) > 0 {
|
||||
// Said, never dropped quietly: a key not in its bucket's shape is a client writing what it should not.
|
||||
out.Said += fmt.Sprintf("; %d key(s) are not in the shape %s and are not listed: %s", len(unshaped),
|
||||
shapes[what], strings.Join(unshaped, ", "))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func said(what string, q Query, n int) string {
|
||||
var narrowed []string
|
||||
for _, p := range [][2]string{{"node", q.Node}, {"module", q.Module}, {"collection", q.Collection}} {
|
||||
if p[1] != "" {
|
||||
narrowed = append(narrowed, p[0]+" "+p[1])
|
||||
}
|
||||
}
|
||||
s := fmt.Sprintf("%d in %s", n, what)
|
||||
if len(narrowed) > 0 {
|
||||
s += " for " + strings.Join(narrowed, ", ")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// queryOf reads a call's arguments strictly: an argument it does not know is refused, not ignored.
|
||||
func queryOf(args map[string]any) (Query, error) {
|
||||
var q Query
|
||||
fields := map[string]*string{"what": &q.What, "node": &q.Node, "module": &q.Module, "collection": &q.Collection}
|
||||
var unknown []string
|
||||
for k, v := range args {
|
||||
into, ok := fields[k]
|
||||
if !ok {
|
||||
unknown = append(unknown, k)
|
||||
continue
|
||||
}
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return Query{}, fmt.Errorf("%s is a string", k)
|
||||
}
|
||||
*into = strings.TrimSpace(s)
|
||||
}
|
||||
if len(unknown) > 0 {
|
||||
sort.Strings(unknown)
|
||||
return Query{}, fmt.Errorf("provisioning takes no %s; it takes what, node, module and collection",
|
||||
strings.Join(unknown, ", "))
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
// fromKeyToken reads a key's token as the client writes it (mesh-provisioner-client collect.go): `=<hex>` is the
|
||||
// character of that code.
|
||||
func fromKeyToken(s string) (string, bool) {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] != '=' {
|
||||
b.WriteByte(s[i])
|
||||
continue
|
||||
}
|
||||
if i+2 >= len(s) {
|
||||
return "", false
|
||||
}
|
||||
v, err := strconv.ParseUint(s[i+1:i+3], 16, 8)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
b.WriteByte(byte(v))
|
||||
i += 2
|
||||
}
|
||||
return b.String(), true
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// The registry: which domain answers which kind of placeholder (novox/hq ADR 0349 clauses 1 and 3). A kind is the
|
||||
// namespace of a placeholder, `${<kind>:…}`; its answer says which module owns the values and where a node's
|
||||
// provisioner client finds them for its node. Compiled here and written to the bucket mesh-provisioner_registry,
|
||||
// which this module alone writes and every client reads: a domain that comes to answer a kind is a change to this
|
||||
// table, through the design that moves it (design 57).
|
||||
|
||||
// The buckets this module keeps (its manifest's `state`), by local name.
|
||||
const (
|
||||
Registry = "registry"
|
||||
Needs = "needs"
|
||||
Applied = "applied"
|
||||
Collect = "collect"
|
||||
)
|
||||
|
||||
// Where a client finds a kind's values.
|
||||
const (
|
||||
// FromBucket is a domain's bucket, watched for the node's keys: a watch delivers the current values first.
|
||||
FromBucket = "bucket"
|
||||
// FromAsk is a seat's verb asked on request and reply: a value never kept in a bucket (ADR 0201 §13).
|
||||
FromAsk = "ask"
|
||||
)
|
||||
|
||||
// Answer is one kind's entry in the registry.
|
||||
type Answer struct {
|
||||
// Kind is the placeholder's namespace.
|
||||
Kind string `json:"kind"`
|
||||
// Domain is the module that owns the kind's values; for bound values, the module providing the provision
|
||||
// the placeholder names, which is not one module but whichever the mesh resolved.
|
||||
Domain string `json:"domain"`
|
||||
// From is where a client finds the values: FromBucket or FromAsk.
|
||||
From string `json:"from"`
|
||||
// Bucket and Key, for a value in a bucket: the bucket, and the shape of a key in it, the node first.
|
||||
Bucket string `json:"bucket,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
// Ask, for a value asked for: the seat's verb, `<seat>.<verb>`.
|
||||
Ask string `json:"ask,omitempty"`
|
||||
// Why says the answer in a sentence, and Decision the record that decided it.
|
||||
Why string `json:"why"`
|
||||
Decision string `json:"decision"`
|
||||
}
|
||||
|
||||
// placement is the controller's: where a module runs, its directories, accesses, ports, seats and the machine's
|
||||
// facts (ADR 0349 clause 6). On the bus, in the provisioner's plan of each module the controller handed to it, which
|
||||
// its assignment carries (mesh-controller internal/inventory Provisioning, issue 671), or for a module only pulled in
|
||||
// by another, its value of its own under the same key (issue 765): a client never reads the node's declaration,
|
||||
// which the node-engine keeps for root alone.
|
||||
func placement(kind, what string) Answer {
|
||||
return Answer{Kind: kind, Domain: "mesh-controller", From: FromBucket, Bucket: "mesh-controller_assignments",
|
||||
Key: "<node>.<module>",
|
||||
Why: what + ": placement, the controller's, in the provisioner's plan its assignment carries (provisioning." +
|
||||
"placement)", Decision: "novox/hq ADR 0349 clause 6"}
|
||||
}
|
||||
|
||||
// oldCollection is a namespace read as a collection of its own name (novox/hq ADR 0349 clause 13, design 57 L1).
|
||||
func oldCollection(kind, what string) Answer {
|
||||
return Answer{Kind: kind, Domain: "mesh-provisioner", From: FromBucket, Bucket: "mesh-provisioner_collect",
|
||||
Key: "<node>.<collection>.<contributor>",
|
||||
Why: what + ": read as the collection `" + kind + ":<name>`, its items recorded by the client of the node " +
|
||||
"its contributors are placed on", Decision: "novox/hq ADR 0349 clause 13"}
|
||||
}
|
||||
|
||||
// TheRegistry is every kind a moved module's template may name, and its answer, in the order of ADR 0349 clause 2.
|
||||
var TheRegistry = []Answer{
|
||||
{Kind: "config", Domain: "mesh-config", From: FromBucket, Bucket: "mesh-config_resolved", Key: "<node>.<module>.<key>",
|
||||
Why: "a config value, resolved for each module on each node by mesh-config", Decision: "novox/hq ADR 0348"},
|
||||
{Kind: "secret", Domain: "mesh-vault", From: FromAsk, Ask: "mesh-vault.get",
|
||||
Why: "a shared secret, handed by the vault sealed to the client's key, on request and reply only",
|
||||
Decision: "novox/hq ADR 0349 clause 11"},
|
||||
// The provider's bucket and its keys, as the providers' helper writes them (mesh-sdk go/provider, issue 678): the
|
||||
// bucket of the module the consumer's plan names as its provider, and the consumer's machine first.
|
||||
{Kind: "bound", Domain: "provider", From: FromBucket, Bucket: "<provider-module>_bound",
|
||||
Key: "<node>.<module>.<provision>[.<local>]",
|
||||
Why: "a consumer's values, written by the module providing the provision to its own bucket, one entry per " +
|
||||
"consumer, the bucket of the provider module the consumer's plan names", Decision: "novox/hq ADR 0349 clause 12"},
|
||||
// A provider's consumers' credentials, each consumer's pair credential, which the provider machine's client asks
|
||||
// the vault for and fills into a file the provider alone reads (issue 679).
|
||||
{Kind: "consumers", Domain: "mesh-vault", From: FromAsk, Ask: "mesh-vault.get",
|
||||
Why: "each bound consumer's credential, asked of the vault by the provider machine's client and filled into a " +
|
||||
"file only the provider reads, one entry per consumer", Decision: "novox/hq ADR 0373"},
|
||||
{Kind: "collected", Domain: "mesh-provisioner", From: FromBucket, Bucket: "mesh-provisioner_collect",
|
||||
Key: "<node>.<collection>.<contributor>",
|
||||
Why: "the items of a collection, recorded by the client of the node its contributors are placed on",
|
||||
Decision: "novox/hq ADR 0349 clause 13"},
|
||||
// The three namespaces the controller fills for a module not handed to the provisioner, read in a handed
|
||||
// collector's template as the collection of the same name (design 57 L1): `${shell:zsh:normal}` is the
|
||||
// collection `shell:zsh:normal`.
|
||||
oldCollection("shell", "a shell's code in one slot, or a session file's or a power moment's"),
|
||||
oldCollection("environment", "the account's environment in one format"),
|
||||
oldCollection("contribution", "what a seat's holder receives of one kind"),
|
||||
placement("dir", "a module's directory"),
|
||||
placement("access", "a directory a module reaches of another's"),
|
||||
placement("port", "a module's port"),
|
||||
placement("seat", "where a seat's holder is reached"),
|
||||
placement("machine", "a fact of the machine"),
|
||||
placement("backup", "what the machine's modules back up: `lines` and `data`, as the controller derives them from each module's declared data"),
|
||||
}
|
||||
|
||||
// Publish brings the registry bucket to TheRegistry: a kind whose answer differs is written, a kind no longer in
|
||||
// the table is deleted, and a pass that finds them equal writes nothing. It answers the kinds it wrote and
|
||||
// deleted.
|
||||
func Publish(s Store) (written, deleted []string, err error) {
|
||||
known := map[string]bool{}
|
||||
for _, a := range TheRegistry {
|
||||
known[a.Kind] = true
|
||||
want, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return written, deleted, err
|
||||
}
|
||||
have, err := s.Get(Registry, a.Kind)
|
||||
if err != nil {
|
||||
return written, deleted, fmt.Errorf("reading the registry's %s: %w", a.Kind, err)
|
||||
}
|
||||
if have != nil && sameJSON(have.Value, want) {
|
||||
continue
|
||||
}
|
||||
if err := s.Put(Registry, a.Kind, a); err != nil {
|
||||
return written, deleted, fmt.Errorf("writing the registry's %s: %w", a.Kind, err)
|
||||
}
|
||||
written = append(written, a.Kind)
|
||||
}
|
||||
keys, err := s.Keys(Registry)
|
||||
if err != nil {
|
||||
return written, deleted, fmt.Errorf("listing the registry: %w", err)
|
||||
}
|
||||
for _, k := range keys {
|
||||
if known[k] {
|
||||
continue
|
||||
}
|
||||
if err := s.Delete(Registry, k); err != nil {
|
||||
return written, deleted, fmt.Errorf("deleting the registry's %s, a kind no domain answers: %w", k, err)
|
||||
}
|
||||
deleted = append(deleted, k)
|
||||
}
|
||||
slices.Sort(deleted)
|
||||
return written, deleted, nil
|
||||
}
|
||||
|
||||
// sameJSON says two JSON texts hold the same value, whatever their spacing or the order of their keys.
|
||||
func sameJSON(a, b []byte) bool {
|
||||
var x, y any
|
||||
if json.Unmarshal(a, &x) != nil || json.Unmarshal(b, &y) != nil {
|
||||
return bytes.Equal(a, b)
|
||||
}
|
||||
ja, _ := json.Marshal(x)
|
||||
jb, _ := json.Marshal(y)
|
||||
return bytes.Equal(ja, jb)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module mesh-provisioner
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 h1:I9rm8HhYRJ0+mzQQz+VAH0kgv2YJWkvctEc67Yd0PX8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"module": "mesh-provisioner",
|
||||
"version": "1",
|
||||
"runs-as": "mesh-provisioner",
|
||||
"claims": [
|
||||
{
|
||||
"name": "mesh-provisioner",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"provisioning"
|
||||
]
|
||||
}
|
||||
],
|
||||
"state": [
|
||||
"registry",
|
||||
"needs",
|
||||
"applied",
|
||||
"collect"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:state}/broker"
|
||||
},
|
||||
"secrets-owner": "mesh-provisioner",
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "mesh-provisioner",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/mesh-provisioner"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-provisioner",
|
||||
"place": "."
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-provisioner",
|
||||
"binary": "mesh-provisioner",
|
||||
"loads": [
|
||||
"mesh-provisioner"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user