Land feature 57's controller work on its release branch

The controller plans config, templates and moved providers' provisions,
hands bus accounts to the vault and judges the new manifest words, on the
feature's SDK and node-engine validator (feature 57, config and provisioning).
This commit is contained in:
2026-10-15 21:27:33 +00:00
parent fcdecb4361
commit 97a2d841c7
349 changed files with 42440 additions and 786 deletions
+46
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -301,6 +302,51 @@ func (i *Inventory) Held(ctx context.Context) (map[string]string, error) {
return held, nil
}
// RegisteredHeld is Held narrowed to the build each module has registered (novox/hq issue 721): an artifact is kept
// only when its reference's digest is in the manifest the module table holds for it, which is what its machines are
// sent. After a gate failed and the mesh put the earlier build back (RestoreModule), the newest successful build is
// not what runs, and its artifacts are left out here, so nothing is compared with them.
func (i *Inventory) RegisteredHeld(ctx context.Context) (map[string]string, error) {
held, err := i.Held(ctx)
if err != nil {
return nil, err
}
rows, err := i.store.Pool().Query(ctx, `select name, coalesce(manifest::text, '') from module`)
if err != nil {
return nil, err
}
defer rows.Close()
registered := map[string]string{}
for rows.Next() {
var name, manifest string
if err := rows.Scan(&name, &manifest); err != nil {
return nil, err
}
registered[name] = manifest
}
if err := rows.Err(); err != nil {
return nil, err
}
return registeredOnly(held, registered), nil
}
// registeredOnly keeps of held each "<module>/<artifact>" whose reference's digest the module's registered manifest
// names.
func registeredOnly(held, registered map[string]string) map[string]string {
out := map[string]string{}
for key, ref := range held {
module, _, ok := strings.Cut(key, "/")
at := strings.LastIndex(ref, "@")
if !ok || at < 0 || len(ref) <= at+1 {
continue
}
if strings.Contains(registered[module], ref[at+1:]) {
out[key] = ref
}
}
return out
}
// BuiltAgainst is what each module's newest successful build stood on, as recorded — the build
// edges (ADR 0009). A module whose last build recorded no bases is absent, which is also what a
// module standing on nothing looks like: an edge the mesh has not derived is not an edge.
+149 -1
View File
@@ -61,12 +61,23 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) {
out := broker.Records{Assigned: map[string][]broker.Declared{}, People: map[string][]string{},
Interchangeable: map[string]bool{}}
assigned := map[string][]string{}
for _, n := range nodes {
out.Nodes = append(out.Nodes, n.Name)
modules, err := i.Assigned(ctx, n.Name)
if err != nil {
return broker.Records{}, fmt.Errorf("cannot read what %s runs: %w", n.Name, err)
}
assigned[n.Name] = modules
}
// What each restricting config value says on each machine running its module (novox/hq ADR 0363 §5): read
// once, for every consumer composed below.
grants, err := i.streamGrantValues(ctx, declared, nodes, assigned)
if err != nil {
return broker.Records{}, err
}
for _, n := range nodes {
out.Nodes = append(out.Nodes, n.Name)
modules := assigned[n.Name]
for _, module := range modules {
m, known := declared[module]
if !known {
@@ -89,6 +100,9 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) {
d.KeyedReads = append(d.KeyedReads, broker.KeyedRead{Read: sr.Read, Key: sr.Key})
}
d.Holds = heldHere(d.Holds, holdings, n.Name, module)
// And the module streams it hears on this machine, as granted (novox/hq ADR 0363 §5): what a
// stream carries whole leaves EVENTS' consumes.
d.Hears, d.HearsAStream, d.Consumes = hearsOf(m, declared, n.Name, grants, d.Consumes)
out.Assigned[n.Name] = append(out.Assigned[n.Name], d)
if m.Instances == catalogue.InstancesInterchangeable {
out.Interchangeable[m.Module] = true
@@ -164,11 +178,15 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
Serves: m.Tools,
// And what it calls (novox/hq ADR 0152) — the console's `*`, nothing else's.
Invokes: m.Invokes,
// And the event families a session may wait on, the tool runner's alone (novox/hq ADR 0367 §2).
Waits: waitsOf(m, seats),
// And the state it keeps and reads (novox/hq ADR 0201).
State: bucketsOf(m),
Reads: m.Reads,
// And the logs it keeps (novox/hq ADR 0297).
Logs: logsOf(m),
// And the streams it owns (novox/hq ADR 0363).
Streams: streamsOf(m),
// And the tools its health asks (novox/hq ADR 0240): the machine's node-engine is granted them.
Checks: catalogue.HealthChecks(m),
// And whether it runs as an account of its own (novox/hq ADR 0259 §8).
@@ -182,6 +200,12 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
if _, reads := m.OwnSecrets["broker"]; !reads {
d.NoAccount = true
}
// Whether it runs the machine's provisioner client, which reaches the bus on an account of its own, delivered
// in the own secret named for it (novox/hq ADR 0349 clause 11, design 57 P3): one that declares none has
// nowhere to read it, as with `broker`.
if _, reads := m.OwnSecrets[broker.ProvisionerSecret]; reads && m.ClaimsSeat(broker.NodeProvisionerSeat) {
d.ProvisionsNode = true
}
// The bus's own module — the one holding mesh-broker — copies the bus's streams for the night's
// backup, and its account is granted that and nothing else (novox/hq ADR 0235).
d.SnapshotsTheBus = m.ClaimsSeat(catalogue.BrokerSeat)
@@ -203,6 +227,32 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
return d
}
// waitsOf is the event families a manifest says a session may wait on, each resolved as a consumed name is
// (novox/hq ADR 0121, ADR 0239): a seat's event when the seat says it, else the event of the module of that
// name. A seat whose event subjects end in a kind (a kinded bench) or in the asker (ADR 0259 §3) is waited on
// with that last name given — `decided.claude-code` — and the family says which it needs, so one given without
// it is refused where it is composed rather than granted a subject nothing publishes on.
func waitsOf(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaration) []broker.Wait {
var out []broker.Wait
for _, w := range m.Waits {
emitter, event, _ := strings.Cut(w, ".")
first, _, _ := strings.Cut(event, ".")
wait := broker.Wait{Name: w, Emitter: emitter, Event: event}
if s, isASeat := seats[emitter]; isASeat {
switch {
case s.Kinded && catalogue.SeatSays(s.Emits, first):
wait.Seat, wait.Last = true, "kind"
case s.NamedByCaller(first) && catalogue.SeatSays(s.Emits, first):
wait.Seat, wait.Last = true, "asker"
case catalogue.SeatSays(s.Emits, event):
wait.Seat = true
}
}
out = append(out, wait)
}
return out
}
// issuedTools are the tools through which a module answers the credentials its software issues (novox/hq ADR 0305).
func issuedTools(m catalogue.Manifest) []string {
var out []string
@@ -362,3 +412,101 @@ func (i *Inventory) DeclaredTrafficSeats(ctx context.Context) ([]broker.Seat, er
}
return out, nil
}
// streamsOf is the streams a module owns, as the bus holds them (novox/hq ADR 0363).
func streamsOf(m catalogue.Manifest) []broker.ModuleStream {
var out []broker.ModuleStream
for _, s := range m.Streams {
events := make([]string, 0, len(s.Events))
for _, e := range s.Events {
events = append(events, broker.WirePattern(e))
}
out = append(out, broker.ModuleStream{Module: m.Module, Name: s.BusName(), Events: events,
MaxAgeDays: s.Days(), MaxMiB: s.Cap(), Restricted: s.Grant != nil})
}
return out
}
// DeclaredStreams is every module stream the catalogue declares, registered modules assigned or not: a
// stream exists from registration, as a log does (novox/hq ADR 0363 §1).
func (i *Inventory) DeclaredStreams(ctx context.Context) ([]broker.ModuleStream, error) {
declared, err := i.Catalogue(ctx)
if err != nil {
return nil, fmt.Errorf("cannot read the catalogue: %w", err)
}
var out []broker.ModuleStream
for _, m := range declared {
out = append(out, streamsOf(m)...)
}
return out, nil
}
// grantValues is each restricting config value on each machine running its module, by `<module>/<key>`.
type grantValues map[string][]any
// streamGrantValues reads, for every module owning a restricted stream, the config value that restricts it on every
// machine the module runs on: the mesh's layer under that machine's, the machine's winning (novox/hq ADR 0363 §5).
func (i *Inventory) streamGrantValues(ctx context.Context, declared map[string]catalogue.Manifest,
nodes []Node, assigned map[string][]string) (grantValues, error) {
out := grantValues{}
for _, n := range nodes {
for _, module := range assigned[n.Name] {
m, known := declared[module]
if !known {
continue
}
var settings []string
for _, s := range m.Streams {
if s.Grant != nil && !slices.Contains(settings, s.Grant.Config) {
settings = append(settings, s.Grant.Config)
}
}
if len(settings) == 0 {
continue
}
layers, err := i.SettingsFor(ctx, n.Name, module)
if err != nil {
return nil, fmt.Errorf("cannot read %s's settings on %s, which grant who hears its stream: %w",
module, n.Name, err)
}
for _, setting := range settings {
if v, set := settingValue(layers, setting); set {
out[module+"/"+setting] = append(out[module+"/"+setting], v)
}
}
}
}
return out, nil
}
// settingValue is one key's value over a machine's layers, the last that sets it winning. Pure.
func settingValue(layers []catalogue.Layer, key string) (any, bool) {
var value any
set := false
for _, l := range layers {
if v, ok := l.Values[key]; ok {
value, set = v, true
}
}
return value, set
}
// hearsOf is what a module hears of the module streams on one machine, and the consumes left for EVENTS
// (novox/hq ADR 0363 §5). Pure.
func hearsOf(m catalogue.Manifest, declared map[string]catalogue.Manifest, node string, grants grantValues,
consumes []string) ([]broker.Heard, bool, []string) {
reading := m
reading.Consumes = consumes
parts, touches, onEvents := catalogue.Hearing(reading, declared, node, func(provider, setting string) []any {
return grants[provider+"/"+setting]
})
var out []broker.Heard
for _, p := range parts {
h := broker.Heard{Stream: p.Stream.BusName(), Emitter: p.Emitter, Restricted: p.Stream.Grant != nil}
for _, pattern := range p.Patterns {
h.Filters = append(h.Filters, broker.ModuleStreamSubject(p.Emitter, broker.WirePattern(pattern)))
}
out = append(out, h)
}
return out, touches, onEvents
}
+44
View File
@@ -345,3 +345,47 @@ func TestABarIsGrantedTheStateItsMachinesModulesOfferIt(t *testing.T) {
t.Fatalf("power reads %v", got)
}
}
// A module running the machine's provisioner client gives that machine a client account of its own, apart from the
// module's and the tool runner's (novox/hq ADR 0349 clause 11, design 57 P3); one with nowhere to read the account
// gives none.
func TestTheProvisionerClientsMachineHasAnAccountOfItsOwn(t *testing.T) {
provisioner := catalogue.Manifest{
Module: "mesh-provisioner", Version: "1",
DefinesSeats: []catalogue.SeatDeclaration{{Name: broker.NodeProvisionerSeat, Scope: catalogue.ScopeNode,
Serves: []catalogue.Verb{{Name: "get"}}}},
Claims: []catalogue.Claim{{Name: broker.NodeProvisionerSeat, Scope: catalogue.ScopeNode}},
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"},
broker.ProvisionerSecret: {Path: "/run/provisioner-broker"}},
}
unread := provisioner
unread.Module = "provisioner-unread"
unread.DefinesSeats = nil
unread.OwnSecrets = catalogue.OwnSecrets{"broker": {Path: "/run/broker"}}
inv, ctx := aMeshWith(t, provisioner, unread)
for node, module := range map[string]string{"one": "mesh-provisioner", "two": "provisioner-unread"} {
if _, err := inv.AddNode(ctx, node); err != nil {
t.Fatal(err)
}
if _, err := inv.Assign(ctx, node, module); err != nil {
t.Fatal(err)
}
}
records, err := inv.BusRecords(ctx)
if err != nil {
t.Fatal(err)
}
users, err := broker.Users(records)
if err != nil {
t.Fatal(err)
}
var clients []string
for _, u := range users {
if u.Kind == broker.KindProvisioner {
clients = append(clients, u.Username()+" for "+u.Module)
}
}
if strings.Join(clients, ",") != "provisioner.one for mesh-provisioner" {
t.Fatalf("the provisioner clients' accounts are %v, want one for the machine whose module reads it", clients)
}
}
+3
View File
@@ -65,6 +65,9 @@ const (
// BusNodeTools is a machine's tool runtime (novox/hq ADR 0175): named like the module it
// stands for, recorded as what it is.
BusNodeTools = "node-tools"
// BusProvisioner is a machine's provisioner client (novox/hq ADR 0349 clause 11, design 57 P3): an account of
// its own, named for the machine.
BusProvisioner = "provisioner"
)
// MintBusPassword makes a bus password and records its hash under a username, replacing whatever was
+51 -12
View File
@@ -869,11 +869,16 @@ func (i *Inventory) setSettings(ctx context.Context, nodeName, module string, va
} else if _, err := i.declared(ctx, module); err != nil {
return fmt.Errorf("%w: %s", ErrNoSuchModule, module)
}
// The ports a layer gives are its placement now (issue 736), read from the layer as it was said.
whole, err := json.Marshal(values)
if err != nil {
return err
}
if nodeName == "" {
// A port is a fact about one machine (novox/hq ADR 0100). Refused here, in composition's
// words: stored, it refuses every node running the module at composition, and the mesh
// cannot be pushed at all until somebody finds the layer that did it.
given, err := givenIn(module, raw)
given, err := givenIn(module, whole)
if err != nil {
return err
}
@@ -906,7 +911,7 @@ func (i *Inventory) setSettings(ctx context.Context, nodeName, module string, va
if err != nil {
return err
}
given, err := givenIn(module, raw)
given, err := givenIn(module, whole)
if err != nil {
return err
}
@@ -1052,10 +1057,7 @@ func refuseGivenCollisions(ctx context.Context, tx pgx.Tx, nodeID any, node, mod
if err := rows.Err(); err != nil {
return err
}
rows, err = tx.Query(ctx,
`select module, values->'ports' from settings
where node = $1 and module <> $2 and jsonb_typeof(values->'ports') = 'object'`,
nodeID, module)
rows, err = tx.Query(ctx, givenPortsSQL+` and module <> $2`, nodeID, module)
if err != nil {
return err
}
@@ -1183,15 +1185,28 @@ func (i *Inventory) SettingsFor(ctx context.Context, nodeName, module string) ([
if err := rows.Scan(&meshWide, &raw, &placed); err != nil {
return nil, err
}
values := map[string]any{}
if err := json.Unmarshal(raw, &values); err != nil {
said := map[string]any{}
if err := json.Unmarshal(raw, &said); err != nil {
return nil, err
}
var placement map[string]any
// A row the move of placement has not reached yet still holds placement keys among its values
// (issue 736: `endpoints`, `expose`, `reach` and `ports` move after the delivery, by a verb). They are
// read as the placement they are, so a machine is told the same before the move and after it.
values, placement := catalogue.SplitPlacement(said)
if values == nil {
values = map[string]any{}
}
if len(placed) > 0 {
if err := json.Unmarshal(placed, &placement); err != nil {
var kept map[string]any
if err := json.Unmarshal(placed, &kept); err != nil {
return nil, err
}
if placement == nil {
placement = map[string]any{}
}
for k, v := range kept {
placement[k] = v
}
}
from := nodeName
if meshWide {
@@ -1199,7 +1214,20 @@ func (i *Inventory) SettingsFor(ctx context.Context, nodeName, module string) ([
}
layers = append(layers, catalogue.Layer{From: from, Values: values, Placement: placement})
}
return layers, rows.Err()
if err := rows.Err(); err != nil {
return nil, err
}
rows.Close()
// Once the move of config has run, a module in the config form takes its config values from mesh-config, and
// its layers keep their placement alone (issue 664).
reads, err := i.ReadsConfig(ctx, module)
if err != nil {
return nil, err
}
if reads {
return i.fromConfig(ctx, nodeName, module, layers)
}
return layers, nil
}
// placementJSON is a layer's placement as the placement table keeps it, or nil when it has none.
@@ -1540,6 +1568,16 @@ type CurrentBuild struct {
Commit string
// RollOut is the module's upgrade policy, as Upgrade.RollOut.
RollOut bool
// From is where the policy came from, as Upgrade.From: a move under a module's own `record` — its manifest's or
// its irreplaceable data's — rolls out when it recreates nothing and moves no data (novox/hq ADR 0377), one under
// a person's or the bus's never does.
From string
}
// OwnRecord is whether a module records because its manifest says so or because it keeps irreplaceable data
// (novox/hq ADR 0377 §1): the `record` whose moves that recreate nothing roll out.
func (b CurrentBuild) OwnRecord() bool {
return !b.RollOut && (b.From == catalogue.FromModule || b.From == catalogue.FromData)
}
// CurrentBuilds is every module's current build and upgrade policy, in one read: what a send records
@@ -1561,7 +1599,8 @@ func (i *Inventory) CurrentBuilds(ctx context.Context) (map[string]CurrentBuild,
if err := rows.Scan(&name, &b.Commit, &chosen, &together, &why, &by, &manifest); err != nil {
return nil, err
}
b.RollOut = upgradeFrom(chosen, together, why, by, manifest).RollOut
u := upgradeFrom(chosen, together, why, by, manifest)
b.RollOut, b.From = u.RollOut, u.From
out[name] = b
}
return out, rows.Err()
+217
View File
@@ -0,0 +1,217 @@
package inventory
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"github.com/jackc/pgx/v5"
"github.com/novox/mesh-controller/internal/catalogue"
)
// **The settings layers move into mesh-config** (novox/hq ADR 0348 §9 and its consequences, design 57 K5, issue
// 664). Each config value a settings layer holds is set in mesh-config by the move of config (`move-config`), one of
// the moves of data a release runs once after its delivery; then the controller reads a module's config values from
// mesh-config_resolved where it read its layers (ADR 0349 clause 17's old way, an exception to ADR 0348 §9 that
// design 57's R1 removes).
//
// **The switch** is the row of config_move (migration 0102), written only by the move once every value it set reads
// back through mesh-config. While it is absent, composition reads the layers exactly as before. Once it is there, a
// module reads mesh-config when it is in the config form and none of its layers still holds a config value: a
// module still in the settings form (the media catalogue's, until they move) keeps its layers, and one that moved
// to the config form after the move keeps its layer until the move runs again, so no value is ever read from where
// it is not yet.
// ConfigValues reads what mesh-config resolved for one module on one node: each value it takes from mesh config,
// node config or its own value on that node, keyed by its config key. A value it takes from its manifest's default,
// or a required value nothing sets, is not among them: composition has the manifest's defaults already, and a
// taken key's default is never laid over its file (the lead's decision on issue 664).
type ConfigValues func(ctx context.Context, node, module string) (map[string]any, error)
// ReadsConfigFrom gives the inventory where it reads config values once the switch is on. Nil reads none, and a
// module the switch reaches then fails to compose by name, never silently without its values.
func (i *Inventory) ReadsConfigFrom(read ConfigValues) { i.configValues = read }
// ConfigMove is the switch: when the move of config ran, who ran it, and why.
type ConfigMove struct {
Moved bool
At string
By string
Why string
}
// ConfigMoved says whether the move of config has run: the switch.
func (i *Inventory) ConfigMoved(ctx context.Context) (ConfigMove, error) {
var m ConfigMove
err := i.store.Pool().QueryRow(ctx,
`select to_char(moved_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), coalesce(moved_by, ''), why
from config_move`).Scan(&m.At, &m.By, &m.Why)
if errors.Is(err, pgx.ErrNoRows) {
return ConfigMove{}, nil
}
if err != nil {
return ConfigMove{}, err
}
m.Moved = true
return m, nil
}
// ReadsConfig says whether a module's config values are read from mesh-config rather than its layers: the move of
// config has run, the module is registered in the config form, and none of its layers still holds a config value.
func (i *Inventory) ReadsConfig(ctx context.Context, module string) (bool, error) {
var moved, holds bool
var raw []byte
err := i.store.Pool().QueryRow(ctx,
`select exists (select 1 from config_move),
(select manifest from module where name = $1),
exists (select 1 from settings where module = $1 and values <> '{}'::jsonb)`, module).
Scan(&moved, &raw, &holds)
if err != nil {
return false, err
}
if !moved || holds || raw == nil {
return false, nil
}
var m catalogue.Manifest
if err := json.Unmarshal(raw, &m); err != nil {
return false, err
}
return m.DeclaresConfig(), nil
}
// fromConfig lays a module's config values, as mesh-config resolved them for the node, over the layers read for
// it: the node's layer takes them as its values, and holds them alone when the module has no layer of the node's.
// The layers keep their placement; their values were emptied by the move.
func (i *Inventory) fromConfig(ctx context.Context, nodeName, module string, layers []catalogue.Layer) (
[]catalogue.Layer, error) {
if i.configValues == nil {
return nil, fmt.Errorf("%s on %s takes its config values from mesh-config, and this controller has no way to "+
"read them (novox/hq ADR 0348 §9, issue 664); nothing is composed without them", module, nodeName)
}
values, err := i.configValues(ctx, nodeName, module)
if err != nil {
return nil, fmt.Errorf("%s on %s takes its config values from mesh-config, and they cannot be read: %w",
module, nodeName, err)
}
for k := range layers {
// A layer the move emptied keeps its placement alone: its config values are mesh-config's now.
layers[k].Values = map[string]any{}
}
if len(values) == 0 {
return layers, nil
}
for k := range layers {
if layers[k].From == nodeName {
layers[k].Values = values
return layers, nil
}
}
return append(layers, catalogue.Layer{From: nodeName, Values: values}), nil
}
// LayerToMove is one settings layer as the move of config reads it: whose, its values, and when it was set.
type LayerToMove struct {
// Node is the layer's machine, empty for the whole mesh's.
Node string `json:"node"`
Module string `json:"module"`
Values map[string]any `json:"values"`
}
// LayersToMove is every settings layer that holds a value, the whole mesh's first, then by machine and module, and
// whether any still holds a placement key (the move of placement has not run).
func (i *Inventory) LayersToMove(ctx context.Context) ([]LayerToMove, []string, error) {
rows, err := i.store.Pool().Query(ctx,
`select coalesce(n.name, ''), s.module, s.values
from settings s left join node n on n.id = s.node
where s.values <> '{}'::jsonb
order by s.node is not null, n.name, s.module`)
if err != nil {
return nil, nil, err
}
defer rows.Close()
var out []LayerToMove
placed := map[string]bool{}
for rows.Next() {
var l LayerToMove
var raw []byte
if err := rows.Scan(&l.Node, &l.Module, &raw); err != nil {
return nil, nil, err
}
if err := json.Unmarshal(raw, &l.Values); err != nil {
return nil, nil, err
}
if _, placement := catalogue.SplitPlacement(l.Values); len(placement) > 0 {
where := l.Node
if where == "" {
where = "the whole mesh"
}
placed[l.Module+" on "+where] = true
}
out = append(out, l)
}
if err := rows.Err(); err != nil {
return nil, nil, err
}
holding := make([]string, 0, len(placed))
for k := range placed {
holding = append(holding, k)
}
sort.Strings(holding)
return out, holding, nil
}
// configMovedWhy is why a layer the move of config moved went into the history.
const configMovedWhy = "moved to mesh-config, ADR 0348 §9 (issue 664)"
// FinishConfigMove ends the move of config once every value it set reads back through mesh-config: in one
// transaction, each layer moved is kept whole in the history first (why: moved to mesh-config), its values are
// emptied (its row stays, so the placement beside it stays), and the switch is written with who ran it and why.
// A layer that changed since the move read it refuses the whole, and nothing is changed: the move is asked again.
func (i *Inventory) FinishConfigMove(ctx context.Context, moved []LayerToMove, by, why string) error {
tx, err := i.store.Pool().Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
for _, l := range moved {
raw, err := json.Marshal(l.Values)
if err != nil {
return err
}
// The layer's row: its machine's, or the whole mesh's (no machine named, so no node).
const theLayer = `s.module = $1 and s.node is not distinct from (select id from node where name = $2)`
var still bool
if err := tx.QueryRow(ctx,
`select exists (select 1 from settings s where `+theLayer+` and s.values = $3::jsonb for update of s)`,
l.Module, l.Node, string(raw)).Scan(&still); err != nil {
return err
}
if !still {
where := l.Node
if where == "" {
where = "the whole mesh"
}
return fmt.Errorf("the layer of %s on %s changed since the move read it; nothing was changed: ask the "+
"move again", l.Module, where)
}
if _, err := tx.Exec(ctx,
`insert into settings_history (node, module, values, set_at, replaced_by, set_by, why)
select s.node, s.module, `+layerWhole+`, s.set_at, 'moved', s.set_by, $3
from settings s where `+theLayer, l.Module, l.Node, configMovedWhy); err != nil {
return err
}
if _, err := tx.Exec(ctx, `update settings s set values = '{}'::jsonb where `+theLayer, l.Module, l.Node); err != nil {
return err
}
}
if _, err := tx.Exec(ctx,
`insert into config_move (moved_by, why) values ($1, $2)
on conflict (only_one) do update set moved_at = now(), moved_by = excluded.moved_by, why = excluded.why`,
nullable(by), why); err != nil {
return err
}
return tx.Commit(ctx)
}
+218
View File
@@ -0,0 +1,218 @@
package inventory
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// configured is a module in the config form: one value a mergeable file takes, and a directory to place.
func configured(t *testing.T, name string) catalogue.Manifest {
t.Helper()
m, err := catalogue.ParseManifest([]byte(`{"module": "` + name + `", "version": "1",
"config": {"level": {"type": "string", "description": "how much it says", "default": "info"}},
"resources": [{"id": "data", "type": "directory", "mode": "0750"}, {"id": "conf", "type": "file", "path": "/etc/` + name + `.json", "mode": "0640",
"content": "{\"level\": \"info\"}", "merge": "json", "takes-config": ["level"]}]}`))
if err != nil {
t.Fatal(err)
}
return m
}
// standInConfig is mesh-config's resolved values as a test holds them: by `<node>.<module>`, and each read.
type standInConfig struct {
values map[string]map[string]any
read []string
fails error
}
func (s *standInConfig) read1(_ context.Context, node, module string) (map[string]any, error) {
s.read = append(s.read, node+"."+module)
if s.fails != nil {
return nil, s.fails
}
return s.values[node+"."+module], nil
}
// aMeshForTheConfigMove is a store with a module in the config form and one still in the settings form, each with a
// layer on anchor, the config one with placement beside it, and a whole-mesh layer of the config one.
func aMeshForTheConfigMove(t *testing.T) (*Inventory, *standInConfig) {
t.Helper()
inv := ForTest(t)
ctx := t.Context()
old := catalogue.Manifest{Module: "older", Version: "1", Resources: []map[string]any{{"id": "conf", "type": "file",
"path": "/etc/older.json", "mode": "0640", "content": `{}`, "merge": "json"}}}
for _, m := range []catalogue.Manifest{configured(t, "newer"), old} {
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
t.Fatal(err)
}
}
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
t.Fatal(err)
}
for _, m := range []string{"newer", "older"} {
if _, err := inv.Assign(ctx, "anchor", m); err != nil {
t.Fatal(err)
}
}
if err := inv.SetSettings(ctx, "anchor", "newer", map[string]any{"level": "debug",
"places": map[string]any{"data": map[string]any{"path": "/srv/newer"}}}); err != nil {
t.Fatal(err)
}
if err := inv.SetSettings(ctx, "", "newer", map[string]any{"level": "warn"}); err != nil {
t.Fatal(err)
}
if err := inv.SetSettings(ctx, "anchor", "older", map[string]any{"colour": "blue"}); err != nil {
t.Fatal(err)
}
config := &standInConfig{values: map[string]map[string]any{"anchor.newer": {"level": "debug"}}}
inv.ReadsConfigFrom(config.read1)
return inv, config
}
// valuesOf is each layer's From and Values, as composition reads them.
func valuesOf(layers []catalogue.Layer) map[string]map[string]any {
out := map[string]map[string]any{}
for _, l := range layers {
out[l.From] = l.Values
}
return out
}
// **The layers are read until the move of config has run** (novox/hq ADR 0348 §9, issue 664): before it, composition
// reads every module's layers and never asks mesh-config. After it, a module in the config form takes its values
// from mesh-config, its layers keep their placement and are kept whole in the history; a module in the settings form
// keeps its layer.
func TestTheLayersAreReadUntilTheMoveOfConfigHasRun(t *testing.T) {
inv, config := aMeshForTheConfigMove(t)
ctx := t.Context()
layers, err := inv.SettingsFor(ctx, "anchor", "newer")
if err != nil {
t.Fatal(err)
}
if got := valuesOf(layers); !reflect.DeepEqual(got, map[string]map[string]any{
catalogue.MeshWideLayer: {"level": "warn"}, "anchor": {"level": "debug"}}) || len(config.read) > 0 {
t.Fatalf("before the move composition reads %v and asked mesh-config %v", got, config.read)
}
if moved, err := inv.ConfigMoved(ctx); err != nil || moved.Moved {
t.Fatalf("the switch is on before the move: %+v (%v)", moved, err)
}
toMove, holding, err := inv.LayersToMove(ctx)
if err != nil || len(holding) > 0 {
t.Fatalf("the layers to move: %v, holding placement %v", err, holding)
}
var newer []LayerToMove
for _, l := range toMove {
if l.Module == "newer" {
newer = append(newer, l)
}
}
if len(newer) != 2 || newer[0].Node != "" || newer[1].Node != "anchor" {
t.Fatalf("the layers to move are %+v", toMove)
}
if err := inv.FinishConfigMove(ctx, newer, "the move of config, run by a test", "release 57 is delivered"); err != nil {
t.Fatal(err)
}
moved, err := inv.ConfigMoved(ctx)
if err != nil || !moved.Moved || moved.Why != "release 57 is delivered" || !strings.Contains(moved.By, "a test") {
t.Fatalf("the switch reads %+v (%v)", moved, err)
}
layers, err = inv.SettingsFor(ctx, "anchor", "newer")
if err != nil {
t.Fatal(err)
}
if got := valuesOf(layers); !reflect.DeepEqual(got, map[string]map[string]any{
catalogue.MeshWideLayer: {}, "anchor": {"level": "debug"}}) {
t.Fatalf("after the move composition reads %v", got)
}
if len(config.read) == 0 {
t.Fatal("after the move mesh-config was not asked")
}
for _, l := range layers {
if l.From == "anchor" && l.Placement["places"] == nil {
t.Fatalf("the placement beside the layer was lost: %+v", l)
}
}
if held := inv.QueryForTest(t, `select values = '{}' from settings where module = 'newer'`); len(held) != 2 ||
held[0][0] != "true" || held[1][0] != "true" {
t.Fatalf("the moved layers hold %v", held)
}
past := inv.QueryForTest(t, `select values->>'level', values ? 'places', why = $1 from settings_history
where module = 'newer' and replaced_by = 'moved' order by node nulls first`, configMovedWhy)
if len(past) != 2 || past[0][0] != `"warn"` || past[1][0] != `"debug"` || past[1][1] != "true" || past[0][2] != "true" {
t.Fatalf("the history holds %v", past)
}
// The module in the settings form keeps its layer, and mesh-config is not asked for it.
config.read = nil
layers, err = inv.SettingsFor(ctx, "anchor", "older")
if err != nil || !reflect.DeepEqual(valuesOf(layers), map[string]map[string]any{"anchor": {"colour": "blue"}}) ||
len(config.read) > 0 {
t.Fatalf("the settings form's layer reads %v (%v), mesh-config asked %v", valuesOf(layers), err, config.read)
}
}
// A module that holds a layer again — one moved to the config form after the move — reads its layer until the move
// runs again, so no value is read from where it is not yet; mesh-config that cannot be read, or no reader at all,
// refuses composition by name rather than composing without the values.
func TestAModuleReadsMeshConfigOnlyWhereItsValuesAre(t *testing.T) {
inv, config := aMeshForTheConfigMove(t)
ctx := t.Context()
toMove, _, err := inv.LayersToMove(ctx)
if err != nil {
t.Fatal(err)
}
var newer []LayerToMove
for _, l := range toMove {
if l.Module == "newer" {
newer = append(newer, l)
}
}
if err := inv.FinishConfigMove(ctx, newer, "a test", "moved"); err != nil {
t.Fatal(err)
}
inv.ExecForTest(t, `update settings set values = '{"level": "trace"}' where module = 'newer' and node is not null`)
layers, err := inv.SettingsFor(ctx, "anchor", "newer")
if err != nil || valuesOf(layers)["anchor"]["level"] != "trace" {
t.Fatalf("a module holding a layer again reads %v (%v)", valuesOf(layers), err)
}
inv.ExecForTest(t, `update settings set values = '{}' where module = 'newer'`)
config.fails = errors.New("the bucket is gone")
if _, err := inv.SettingsFor(ctx, "anchor", "newer"); err == nil || !strings.Contains(err.Error(), "the bucket is gone") ||
!strings.Contains(err.Error(), "newer on anchor") {
t.Fatalf("an unreadable mesh-config composes: %v", err)
}
inv.ReadsConfigFrom(nil)
if _, err := inv.SettingsFor(ctx, "anchor", "newer"); err == nil || !strings.Contains(err.Error(), "no way to read") {
t.Fatalf("no reader composes: %v", err)
}
}
// **The move ends only on the layers it read** (issue 664): a layer changed between the move reading it and its
// end refuses the whole, and nothing is emptied or switched.
func TestTheEndOfTheMoveRefusesALayerChangedSinceItWasRead(t *testing.T) {
inv, _ := aMeshForTheConfigMove(t)
ctx := t.Context()
toMove, _, err := inv.LayersToMove(ctx)
if err != nil {
t.Fatal(err)
}
if err := inv.SetSettings(ctx, "anchor", "newer", map[string]any{"level": "error"}); err != nil {
t.Fatal(err)
}
err = inv.FinishConfigMove(ctx, toMove, "a test", "moved")
if err == nil || !strings.Contains(err.Error(), "newer on anchor changed") {
t.Fatalf("a layer changed since it was read was moved: %v", err)
}
if moved, _ := inv.ConfigMoved(ctx); moved.Moved {
t.Fatal("the switch is on after a refused move")
}
if held := inv.QueryForTest(t, `select count(*) from settings where values <> '{}'`); held[0][0] != "3" {
t.Fatalf("a refused move emptied a layer: %v", held)
}
}
+65
View File
@@ -0,0 +1,65 @@
package inventory
import (
"context"
"encoding/json"
"fmt"
"time"
)
// CoreBox is a core module's own credential as the vault sealed it to its machine (novox/hq ADR 0349 §16, issue 755):
// the vault's generation, the machine's sealing key it was sealed to, and the box. Never a value: the controller
// cannot open it.
type CoreBox struct {
Module string
Name string
Generation int
SealedTo string
Box json.RawMessage
GotAt time.Time
}
// KeptCoreBoxes is every box kept for a machine, by module and name.
func (i *Inventory) KeptCoreBoxes(ctx context.Context, node string) (map[string]map[string]CoreBox, error) {
rows, err := i.store.Pool().Query(ctx,
`select b.module, b.name, b.generation, b.sealed_to, b.box, b.got_at
from core_box b join node n on n.id = b.node where n.name = $1`, node)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]map[string]CoreBox{}
for rows.Next() {
var b CoreBox
var box string
if err := rows.Scan(&b.Module, &b.Name, &b.Generation, &b.SealedTo, &box, &b.GotAt); err != nil {
return nil, err
}
b.Box = json.RawMessage(box)
if out[b.Module] == nil {
out[b.Module] = map[string]CoreBox{}
}
out[b.Module][b.Name] = b
}
return out, rows.Err()
}
// KeepCoreBox keeps the box the vault answered for one of a machine's core credentials, over the one kept before.
func (i *Inventory) KeepCoreBox(ctx context.Context, node string, b CoreBox) error {
if b.Generation <= 0 || b.SealedTo == "" || len(b.Box) == 0 || !json.Valid(b.Box) {
return fmt.Errorf("the vault's box for %s/%s on %s is not whole: nothing kept", b.Module, b.Name, node)
}
tag, err := i.store.Pool().Exec(ctx,
`insert into core_box (node, module, name, generation, sealed_to, box)
select n.id, $2, $3, $4, $5, $6 from node n where n.name = $1
on conflict (node, module, name) do update set generation = excluded.generation,
sealed_to = excluded.sealed_to, box = excluded.box, got_at = now()`,
node, b.Module, b.Name, b.Generation, b.SealedTo, string(b.Box))
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("%w: %s", ErrNoSuchNode, node)
}
return nil
}
+47
View File
@@ -0,0 +1,47 @@
package inventory
import (
"context"
"encoding/json"
"testing"
)
// The box the vault sealed a core credential in is kept per machine, module and name, replaced by a newer one, and
// gone with its machine (issue 755).
func TestACoreCredentialsVaultBoxIsKeptPerMachineAndReplaced(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
t.Fatal(err)
}
first := CoreBox{Module: "nats", Name: "broker", Generation: 1, SealedTo: "a2V5",
Box: json.RawMessage(`{"v":1,"eph":"ZQ==","iv":"aQ==","tag":"dA==","ct":"MQ=="}`)}
if err := inv.KeepCoreBox(ctx, "anchor", first); err != nil {
t.Fatal(err)
}
second := first
second.Generation, second.Box = 2, json.RawMessage(`{"v":1,"eph":"ZQ==","iv":"aQ==","tag":"dA==","ct":"Mg=="}`)
if err := inv.KeepCoreBox(ctx, "anchor", second); err != nil {
t.Fatal(err)
}
kept, err := inv.KeptCoreBoxes(ctx, "anchor")
if err != nil {
t.Fatal(err)
}
got := kept["nats"]["broker"]
var box map[string]any
_ = json.Unmarshal(got.Box, &box)
if len(kept) != 1 || got.Generation != 2 || got.SealedTo != "a2V5" || box["ct"] != "Mg==" || got.GotAt.IsZero() {
t.Fatalf("kept %+v", kept)
}
if err := inv.KeepCoreBox(ctx, "nowhere", first); err == nil {
t.Error("a box was kept for a machine the mesh does not know")
}
if err := inv.KeepCoreBox(ctx, "anchor", CoreBox{Module: "nats", Name: "broker", Generation: 3, SealedTo: "a2V5",
Box: json.RawMessage(`not json`)}); err == nil {
t.Error("a box that is not whole was kept")
}
if others, err := inv.KeptCoreBoxes(ctx, "laptop"); err != nil || len(others) != 0 {
t.Errorf("another machine has %v, %v", others, err)
}
}
@@ -0,0 +1,22 @@
-- The move of the controller's secrets into the vault (novox/hq ADR 0349 clause 7; design 57's V3, issue 676).
--
-- The controller cannot open a value it holds, so each machine's node-engine hands in what was sealed to it, and a
-- machine asleep when the move starts is moved when it is next heard from. These two tables are what lets the
-- controller tell, then and later, what has moved: that the move was started, by whom, and whether the record of
-- asks was copied; and each row the vault took, under the vault's name for it, with the machine whose engine handed
-- it in, the vault's generation, and a digest of the sealed text the row held then — so a row the controller
-- changed after it moved is said, never moved over the vault's value. No value and no sealed value is kept here.
create table secret_move (
one boolean primary key default true check (one),
started_at timestamptz not null default now(),
started_by text not null,
asks_moved_at timestamptz
);
create table secret_moved (
secret text primary key,
node uuid not null references node(id) on delete cascade,
generation integer not null check (generation > 0),
sealed_digest text not null,
moved_at timestamptz not null default now()
);
@@ -0,0 +1,10 @@
-- What a machine was last sent of the provisioner's plans (novox/hq ADR 0349 §4, design 57 P6, issue 672).
--
-- A module marked moved is handed to the provisioner on a machine whose node-engine takes a filled file on the
-- client's word alone: its declaration names the files the client fills, and the client fills them from the
-- module's plan — its templates archive by digest, each file's path and template, and the placement values its
-- templates name. The plans are composed with the declaration and kept with its send, by module, so the
-- assignments mirror carries the plan of the build that machine was sent (inventory.Provisioning) and a walk waits
-- for the client's report on that build's templates. Null for a machine sent nothing handed. No secret: placement
-- is not one.
alter table node add column sent_provisioning jsonb;
@@ -0,0 +1,18 @@
-- The core's own credentials as the vault sealed them to their machine (novox/hq ADR 0349 §16, issue 755).
--
-- The bus's and the provisioner's credentials travel in their machine's declaration sealed by the vault to that
-- machine, which the controller carries and cannot open. The vault seals each answer afresh, so the box the
-- controller last got is kept here and carried again while the vault's generation and the machine's key are the
-- same: a declaration that changed with every answer would be sent for ever. It is also what a send carries when
-- the vault does not answer, so the machine keeps the file it has. Kept as text, byte for byte as it was carried
-- (jsonb would reorder its keys, and the declaration with them). Sealed to the machine's key: no value.
create table core_box (
node uuid not null references node(id) on delete cascade,
module text not null,
name text not null,
generation integer not null check (generation > 0),
sealed_to text not null,
box text not null,
got_at timestamptz not null default now(),
primary key (node, module, name)
);
@@ -0,0 +1,15 @@
-- The settings layers move into mesh-config (novox/hq ADR 0348 §9, design 57 K5, issue 664).
--
-- Every config value a settings layer holds is set in mesh-config by the move of config (`move-config`), one of the
-- moves of data a release runs once after its delivery; the layers it moved keep their rows, emptied, so the
-- placement kept beside each stays where it is, and each is kept whole in the history first. This row is the
-- switch: while it is absent the controller composes from the layers as before; once the move has run and read
-- every value back through mesh-config, it is written, and the controller reads a module in the config form from
-- mesh-config_resolved where it read its layers (ADR 0349 clause 17's old way, an exception to ADR 0348 §9 that
-- design 57's R1 removes). One row at most; never written by a migration, so a delivery alone changes nothing.
create table config_move (
only_one boolean primary key default true check (only_one),
moved_at timestamptz not null default now(),
moved_by text,
why text not null
);
+204
View File
@@ -0,0 +1,204 @@
package inventory
import (
"bytes"
"context"
"encoding/json"
"fmt"
"time"
)
// The values of the controller's record mirrors, built from the store (novox/hq ADR 0289, design 54 §1,
// design 57 package F3).
//
// **Each value is built from a named list of fields, and nothing else** (design 54 §1): no epoch, no time
// of writing and no time of the last look, so any lease holder builds the same bytes from the same record
// and a pass over unchanged records writes nothing (ADR 0289 §1, §4). The encoding is fixed: the fields in
// their struct's order, a declaration's maps sorted.
// AssignmentMirrored is one assignment as the assignments mirror holds it: the module, the node, and since
// when it is assigned there. Design 54 §1 also lists whether it is pending, the builds sent and running and
// the seats and provisions it holds there; those join with issue 419, which builds the rest of design 54.
type AssignmentMirrored struct {
Node string `json:"node"`
Module string `json:"module"`
Assigned time.Time `json:"assigned,omitzero"`
// RequiredBy is, for a module only pulled in by another and handed to the provisioner on this node (issue 765),
// the module that requires it there; such a value has no time assigned. Absent for an assignment.
RequiredBy string `json:"required-by,omitempty"`
// Provisioning is the plan of a module handed to the provisioner on this node (novox/hq ADR 0349 clause 4,
// design 57 P5): absent for every other, so their values are the bytes they were.
Provisioning *Provisioning `json:"provisioning,omitempty"`
}
// DeclaredConfigMirrored is one registered module's config declarations as the declared-config mirror holds
// them: the module, and each key its manifest declares — under `config`, or under its old name `settings` for a
// module not yet moved (ADR 0348 §1) — with that declaration's listed fields. An empty object when the module
// declares none: registered, declaring nothing.
type DeclaredConfigMirrored struct {
Module string `json:"module"`
Settings map[string]DeclaredMirrored `json:"settings"`
}
// DeclaredMirrored is one key's declaration as the mirror holds it: **its listed fields and no other**
// (ADR 0289 §12), and every field a declaration has (catalogue.SettingDeclaration), under the same names. The
// mirror holds what the module's manifest declares about its config values, and mesh-config validates and
// resolves from it (ADR 0356 §4, ADR 0348 §4, §9), so it carries the whole declaration: kind, default and why
// of the settings form, and type, description, validation, trusted, predefined, required and runtime of the
// config form. None is a secret: a secret lives in the vault, never in config, and the catalogue refuses a
// default for a key whose name says secret. A field the manifest gains reaches the bus only once it is added
// here, deliberately: a test fails until it is listed. The config form's fields are left out when unset, so a
// module not yet moved is mirrored as it was before they were carried.
type DeclaredMirrored struct {
Kind string `json:"kind"`
Default json.RawMessage `json:"default"`
Why string `json:"why"`
Type string `json:"type,omitempty"`
Description string `json:"description,omitempty"`
Enum json.RawMessage `json:"enum,omitempty"`
Minimum *float64 `json:"minimum,omitempty"`
Maximum *float64 `json:"maximum,omitempty"`
Pattern string `json:"pattern,omitempty"`
Trusted bool `json:"trusted,omitempty"`
Predefined string `json:"predefined,omitempty"`
Required bool `json:"required,omitempty"`
Runtime bool `json:"runtime,omitempty"`
}
// DeclaredMirroredFields are the fields of a declaration the mirror carries, by their manifest names.
var DeclaredMirroredFields = []string{"kind", "default", "why", "type", "description", "enum", "minimum", "maximum",
"pattern", "trusted", "predefined", "required", "runtime"}
// AssignmentsMirror is every assignment's mirrored value, keyed `<node>.<module>`, with the provisioner's plan of
// each assignment of a module handed to it, and a value of its own for each module handed to it on a node that
// nothing assigned there, only pulled in by another (issue 765).
func (i *Inventory) AssignmentsMirror(ctx context.Context) (map[string][]byte, error) {
var plans map[string]Provisioning
if i.provisioningPlans != nil {
var err error
if plans, err = i.provisioningPlans(ctx); err != nil {
return nil, fmt.Errorf("composing the provisioner's plans: %w", err)
}
}
assignments, err := i.assignmentsMirrored(ctx)
if err != nil {
return nil, err
}
return AssignmentValues(assignments, plans)
}
func (i *Inventory) assignmentsMirrored(ctx context.Context) ([]AssignmentMirrored, error) {
rows, err := i.store.Pool().Query(ctx,
`select n.name, a.module, a.assigned from assignment a join node n on n.id = a.node`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AssignmentMirrored
for rows.Next() {
var a AssignmentMirrored
if err := rows.Scan(&a.Node, &a.Module, &a.Assigned); err != nil {
return nil, err
}
a.Assigned = a.Assigned.UTC()
out = append(out, a)
}
return out, rows.Err()
}
// DeclaredConfigMirror is every registered module's mirrored config declarations, keyed by the module.
func (i *Inventory) DeclaredConfigMirror(ctx context.Context) (map[string][]byte, error) {
rows, err := i.store.Pool().Query(ctx,
`select name, coalesce(manifest->'config', manifest->'settings', '{}'::jsonb) from module`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string][]byte{}
for rows.Next() {
var (
d DeclaredConfigMirrored
raw []byte
)
if err := rows.Scan(&d.Module, &raw); err != nil {
return nil, err
}
if d.Settings, err = declaredMirrored(raw); err != nil {
return nil, fmt.Errorf("the declarations of %s cannot be read: %w", d.Module, err)
}
value, err := json.Marshal(d)
if err != nil {
return nil, err
}
out[d.Module] = value
}
return out, rows.Err()
}
// declaredMirrored is a manifest's config declarations, keyed by the value, as the mirror holds them: each
// with its listed fields, a default and an enum encoded the one way.
func declaredMirrored(raw []byte) (map[string]DeclaredMirrored, error) {
var declared map[string]DeclaredMirrored
if err := json.Unmarshal(raw, &declared); err != nil {
return nil, err
}
out := map[string]DeclaredMirrored{}
for key, decl := range declared {
var err error
if len(decl.Default) > 0 {
if decl.Default, err = canonical(decl.Default); err != nil {
return nil, err
}
}
if len(decl.Enum) > 0 {
if decl.Enum, err = canonical(decl.Enum); err != nil {
return nil, err
}
}
out[key] = decl
}
return out, nil
}
// canonical is a JSON document encoded the one way: objects' keys sorted, numbers as written.
func canonical(raw []byte) ([]byte, error) {
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var v any
if err := dec.Decode(&v); err != nil {
return nil, err
}
return json.Marshal(v)
}
// PredefinedMirrorKey is the predefined-keys mirror's one key: the list kept is one record.
const PredefinedMirrorKey = "list"
// PredefinedMirrored is the list of predefined keys kept as its mirror holds it (design 57 K3, issue 662): the
// keys, canonical, and the trunk build of the catalogue they came from, by its commit and generation, so a reader
// says which list it holds. Not when it was kept: any lease holder builds the same bytes from the same record.
type PredefinedMirrored struct {
Keys json.RawMessage `json:"keys"`
Repository string `json:"repository"`
Commit string `json:"commit"`
Generation int `json:"generation"`
Build string `json:"build"`
}
// PredefinedMirror is the list of predefined keys kept, under PredefinedMirrorKey; no key while none is kept.
func (i *Inventory) PredefinedMirror(ctx context.Context) (map[string][]byte, error) {
kept, has, err := i.PredefinedKept(ctx)
if err != nil || !has {
return map[string][]byte{}, err
}
keys, err := kept.Keys.Canonical()
if err != nil {
return nil, err
}
value, err := json.Marshal(PredefinedMirrored{Keys: keys, Repository: kept.Repository, Commit: kept.Commit,
Generation: kept.Generation, Build: kept.Build})
if err != nil {
return nil, err
}
return map[string][]byte{PredefinedMirrorKey: value}, nil
}
+16 -1
View File
@@ -26,6 +26,12 @@ type Inventory struct {
store *store.Store
// acting is the gate a write that acts passes (ActsUnder, novox/hq to-be 45 §6); nil passes all.
acting func(ctx context.Context) (uint64, error)
// provisioningPlans composes the provisioner's plan of each assignment of a module handed to the provisioner, keyed
// `<node>.<module>` (ProvisioningPlans); nil hands none.
provisioningPlans func(ctx context.Context) (map[string]Provisioning, error)
// configValues reads a module's config values from mesh-config once the move of config has run (ReadsConfigFrom,
// issue 664); nil reads none.
configValues ConfigValues
}
// Open connects to the inventory store.
@@ -1142,6 +1148,10 @@ type SentDeclaration struct {
Digest string `json:"digest"`
Sequence int64 `json:"sequence,omitempty"`
At time.Time `json:"at,omitzero"`
// Provisioned is, for each module the send handed to the provisioner, the digest of the templates its plan
// named (novox/hq ADR 0349 §4, ADR 0362): the build the machine's provisioner client reports `prov.applied` on.
// The machine has applied the send of such a module only once both it and the client reported it.
Provisioned map[string]string `json:"provisioned,omitempty"`
}
// ReportsOn says a report is about this send: the declaration itself; one the same machine was sequenced
@@ -1161,7 +1171,9 @@ func (i *Inventory) SentTo(ctx context.Context, name string) (SentDeclaration, b
var digest *string
var seq *int64
var at *time.Time
err := i.store.Pool().QueryRow(ctx, `select sent, sequence, sent_at from node where name = $1`, name).Scan(&digest, &seq, &at)
var plans []byte
err := i.store.Pool().QueryRow(ctx, `select sent, sequence, sent_at, sent_provisioning from node where name = $1`,
name).Scan(&digest, &seq, &at, &plans)
if errors.Is(err, pgx.ErrNoRows) {
return SentDeclaration{}, false, fmt.Errorf("%w: %s", ErrNoSuchNode, name)
}
@@ -1175,6 +1187,9 @@ func (i *Inventory) SentTo(ctx context.Context, name string) (SentDeclaration, b
if at != nil {
s.At = at.UTC()
}
if s.Provisioned, err = provisionedBy(plans); err != nil {
return SentDeclaration{}, false, fmt.Errorf("what %s was sent of the provisioner's plans: %w", name, err)
}
return s, true, nil
}
+106
View File
@@ -0,0 +1,106 @@
package inventory
import (
"context"
"sort"
"github.com/novox/mesh-controller/internal/catalogue"
)
// **The move of placement** (novox/hq ADR 0348 clause 11, issue 736; design 57: the moves of data run after the
// delivery, each a verb that says what it did). Migration 0097 moved `places`, `accesses` and `networks` out of
// every settings layer; `endpoints`, `expose`, `reach` and `ports` are placement too, and a layer set before this
// controller still holds them among its settings. Composition already reads them as placement wherever they are
// (SettingsFor), so a machine is told the same before the move and after; the move only puts them where they
// belong, so that no settings layer holds a placement key and the settings layers can move into mesh-config (K5).
//
// One transaction: each layer that holds a placement key is kept whole in the history first ("moved", with why),
// its placement keys are laid over the placement kept beside it, and its settings row keeps its other keys, when
// it was set and who set it. Asked again, it moves nothing.
// MovedLayer is one layer the move of placement moved, or would move: whose, and which keys.
type MovedLayer struct {
// Node is the layer's machine, empty for the whole mesh's.
Node string `json:"node"`
Module string `json:"module"`
Keys []string `json:"keys"`
}
// movedWhy is why a layer the move of placement moved went into the history, as migration 0097 says it.
const movedWhy = "moved to placement, ADR 0348 clause 11"
// MovePlacement moves every placement key a settings layer still holds beside it, and says which layers held which,
// the whole mesh's first, then by machine and module. Without apply it moves nothing and says what it would.
func (i *Inventory) MovePlacement(ctx context.Context, apply bool) ([]MovedLayer, error) {
keys := catalogue.PlacementKeys()
tx, err := i.store.Pool().Begin(ctx)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
// Locked for the move, so a set between the read and the write cannot be lost under it.
rows, err := tx.Query(ctx,
`select coalesce(n.name, ''), s.module,
(select array_agg(k order by k) from jsonb_object_keys(s.values) k where k = any($1))
from settings s left join node n on n.id = s.node
where s.values ?| $1
order by s.node is not null, n.name, s.module
for update of s`, keys)
if err != nil {
return nil, err
}
moved := []MovedLayer{}
for rows.Next() {
var m MovedLayer
if err := rows.Scan(&m.Node, &m.Module, &m.Keys); err != nil {
rows.Close()
return nil, err
}
sort.Strings(m.Keys)
moved = append(moved, m)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
if !apply || len(moved) == 0 {
return moved, nil
}
// Each layer kept whole first, as the history reads a layer: its settings and the placement beside it.
if _, err := tx.Exec(ctx,
`insert into settings_history (node, module, values, set_at, replaced_by, set_by, why)
select s.node, s.module, `+layerWhole+`, s.set_at, 'moved', s.set_by, $2
from settings s where s.values ?| $1`, keys, movedWhy); err != nil {
return nil, err
}
// Laid over what is kept beside the layer: a key the layer still holds was said after anything kept there.
if _, err := tx.Exec(ctx,
`update placement p
set values = p.values || (select jsonb_object_agg(e.key, e.value) from jsonb_each(s.values) e
where e.key = any($1))
from settings s
where s.module = p.module and s.node is not distinct from p.node and s.values ?| $1`, keys); err != nil {
return nil, err
}
if _, err := tx.Exec(ctx,
`insert into placement (node, module, values, set_at, set_by)
select s.node, s.module,
(select jsonb_object_agg(e.key, e.value) from jsonb_each(s.values) e where e.key = any($1)),
s.set_at, s.set_by
from settings s
where s.values ?| $1
and not exists (select 1 from placement p
where p.module = s.module and p.node is not distinct from s.node)`, keys); err != nil {
return nil, err
}
// The layer keeps its other keys, when it was set and who set it; one that held only placement stays, empty.
if _, err := tx.Exec(ctx,
`update settings set values = values - $1::text[] where values ?| $1`, keys); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return moved, nil
}
+262
View File
@@ -0,0 +1,262 @@
package inventory
import (
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// reachedForge is a module every one of the four words reaches: two endpoints, both published, one with a
// directory to place.
func reachedForge() catalogue.Manifest {
return catalogue.Manifest{Module: "forge", Version: "1",
Listens: []catalogue.Listening{{Name: "web", Port: 3000, Protocol: "tcp", From: catalogue.FromMesh},
{Name: "ssh", Port: 22, Protocol: "tcp", From: catalogue.FromMesh}},
Resources: []map[string]any{
{"id": "data", "type": "directory", "mode": "0750"},
{"id": "server", "type": "container", "ports": []any{"3000", "222:22"},
"image": "registry.invalid:5000/forge@sha256:" + strings.Repeat("a", 64),
"volumes": []any{"${dir:data}:/data"}},
{"id": "runtime-config", "type": "file", "path": "${dir:data}/runtime.json", "mode": "0640",
"content": `{"log":"info"}`, "merge": "json"},
}}
}
// **The four words are kept beside the layer, never in it** (novox/hq ADR 0348 clause 11, issue 736): a set of
// `endpoints`, `expose`, `reach` and `ports` keeps them as placement and the rest as the layer; the layer reads
// back whole, composition reads them as placement, and a port given through the placement is held against the
// next module as a port given in a layer was.
func TestTheFourWordsAreKeptBesideTheLayer(t *testing.T) {
inv := ForTest(t)
ctx := t.Context()
for _, m := range []catalogue.Manifest{reachedForge(), {Module: "other", Version: "1",
Listens: []catalogue.Listening{{Name: "ssh", Port: 2200, Protocol: "tcp", From: catalogue.FromMesh}},
Resources: []map[string]any{{"id": "server", "type": "container", "ports": []any{"2200"}, "image": "x"}}}} {
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
t.Fatal(err)
}
}
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
t.Fatal(err)
}
set := map[string]any{"log": "warn",
"expose": map[string]any{"22": "anywhere"},
"ports": map[string]any{"22": float64(2222)},
"reach": map[string]any{"3000": "internal"},
"endpoints": map[string]any{"web": map[string]any{"label": "git"}},
"places": map[string]any{"data": map[string]any{"path": "/var/lib/forge/data"}}}
if err := inv.SetSettings(ctx, "anchor", "forge", set); err != nil {
t.Fatal(err)
}
stored := inv.QueryForTest(t, `select s.values, p.values from settings s join placement p using (module, node)
where s.node is not null`)
if len(stored) != 1 || stored[0][0] != `{"log":"warn"}` {
t.Fatalf("the layer is kept as %v", stored)
}
for _, word := range []string{"expose", "ports", "reach", "endpoints", "places"} {
if !strings.Contains(stored[0][1], `"`+word+`"`) {
t.Errorf("%s is not kept beside the layer: %v", word, stored[0][1])
}
}
if got, _, _ := inv.Layer(ctx, "anchor", "forge"); !reflect.DeepEqual(roundTrip(t, got), roundTrip(t, set)) {
t.Fatalf("the layer reads %v, was set %v", got, set)
}
layers, err := inv.SettingsFor(ctx, "anchor", "forge")
if err != nil || len(layers) != 1 || len(layers[0].Values) != 1 || len(layers[0].Placement) != 5 {
t.Fatalf("composition reads %+v (%v)", layers, err)
}
if given, err := catalogue.GivenPorts(reachedForge(), layers); err != nil || given[22] != 2222 {
t.Fatalf("the given port is %v (%v)", given, err)
}
// Held against the next module: the machine port is the forge's.
err = inv.SetSettings(ctx, "anchor", "other", map[string]any{"ports": map[string]any{"2200": float64(2222)}})
if err == nil || !strings.Contains(err.Error(), "2222") {
t.Fatalf("a machine port the placement gave was given again: %v", err)
}
anchor, err := inv.NodeByName(ctx, "anchor")
if err != nil {
t.Fatal(err)
}
given, err := inv.givenOn(ctx, anchor.ID)
if err != nil || given[2222] != "forge" {
t.Fatalf("the ports given on anchor read %v (%v)", given, err)
}
// And a mesh-wide port is refused as before.
if err := inv.SetSettings(ctx, "", "forge", map[string]any{"ports": map[string]any{"22": float64(2222)}}); err == nil ||
!strings.Contains(err.Error(), "per node") {
t.Fatalf("a mesh-wide port: %v", err)
}
}
// The layers of the issue (736) as the store holds them once migration 0097 ran and before the move: the four words
// still among the settings, a place already beside one of them, a mesh-wide reach, and a layer with none.
var wordedLayers = []struct{ node, settings, placement string }{
{"", `{"reach": {"3000": "internal"}, "log": "warn"}`, ``},
{"anchor", `{"expose": {"22": "anywhere"}, "ports": {"22": 2222}, "endpoints": {"web": {"label": "git"}}, "workers": 4}`,
`{"places": {"data": {"path": "/var/lib/forge/data"}}}`},
{"laptop", `{"ports": {"22": 2022}}`, ``},
{"spare", `{"log": "debug"}`, ``},
}
func seedWordedLayers(t *testing.T) *Inventory {
t.Helper()
inv := ForTest(t)
ctx := t.Context()
if err := inv.RegisterModule(ctx, reachedForge(), Source{}); err != nil {
t.Fatal(err)
}
for _, n := range []string{"anchor", "laptop", "spare"} {
if _, err := inv.AddNode(ctx, n); err != nil {
t.Fatal(err)
}
}
for _, l := range wordedLayers {
if l.node == "" {
inv.ExecForTest(t, `insert into settings (node, module, values, set_by) values (null, 'forge', $1, 'the operator')`,
l.settings)
continue
}
inv.ExecForTest(t, `insert into settings (node, module, values) select id, 'forge', $2 from node where name = $1`,
l.node, l.settings)
if l.placement != "" {
inv.ExecForTest(t, `insert into placement (node, module, values) select id, 'forge', $2 from node where name = $1`,
l.node, l.placement)
}
}
return inv
}
// wholeLayer is a seeded layer as a person said it: its settings and its placement in one map.
func wholeLayer(t *testing.T, settings, placement string) map[string]any {
t.Helper()
out := map[string]any{}
for _, text := range []string{settings, placement} {
if text == "" {
continue
}
var part map[string]any
if err := json.Unmarshal([]byte(text), &part); err != nil {
t.Fatal(err)
}
for k, v := range part {
out[k] = v
}
}
return out
}
// **A layer that still holds the four words reads as placement until the move has run** (issue 736): the move is
// a verb run after the delivery, so until then composition finds the words among a layer's settings and reads them
// as its placement, beside what is kept beside it, and a port given there is held as before.
func TestALayerStillHoldingTheWordsReadsThemAsPlacement(t *testing.T) {
inv := seedWordedLayers(t)
ctx := t.Context()
layers, err := inv.SettingsFor(ctx, "anchor", "forge")
if err != nil || len(layers) != 2 {
t.Fatalf("anchor's layers: %v, %v", layers, err)
}
if layers[0].Values["log"] != "warn" || layers[0].Values["reach"] != nil || layers[0].Placement["reach"] == nil {
t.Fatalf("the mesh's layer split wrong: %+v", layers[0])
}
if layers[1].Values["workers"] == nil || len(layers[1].Values) != 1 || len(layers[1].Placement) != 4 {
t.Fatalf("anchor's layer split wrong: %+v", layers[1])
}
for i, l := range layers {
want := wholeLayer(t, wordedLayers[i].settings, wordedLayers[i].placement)
if !reflect.DeepEqual(roundTrip(t, l.Whole()), want) {
t.Errorf("anchor's layer %s is %v whole, was %v", l.From, l.Whole(), want)
}
}
if given, err := catalogue.GivenPorts(reachedForge(), layers); err != nil || given[22] != 2222 {
t.Fatalf("the given port is %v (%v)", given, err)
}
}
// **The move of placement moves what a layer still holds, and says what it did** (issue 736). Asked without
// applying, it says which layers hold which words and changes nothing. Applied, no settings row holds a placement
// key after, every layer reads whole as it did and composes as it did, each moved layer is kept whole in the
// history with why, when it was set and who set it stay, a layer without placement is not touched, and asked again
// it moves nothing.
func TestTheMoveOfPlacementMovesWhatTheLayersStillHold(t *testing.T) {
inv := seedWordedLayers(t)
ctx := t.Context()
before := map[string][]catalogue.Layer{}
for _, n := range []string{"anchor", "laptop", "spare"} {
layers, err := inv.SettingsFor(ctx, n, "forge")
if err != nil {
t.Fatal(err)
}
before[n] = layers
}
rows := `select coalesce(n.name, ''), s.values, s.set_at, s.set_by from settings s left join node n on n.id = s.node order by 1`
stored := inv.QueryForTest(t, rows)
setAt := inv.QueryForTest(t, `select coalesce(n.name, ''), s.set_at, s.set_by from settings s left join node n on n.id = s.node order by 1`)
want := []MovedLayer{
{Node: "", Module: "forge", Keys: []string{"reach"}},
{Node: "anchor", Module: "forge", Keys: []string{"endpoints", "expose", "ports"}},
{Node: "laptop", Module: "forge", Keys: []string{"ports"}},
}
said, err := inv.MovePlacement(ctx, false)
if err != nil || !reflect.DeepEqual(said, want) {
t.Fatalf("the move, not applied, says %+v (%v), want %+v", said, err, want)
}
if after := inv.QueryForTest(t, rows); !reflect.DeepEqual(after, stored) {
t.Fatalf("the move not applied changed the layers: %v, was %v", after, stored)
}
moved, err := inv.MovePlacement(ctx, true)
if err != nil || !reflect.DeepEqual(moved, want) {
t.Fatalf("the move says %+v (%v), want %+v", moved, err, want)
}
if held := inv.QueryForTest(t, `select module from settings where values ?| $1`, catalogue.PlacementKeys()); len(held) != 0 {
t.Fatalf("a settings layer still holds a placement key: %v", held)
}
if after := inv.QueryForTest(t, `select coalesce(n.name, ''), s.set_at, s.set_by from settings s left join node n on n.id = s.node order by 1`); !reflect.DeepEqual(after, setAt) {
t.Fatalf("the layers or when and by whom they were set changed: %v, was %v", after, setAt)
}
for i, l := range wordedLayers {
got, has, err := inv.Layer(ctx, l.node, "forge")
if want := wholeLayer(t, l.settings, l.placement); err != nil || !has || !reflect.DeepEqual(roundTrip(t, got), want) {
t.Errorf("layer %d on %q reads %v (%v, %v), was %v", i, l.node, got, has, err, want)
}
}
for n, was := range before {
layers, err := inv.SettingsFor(ctx, n, "forge")
if err != nil || !reflect.DeepEqual(roundTripLayers(t, layers), roundTripLayers(t, was)) {
t.Errorf("%s composes from %+v (%v), was %+v", n, layers, err, was)
}
}
history := inv.QueryForTest(t, `select coalesce(n.name, ''), h.replaced_by, h.why, h.set_by from settings_history h
left join node n on n.id = h.node order by 1`)
if !reflect.DeepEqual(history, [][]string{
{`""`, `"moved"`, `"moved to placement, ADR 0348 clause 11"`, `"the operator"`},
{`"anchor"`, `"moved"`, `"moved to placement, ADR 0348 clause 11"`, `null`},
{`"laptop"`, `"moved"`, `"moved to placement, ADR 0348 clause 11"`, `null`},
}) {
t.Fatalf("the history of what moved: %v", history)
}
past, err := inv.SettingsHistory(ctx, "anchor", "forge")
if err != nil || len(past) != 1 || past[0].Values["expose"] == nil || past[0].Values["places"] == nil || past[0].Values["workers"] == nil {
t.Fatalf("anchor's moved layer is not kept whole: %+v, %v", past, err)
}
if again, err := inv.MovePlacement(ctx, true); err != nil || len(again) != 0 {
t.Fatalf("asked again, the move moved %+v (%v)", again, err)
}
if n := inv.QueryForTest(t, `select count(*) from settings_history`); n[0][0] != "3" {
t.Fatalf("asked again, the move kept history: %v", n)
}
}
func roundTripLayers(t *testing.T, layers []catalogue.Layer) []map[string]any {
t.Helper()
out := make([]map[string]any, len(layers))
for i, l := range layers {
out[i] = map[string]any{"from": l.From, "values": roundTrip(t, l.Values), "placement": roundTrip(t, l.Placement)}
}
return out
}
+8
View File
@@ -104,6 +104,9 @@ type PlanMerge struct {
Number int `json:"number,omitempty"`
Title string `json:"title,omitempty"`
Moves []string `json:"moves,omitempty"`
// Head is the pull request's head commit the forge merged (novox/hq issue 703): the walk takes only a build a
// check of that head made. Empty where the announcement said none, and then it takes none.
Head string `json:"head,omitempty"`
// Merged is when the forge made the merge and Heard when the controller heard it (novox/hq ADR 0282): where
// its delivery time starts. Zero for a merge named before they were kept.
Merged time.Time `json:"merged,omitzero"`
@@ -310,6 +313,11 @@ type PlanModule struct {
// Rest is, per machine of the rest, the declaration the send after the first-node gate carried there (novox/hq ADR
// 0282 decision 6): the machine's first report of it, applied, is when the build runs there.
Rest map[string]SentDeclaration `json:"rest,omitempty"`
// Recorded is, for a module whose policy records, per machine running it with a move, which way the walk took it
// (novox/hq ADR 0377 §6): "went out: …" when the move recreates none of its containers and moves none of its
// data, or "waits: …" with the recreate words, the data change named, the person's or the bus's hold, or why it
// could not be judged.
Recorded map[string]string `json:"recorded,omitempty"`
}
// PlanGate is one module's rollout record at its gate (to-be 45 §8): the component, the first machine,
+2 -1
View File
@@ -1,6 +1,7 @@
package inventory
import (
"reflect"
"testing"
"time"
)
@@ -166,7 +167,7 @@ func TestASendAndAReportAreKnownByTheirDeclaration(t *testing.T) {
t.Fatal(err)
}
kept, err := inv.PlanByID(ctx, "plan-352")
if err != nil || kept.Modules["app"].Gate.Sent["anchor"] != sent {
if err != nil || !reflect.DeepEqual(kept.Modules["app"].Gate.Sent["anchor"], sent) {
t.Fatalf("the gate's send was not kept with the plan: %+v %v", kept.Modules["app"].Gate, err)
}
}
+12 -5
View File
@@ -271,12 +271,19 @@ func (i *Inventory) ReleasePorts(ctx context.Context, node, module string) error
return err
}
// givenOn is every machine port a module was given on this node by its `ports` setting, and which
// module it was given to.
// givenPortsSQL is each module's `ports` on one node ($1), as `module, ports`: from the placement kept beside its
// layer (novox/hq ADR 0348 clause 11, issue 736), and from a layer the move of placement has not reached yet. A
// layer is the one or the other, never both: a set writes its ports beside it and none in it.
const givenPortsSQL = `select module, ports from (
select module, node, values->'ports' as ports from settings
union all
select module, node, values->'ports' from placement) given
where node = $1 and jsonb_typeof(ports) = 'object'`
// givenOn is every machine port a module was given on this node by its `ports`, and which module it was
// given to.
func (i *Inventory) givenOn(ctx context.Context, nodeID any) (map[int]string, error) {
rows, err := i.store.Pool().Query(ctx,
`select module, values->'ports' from settings
where node = $1 and jsonb_typeof(values->'ports') = 'object'`, nodeID)
rows, err := i.store.Pool().Query(ctx, givenPortsSQL, nodeID)
if err != nil {
return nil, err
}
+120
View File
@@ -0,0 +1,120 @@
package inventory
import (
"fmt"
"sort"
"time"
)
// A machine has applied a send of a provisioned module only once its files are placed (novox/hq ADR 0362, design
// 57 P6, issue 672): for a module the send handed to the provisioner (SentDeclaration.Provisioned), the machine's
// own report that it applied the send is half of it, and its provisioner client's `prov.applied` for the build's
// templates the other half.
//
// - both reported, applied: the send is applied, at the later of the two (ADR 0362 §5: delivery time runs to it);
// - the client reported it failed on that build: a failed apply, named with its reason (§3);
// - the client has not reported it applied on that build (not yet, waiting for a value, an older build): not yet
// applied, and once ApplySilentAfter has passed since the send the walk names the machine silent (§4);
// - a send that handed nothing: the machine's own report alone, as before.
// ProvReport is a provisioner client's newest report of one module on its machine, as `prov.applied` says it
// (mesh-catalog mesh-provisioner-client Report): applied, waiting, failed with the reason, or removed; the digest
// of the templates it filled from; and when it said it.
type ProvReport struct {
Node string `json:"node"`
Module string `json:"module"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
WaitsFor []string `json:"waits-for,omitempty"`
Templates string `json:"templates,omitempty"`
At time.Time `json:"at"`
}
// The words a client's report says.
const (
ProvApplied = "applied"
ProvWaiting = "waiting"
ProvFailed = "failed"
ProvRemoved = "removed"
)
// ProvLookup answers a client's newest report of a module on a machine; false when it has said nothing of it.
type ProvLookup func(node, module string) (ProvReport, bool)
// OutcomeProvisioningFailed begins the outcome of a send whose module's files the provisioner failed to place.
const OutcomeProvisioningFailed = "provisioning failed"
// ProvisionedSend is a machine's report of a send, with its provisioner client's reports of every module the send
// handed to it: what a walk counts as that machine's report. engine and reported are the machine's own report and
// whether it said one. A failed client report is the send's outcome, whatever the machine said; none, or one not
// yet applied on the build sent, leaves the send unreported.
func ProvisionedSend(node string, send SentDeclaration, engine AppliedReport, reported bool, prov ProvLookup) (AppliedReport, bool) {
if len(send.Provisioned) == 0 {
return engine, reported
}
// The machine's own failure, or its wait for a busy container, is its report whatever the client says.
if reported && (engine.Outcome != OutcomeApplied || engine.Waiting) {
return engine, true
}
modules := make([]string, 0, len(send.Provisioned))
for m := range send.Provisioned {
modules = append(modules, m)
}
sort.Strings(modules)
at, placed := engine.At, true
for _, m := range modules {
r, said := ProvReport{}, false
if prov != nil {
r, said = prov(node, m)
}
switch {
case said && r.Status == ProvFailed && aboutTheSend(r, send, send.Provisioned[m]):
return AppliedReport{At: r.At, Outcome: fmt.Sprintf("%s (%s: %s)", OutcomeProvisioningFailed, m,
orNoReason(r.Reason))}, true
case said && r.Status == ProvApplied && r.Templates == send.Provisioned[m]:
if r.At.After(at) {
at = r.At
}
default:
placed = false
}
}
if !reported || !placed {
return AppliedReport{}, false
}
engine.At = at
return engine, true
}
// Only is the send narrowed to what it handed to the provisioner for the named modules: a delivery waits on the
// modules it carries, never on another module the same machine's declaration hands (ADR 0362 §1-2, issue 840).
// A send handing nothing is returned as it is.
func (s SentDeclaration) Only(modules ...string) SentDeclaration {
if len(s.Provisioned) == 0 {
return s
}
kept := map[string]string{}
for _, m := range modules {
if d, ok := s.Provisioned[m]; ok {
kept[m] = d
}
}
s.Provisioned = kept
return s
}
// aboutTheSend says a client's failure is about what the send handed: on the build's templates, or, said before it
// had any to name (its templates could not be fetched), no earlier than the send.
func aboutTheSend(r ProvReport, send SentDeclaration, digest string) bool {
if r.Templates != "" {
return r.Templates == digest
}
return send.At.IsZero() || !r.At.Before(send.At)
}
func orNoReason(s string) string {
if s == "" {
return "no reason given"
}
return s
}
+124
View File
@@ -0,0 +1,124 @@
package inventory
import (
"strings"
"testing"
"time"
)
// A walk counts a provisioned module applied on a machine only once its files are placed (novox/hq ADR 0362, design
// 57 P6, issue 672). The walk of issue 582 (one module, three machines of the rest), with g14's send having handed
// dunst to the provisioner: g14's own report comes 5 s after its send, shanks's, the last of the others, 15 s
// after its own.
const dunstTemplates = "sha256:" + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
func walkHandingDunst(t *testing.T) sendRest582 {
t.Helper()
c := walk582(t)
s := c.walk.Modules["build-agent"].Rest["g14"]
s.Provisioned = map[string]string{"build-agent": dunstTemplates}
c.walk.Modules["build-agent"].Rest["g14"] = s
return c
}
// provisioning is a walk's lookup with the machines' clients saying reports.
func (w sendRest582) provisioning(reports map[string]ProvReport) AppliedLookup {
return func(node string, send SentDeclaration, walkSent time.Time) (AppliedReport, bool) {
r, ok := w.lookup(node, send, walkSent)
return ProvisionedSend(node, send, r, ok, func(node, module string) (ProvReport, bool) {
p, said := reports[node+"."+module]
return p, said
})
}
}
func restOf(w *WalkPhases, node string) RestReport {
for _, r := range w.Rest {
if r.Node == node {
return r
}
}
return RestReport{}
}
// The node-engine's report alone does not end the walk: it waits for prov.applied; with it, the walk ends, and
// its delivery time runs to the later of the two reports.
func TestAWalkWaitsForTheProvisionersReportAndEndsAtTheLater(t *testing.T) {
c := walkHandingDunst(t)
g14Sent := c.walk.Modules["build-agent"].Rest["g14"].At
w := c.walk.WalkPhases(g14Sent.Add(time.Minute), c.provisioning(nil))
if w.End != nil || restOf(w, "g14").State != RestWaiting ||
!strings.Contains(phase(w, PhaseApply, -1).Said, "waiting for g14") {
t.Fatalf("the walk did not wait for g14's prov.applied: end %v, g14 %+v, apply %+v", w.End, restOf(w, "g14"),
phase(w, PhaseApply, -1))
}
// A report on an older build of the templates is not this send's.
older := map[string]ProvReport{"g14.build-agent": {Status: ProvApplied, Templates: "sha256:" + strings.Repeat("0", 64),
At: g14Sent.Add(2 * time.Second)}}
if w := c.walk.WalkPhases(g14Sent.Add(time.Minute), c.provisioning(older)); w.End != nil {
t.Fatalf("a report on an older build ended the walk at %v", w.End)
}
placed := g14Sent.Add(50 * time.Second) // after shanks's report, 35 s after the judgement
w = c.walk.WalkPhases(g14Sent.Add(time.Hour), c.provisioning(map[string]ProvReport{
"g14.build-agent": {Status: ProvApplied, Templates: dunstTemplates, At: placed}}))
sumsUp(t, w)
if w.End == nil || !w.End.Equal(placed) {
t.Fatalf("the walk ends at %v, want the provisioner's report at %s: %+v", w.End, placed, w.Rest)
}
if g := restOf(w, "g14"); g.State != RestApplied || !g.Reported.Equal(placed) {
t.Fatalf("g14 is not applied at its later report: %+v", g)
}
}
// A failed prov.applied is the machine's failed apply, named in the walk's end with its reason.
func TestAFailedProvisioningIsAFailedApply(t *testing.T) {
c := walkHandingDunst(t)
g14Sent := c.walk.Modules["build-agent"].Rest["g14"].At
w := c.walk.WalkPhases(g14Sent.Add(time.Hour), c.provisioning(map[string]ProvReport{
"g14.build-agent": {Status: ProvFailed, Reason: "the templates cannot be fetched", At: g14Sent.Add(3 * time.Second)}}))
if w.End != nil {
t.Fatalf("a failed provisioning ended the walk: %v", w.End)
}
if g := restOf(w, "g14"); g.State != RestFailed || !strings.Contains(g.Outcome, "the templates cannot be fetched") {
t.Fatalf("g14 is not named failed with the reason: %+v", g)
}
if said := phase(w, PhaseApply, -1).Said; !strings.Contains(said, "not applied on g14 (provisioning failed (build-agent: "+
"the templates cannot be fetched))") {
t.Fatalf("the walk's end does not name g14's failure: %q", said)
}
// A failure said before the send, of no build, is not this send's.
stale := map[string]ProvReport{"g14.build-agent": {Status: ProvFailed, Reason: "old", At: g14Sent.Add(-time.Hour)}}
if g := restOf(c.walk.WalkPhases(g14Sent.Add(time.Minute), c.provisioning(stale)), "g14"); g.State != RestWaiting {
t.Fatalf("a failure from before the send was read as this send's: %+v", g)
}
}
// None within ApplySilentAfter names the machine silent, never counted applied; the others end the walk.
func TestNoProvisioningReportWithinTheBoundIsSilence(t *testing.T) {
c := walkHandingDunst(t)
g14Sent := c.walk.Modules["build-agent"].Rest["g14"].At
waiting := map[string]ProvReport{"g14.build-agent": {Status: ProvWaiting, WaitsFor: []string{"config:font-size"},
At: g14Sent.Add(3 * time.Second)}}
w := c.walk.WalkPhases(g14Sent.Add(ApplySilentAfter+time.Minute), c.provisioning(waiting))
sumsUp(t, w)
if g := restOf(w, "g14"); g.State != RestSilent {
t.Fatalf("g14 is not silent past ApplySilentAfter: %+v", g)
}
if len(w.Silent) != 1 || w.Silent[0] != "g14" || w.End == nil || !w.End.Equal(c.judged.Add(35*time.Second)) {
t.Fatalf("the walk does not end on the others, naming g14 silent: end %v, silent %v", w.End, w.Silent)
}
}
// A send that handed nothing ends on the node-engine's report alone, as before.
func TestASendThatHandedNothingEndsOnTheEnginesReport(t *testing.T) {
c := walk582(t)
before := c.walk.WalkPhases(c.judged.Add(time.Hour), c.lookup)
now := c.walk.WalkPhases(c.judged.Add(time.Hour), c.provisioning(nil))
if before.End == nil || now.End == nil || !now.End.Equal(*before.End) {
t.Fatalf("a walk handing nothing ends differently: before %v, now %v", before.End, now.End)
}
}
+517
View File
@@ -0,0 +1,517 @@
package inventory
import (
"context"
"encoding/json"
"fmt"
"path"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/catalogue"
)
// The provisioner's plan of a module on a node (novox/hq ADR 0349 clause 4, design 57 P5, issue 671): what a node's
// provisioner client needs to fill the module's files there, carried in the assignment's value of the assignments
// mirror, `<node>.<module>`, which the client already reads for its own machine alone (broker.ProvisionerReads).
//
// **Placement is the controller's, and on the bus** (ADR 0349 clauses 1 and 6): the client watches each owner's
// keys for its node, and this is the placement owner's key. So the client never reads the node's declaration,
// which the node-engine keeps for root alone. The plan names the templates archive the module's build packed (by
// its reference and digest), each file the provisioner fills (its id, path, template and who owns it at runtime),
// the value of every placement placeholder its templates name, and the name of each of the module's own secrets the
// declaration names there (design 57 V4): the client asks the vault for those, and the vault hands one only when
// the plan names it. No secret's value: placement is not one, and a secret is never in a bucket (ADR 0201).
//
// Composed only for a module handed to the provisioner (design 57 P6); every other assignment's value carries no
// plan and is the bytes it was. A module handed there that nothing assigned, only pulled in by another, has a value
// of its own under the same key, naming what requires it (issue 765).
// Provisioning is one module's plan on one node.
type Provisioning struct {
// Templates is the archive the module's build packed its templates directory into, fetched by digest.
Templates TemplatesArchive `json:"templates"`
// Files are the files the provisioner fills for the module on this node, in the manifest's order.
Files []ProvisionedFile `json:"files"`
// Placement is the value of each placement placeholder the templates name, keyed as the placeholder names
// it without its braces: `dir:state`, `machine:account`, `port:8080`, `seat:mesh-broker:4222`, `access:tls`.
Placement map[string]string `json:"placement,omitempty"`
// Secrets are the module's own secrets its declaration names on this node, by name (design 57 V4, issue 647):
// each its templates name as `${secret:<name>}`, and each it keeps in a file of its own, with that file's path.
// The client asks the vault for each; the vault hands it one only when its plan names it.
Secrets []ProvisionedSecret `json:"secrets,omitempty"`
// Items are the module's items of collections (design 57 L1, issue 684): each its collection and its template
// inside the archive, filled by the client and recorded in mesh-provisioner_collect for the node.
Items []ProvisionedItem `json:"items,omitempty"`
// Collections are the collections the module's files name, as their collector, each with the comment that
// introduces each contributor's item.
Collections []ProvisionedCollection `json:"collections,omitempty"`
// Provisions are what the module requires on this node (issue 760), each under its local name with the
// provider resolution chose to serve it: the providing machine and module. The vault reads them to see a
// consumer bind to a provider and unbind from it (novox/hq ADR 0349 clause 8); of a mesh-scoped provision with
// several providers, it is the one serving this consumer. An entry with a name is a credential the vault keeps
// for this holder alone (issue 757): the value the provider's software issued for it, by the name the module's
// files call it and the file holding it. The client asks the vault for
// provision/<provider>/<provision>/<node>/<module>; the vault hands it only when the plan names it so.
Provisions []ProvisionedCredential `json:"provisions,omitempty"`
// RequiredBy is, for a module only pulled in by another on this node (issue 765), the module that requires it
// there: the plan is then mirrored in a value of its own, which says so (AssignmentMirrored.RequiredBy). Kept
// with the send; never part of the plan the client reads.
RequiredBy string `json:"-"`
}
// ProvisionedItem is one item the module contributes.
type ProvisionedItem struct {
Collection string `json:"collection"`
// Name tells an item from the module's others of one collection, and Attributes are what a placeholder selecting
// by them (`${collected:<collection>:<k>=<v>,…}`) is matched against (design 57 L3, issue 777).
Name string `json:"name,omitempty"`
Attributes map[string]string `json:"attributes,omitempty"`
Template string `json:"template"`
}
// ProvisionedCollection is one collection the module collects.
type ProvisionedCollection struct {
Name string `json:"name"`
Comment string `json:"comment,omitempty"`
}
// ProvisionedCredential is one provision the module requires, under one local name, and its provider (issue 760);
// with a name, a credential issued for this holder alone (issue 757).
type ProvisionedCredential struct {
Name string `json:"name,omitempty"`
Provision string `json:"provision"`
Local string `json:"local,omitempty"`
Provider string `json:"provider"`
ProviderModule string `json:"provider-module"`
// CredentialForm is the form the provider's offer declares for its credential (issue 768): shared, pair,
// issued or given; empty when the provider's manifest was not in hand.
CredentialForm string `json:"credential-form,omitempty"`
Path string `json:"path,omitempty"`
}
// ProvisionedSecret is one of the module's own secrets, by its name in the manifest. Path, when set, is the file
// holding the secret alone (the manifest's `own-secrets`), which the client fills with its value and nothing else.
type ProvisionedSecret struct {
Name string `json:"name"`
Path string `json:"path,omitempty"`
}
// TemplatesArchive is where the templates archive is fetched from, and what it hashes to.
type TemplatesArchive struct {
Source string `json:"source"`
Digest string `json:"digest"`
}
// ProvisionedFile is one file the provisioner fills.
type ProvisionedFile struct {
// ID is the file resource's id in the module's manifest.
ID string `json:"id"`
// Path is where it is placed: one of the node's declaration's filled files, which the node-engine writes
// only for the client (ADR 0349 clause 4).
Path string `json:"path"`
// Template is the file's template by its path inside the templates archive: `dunstrc` for a manifest's
// `files/dunstrc`, the module's templates directory (files/, or the one it names, issue 731) being the
// archive's root.
Template string `json:"template"`
// OwnerAtRuntime is `owned`, `seed` or `managed` (ADR 0349 clause 5).
OwnerAtRuntime string `json:"owner-at-runtime"`
// Takes is how its program takes a change (ADR 0349 clause 2): `restart-on`, `reload-on` or `read-on-use`.
// Said for the client's report; the node-engine restarts or reloads on the write itself.
Takes string `json:"takes,omitempty"`
}
// The words a plan's file says who owns it at runtime with, and how its program takes a change.
var (
ownersAtRuntime = []string{"owned", "seed", "managed"}
takings = []string{"restart-on", "reload-on", "read-on-use"}
)
// PlacementKinds are the placeholder kinds placement answers (ADR 0349 clause 2), as a plan's Placement keys
// begin.
var PlacementKinds = []string{"dir", "access", "port", "seat", "machine", "backup"}
// credentialForms are the forms a provider's offer may declare for its credential (issue 768).
var credentialForms = []string{catalogue.CredentialFormShared, catalogue.CredentialFormPair,
catalogue.CredentialFormIssued, catalogue.CredentialFormGiven}
var (
digestShape = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
fileIDShape = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// secretShape is a secret's name as `${secret:…}` names it (catalogue secrets_into_files.go).
secretShape = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// collectionShape is a collection's name (catalogue collections.go).
collectionShape = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(:[a-z0-9][a-z0-9-]*)*$`)
// attributeKeyShape and attributeValueShape are an item's attribute as the catalogue writes it (collections.go).
attributeKeyShape = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
attributeValueShape = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
// machineShape is a machine's name as the vault writes it in a secret's name (mesh-vault intake.go namePart).
machineShape = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`)
)
// Problems is what is wrong with a plan: each a sentence naming the field. A plan with any is never mirrored, so a
// client is never handed one it would have to guess at.
func (p Provisioning) Problems() []string {
var problems []string
if strings.TrimSpace(p.Templates.Source) == "" {
problems = append(problems, "templates.source is empty: the client has nowhere to fetch the templates from")
}
if !digestShape.MatchString(p.Templates.Digest) {
problems = append(problems, fmt.Sprintf("templates.digest %q is not sha256:<64 hex characters>",
p.Templates.Digest))
}
if len(p.Files) == 0 && len(p.Items) == 0 {
problems = append(problems, "files and items are empty: a module handed to the provisioner has a file it "+
"fills or an item it contributes")
}
contributed := map[string]bool{}
for i, it := range p.Items {
key := it.Collection
if it.Name != "" {
key += "/" + it.Name
}
where := fmt.Sprintf("items[%d]", i)
switch {
case !collectionShape.MatchString(it.Collection):
problems = append(problems, fmt.Sprintf("%s: collection %q is not a collection's name", where,
it.Collection))
case it.Name != "" && !fileIDShape.MatchString(it.Name):
problems = append(problems, fmt.Sprintf("%s: name %q is not a name", where, it.Name))
case contributed[key]:
problems = append(problems, fmt.Sprintf("%s: %s %s is contributed to twice", where,
map[bool]string{true: "item", false: "collection"}[it.Name != ""], key))
}
contributed[key] = true
for _, k := range sortedAttributeKeys(it.Attributes) {
if !attributeFits(it.Collection, k, it.Attributes[k]) {
problems = append(problems, fmt.Sprintf("%s: attribute %s=%q is not a name and a value", where, k,
it.Attributes[k]))
}
}
if it.Template == "" || strings.HasPrefix(it.Template, "/") || path.Clean(it.Template) != it.Template ||
it.Template == ".." || strings.HasPrefix(it.Template, "../") {
problems = append(problems, fmt.Sprintf("%s: template %q is not a plain path inside the templates "+
"archive", where, it.Template))
}
}
collected := map[string]bool{}
for i, c := range p.Collections {
where := fmt.Sprintf("collections[%d]", i)
switch {
case !collectionShape.MatchString(c.Name):
problems = append(problems, fmt.Sprintf("%s: %q is not a collection's name", where, c.Name))
case collected[c.Name]:
problems = append(problems, fmt.Sprintf("%s: %s is named twice", where, c.Name))
}
collected[c.Name] = true
if strings.ContainsAny(c.Comment, "\n\r") {
problems = append(problems, fmt.Sprintf("%s: the comment of %s holds a line break", where, c.Name))
}
}
ids, paths := map[string]bool{}, map[string]bool{}
for i, f := range p.Files {
where := fmt.Sprintf("files[%d]", i)
switch {
case !fileIDShape.MatchString(f.ID):
problems = append(problems, fmt.Sprintf("%s: id %q is not a resource id", where, f.ID))
case ids[f.ID]:
problems = append(problems, fmt.Sprintf("%s: id %q is named twice", where, f.ID))
}
ids[f.ID] = true
switch {
case !filepath.IsAbs(f.Path) || filepath.Clean(f.Path) != f.Path:
problems = append(problems, fmt.Sprintf("%s: path %q is not a clean absolute path", where, f.Path))
case paths[f.Path]:
problems = append(problems, fmt.Sprintf("%s: path %q is named twice", where, f.Path))
}
paths[f.Path] = true
if f.Template == "" || strings.HasPrefix(f.Template, "/") || path.Clean(f.Template) != f.Template ||
f.Template == ".." || strings.HasPrefix(f.Template, "../") {
problems = append(problems, fmt.Sprintf("%s: template %q is not a plain path inside the templates "+
"archive", where, f.Template))
}
if !slices.Contains(ownersAtRuntime, f.OwnerAtRuntime) {
problems = append(problems, fmt.Sprintf("%s: owner-at-runtime %q is not %s", where, f.OwnerAtRuntime,
strings.Join(ownersAtRuntime, ", ")))
}
if f.Takes != "" && !slices.Contains(takings, f.Takes) {
problems = append(problems, fmt.Sprintf("%s: takes %q is not %s", where, f.Takes,
strings.Join(takings, ", ")))
}
}
named := map[string]bool{}
for _, sec := range p.Secrets {
if !secretShape.MatchString(sec.Name) {
problems = append(problems, fmt.Sprintf("secret %q is not a secret's name", sec.Name))
}
if named[sec.Name] {
problems = append(problems, fmt.Sprintf("secret %s is named twice", sec.Name))
}
named[sec.Name] = true
switch {
case sec.Path == "":
case !filepath.IsAbs(sec.Path) || filepath.Clean(sec.Path) != sec.Path:
problems = append(problems, fmt.Sprintf("secret %s: path %q is not a clean absolute path", sec.Name,
sec.Path))
case paths[sec.Path]:
problems = append(problems, fmt.Sprintf("secret %s: path %s is named twice", sec.Name, sec.Path))
default:
paths[sec.Path] = true
}
}
bound := map[[2]string]bool{}
for _, pr := range p.Provisions {
which := pr.Name
if which == "" {
which = pr.Provision
if pr.Local != "" {
which += " (" + pr.Local + ")"
}
}
switch {
case pr.Name == "":
case !secretShape.MatchString(pr.Name):
problems = append(problems, fmt.Sprintf("provision credential %q is not a secret's name", pr.Name))
case named[pr.Name]:
problems = append(problems, fmt.Sprintf("secret %s is named twice", pr.Name))
}
if pr.Name != "" {
named[pr.Name] = true
}
if !secretShape.MatchString(pr.Provision) {
problems = append(problems, fmt.Sprintf("%s: provision %q is not a provision's name", which,
pr.Provision))
}
if !machineShape.MatchString(pr.Provider) {
problems = append(problems, fmt.Sprintf("%s: provider %q is not a machine's name", which, pr.Provider))
}
if !secretShape.MatchString(pr.ProviderModule) {
problems = append(problems, fmt.Sprintf("%s: provider-module %q is not a module's name", which,
pr.ProviderModule))
}
if pr.CredentialForm != "" && !slices.Contains(credentialForms, pr.CredentialForm) {
problems = append(problems, fmt.Sprintf("%s: credential-form %q is not %s", which, pr.CredentialForm,
strings.Join(credentialForms, ", ")))
}
if key := [2]string{pr.Provision, pr.Local}; bound[key] {
problems = append(problems, fmt.Sprintf("%s: its provider is named twice; a consumer has one provider "+
"for a provision and local name", which))
} else {
bound[key] = true
}
switch {
case pr.Path == "":
case pr.Name == "":
problems = append(problems, fmt.Sprintf("%s: path %q names a file and no credential to fill it with",
which, pr.Path))
case !filepath.IsAbs(pr.Path) || filepath.Clean(pr.Path) != pr.Path:
problems = append(problems, fmt.Sprintf("secret %s: path %q is not a clean absolute path", pr.Name,
pr.Path))
case paths[pr.Path]:
problems = append(problems, fmt.Sprintf("secret %s: path %s is named twice", pr.Name, pr.Path))
default:
paths[pr.Path] = true
}
}
keys := make([]string, 0, len(p.Placement))
for k := range p.Placement {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
kind, name, ok := strings.Cut(k, ":")
if !ok || name == "" || !slices.Contains(PlacementKinds, kind) {
problems = append(problems, fmt.Sprintf("placement %q is not <kind>:<name> of a placement kind (%s)", k,
strings.Join(PlacementKinds, ", ")))
}
}
return problems
}
// SetProvisioningPlans gives the assignments mirror the provisioner's plans: plans composes each assignment's of a
// module handed to the provisioner, keyed `<node>.<module>`. Set once, before the mirror is first written.
func (i *Inventory) SetProvisioningPlans(plans func(ctx context.Context) (map[string]Provisioning, error)) {
i.provisioningPlans = plans
}
// AssignmentValues is the assignments mirror's values, keyed `<node>.<module>`: each assignment, with its plan
// when plans has one for its key, and each plan of a module only pulled in by another (RequiredBy) in a value of its
// own, which names what requires it and no time assigned (issue 765). A plan for a module neither assigned nor
// required there, or one with problems, is refused by name, never written: a client is never told to fill a module
// that is not on its node, or handed a plan it must guess at.
func AssignmentValues(assignments []AssignmentMirrored, plans map[string]Provisioning) (map[string][]byte, error) {
out := map[string][]byte{}
used := map[string]bool{}
var problems []string
for _, a := range assignments {
key := a.Node + "." + a.Module
if p, ok := plans[key]; ok {
used[key] = true
if wrong := p.Problems(); len(wrong) > 0 {
problems = append(problems, fmt.Sprintf("%s: %s", key, strings.Join(wrong, "; ")))
continue
}
p := p
a.Provisioning = &p
}
value, err := json.Marshal(a)
if err != nil {
return nil, err
}
if len(value) > broker.MirrorValueBytes {
problems = append(problems, fmt.Sprintf("%s: its value is %d bytes, over the mirror's %d", key,
len(value), broker.MirrorValueBytes))
continue
}
out[key] = value
}
for _, key := range sortedPlanKeys(plans) {
if used[key] {
continue
}
p := plans[key]
// A key is `<node>.<module>`; a module's name holds no dot, a machine's may.
cut := strings.LastIndex(key, ".")
if p.RequiredBy == "" || cut <= 0 {
problems = append(problems, fmt.Sprintf("%s: a plan for a module not assigned there", key))
continue
}
if wrong := p.Problems(); len(wrong) > 0 {
problems = append(problems, fmt.Sprintf("%s: %s", key, strings.Join(wrong, "; ")))
continue
}
value, err := json.Marshal(AssignmentMirrored{Node: key[:cut], Module: key[cut+1:], RequiredBy: p.RequiredBy,
Provisioning: &p})
if err != nil {
return nil, err
}
if len(value) > broker.MirrorValueBytes {
problems = append(problems, fmt.Sprintf("%s: its value is %d bytes, over the mirror's %d", key,
len(value), broker.MirrorValueBytes))
continue
}
out[key] = value
}
if len(problems) > 0 {
return nil, fmt.Errorf("the provisioner's plans are refused, and the assignments mirror is not written: %s",
strings.Join(problems, "; "))
}
return out, nil
}
func sortedPlanKeys(plans map[string]Provisioning) []string {
keys := make([]string, 0, len(plans))
for k := range plans {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// The plans a machine was last sent (design 57 P6, issue 672): composed with its declaration, kept with the send
// (migration 0100) by module, and carried by the assignments mirror in each handed module's assignment. So the
// client is handed the plan of the build its machine was sent, and a controller started afresh mirrors the same.
// SentProvisioningPlans is every plan the machines were last sent, keyed `<node>.<module>`: what
// SetProvisioningPlans is given. A plan of a module only pulled in by another is kept with what required it, and is
// mirrored in a value of its own until a send drops it (issue 765). A plan of a module assigned when it was sent and
// no longer assigned there is left out: the machine's next send drops it.
func (i *Inventory) SentProvisioningPlans(ctx context.Context) (map[string]Provisioning, error) {
rows, err := i.store.Pool().Query(ctx,
`select n.name, n.sent_provisioning from node n where n.sent_provisioning is not null`)
if err != nil {
return nil, err
}
defer rows.Close()
sent := map[string]map[string]Provisioning{}
for rows.Next() {
var node string
var raw []byte
if err := rows.Scan(&node, &raw); err != nil {
return nil, err
}
var kept map[string]keptPlan
if err := json.Unmarshal(raw, &kept); err != nil {
return nil, fmt.Errorf("the plans %s was sent cannot be read: %w", node, err)
}
plans := map[string]Provisioning{}
for module, k := range kept {
p := k.Provisioning
p.RequiredBy = k.RequiredBy
plans[module] = p
}
sent[node] = plans
}
if err := rows.Err(); err != nil {
return nil, err
}
assignments, err := i.assignmentsMirrored(ctx)
if err != nil {
return nil, err
}
out := map[string]Provisioning{}
for _, a := range assignments {
if p, ok := sent[a.Node][a.Module]; ok {
p.RequiredBy = ""
out[a.Node+"."+a.Module] = p
}
}
for node, plans := range sent {
for module, p := range plans {
if _, assigned := out[node+"."+module]; !assigned && p.RequiredBy != "" {
out[node+"."+module] = p
}
}
}
return out, nil
}
// keptPlan is one module's plan as a send keeps it: the plan, and for a module only pulled in by another, the module
// that requires it there (issue 765).
type keptPlan struct {
Provisioning
RequiredBy string `json:"required-by,omitempty"`
}
// provisionedBy is, from a send's plans as kept, each handed module's templates digest; nil for none.
func provisionedBy(raw []byte) (map[string]string, error) {
if len(raw) == 0 {
return nil, nil
}
var plans map[string]Provisioning
if err := json.Unmarshal(raw, &plans); err != nil {
return nil, err
}
if len(plans) == 0 {
return nil, nil
}
out := map[string]string{}
for module, p := range plans {
out[module] = p.Templates.Digest
}
return out, nil
}
func sortedAttributeKeys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// attributeFits is whether an item's attribute is of the shape its collection allows: words of lower case, except the
// environment's `name`, which is a variable's name (catalogue collections.go, design 57 L3, issue 795).
func attributeFits(collection, k, v string) bool {
if collection == "environment" && k == "name" {
return environmentVariableName.MatchString(v)
}
return attributeKeyShape.MatchString(k) && attributeValueShape.MatchString(v)
}
var environmentVariableName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
+354
View File
@@ -0,0 +1,354 @@
package inventory
import (
"bytes"
"encoding/json"
"reflect"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
)
// APlan is a whole plan, as design 57 P6 composes one for a module handed to the provisioner.
func aPlan() Provisioning {
return Provisioning{
Templates: TemplatesArchive{Source: "artifacts/dunst/files.tar.gz",
Digest: "sha256:" + strings.Repeat("ab", 32)},
Files: []ProvisionedFile{{ID: "dunstrc", Path: "/home/nox/.config/dunst/dunstrc",
Template: "dunstrc", OwnerAtRuntime: "owned", Takes: "reload-on"}},
Placement: map[string]string{"machine:account-home": "/home/nox", "dir:state": "/var/lib/dunst"},
}
}
// **An assignment with no plan is the bytes it was** (design 57 P5, issue 671): the plan joins the value of a
// module handed to the provisioner alone, so no other assignment's key is written again.
func TestAnAssignmentWithoutAPlanIsMirroredAsBefore(t *testing.T) {
at := time.Date(2026, 10, 14, 20, 0, 0, 0, time.UTC)
values, err := AssignmentValues([]AssignmentMirrored{{Node: "g14", Module: "zsh", Assigned: at}}, nil)
if err != nil {
t.Fatal(err)
}
if got, want := string(values["g14.zsh"]), `{"node":"g14","module":"zsh","assigned":"2026-10-14T20:00:00Z"}`; got != want {
t.Errorf("an assignment without a plan is mirrored as %s, want %s", got, want)
}
}
// **A handed module's assignment carries its plan**, whole, under `provisioning`, as the client reads it.
func TestAHandedModulesAssignmentCarriesItsPlan(t *testing.T) {
at := time.Date(2026, 10, 14, 20, 0, 0, 0, time.UTC)
values, err := AssignmentValues([]AssignmentMirrored{{Node: "g14", Module: "dunst", Assigned: at},
{Node: "g14", Module: "zsh", Assigned: at}}, map[string]Provisioning{"g14.dunst": aPlan()})
if err != nil {
t.Fatal(err)
}
var got AssignmentMirrored
if err := json.Unmarshal(values["g14.dunst"], &got); err != nil {
t.Fatal(err)
}
if got.Provisioning == nil || got.Provisioning.Files[0].Path != "/home/nox/.config/dunst/dunstrc" ||
got.Provisioning.Placement["dir:state"] != "/var/lib/dunst" {
t.Fatalf("the plan is mirrored as %s", values["g14.dunst"])
}
for _, field := range []string{`"templates":{"source":`, `"digest":"sha256:`, `"owner-at-runtime":"owned"`,
`"template":"dunstrc"`, `"takes":"reload-on"`} {
if !strings.Contains(string(values["g14.dunst"]), field) {
t.Errorf("the plan's value lacks %s: %s", field, values["g14.dunst"])
}
}
if strings.Contains(string(values["g14.zsh"]), "provisioning") {
t.Errorf("an assignment with no plan carries one: %s", values["g14.zsh"])
}
}
// **A plan the client would have to guess at is refused, by name**, and so is one for a module not assigned
// there: nothing is written rather than something wrong.
func TestAPlanWithAProblemIsRefusedByName(t *testing.T) {
at := time.Now().UTC()
assigned := []AssignmentMirrored{{Node: "g14", Module: "dunst", Assigned: at}}
cases := map[string]func(*Provisioning){
"templates.digest": func(p *Provisioning) { p.Templates.Digest = "sha256:short" },
"templates.source": func(p *Provisioning) { p.Templates.Source = "" },
"files and items are empty": func(p *Provisioning) { p.Files = nil },
`collection "Shell"`: func(p *Provisioning) { p.Items = []ProvisionedItem{{Collection: "Shell", Template: "x"}} },
"collection a is contributed to twice": func(p *Provisioning) {
p.Items = []ProvisionedItem{{Collection: "a", Template: "x"}, {Collection: "a", Template: "y"}}
},
`items[0]: template "/x"`: func(p *Provisioning) { p.Items = []ProvisionedItem{{Collection: "a", Template: "/x"}} },
"collections[1]: a is named twice": func(p *Provisioning) {
p.Collections = []ProvisionedCollection{{Name: "a"}, {Name: "a"}}
},
"the comment of a holds a line break": func(p *Provisioning) {
p.Collections = []ProvisionedCollection{{Name: "a", Comment: "#\n"}}
},
"not a clean": func(p *Provisioning) { p.Files[0].Path = "relative/dunstrc" },
"inside the templates": func(p *Provisioning) { p.Files[0].Template = "../dunstrc" },
"owner-at-runtime": func(p *Provisioning) { p.Files[0].OwnerAtRuntime = "mine" },
"takes": func(p *Provisioning) { p.Files[0].Takes = "now" },
`placement "secret`: func(p *Provisioning) { p.Placement["secret:token"] = "x" },
`placement "dir:"`: func(p *Provisioning) { p.Placement["dir:"] = "x" },
"is named twice": func(p *Provisioning) { p.Files = append(p.Files, p.Files[0]) },
"not a resource id": func(p *Provisioning) { p.Files[0].ID = "Dunst RC" },
`secret "Token"`: func(p *Provisioning) { p.Secrets = []ProvisionedSecret{{Name: "Token"}} },
"secret token is named twice": func(p *Provisioning) {
p.Secrets = []ProvisionedSecret{{Name: "token"}, {Name: "token"}}
},
`secret token: path "rel/token"`: func(p *Provisioning) {
p.Secrets = []ProvisionedSecret{{Name: "token", Path: "rel/token"}}
},
"secret token: path /home/nox/.config/dunst/dunstrc is named twice": func(p *Provisioning) {
p.Secrets = []ProvisionedSecret{{Name: "token", Path: "/home/nox/.config/dunst/dunstrc"}}
},
}
for want, spoil := range cases {
p := aPlan()
spoil(&p)
_, err := AssignmentValues(assigned, map[string]Provisioning{"g14.dunst": p})
if err == nil || !strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), "g14.dunst") {
t.Errorf("%s: refused with %v", want, err)
}
}
if _, err := AssignmentValues(assigned, map[string]Provisioning{"shanks.dunst": aPlan()}); err == nil ||
!strings.Contains(err.Error(), "shanks.dunst: a plan for a module not assigned there") {
t.Errorf("a plan for a module not assigned there: %v", err)
}
}
// **A plan names the module's own secrets its declaration names there** (design 57 V4, issue 647): each by name, and
// a secret kept in a file of its own with that file's path; the vault hands a node's client a secret only when the
// plan names it. Names, never a value.
func TestAPlanNamesTheModulesSecretsAndNoValue(t *testing.T) {
p := aPlan()
p.Secrets = []ProvisionedSecret{{Name: "token"}, {Name: "db-password", Path: "/var/lib/dunst/db"}}
values, err := AssignmentValues([]AssignmentMirrored{{Node: "g14", Module: "dunst", Assigned: time.Now().UTC()}},
map[string]Provisioning{"g14.dunst": p})
if err != nil {
t.Fatal(err)
}
if want := `"secrets":[{"name":"token"},{"name":"db-password","path":"/var/lib/dunst/db"}]`; !strings.Contains(
string(values["g14.dunst"]), want) {
t.Errorf("the plan's value is %s, want it to carry %s", values["g14.dunst"], want)
}
plain := aPlan()
values, err = AssignmentValues([]AssignmentMirrored{{Node: "g14", Module: "dunst", Assigned: time.Now().UTC()}},
map[string]Provisioning{"g14.dunst": plain})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(values["g14.dunst"]), "secrets") {
t.Errorf("a plan naming no secret says secrets: %s", values["g14.dunst"])
}
}
// **A contributor's plan may fill no file** (design 57 L1, issue 684): its item is its part on the node; and a
// collector's names the collections its files read, the old namespaces' by their own names.
func TestAPlanMayContributeAnItemAndNameItsCollections(t *testing.T) {
p := aPlan()
p.Files = nil
p.Items = []ProvisionedItem{{Collection: "shell:zsh:normal", Template: "zsh"}}
p.Collections = []ProvisionedCollection{{Name: "widgets", Comment: "#"}, {Name: "contribution:node-hotkeys:trigger"}}
values, err := AssignmentValues([]AssignmentMirrored{{Node: "g14", Module: "prompt", Assigned: time.Now()}},
map[string]Provisioning{"g14.prompt": p})
if err != nil {
t.Fatal(err)
}
var back AssignmentMirrored
if err := json.Unmarshal(values["g14.prompt"], &back); err != nil || back.Provisioning == nil ||
len(back.Provisioning.Items) != 1 || len(back.Provisioning.Collections) != 2 {
t.Fatalf("mirrored %s (%v)", values["g14.prompt"], err)
}
}
// **A plan names the credentials of what the module requires that the vault keeps for it alone** (issue 757): each
// by the name its files call it, its provision, its provider and its file; never a value. Read as strictly as the
// rest of the plan.
func TestAPlanNamesTheCredentialsIssuedForTheModuleAndNoValue(t *testing.T) {
assigned := []AssignmentMirrored{{Node: "g14", Module: "dunst", Assigned: time.Now().UTC()}}
good := ProvisionedCredential{Name: "gitea-read", Provision: "gitea-read", Provider: "novox",
ProviderModule: "gitea", Path: "/var/lib/dunst/gitea-read.secret"}
p := aPlan()
p.Provisions = []ProvisionedCredential{good}
values, err := AssignmentValues(assigned, map[string]Provisioning{"g14.dunst": p})
if err != nil {
t.Fatal(err)
}
if want := `"provisions":[{"name":"gitea-read","provision":"gitea-read","provider":"novox",` +
`"provider-module":"gitea","path":"/var/lib/dunst/gitea-read.secret"}]`; !strings.Contains(string(values["g14.dunst"]), want) {
t.Errorf("the plan's value is %s, want it to carry %s", values["g14.dunst"], want)
}
plain, err := AssignmentValues(assigned, map[string]Provisioning{"g14.dunst": aPlan()})
if err != nil || strings.Contains(string(plain["g14.dunst"]), "provisions") {
t.Errorf("a plan naming no credential says provisions: %s (%v)", plain["g14.dunst"], err)
}
for want, spoil := range map[string]func(*ProvisionedCredential){
`provision credential "Gitea Read"`: func(c *ProvisionedCredential) { c.Name = "Gitea Read" },
`provision ""`: func(c *ProvisionedCredential) { c.Provision = "" },
`provider "../ace"`: func(c *ProvisionedCredential) { c.Provider = "../ace" },
`path "rel"`: func(c *ProvisionedCredential) { c.Path = "rel" },
"is named twice": func(c *ProvisionedCredential) { c.Path = "/home/nox/.config/dunst/dunstrc" },
} {
c := good
spoil(&c)
bad := aPlan()
bad.Provisions = []ProvisionedCredential{c}
if _, err := AssignmentValues(assigned, map[string]Provisioning{"g14.dunst": bad}); err == nil ||
!strings.Contains(err.Error(), want) {
t.Errorf("%s: refused with %v", want, err)
}
}
twice := aPlan()
twice.Secrets = []ProvisionedSecret{{Name: "gitea-read"}}
twice.Provisions = []ProvisionedCredential{good}
if _, err := AssignmentValues(assigned, map[string]Provisioning{"g14.dunst": twice}); err == nil ||
!strings.Contains(err.Error(), "secret gitea-read is named twice") {
t.Errorf("a credential named as an own secret too: %v", err)
}
}
// **A module only pulled in by another has a value of its own** (issue 765): where the machine runs a handed module
// that nothing assigned there, its plan is mirrored under `<node>.<module>` like an assigned one's, saying which
// module requires it and no time it was assigned. A plan for a module neither assigned nor required there is still
// refused, and a module assigned there is mirrored as an assignment whatever else requires it.
func TestAModulePulledInHasAValueOfItsOwnWithItsPlan(t *testing.T) {
at := time.Date(2026, 10, 14, 20, 0, 0, 0, time.UTC)
assigned := []AssignmentMirrored{{Node: "g14", Module: "zsh", Assigned: at}}
pulled := aPlan()
pulled.RequiredBy = "desk"
both := aPlan()
both.RequiredBy = "desk"
values, err := AssignmentValues(assigned, map[string]Provisioning{"g14.dunst": pulled, "g14.zsh": both})
if err != nil {
t.Fatal(err)
}
if got := string(values["g14.dunst"]); !strings.HasPrefix(got, `{"node":"g14","module":"dunst","required-by":"desk",`+
`"provisioning":{"templates":{"source":"artifacts/dunst/files.tar.gz",`) || strings.Contains(got, "assigned") {
t.Errorf("a pulled-in module is mirrored as %s", got)
}
var got AssignmentMirrored
if err := json.Unmarshal(values["g14.dunst"], &got); err != nil || got.Provisioning == nil ||
got.Provisioning.Files[0].Path != "/home/nox/.config/dunst/dunstrc" {
t.Fatalf("the pulled-in module's plan is mirrored as %s (%v)", values["g14.dunst"], err)
}
if zsh := string(values["g14.zsh"]); !strings.HasPrefix(zsh, `{"node":"g14","module":"zsh","assigned":"2026-10-14T20:00:00Z",`+
`"provisioning":`) || strings.Contains(zsh, "required-by") {
t.Errorf("an assigned module also required is mirrored as %s", zsh)
}
if len(values) != 2 {
t.Errorf("the mirror holds %d values, want 2", len(values))
}
spoilt := pulled
spoilt.Templates.Digest = "sha256:short"
if _, err := AssignmentValues(assigned, map[string]Provisioning{"g14.dunst": spoilt}); err == nil ||
!strings.Contains(err.Error(), "g14.dunst: templates.digest") {
t.Errorf("a pulled-in module's spoilt plan: %v", err)
}
if _, err := AssignmentValues(assigned, map[string]Provisioning{"shanks.dunst": aPlan()}); err == nil ||
!strings.Contains(err.Error(), "shanks.dunst: a plan for a module not assigned there") {
t.Errorf("a plan for a module neither assigned nor required there: %v", err)
}
}
// **A plan names the provider serving each provision its module requires** (design 57 V5a, issue 760): machine,
// module and local name, so the vault reading the mirror sees a bind and an unbind; an entry with no credential
// to fill has no name and no file. Read as strictly as the rest of the plan.
func TestAPlanNamesTheProviderOfEachProvision(t *testing.T) {
assigned := []AssignmentMirrored{{Node: "g14", Module: "dunst", Assigned: time.Now().UTC()}}
p := aPlan()
p.Provisions = []ProvisionedCredential{
{Provision: "postgres-database", Provider: "novox", ProviderModule: "postgres"},
{Provision: "s3-bucket", Local: "hot", Provider: "ace", ProviderModule: "minio"}}
values, err := AssignmentValues(assigned, map[string]Provisioning{"g14.dunst": p})
if err != nil {
t.Fatal(err)
}
if want := `"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres"},` +
`{"provision":"s3-bucket","local":"hot","provider":"ace","provider-module":"minio"}]`; !strings.Contains(
string(values["g14.dunst"]), want) {
t.Errorf("the plan's value is %s, want it to carry %s", values["g14.dunst"], want)
}
for want, provisions := range map[string][]ProvisionedCredential{
`postgres-database: provider-module ""`: {{Provision: "postgres-database", Provider: "novox"}},
`postgres-database: provider ""`: {{Provision: "postgres-database", ProviderModule: "postgres"}},
"s3-bucket (hot): its provider is named twice": {
{Provision: "s3-bucket", Local: "hot", Provider: "ace", ProviderModule: "minio"},
{Provision: "s3-bucket", Local: "hot", Provider: "novox", ProviderModule: "minio"}},
`postgres-database: path "/var/lib/dunst/db" names a file and no credential`: {{Provision: "postgres-database",
Provider: "novox", ProviderModule: "postgres", Path: "/var/lib/dunst/db"}},
} {
bad := aPlan()
bad.Provisions = provisions
if _, err := AssignmentValues(assigned, map[string]Provisioning{"g14.dunst": bad}); err == nil ||
!strings.Contains(err.Error(), want) {
t.Errorf("%s: refused with %v", want, err)
}
}
}
// The plan a composition makes (catalogue.Plan) is the plan the mirror carries, its provisions whole: kept with the
// send as the one and read back as the other, nothing is dropped and nothing is unknown.
func TestAComposedPlansProvisionsAreTheMirroredPlans(t *testing.T) {
composed := catalogue.Plan{Templates: catalogue.PlanTemplates{Source: "s", Digest: "d"},
Provisions: []catalogue.PlanProvision{{Name: "db-hot", Provision: "postgres-database", Local: "hot",
Provider: "novox", ProviderModule: "postgres", CredentialForm: catalogue.CredentialFormPair,
Path: "/var/lib/x/hot"}}}
raw, err := json.Marshal(composed)
if err != nil {
t.Fatal(err)
}
var mirrored Provisioning
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&mirrored); err != nil {
t.Fatal(err)
}
want := []ProvisionedCredential{{Name: "db-hot", Provision: "postgres-database", Local: "hot", Provider: "novox",
ProviderModule: "postgres", CredentialForm: "pair", Path: "/var/lib/x/hot"}}
if !reflect.DeepEqual(mirrored.Provisions, want) {
t.Fatalf("the mirrored plan's provisions are %+v, want %+v", mirrored.Provisions, want)
}
}
// **A plan names the credential form of each provider's offer** (issue 768), so the vault makes a first value in
// that form for a provider it holds no row of: carried as written, and refused when it is not a form.
func TestAPlanNamesTheCredentialFormOfEachProvidersOffer(t *testing.T) {
assigned := []AssignmentMirrored{{Node: "g14", Module: "dunst", Assigned: time.Now().UTC()}}
p := aPlan()
p.Provisions = []ProvisionedCredential{
{Provision: "postgres-database", Provider: "novox", ProviderModule: "postgres", CredentialForm: "pair"},
{Provision: "sonarr-api", Provider: "ace", ProviderModule: "sonarr", CredentialForm: "shared"}}
values, err := AssignmentValues(assigned, map[string]Provisioning{"g14.dunst": p})
if err != nil {
t.Fatal(err)
}
if want := `{"provision":"postgres-database","provider":"novox","provider-module":"postgres",` +
`"credential-form":"pair"},{"provision":"sonarr-api","provider":"ace","provider-module":"sonarr",` +
`"credential-form":"shared"}`; !strings.Contains(string(values["g14.dunst"]), want) {
t.Errorf("the plan's value is %s, want it to carry %s", values["g14.dunst"], want)
}
bad := aPlan()
bad.Provisions = []ProvisionedCredential{{Provision: "postgres-database", Provider: "novox",
ProviderModule: "postgres", CredentialForm: "one-for-all"}}
if _, err := AssignmentValues(assigned, map[string]Provisioning{"g14.dunst": bad}); err == nil ||
!strings.Contains(err.Error(), `credential-form "one-for-all"`) {
t.Errorf("a credential form that is not one was refused with %v", err)
}
}
func TestAPlanMayHoldSeveralNamedItemsOfOneCollectionAndRefusesABadOne(t *testing.T) {
p := aPlan()
p.Items = []ProvisionedItem{
{Collection: "blocks", Name: "a", Attributes: map[string]string{"bar": "top"}, Template: "a"},
{Collection: "blocks", Name: "b", Template: "b"}}
if problems := p.Problems(); len(problems) != 0 {
t.Fatalf("a plan with two named items is refused: %v", problems)
}
p.Items = append(p.Items, ProvisionedItem{Collection: "blocks", Name: "b", Template: "c"},
ProvisionedItem{Collection: "blocks", Name: "c", Attributes: map[string]string{"Bar": "x y"}, Template: "d"})
got := strings.Join(p.Problems(), "; ")
for _, want := range []string{"item blocks/b is contributed to twice", "attribute Bar"} {
if !strings.Contains(got, want) {
t.Errorf("the problems %q lack %q", got, want)
}
}
}
+113
View File
@@ -0,0 +1,113 @@
package inventory
import (
"encoding/json"
"reflect"
"slices"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// jsonFields is the names a struct's fields are encoded under.
func jsonFields(t reflect.Type) []string {
var out []string
for i := 0; i < t.NumField(); i++ {
name, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",")
if name != "" && name != "-" {
out = append(out, name)
}
}
return out
}
// **A declaration reaches the bus with its listed fields and no other** (ADR 0289 §12): the mirror's value
// carries exactly DeclaredMirroredFields, and every field a manifest's declaration has is on that list. A
// field a declaration gains (package K1 widens it, ADR 0348 clause 4) fails here until it is listed — read,
// judged not a secret and carried — so nothing reaches the declared-config mirror unreviewed.
func TestTheDeclaredConfigMirrorCarriesOnlyItsListedFields(t *testing.T) {
if got := jsonFields(reflect.TypeOf(DeclaredMirrored{})); !slices.Equal(got, DeclaredMirroredFields) {
t.Errorf("the mirrored declaration carries %v, its list is %v", got, DeclaredMirroredFields)
}
for _, field := range jsonFields(reflect.TypeOf(catalogue.SettingDeclaration{})) {
if !slices.Contains(DeclaredMirroredFields, field) {
t.Errorf("a declaration has the field %q, which the declared-config mirror does not list: list it "+
"in DeclaredMirrored (and say why it is no secret), or say here why it stays off the bus", field)
}
}
if got := jsonFields(reflect.TypeOf(DeclaredConfigMirrored{})); !slices.Equal(got, []string{"module", "settings"}) {
t.Errorf("the mirrored module carries %v", got)
}
}
// **A declaration using every field reaches the mirror whole** (ADR 0356 §4): the mirror holds what the manifest
// declares about a config value, so a declaration with every field set reads back from the mirror's value with
// each field as written.
func TestADeclarationUsingEveryFieldRoundTripsThroughTheMirror(t *testing.T) {
low, high := 1.0, 65535.0
every := catalogue.SettingDeclaration{Kind: catalogue.KindPreference, Default: 8080, Why: "the port it always had",
Type: catalogue.ConfigNumber, Description: "the port it listens on, a TCP port number",
Enum: []any{8080, 8443}, Minimum: &low, Maximum: &high, Pattern: "^[0-9]+$", Trusted: true,
Predefined: "http.port", Required: true, Runtime: true}
declared, err := json.Marshal(map[string]catalogue.SettingDeclaration{"port": every})
if err != nil {
t.Fatal(err)
}
var written map[string]map[string]any
if err := json.Unmarshal(declared, &written); err != nil {
t.Fatal(err)
}
for _, field := range jsonFields(reflect.TypeOf(catalogue.SettingDeclaration{})) {
if _, has := written["port"][field]; !has {
t.Fatalf("the declaration under test leaves %q unset: set every field", field)
}
}
mirrored, err := declaredMirrored(declared)
if err != nil {
t.Fatal(err)
}
value, err := json.Marshal(DeclaredConfigMirrored{Module: "proxy", Settings: mirrored})
if err != nil {
t.Fatal(err)
}
var read struct {
Settings map[string]map[string]any `json:"settings"`
}
if err := json.Unmarshal(value, &read); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(read.Settings, written) {
t.Errorf("the mirror holds %v; the manifest declared %v", read.Settings, written)
}
}
// **A module moved to the config form is mirrored from it** (ADR 0348 §1): the store keeps its manifest in the
// form it was written in, under `config`, and the declared-config mirror reads its declarations from there with
// the config form's fields.
func TestAModuleInTheConfigFormIsMirroredWithItsWholeDeclaration(t *testing.T) {
inv := fresh(t)
var m catalogue.Manifest
if err := json.Unmarshal([]byte(`{"module": "proxy", "version": "1", "config": {"port": {"type": "number",
"description": "the port it listens on, a TCP port number", "default": 8080, "minimum": 1,
"maximum": 65535, "enum": [8080, 8443], "predefined": "http.port"}}}`), &m); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(t.Context(), m, Source{}); err != nil {
t.Fatal(err)
}
values, err := inv.DeclaredConfigMirror(t.Context())
if err != nil {
t.Fatal(err)
}
var got DeclaredConfigMirrored
if err := json.Unmarshal(values["proxy"], &got); err != nil {
t.Fatal(err)
}
port, has := got.Settings["port"]
if !has || port.Type != catalogue.ConfigNumber || port.Description == "" || string(port.Default) != "8080" ||
port.Minimum == nil || *port.Minimum != 1 || port.Maximum == nil || *port.Maximum != 65535 ||
string(port.Enum) != "[8080,8443]" || port.Predefined != "http.port" {
t.Errorf("a module in the config form is mirrored as %s", values["proxy"])
}
}
@@ -0,0 +1,72 @@
package inventory
import (
"encoding/json"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
)
// TestWhatRunsIsTheRegisteredBuildNotANewerOnePutBack is novox/hq issue 721's review: the newest build of a module
// failed its gate and the mesh put the build before it back. Held names the newest; what runs, and what a merge's build
// is compared with, is the build put back — so a merge whose bytes equal the failed build is never said unchanged.
func TestWhatRunsIsTheRegisteredBuildNotANewerOnePutBack(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
manifestOf := func(image string) catalogue.Manifest {
return catalogue.Manifest{Module: "one", Version: "1", Resources: []map[string]any{
{"id": "app", "type": "container", "name": "one", "image": image}}}
}
record := func(id, image string, asked time.Time) Build {
t.Helper()
raw, _ := json.Marshal(manifestOf(image))
b := Build{ID: id, Module: "one", Commit: id + "aaaaaaaa", Repository: "novox/one", Manifest: raw,
Asked: asked, At: asked, Made: []Artifact{{Name: "one", Kind: "image", Reference: image}}}
if err := inv.RecordBuild(ctx, b); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, manifestOf(image), Source{Repository: "novox/one", BuiltFrom: b.Commit,
Asked: asked}); err != nil {
t.Fatal(err)
}
return b
}
x := "store/one@sha256:" + "1111111111111111111111111111111111111111111111111111111111111111"
y := "store/one@sha256:" + "2222222222222222222222222222222222222222222222222222222222222222"
before := record("x", x, time.Now().Add(-2*time.Hour))
failed := record("y", y, time.Now().Add(-time.Minute))
if err := inv.RecordGate(ctx, GateVerdict{Build: failed.ID, Module: "one", Commit: failed.Commit,
Machines: []string{"anchor"}, Verdict: GateFailed}); err != nil {
t.Fatal(err)
}
if err := inv.RestoreModule(ctx, before); err != nil {
t.Fatal(err)
}
held, err := inv.Held(ctx)
if err != nil {
t.Fatal(err)
}
running, err := inv.RegisteredHeld(ctx)
if err != nil {
t.Fatal(err)
}
if running["one/one"] == y {
t.Fatalf("what runs is read as the build whose gate failed (%s); Held says %s", y, held["one/one"])
}
if got := running["one/one"]; got != "" && got != x {
t.Fatalf("what runs is read as %s, want the build put back %s or nothing", got, x)
}
}
// TestOnlyArtifactsTheRegisteredManifestNamesAreRunning: registeredOnly keeps a held artifact only when its digest is
// in its module's registered manifest.
func TestOnlyArtifactsTheRegisteredManifestNamesAreRunning(t *testing.T) {
held := map[string]string{"one/app": "store/one@sha256:aaaa", "two/app": "store/two@sha256:bbbb", "three/app": "nodigest"}
registered := map[string]string{"one": `{"resources":[{"image":"store/one@sha256:aaaa"}]}`,
"two": `{"resources":[{"image":"store/two@sha256:cccc"}]}`, "three": "nodigest"}
got := registeredOnly(held, registered)
if len(got) != 1 || got["one/app"] == "" {
t.Fatalf("running reads %v, want one/app alone", got)
}
}
+218
View File
@@ -0,0 +1,218 @@
package inventory
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
// The move of the controller's secrets into the vault (novox/hq ADR 0349 clause 7; design 57's V3, issue 676; the
// operator's decision of 2026-10-14 on that issue).
//
// The controller opens none of the values it holds. Each row is moved by the node-engine of a machine it was sealed
// to: a module's own secret by its machine's, a pair credential by either end's. This is what the controller reads to
// ask each machine — every row sealed to it, as the store holds it — and where it keeps what moved. Nothing here
// opens, makes or changes a value: the rows are read as they are.
// SecretToMove is one row of the controller's store as the move asks a machine to hand it in.
type SecretToMove struct {
// Secret is the vault's name for the row (VaultNameOfOwn, VaultNameOfPair).
Secret string
// Node is the machine asked: the module's machine for an own secret, the end being asked for a pair.
Node string
// Module and Name are an own secret's; empty for a pair.
Module, Name string
// Provider, Provision, Consumer, ConsumerModule and Local are a pair credential's; empty for an own secret.
Provider, Provision, Consumer, ConsumerModule, Local string
Origin string
// Generation is the controller's stamp of the act that made the value (ADR 0158, ADR 0305 §5); empty for none.
Generation string
MadeAt time.Time
// Sealed is the value sealed to Node, and SealedTo the key the row says it was sealed to.
Sealed, SealedTo string
// Current says SealedTo is Node's sealing key now: only then can its engine open it.
Current bool
// Digest is the digest of the row's sealed text as the store holds it — both ends' for a pair, so either end
// reads the same — by which a row the controller changed after it moved is told from one that did not.
Digest string
// Moved is the record of the row having moved, by either end, or nil.
Moved *SecretMoved
}
// Own says the row is a module's own secret.
func (s SecretToMove) Own() bool { return s.Module != "" }
// SealedDigest is sha256 of a row's sealed texts, each on its own line, in hex: nothing of the value is kept.
func SealedDigest(sealed ...string) string {
h := sha256.New()
for _, s := range sealed {
h.Write([]byte(s + "\n"))
}
return "sha256:" + hex.EncodeToString(h.Sum(nil))
}
// SecretMoved is a row the vault took: which machine's engine handed it in, the vault's generation, the digest of the
// sealed text it was moved from, and when.
type SecretMoved struct {
Node string
Generation int
Digest string
At time.Time
}
// VaultNameOfOwn is the vault's name for a module's own secret (mesh-vault OwnSecret).
func VaultNameOfOwn(node, module, name string) string {
return "own/" + node + "/" + module + "/" + name
}
// VaultNameOfPair is the vault's name for one row of the pair credentials (mesh-vault PairSecret): both ends, the
// consuming module, and the local name last when it has one.
func VaultNameOfPair(provider, provision, consumer, consumerModule, local string) string {
name := "pair/" + provider + "/" + provision + "/" + consumer + "/" + consumerModule
if local != "" {
name += "/" + local
}
return name
}
// SecretsToMoveFrom is every row sealed to a machine, as the store holds it: its modules' own secrets, and each pair
// credential it is an end of, sealed to it; each with whether its key is the machine's now and whether it moved.
func (i *Inventory) SecretsToMoveFrom(ctx context.Context, node string) ([]SecretToMove, error) {
record, err := i.NodeByName(ctx, node)
if err != nil {
return nil, err
}
current, err := i.SealingKeyOf(ctx, node)
if err != nil {
return nil, err
}
moved, err := i.secretsMoved(ctx)
if err != nil {
return nil, err
}
var out []SecretToMove
rows, err := i.store.Pool().Query(ctx,
`select module, name, origin, coalesce(generation, ''), made_at, sealed, node_key from module_secret
where node = $1 order by module, name`, record.ID)
if err != nil {
return nil, err
}
for rows.Next() {
s := SecretToMove{Node: node}
if err := rows.Scan(&s.Module, &s.Name, &s.Origin, &s.Generation, &s.MadeAt, &s.Sealed, &s.SealedTo); err != nil {
rows.Close()
return nil, err
}
s.Secret, s.Digest = VaultNameOfOwn(node, s.Module, s.Name), SealedDigest(s.Sealed)
out = append(out, s)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
rows, err = i.store.Pool().Query(ctx,
`select p.name, s.name, c.name, s.consumer_module, s.local, s.origin, coalesce(s.generation, ''), s.created_at,
case when s.consumer = $1 then s.for_consumer else s.for_provider end,
case when s.consumer = $1 then s.consumer_key else s.provider_key end,
s.for_consumer, s.for_provider
from secret s join node p on p.id = s.provider join node c on c.id = s.consumer
where s.consumer = $1 or s.provider = $1
order by p.name, s.name, c.name, s.consumer_module, s.local`, record.ID)
if err != nil {
return nil, err
}
for rows.Next() {
s := SecretToMove{Node: node}
var forConsumer, forProvider string
if err := rows.Scan(&s.Provider, &s.Provision, &s.Consumer, &s.ConsumerModule, &s.Local, &s.Origin,
&s.Generation, &s.MadeAt, &s.Sealed, &s.SealedTo, &forConsumer, &forProvider); err != nil {
rows.Close()
return nil, err
}
s.Digest = SealedDigest(forConsumer, forProvider)
s.Secret = VaultNameOfPair(s.Provider, s.Provision, s.Consumer, s.ConsumerModule, s.Local)
out = append(out, s)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
for n := range out {
out[n].Current = current != "" && out[n].SealedTo == current
if m, ok := moved[out[n].Secret]; ok {
out[n].Moved = &m
}
}
return out, nil
}
func (i *Inventory) secretsMoved(ctx context.Context) (map[string]SecretMoved, error) {
rows, err := i.store.Pool().Query(ctx,
`select m.secret, n.name, m.generation, m.sealed_digest, m.moved_at from secret_moved m join node n on n.id = m.node`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]SecretMoved{}
for rows.Next() {
var name string
var m SecretMoved
if err := rows.Scan(&name, &m.Node, &m.Generation, &m.Digest, &m.At); err != nil {
return nil, err
}
out[name] = m
}
return out, rows.Err()
}
// RecordSecretMoved keeps that the vault took a row, read back the same, from a machine's engine. Kept once: the
// first record of a row stands, so the other end of a pair moving it again changes nothing.
func (i *Inventory) RecordSecretMoved(ctx context.Context, s SecretToMove, generation int) error {
record, err := i.NodeByName(ctx, s.Node)
if err != nil {
return err
}
_, err = i.store.Pool().Exec(ctx,
`insert into secret_moved (secret, node, generation, sealed_digest) values ($1, $2, $3, $4)
on conflict (secret) do nothing`, s.Secret, record.ID, generation, s.Digest)
return err
}
// SecretMove is the move as started: when, by whom, and when the record of asks was copied.
type SecretMove struct {
StartedAt time.Time
StartedBy string
AsksMovedAt *time.Time
}
// StartSecretMove records that the move started, the first time; later starts keep the first. It answers the move
// as recorded.
func (i *Inventory) StartSecretMove(ctx context.Context, by string) (SecretMove, error) {
if _, err := i.store.Pool().Exec(ctx,
`insert into secret_move (started_by) values ($1) on conflict (one) do nothing`, by); err != nil {
return SecretMove{}, err
}
m, _, err := i.SecretMoveStarted(ctx)
return m, err
}
// SecretMoveStarted is the move as recorded, and whether it was started: a machine heard from is moved only then.
func (i *Inventory) SecretMoveStarted(ctx context.Context) (SecretMove, bool, error) {
var m SecretMove
err := i.store.Pool().QueryRow(ctx, `select started_at, started_by, asks_moved_at from secret_move`).
Scan(&m.StartedAt, &m.StartedBy, &m.AsksMovedAt)
if errors.Is(err, pgx.ErrNoRows) {
return SecretMove{}, false, nil
}
return m, err == nil, err
}
// RecordAsksMoved keeps that the record of asks was copied into the vault.
func (i *Inventory) RecordAsksMoved(ctx context.Context) error {
_, err := i.store.Pool().Exec(ctx, `update secret_move set asks_moved_at = now() where asks_moved_at is null`)
return err
}
+121
View File
@@ -0,0 +1,121 @@
package inventory
import (
"context"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// The move reads every row sealed to a machine as the store holds it (design 57's V3, issue 676): its modules' own
// secrets and each pair credential it is an end of, sealed to it, under the vault's name for the row, with its
// origin and generation; a row sealed to a key the machine no longer has is said as not current; what moved is kept
// once, for both ends of a pair; and the move's start and the copy of the asks are recorded once.
func TestTheMoveReadsEveryRowSealedToAMachineAsItIs(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
opens := map[string]func(string) ([]byte, bool){}
for _, n := range []string{"consumer", "provider"} {
node, err := inv.AddNode(ctx, n)
if err != nil {
t.Fatal(err)
}
key, open := aSealingKey(t)
opens[n] = open
if err := inv.RecordSealingKey(ctx, node.ID, key); err != nil {
t.Fatal(err)
}
}
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "gitea", Version: "1",
Requires: []string{"postgres-database"}, Secrets: map[string]string{"postgres-database": "/run/pg"}},
Source{}); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "postgres", Version: "1"}, Source{}); err != nil {
t.Fatal(err)
}
if _, err := inv.SecretForModule(ctx, "provider", "postgres", "superuser"); err != nil {
t.Fatal(err)
}
if _, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider", ""); err != nil {
t.Fatal(err)
}
provider, err := inv.SecretsToMoveFrom(ctx, "provider")
if err != nil {
t.Fatal(err)
}
consumer, err := inv.SecretsToMoveFrom(ctx, "consumer")
if err != nil {
t.Fatal(err)
}
if len(provider) != 2 || len(consumer) != 1 {
t.Fatalf("the provider has %d rows, the consumer %d: %+v %+v", len(provider), len(consumer), provider, consumer)
}
own := provider[0]
if own.Secret != "own/provider/postgres/superuser" || !own.Own() || own.Origin != "made" || !own.Current ||
own.Moved != nil || own.MadeAt.IsZero() {
t.Errorf("the own secret: %+v", own)
}
pair := provider[1]
if pair.Secret != "pair/provider/postgres-database/consumer/gitea" || pair.Own() || pair.Provider != "provider" ||
pair.Consumer != "consumer" || pair.ConsumerModule != "gitea" || !pair.Current {
t.Errorf("the pair from the provider's end: %+v", pair)
}
// Each end is handed what was sealed to it, and it opens there to the same value.
atProvider, ok1 := opens["provider"](pair.Sealed)
atConsumer, ok2 := opens["consumer"](consumer[0].Sealed)
if !ok1 || !ok2 || string(atProvider) != string(atConsumer) {
t.Fatal("the two ends of the pair were not each handed what was sealed to them")
}
if _, ok := opens["provider"](own.Sealed); !ok {
t.Fatal("the own secret does not open with its machine's key")
}
// Moved by one end, the row is moved for both, and the first record stands.
if err := inv.RecordSecretMoved(ctx, consumer[0], 1); err != nil {
t.Fatal(err)
}
if err := inv.RecordSecretMoved(ctx, pair, 2); err != nil {
t.Fatal(err)
}
provider, _ = inv.SecretsToMoveFrom(ctx, "provider")
if m := provider[1].Moved; m == nil || m.Node != "consumer" || m.Generation != 1 || m.Digest != consumer[0].Digest || m.Digest != provider[1].Digest {
t.Errorf("the pair as moved: %+v", m)
}
if provider[0].Moved != nil {
t.Error("the own secret reads as moved")
}
// A machine that rejoined has a key its old rows were not sealed to.
node, _ := inv.NodeByName(ctx, "provider")
key, _ := aSealingKey(t)
if err := inv.RecordSealingKey(ctx, node.ID, key); err != nil {
t.Fatal(err)
}
provider, _ = inv.SecretsToMoveFrom(ctx, "provider")
if provider[0].Current {
t.Error("a row sealed to the machine's earlier key reads as current")
}
if _, started, _ := inv.SecretMoveStarted(ctx); started {
t.Fatal("the move reads as started before it was")
}
first, err := inv.StartSecretMove(ctx, "jochen")
if err != nil || first.StartedBy != "jochen" || first.AsksMovedAt != nil {
t.Fatalf("started %+v, %v", first, err)
}
again, _ := inv.StartSecretMove(ctx, "someone else")
if again.StartedBy != "jochen" || !again.StartedAt.Equal(first.StartedAt) {
t.Errorf("a second start replaced the first: %+v", again)
}
if err := inv.RecordAsksMoved(ctx); err != nil {
t.Fatal(err)
}
if m, _, _ := inv.SecretMoveStarted(ctx); m.AsksMovedAt == nil {
t.Error("the copy of the asks was not recorded")
}
if _, err := inv.SecretsToMoveFrom(ctx, "nowhere"); err == nil {
t.Error("a machine the mesh does not know was read")
}
}
+22 -1
View File
@@ -11,6 +11,7 @@ import (
"github.com/jackc/pgx/v5"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/secrets"
)
@@ -595,6 +596,26 @@ func (i *Inventory) DeclaresOwnSecret(ctx context.Context, module, name string)
return GivableAtDesk(m, name)
}
// OwnSecretPrompt is the one line the desk asks for this secret as — the module's declared `prompt`, read from
// the controller's own catalogue, never from the call or the module's runtime (novox/hq issue 789). For a family
// member it is the family's prompt with `{member}` replaced by the member's suffix. Empty when the module or the
// secret is unknown, or declares no prompt — the desk then asks by name alone, as it does today.
func (i *Inventory) OwnSecretPrompt(ctx context.Context, module, name string) (string, error) {
m, err := i.declared(ctx, module)
if err != nil {
return "", err
}
own, family, ok := m.OwnSecrets.Lookup(name)
if !ok || own.Prompt == "" {
return "", nil
}
if family == "" {
return own.Prompt, nil
}
member := strings.TrimPrefix(name, catalogue.FamilyPrefix(family))
return strings.ReplaceAll(own.Prompt, "{member}", member), nil
}
// RunsAsItsOwnAccount says a module runs as an account of its own (novox/hq ADR 0259 §8): a trusted party — the
// router, a channel that proves its sender or shows a link's code — whose own secret is what the operator's
// answers are believed by. Its value is given at the controller's terminal alone.
@@ -619,7 +640,7 @@ func GivableAtDesk(m catalogue.Manifest, name string) error {
return fmt.Errorf("%s does not declare %q as an own secret; %s", m.Module, name, declaresOwn(m))
}
switch {
case name == BrokerSecret:
case name == BrokerSecret || name == broker.ProvisionerSecret:
return fmt.Errorf("%q is %s's account on the bus, which the mesh makes (`issue`), never a value a person gives",
name, m.Module)
case own.MeshMayMake():
+17 -3
View File
@@ -91,7 +91,11 @@ type Send struct {
Digest string
// Modules are the modules it named.
Modules []string
SentAt time.Time
// Provisioning is the provisioner's plan of each module the send handed to it, by module, as JSON (novox/hq
// ADR 0349 §4, design 57 P6): kept on the machine's record with the send, nil when it handed none. Never in
// the record of the send itself.
Provisioning []byte
SentAt time.Time
// RefusedAt is when the machine refused it for its generation, nil when it did not; RefusedApplied the
// generation the machine said it had applied then; CounterRaisedTo what the mesh's counter was raised to
// because the refusal showed it behind the machine, zero when it was not.
@@ -161,8 +165,9 @@ func (i *Inventory) RecordSentWith(ctx context.Context, node, digest string, bui
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx,
`update node set sent = $2, sent_at = now(), sent_builds = $3::jsonb, sent_epoch = $4, sent_generation = $5
where id = $1`, node, digest, carried, sentEpoch, nullIfZero(generation)); err != nil {
`update node set sent = $2, sent_at = now(), sent_builds = $3::jsonb, sent_epoch = $4, sent_generation = $5,
sent_provisioning = $6::jsonb
where id = $1`, node, digest, carried, sentEpoch, nullIfZero(generation), nullIfEmpty(s.Provisioning)); err != nil {
return err
}
recordErr := recordSend(ctx, tx, node, s)
@@ -338,3 +343,12 @@ func (i *Inventory) RefusedSendsSince(ctx context.Context, since time.Time) ([]S
}
return scanSends(rows)
}
// nullIfEmpty is raw JSON, or null for none.
func nullIfEmpty(raw []byte) *string {
if len(raw) == 0 {
return nil
}
text := string(raw)
return &text
}
+2 -2
View File
@@ -73,10 +73,10 @@ func TestTheCurrentBuildsAreTheCatalogues(t *testing.T) {
t.Fatal(err)
}
// Rolled out by default, one machine first and gated (novox/hq ADR 0236).
if got := current["resolver"]; got != (CurrentBuild{Commit: "c1", RollOut: true}) {
if got := current["resolver"]; got != (CurrentBuild{Commit: "c1", RollOut: true, From: catalogue.FromDefault}) {
t.Errorf("resolver is at %+v", got)
}
if got := current["by-hand"]; got != (CurrentBuild{RollOut: true}) {
if got := current["by-hand"]; got != (CurrentBuild{RollOut: true, From: catalogue.FromPerson}) {
t.Errorf("a module with no source is at %+v", got)
}
}
+96
View File
@@ -0,0 +1,96 @@
package inventory
import (
"encoding/json"
"reflect"
"strings"
"testing"
)
// A send keeps the provisioner's plans it handed (design 57 P6, issue 672, migration 0100): the assignments mirror
// carries the plan of the build that machine was sent, in the module's own assignment and no other, and a walk
// reads which templates the send handed.
func TestASendKeepsThePlansItHandedAndTheMirrorCarriesThem(t *testing.T) {
inv, node := aNodeWithModules(t, "dunst", "web")
ctx := t.Context()
for _, m := range []string{"dunst", "web"} {
if _, err := inv.Assign(ctx, node, m); err != nil {
t.Fatal(err)
}
}
record, err := inv.NodeByName(ctx, node)
if err != nil {
t.Fatal(err)
}
digest := "sha256:" + strings.Repeat("d", 64)
plan := Provisioning{Templates: TemplatesArchive{Source: "http://store:5000/v2/dunst/files/blobs/" + digest,
Digest: digest}, Files: []ProvisionedFile{{ID: "configuration", Path: "/home/op/.config/dunst/dunstrc",
Template: "dunstrc", OwnerAtRuntime: "owned", Takes: "reload-on"}}}
// A plan for a module the machine no longer has assigned is kept with the send and mirrored nowhere; one for a
// module only pulled in by another is mirrored under its own key, saying what requires it (issue 765).
raw := []byte(`{"dunst":` + mustJSON(t, plan) + `,"gone":` + mustJSON(t, plan) + `,"notify":` +
strings.Replace(mustJSON(t, plan), `{`, `{"required-by":"desk",`, 1) + `}`)
if err := inv.RecordSentWith(ctx, record.ID, "first", nil, 0, 0, Send{Digest: "first", Provisioning: raw}); err != nil {
t.Fatal(err)
}
sent, _, err := inv.SentTo(ctx, node)
if err != nil || !reflect.DeepEqual(sent.Provisioned, map[string]string{"dunst": digest, "gone": digest,
"notify": digest}) {
t.Fatalf("the send says it handed %v (%v)", sent.Provisioned, err)
}
plans, err := inv.SentProvisioningPlans(ctx)
required := plan
required.RequiredBy = "desk"
if err != nil || !reflect.DeepEqual(plans, map[string]Provisioning{node + ".dunst": plan, node + ".notify": required}) {
t.Fatalf("the plans mirrored are %+v (%v)", plans, err)
}
inv.SetProvisioningPlans(inv.SentProvisioningPlans)
mirror, err := inv.AssignmentsMirror(ctx)
if err != nil {
t.Fatal(err)
}
var dunst, web AssignmentMirrored
if err := json.Unmarshal(mirror[node+".dunst"], &dunst); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(mirror[node+".web"], &web); err != nil {
t.Fatal(err)
}
if dunst.Provisioning == nil || !reflect.DeepEqual(*dunst.Provisioning, plan) || web.Provisioning != nil {
t.Fatalf("the mirror carries %+v for dunst and %+v for web", dunst.Provisioning, web.Provisioning)
}
var notify AssignmentMirrored
if err := json.Unmarshal(mirror[node+".notify"], &notify); err != nil {
t.Fatalf("no value for the pulled-in module: %s (%v)", mirror[node+".notify"], err)
}
if notify.RequiredBy != "desk" || !notify.Assigned.IsZero() || notify.Provisioning == nil ||
!reflect.DeepEqual(*notify.Provisioning, plan) {
t.Fatalf("the mirror carries %s for the pulled-in module", mirror[node+".notify"])
}
if _, has := mirror[node+".gone"]; has {
t.Fatalf("a module no longer assigned is mirrored: %s", mirror[node+".gone"])
}
// The next send hands nothing: nothing is kept, and the assignment's value is the bytes it was.
if err := inv.RecordSentWith(ctx, record.ID, "second", nil, 0, 0, Send{Digest: "second"}); err != nil {
t.Fatal(err)
}
if sent, _, err := inv.SentTo(ctx, node); err != nil || sent.Provisioned != nil {
t.Fatalf("a send handing nothing says it handed %v (%v)", sent.Provisioned, err)
}
if mirror, err := inv.AssignmentsMirror(ctx); err != nil || strings.Contains(string(mirror[node+".dunst"]), "provisioning") {
t.Fatalf("dunst's assignment still carries a plan: %s (%v)", mirror[node+".dunst"], err)
} else if _, has := mirror[node+".notify"]; has {
t.Fatalf("a module the machine no longer runs is mirrored: %s", mirror[node+".notify"])
}
}
func mustJSON(t *testing.T, v any) string {
t.Helper()
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
+45
View File
@@ -0,0 +1,45 @@
package inventory
import (
"encoding/json"
"slices"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// A machine's layer wins over the mesh's for the setting that restricts a stream (novox/hq ADR 0363 §5).
func TestARestrictingSettingIsReadMachineOverMesh(t *testing.T) {
layers := []catalogue.Layer{
{From: catalogue.MeshWideLayer, Values: map[string]any{"grants": "mesh", "other": 1}},
{From: "novox", Values: map[string]any{"grants": "novox"}},
}
if v, set := settingValue(layers, "grants"); !set || v != "novox" {
t.Fatalf("read %v, %v", v, set)
}
if _, set := settingValue(layers[:1], "absent"); set {
t.Fatal("a key no layer sets read as set")
}
}
// What a consumer hears is composed into the bus's subjects, and what a stream carries leaves EVENTS.
func TestWhatAModuleHearsIsComposedOntoTheStreamsSubjects(t *testing.T) {
provider := catalogue.Manifest{Module: "mesh-mail", Streams: []catalogue.StreamDeclaration{{Name: "mail",
Events: []string{"mail.*.message.added"}, Grant: &catalogue.StreamGrant{Config: "grants", Events: "mail.{key}.**"}}}}
var value any
_ = json.Unmarshal([]byte(`{"mesh":["reader"]}`), &value)
grants := grantValues{"mesh-mail/grants": {value}}
reader := catalogue.Manifest{Module: "reader"}
hears, touches, onEvents := hearsOf(reader, map[string]catalogue.Manifest{"mesh-mail": provider}, "ace", grants,
[]string{"mesh-mail.mail.**", "billing.paid"})
if !touches || len(hears) != 1 || hears[0].Stream != "MAIL" || !hears[0].Restricted ||
!slices.Equal(hears[0].Filters, []string{"mesh.stream.mesh-mail.event.mail.mesh.message.added"}) {
t.Fatalf("heard %+v", hears)
}
if !slices.Equal(onEvents, []string{"billing.paid"}) {
t.Fatalf("left on EVENTS: %v", onEvents)
}
if got := streamsOf(provider); len(got) != 1 || got[0].Name != "MAIL" || got[0].MaxAgeDays != 7 || !got[0].Restricted {
t.Fatalf("the provider's stream: %+v", got)
}
}
+167
View File
@@ -0,0 +1,167 @@
package inventory
import (
"reflect"
"sort"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/catalogue"
)
// The tool runner's manifest with the families novox/hq ADR 0367 §5 starts the grant with, and one more.
const runtimeWaiting = `{"module": "node-tools", "version": "1", "invokes": ["*"], "waits": [%s]}`
var adr0367Families = []string{`"mesh-knowledge.reviewed"`, `"mesh-delivery.transition"`, `"gitea.forge.status"`,
`"mesh-controller.checked"`, `"operator-channel.decided.claude-code"`}
// composeWaits composes the bus's users from manifests as BusRecords does — the router declaring the operator
// channel, the mesh's own seats, a person granted every tool — and answers the tool runner's account's
// subscriptions on events, and the subjects its membership names.
func composeWaits(t *testing.T, families []string, people map[string][]string) (granted, issued []string) {
t.Helper()
parse := func(s string) catalogue.Manifest {
m, err := catalogue.ParseManifest([]byte(s))
if err != nil {
t.Fatal(err)
}
return m
}
router := parse(`{"module": "messenger", "version": "1", "runs-as": "messenger",
"seats": [{"name": "operator-channel", "scope": "mesh", "serves": ["open"], "accepts": ["ask"],
"emits": ["decided"], "by-caller": ["ask", "decided"]}],
"claims": [{"name": "operator-channel", "scope": "mesh", "serves": ["open"]}],
"own-secrets": {"broker": "${dir:state}/broker"}, "secrets-owner": "messenger",
"resources": [{"id": "account", "type": "user", "name": "messenger", "shell": "/usr/bin/nologin", "home": "/var/lib/messenger"},
{"id": "state", "type": "directory", "mode": "0700", "place": ".", "owner": "messenger"}]}`)
asker := parse(`{"module": "claude-code", "version": "1", "consumes": ["mesh-knowledge.reviewed"],
"own-secrets": {"broker": "${dir:state}/broker"}, "resources": [{"id": "state", "type": "directory", "mode": "0700", "place": "."}]}`)
runtime := parse(strings.Replace(runtimeWaiting, "%s", strings.Join(families, ", "), 1))
manifests := []catalogue.Manifest{router, asker, runtime}
seats := map[string]catalogue.SeatDeclaration{}
declarers := map[string]string{}
for _, m := range manifests {
for _, s := range m.DefinesSeats {
seats[s.Name], declarers[s.Name] = s, m.Module
}
}
for _, own := range catalogue.SeatsWithAProtocol() {
seats[own.Name] = catalogue.SeatDeclaration{Name: own.Name, Scope: own.Scope, Accepts: own.Accepts,
Emits: own.Emits, Serves: own.Serves}
}
records := broker.Records{Nodes: []string{"laptop"}, Assigned: map[string][]broker.Declared{},
People: people, Interchangeable: map[string]bool{}}
for _, m := range manifests {
records.Assigned["laptop"] = append(records.Assigned["laptop"], declaredFor(m, seats, declarers))
}
users, err := broker.Users(records)
if err != nil {
t.Fatal(err)
}
for _, u := range users {
if u.Kind != broker.KindNodeTools {
continue
}
p, err := broker.PermissionsFor(u)
if err != nil {
t.Fatal(err)
}
for _, s := range p.Subscribe {
if strings.Contains(s, ".event.") {
granted = append(granted, s)
}
}
}
for _, d := range records.Assigned["laptop"] {
for _, w := range broker.MembershipFor("laptop", d, broker.PlacementsOf(records, nil)).Waits {
issued = append(issued, w.Subject)
}
}
sort.Strings(granted)
sort.Strings(issued)
return granted, issued
}
// novox/hq ADR 0367 "How it is checked": the account's wait grant equals the manifest's families — a seat's
// event under the seat, the asker's own `decided` alone, a module's under the module — and the membership
// names the same; no person's grant, however wide, widens it; a family is granted only once the manifest
// that adds it is composed.
func TestTheWaitGrantIsTheToolRunnersManifestAndNothingElse(t *testing.T) {
want := []string{
"mesh.mod.gitea.event.forge.status",
"mesh.mod.mesh-delivery.event.transition",
"mesh.mod.mesh-knowledge.event.reviewed",
"mesh.seat.mesh-controller.event.checked",
"mesh.seat.operator-channel.event.decided.claude-code",
}
granted, issued := composeWaits(t, adr0367Families, map[string][]string{})
if !reflect.DeepEqual(granted, want) {
t.Errorf("the tool runner hears %v, want the manifest's families %v", granted, want)
}
if !reflect.DeepEqual(issued, want) {
t.Errorf("its membership names %v, want the grant %v", issued, want)
}
// No call or setting widens it: a person granted every tool, an operator's account, changes nothing.
if wide, _ := composeWaits(t, adr0367Families, map[string][]string{"operator": {"*"}}); !reflect.DeepEqual(wide, want) {
t.Errorf("a person's grant widened the wait grant: %v", wide)
}
// And without the families it hears no event at all (ADR 0043 §3): a module beside it consuming one
// lends it nothing.
if none, issuedNone := composeWaits(t, nil, map[string][]string{}); len(none)+len(issuedNone) > 0 {
t.Errorf("a tool runner declaring no waits hears %v, issued %v", none, issuedNone)
}
// A family is granted only after the manifest change that adds it is composed.
more := append(append([]string{}, adr0367Families...), `"mesh-knowledge.accepted"`)
grown, _ := composeWaits(t, more, map[string][]string{})
if added := without(grown, want); !reflect.DeepEqual(added, []string{"mesh.mod.mesh-knowledge.event.accepted"}) {
t.Errorf("adding one family to the manifest granted %v", added)
}
}
func without(all, some []string) []string {
drop := map[string]bool{}
for _, s := range some {
drop[s] = true
}
var out []string
for _, s := range all {
if !drop[s] {
out = append(out, s)
}
}
return out
}
// A family of a seat whose event subjects end in a kind (a kinded bench) or in the asker (a seat naming its
// caller) is composed with that last token, and refused without it, naming the family: a subject without it
// is one nothing publishes on, and a wait on it would never hear (the review of issue 745).
func TestAWaitOnASeatNamingAKindOrAnAskerNeedsThatName(t *testing.T) {
seats := map[string]catalogue.SeatDeclaration{
"intake": {Name: "intake", Kinded: true, Emits: []string{"choice", "failed"}},
"operator-channel": {Name: "operator-channel", Emits: []string{"decided"}, ByCaller: []string{"ask", "decided"}},
}
compose := func(family string) (string, error) {
m := catalogue.Manifest{Module: broker.RuntimeModule, Waits: []string{family}}
waits := waitsOf(m, seats)
return broker.WaitSubject(waits[0])
}
if s, err := compose("intake.choice.telegram"); err != nil || s != "mesh.seat.intake.event.choice.telegram" {
t.Errorf("a kind's event: %q, %v", s, err)
}
if s, err := compose("operator-channel.decided.claude-code"); err != nil || s != "mesh.seat.operator-channel.event.decided.claude-code" {
t.Errorf("an asker's event: %q, %v", s, err)
}
for family, want := range map[string]string{"intake.choice": "kind", "operator-channel.decided": "asker"} {
s, err := compose(family)
if err == nil || !strings.Contains(err.Error(), family) || !strings.Contains(err.Error(), want) {
t.Errorf("%s composed as %q (%v), want it refused for want of its %s", family, s, err, want)
}
}
// Refused where the bus's users are composed, naming it, so the merge gate is red before it lands.
p := broker.Principal{Kind: broker.KindNodeTools, Node: "laptop", Module: broker.RuntimeModule, Carries: []broker.Declared{
{Module: broker.RuntimeModule, Waits: waitsOf(catalogue.Manifest{Module: broker.RuntimeModule, Waits: []string{"intake.choice"}}, seats)}}}
if _, err := broker.PermissionsFor(p); err == nil || !strings.Contains(err.Error(), "intake.choice") {
t.Errorf("the runtime was composed with a family no subject carries: %v", err)
}
}
+10 -2
View File
@@ -397,8 +397,9 @@ func (p Plan) points(now time.Time, applied AppliedLookup, w *WalkPhases) []poin
return append(out, point{phase: PhaseApply, tier: -1, said: "the rest's sends are not kept for a walk made before ADR 0282"})
}
if len(lastSends) == 0 {
// novox/hq issue 720: since ADR 0360 no gate reads a walk; its one machine's report is its run.
return append(out, point{phase: PhaseApply, tier: -1, none: true,
said: "no machine beyond the first: the first-node gate's readings were its run"})
said: "no machine beyond the first: its one machine's report was its run"})
}
if applied == nil {
return append(out, point{phase: PhaseApply, tier: -1, said: "the rest's reports were not read"})
@@ -420,7 +421,14 @@ func (p Plan) points(now time.Time, applied AppliedLookup, w *WalkPhases) []poin
continue
}
rr := &RestReport{Node: s.node, Modules: []string{s.module}, Sent: at, SentSaid: said, Sequence: s.what.Sequence}
if r, ok := applied(s.node, s.what, s.walkSent); ok {
// Judged on the modules this walk sent there, not on every module the machine's declaration hands (ADR 0362).
var sentThere []string
for _, o := range lastSends {
if o.node == s.node && o.what.Digest == s.what.Digest && o.what.At.Equal(s.what.At) {
sentThere = append(sentThere, o.module)
}
}
if r, ok := applied(s.node, s.what.Only(sentThere...), s.walkSent); ok {
got := r.At
rr.Reported, rr.Outcome, rr.Took = &got, r.Outcome, words(r.At.Sub(at))
if r.Busy > 0 {
+22
View File
@@ -186,3 +186,25 @@ func TestAWalksRestIsReadFromEachMachinesOwnSend(t *testing.T) {
t.Fatalf("a later send's report read as this walk's: %+v, was %+v", again, r)
}
}
// Issue 840 (ADR 0362): the walk's report of a machine is asked for with only the provisioned modules the walk sent
// there, never every module the machine's declaration hands to the provisioner.
func TestTheRestIsAskedAboutTheModulesTheWalkSent(t *testing.T) {
c := walk582(t)
rest := c.walk.Modules["build-agent"].Rest
for n, s := range rest {
s.Provisioned = map[string]string{"build-agent": "t1", "other": "t2"}
rest[n] = s
}
asked := 0
c.walk.WalkPhases(c.judged.Add(time.Hour), func(node string, send SentDeclaration, walkSent time.Time) (AppliedReport, bool) {
asked++
if len(send.Provisioned) != 1 || send.Provisioned["build-agent"] != "t1" {
t.Fatalf("%s is asked about %v", node, send.Provisioned)
}
return c.lookup(node, send, walkSent)
})
if asked == 0 {
t.Fatal("no machine was asked")
}
}
+1 -1
View File
@@ -89,7 +89,7 @@ func TestARecordedWalksPhasesAddUpToItsTotal(t *testing.T) {
if got := phase(w, PhaseWord, -1); got.State != PhaseNone {
t.Errorf("a walk that waits for no word says its word phase %s", got.State)
}
if got := phase(w, PhaseApply, -1); got.State != PhaseNone || !strings.Contains(got.Said, "no machine beyond the first") {
if got := phase(w, PhaseApply, -1); got.State != PhaseNone || !strings.Contains(got.Said, "no machine beyond the first: its one machine's report was its run") {
t.Errorf("one machine only: apply %s (%s)", got.State, got.Said)
}
}