Feature 57 (config and provisioning): modules declare config and provisions, their files rendered from templates; synced with main (issues of feature 57, 874)
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
# mesh-config
|
||||
|
||||
Holds the `mesh-config` seat: the owner of every config value (novox/hq ADR 0348 §9, design 57 K3). Go, one
|
||||
binary (`cmd/mesh-config`), served by a runtime of its own on its own bus account (`runs-as`), so that its
|
||||
account alone writes its buckets.
|
||||
|
||||
## What it keeps
|
||||
|
||||
- `mesh-config_values`: each value set, keyed by its level: `mesh.<key>` (mesh config), `node.<node>.<key>`
|
||||
(node config) and `module.<node>.<module>.<key>` (a module's own value on one node), each with who set it,
|
||||
through which account, why and when.
|
||||
- `mesh-config_resolved`: each assigned module's value for each key it declares on its node, keyed
|
||||
`<node>.<module>.<key>`, with its source: `default`, `mesh`, `node`, `module`, or `unset` for a required value
|
||||
nothing sets.
|
||||
- `mesh-config_predefined`: the list of predefined keys, published from the list the controller kept (its mirror
|
||||
`mesh-controller.predefined`), the newest trunk build winning. A newer list dropping a key that still holds a
|
||||
value at the mesh or a node is withheld, and every listing says why, until that value is cleared.
|
||||
- `mesh-config_proposals`: each value proposed for the operator's Approve, whole, with the value stored at its level
|
||||
when it was proposed (what the operator is shown the change against), who proposed it, why, and what came of it.
|
||||
- the log `history`: every set, clear and move of resolved values, with the proven caller and why.
|
||||
|
||||
## How a value resolves
|
||||
|
||||
From the controller's mirrors of the assignments and of each module's declarations (`mesh-controller.assignments`,
|
||||
`mesh-controller.declared-config`): the manifest's default; mesh config for the predefined key the value falls back
|
||||
to; node config for that key on the node; the module's own value on that node, the most specific winning. A value
|
||||
naming no predefined key resolves from its default and the module's own value only (ADR 0348 §2). It resolves at
|
||||
start, whenever a mirror moves, and every five minutes, writing only the values that moved.
|
||||
|
||||
## The verb
|
||||
|
||||
`mesh-config.config` with `action` `list` (the default), `set`, `clear` or `history`, narrowed by `module`, `node`
|
||||
and `key`. A set or a clear names one level (no module and no node for mesh config, a node for node config, both
|
||||
for a module's own value), needs `why`, refuses a key the module or the predefined list does not declare (naming
|
||||
the declared keys), validates the value, writes it and the resolved values it moves, keeps it in the history and
|
||||
the controller's hand-act log, raises `config-changed`, and answers with each module on each node whose value
|
||||
moved. It opens no walk. A trusted value is taken only from the controller, which carries it from its terminal and
|
||||
names who gave it in `by`, or on the operator's Approve of a proposal.
|
||||
|
||||
## Proposing a value (ADR 0348 §8, design 57 K4)
|
||||
|
||||
`action` `propose` (with `value`, or `clear`) judges the value as a set would, keeps it in `mesh-config_proposals`,
|
||||
and asks the operator under mesh-config's own name on the `operator-channel` seat (its own grant, from `uses`, and
|
||||
its own cap of three open asks; the same change twice is refused, and a newer proposal for the same level and key
|
||||
replaces the older). The ask says the level, the key and the value, exact where who answers is proven, and that,
|
||||
approved, it takes effect at once on every node it reaches; Approve and Decline are both at the level approve and
|
||||
each binds the proposal's level, key, value and the value it was shown against. On the answer mesh-config reads
|
||||
the router's own record of its ask and holds the warrant to the ask composed again from the proposal; it refuses,
|
||||
and sets nothing, when the proposal or the value stored at the level changed since. An Approve that holds sets the
|
||||
value as a set does, keeps who approved it beside the value (`approved`), and asks the controller to record the
|
||||
warrant in its hand-act log. A Decline, an expiry or the router's refusal sets nothing. `action` `proposals` lists
|
||||
them, newest first; with `proposal`, one read whole, with whether the router's ask is the one composed from it.
|
||||
@@ -0,0 +1,438 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The verb `config` (novox/hq ADR 0348 §1, §8, §9, §10, design 57 K3): set or clear a value at one level with why,
|
||||
// list the values, and read their history, narrowed by module and node. A set refuses a key the module or the list
|
||||
// of predefined keys does not declare, naming the declared keys; validates the value; writes it and the resolved
|
||||
// values it moves; records the change in the history and the hand-act log; raises `config-changed`; and answers
|
||||
// with each module on each node whose value moved. A config change opens no walk and waits at no first-node gate.
|
||||
//
|
||||
// **Trusted values at the terminal or on the operator's Approve.** A trusted value (ADR 0348 §8) is set only by a
|
||||
// call the controller carries from its terminal, or on the operator's warrant for a proposal (proposals.go, design
|
||||
// 57 K4); a set through the verb is refused and names the proposal as the way.
|
||||
|
||||
// ControllerCaller is the controller's bus user: the one caller that carries a value given at its terminal.
|
||||
const ControllerCaller = "controller"
|
||||
|
||||
// Unproven is the caller of a call the runtime named none for (the SDK's word).
|
||||
const Unproven = "unproven"
|
||||
|
||||
// Entry is one key's current value in a state.
|
||||
type Entry struct {
|
||||
Key string
|
||||
Value json.RawMessage
|
||||
Revision uint64
|
||||
}
|
||||
|
||||
// Store is the bus as this module reaches it: the state it keeps and reads, its history log, its events, and the
|
||||
// controller's hand-act log.
|
||||
type Store interface {
|
||||
Get(state, key string) (*Entry, error)
|
||||
Put(state, key string, value any) error
|
||||
Delete(state, key string) error
|
||||
Keys(state string) ([]string, error)
|
||||
Append(log, key string, value any) error
|
||||
Read(log string) ([]LogEntry, error)
|
||||
Emit(event string, body any) error
|
||||
HandAct(what, why, cause string) error
|
||||
}
|
||||
|
||||
// LogEntry is one entry of a log.
|
||||
type LogEntry struct {
|
||||
Key string
|
||||
Value json.RawMessage
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// ConfigChanged is the event every change raises: what was set or cleared at which level, by whom and why, and
|
||||
// each module on each node whose resolved value moved. Never a secret: config holds none.
|
||||
const ConfigChanged = "config-changed"
|
||||
|
||||
// Change is a set, a clear, or a move of resolved values with no set behind it (a declaration or an assignment
|
||||
// changed), as the history and the event say it.
|
||||
type Change struct {
|
||||
Action string `json:"action"`
|
||||
Level *Level `json:"level,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Value json.RawMessage `json:"value,omitempty"`
|
||||
Was json.RawMessage `json:"was,omitempty"`
|
||||
By string `json:"by,omitempty"`
|
||||
Via string `json:"via,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
// Approved is who approved it on a channel that proves who answered, for a value set on the operator's warrant.
|
||||
Approved string `json:"approved,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
Moved []Moved `json:"moved"`
|
||||
}
|
||||
|
||||
// Config is the module: its store, its clock, and one lock, so a set and a resolution never interleave.
|
||||
type Config struct {
|
||||
Store Store
|
||||
Now func() time.Time
|
||||
Logf func(format string, a ...any)
|
||||
|
||||
mu sync.Mutex
|
||||
// withheld is why the newest list of predefined keys was not published, empty when it was (publish).
|
||||
withheld string
|
||||
|
||||
// Asks is the operator channel as this module asks on it (proposals.go); nil, nothing can be proposed.
|
||||
Asks AskBus
|
||||
// RouterWithin and RouterEvery bound how long a proposal waits for the router's word on its ask, and how often
|
||||
// it looks; zero is RouterAnswersWithin and RouterAnswersEvery.
|
||||
RouterWithin, RouterEvery time.Duration
|
||||
endedMu sync.Mutex
|
||||
ended map[string]endedWord
|
||||
}
|
||||
|
||||
// Call is one call of the verb, its arguments read.
|
||||
type Call struct {
|
||||
Action string
|
||||
Key string
|
||||
Value any
|
||||
Module string
|
||||
Node string
|
||||
Why string
|
||||
// Caller is the bus user the runtime proved; By is who gave the value at the controller's terminal, said by the
|
||||
// controller alone.
|
||||
Caller string
|
||||
By string
|
||||
Limit int
|
||||
// Clear asks a proposal to clear the value instead of setting it; Proposal names one proposal to read.
|
||||
Clear bool
|
||||
Proposal string
|
||||
|
||||
// approval is the operator's warrant a set or a clear is made on: given by a decided proposal alone, never by
|
||||
// a call's arguments.
|
||||
approval *approval
|
||||
}
|
||||
|
||||
// SetAnswer is what a set or a clear answers.
|
||||
type SetAnswer struct {
|
||||
Said string `json:"said"`
|
||||
Level Level `json:"level"`
|
||||
Key string `json:"key"`
|
||||
Value json.RawMessage `json:"value,omitempty"`
|
||||
Was json.RawMessage `json:"was,omitempty"`
|
||||
Moved []Moved `json:"moved"`
|
||||
Result []NodeResult `json:"result"`
|
||||
// Unsaid is what could not be recorded or announced after the value was written: said, never dropped.
|
||||
Unsaid []string `json:"unsaid,omitempty"`
|
||||
}
|
||||
|
||||
// NodeResult is one affected node's result. Until the provisioner client fills files from mesh-config_resolved
|
||||
// (design 57 P5, P6), a module on a node takes its new value at its next composition; that is said, not hidden.
|
||||
type NodeResult struct {
|
||||
Node string `json:"node"`
|
||||
Modules []string `json:"modules"`
|
||||
Result string `json:"result"`
|
||||
}
|
||||
|
||||
// Do runs one call of the verb.
|
||||
func (c *Config) Do(call Call) (any, error) {
|
||||
switch call.Action {
|
||||
case "", "list":
|
||||
return c.List(call)
|
||||
case "set", "clear":
|
||||
return c.Change(call)
|
||||
case "history":
|
||||
return c.History(call)
|
||||
case "propose":
|
||||
return c.Propose(call)
|
||||
case "proposals":
|
||||
return c.Proposals(call)
|
||||
}
|
||||
return nil, fmt.Errorf("config does set, clear, list, history, propose or proposals; %q is none of them", call.Action)
|
||||
}
|
||||
|
||||
// level is the level a call names, refused as ADR 0348 §3 says: a module's own value is for one node.
|
||||
func (call Call) level() (Level, error) {
|
||||
for _, name := range []string{call.Node, call.Module} {
|
||||
if name != "" && !nodeOrModule.MatchString(name) {
|
||||
return Level{}, fmt.Errorf("%q is not a node's or a module's name", name)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case call.Module != "" && call.Node != "":
|
||||
return Level{Kind: LevelModule, Node: call.Node, Module: call.Module}, nil
|
||||
case call.Module != "":
|
||||
return Level{}, fmt.Errorf("a module's own value is set for one node, and no node is named: there is no "+
|
||||
"module value for the whole mesh (novox/hq ADR 0348 §3); name the node, or set mesh config for the "+
|
||||
"predefined key %s's value falls back to, or change its default in the catalogue", call.Module)
|
||||
case call.Node != "":
|
||||
return Level{Kind: LevelNode, Node: call.Node}, nil
|
||||
}
|
||||
return Level{Kind: LevelMesh}, nil
|
||||
}
|
||||
|
||||
// Change sets or clears one value.
|
||||
func (c *Config) Change(call Call) (*SetAnswer, error) {
|
||||
if call.Clear || call.Proposal != "" {
|
||||
return nil, fmt.Errorf("clear and proposal are a proposal's: %s takes neither", call.Action)
|
||||
}
|
||||
call.approval = nil // only a decided proposal gives one
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.change(call)
|
||||
}
|
||||
|
||||
// change sets or clears one value; the caller holds the lock.
|
||||
func (c *Config) change(call Call) (*SetAnswer, error) {
|
||||
level, err := call.level()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !keyName.MatchString(call.Key) {
|
||||
return nil, fmt.Errorf("name the key to %s: lower-case letters, digits, dots, dashes and underscores", call.Action)
|
||||
}
|
||||
if strings.TrimSpace(call.Why) == "" {
|
||||
return nil, fmt.Errorf("say why the value is %s: every change keeps its why (novox/hq ADR 0348 §9)",
|
||||
map[string]string{"set": "set", "clear": "cleared"}[call.Action])
|
||||
}
|
||||
if call.Caller == "" || call.Caller == Unproven {
|
||||
return nil, errors.New("the call names no proven caller, and every change keeps who made it " +
|
||||
"(novox/hq ADR 0348 §9): call config through the mesh, which names you")
|
||||
}
|
||||
by := call.Caller
|
||||
if call.By != "" {
|
||||
if call.Caller != ControllerCaller {
|
||||
return nil, fmt.Errorf("by names who gave a value at the controller's terminal, which only the controller "+
|
||||
"says; %s is not the controller", call.Caller)
|
||||
}
|
||||
by = call.By
|
||||
}
|
||||
s, err := c.sources()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, trusted, err := c.declared(s, level, call.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vk := level.ValueKey(call.Key)
|
||||
was, had := s.Values[vk]
|
||||
if call.approval != nil {
|
||||
// **On Approve, the value stored must still be the one the operator was shown the change against** (ADR
|
||||
// 0348 §8, ADR 0277 §13): a value set or cleared since, at the terminal or on another warrant, refuses it.
|
||||
if had != call.approval.HadValue || valueDigest(was.Value, had) != call.approval.WasDigest {
|
||||
return nil, fmt.Errorf("%s at %s is %s now, and the operator was shown the change against %s: it changed "+
|
||||
"since, so nothing is set; propose it again", call.Key, level, shownOrNone(was.Value, had),
|
||||
shownOrNone(call.approval.Was, call.approval.HadValue))
|
||||
}
|
||||
by = call.approval.By
|
||||
}
|
||||
answer := &SetAnswer{Level: level, Key: call.Key}
|
||||
if had {
|
||||
answer.Was = compact(was.Value)
|
||||
}
|
||||
now := c.now()
|
||||
switch call.Action {
|
||||
case "set":
|
||||
if call.Value == nil {
|
||||
return nil, fmt.Errorf("give the value to set %s to", call.Key)
|
||||
}
|
||||
raw, err := json.Marshal(call.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the value cannot be read: %v", err)
|
||||
}
|
||||
var value any
|
||||
_ = json.Unmarshal(raw, &value)
|
||||
if problems := r.check(value); len(problems) > 0 {
|
||||
return nil, fmt.Errorf("%s is refused for %s at %s: %s", show(value), call.Key, level,
|
||||
strings.Join(problems, "; "))
|
||||
}
|
||||
if trusted && call.Caller != ControllerCaller && call.approval == nil {
|
||||
return nil, fmt.Errorf("%s is trusted (it decides what runs or who can reach what), so it is set only at "+
|
||||
"the controller's terminal or on the operator's Approve (novox/hq ADR 0348 §8); %s is not the terminal: "+
|
||||
"propose it instead, with config's action propose", call.Key, call.Caller)
|
||||
}
|
||||
v := Value{Value: compact(raw), By: by, Via: call.Caller, Why: strings.TrimSpace(call.Why), At: now}
|
||||
if call.approval != nil {
|
||||
v.Approved = call.approval.Says
|
||||
}
|
||||
if err := c.Store.Put(ValuesState, vk, v); err != nil {
|
||||
return nil, fmt.Errorf("the value was not written: %v", err)
|
||||
}
|
||||
s.Values[vk] = v
|
||||
answer.Value = v.Value
|
||||
case "clear":
|
||||
if !had {
|
||||
return nil, fmt.Errorf("nothing is set for %s at %s, so there is nothing to clear", call.Key, level)
|
||||
}
|
||||
if trusted && call.Caller != ControllerCaller && call.approval == nil {
|
||||
return nil, fmt.Errorf("%s is trusted, so it is cleared only at the controller's terminal or on the "+
|
||||
"operator's Approve (novox/hq ADR 0348 §8); %s is not the terminal: propose the clear instead, with "+
|
||||
"config's action propose and clear", call.Key, call.Caller)
|
||||
}
|
||||
if err := c.Store.Delete(ValuesState, vk); err != nil {
|
||||
return nil, fmt.Errorf("the value was not cleared: %v", err)
|
||||
}
|
||||
delete(s.Values, vk)
|
||||
}
|
||||
moved, err := c.writeResolved(s)
|
||||
answer.Moved = moved
|
||||
if err != nil {
|
||||
// The value is written and the resolved values are not all: said, and put right on the next resolution.
|
||||
answer.Unsaid = append(answer.Unsaid, "the resolved values are not all written yet ("+err.Error()+"); "+
|
||||
"the next resolution writes them")
|
||||
}
|
||||
change := Change{Action: call.Action, Level: &level, Key: call.Key, Value: answer.Value, Was: answer.Was, By: by,
|
||||
Via: call.Caller, Why: strings.TrimSpace(call.Why), At: now, Moved: moved}
|
||||
cause := "the config verb, called by " + call.Caller
|
||||
if call.approval != nil {
|
||||
change.Approved = call.approval.Says
|
||||
cause = "the operator's Approve of proposal " + call.approval.Proposal + ", proposed by " + call.Caller
|
||||
}
|
||||
answer.Unsaid = append(answer.Unsaid, c.record(vk, change)...)
|
||||
if err := c.Store.HandAct(handActWhat(change), change.Why, cause); err != nil {
|
||||
answer.Unsaid = append(answer.Unsaid, "the hand-act log does not hold it: "+err.Error())
|
||||
}
|
||||
answer.Result = nodeResults(moved)
|
||||
answer.Said = said(change)
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// declared is the rule a value at a level is held to, and whether it is trusted; refused, naming the declared
|
||||
// keys, for a key the module or the list of predefined keys does not declare (ADR 0348 §10).
|
||||
func (c *Config) declared(s Sources, level Level, key string) (rule, bool, error) {
|
||||
if level.Kind == LevelModule {
|
||||
decl, ok := s.Declared[level.Module]
|
||||
if !ok {
|
||||
return rule{}, false, fmt.Errorf("%s is not a registered module, so it declares no config", level.Module)
|
||||
}
|
||||
d, ok := decl.Config[key]
|
||||
if !ok {
|
||||
return rule{}, false, fmt.Errorf("%s declares no value %q; it declares: %s", level.Module, key,
|
||||
joinKeys(decl.Config))
|
||||
}
|
||||
if !assigned(s, level.Node, level.Module) {
|
||||
return rule{}, false, fmt.Errorf("%s is not assigned to %s, and a module's own value is set for a node it "+
|
||||
"is assigned to (novox/hq ADR 0348 §3); it is assigned to: %s", level.Module, level.Node,
|
||||
strings.Join(nodesOf(s, level.Module), ", "))
|
||||
}
|
||||
trusted := d.Trusted
|
||||
if p, ok := s.Predefined[d.Predefined]; ok && p.Trusted {
|
||||
trusted = true
|
||||
}
|
||||
return d.rule(), trusted, nil
|
||||
}
|
||||
if len(s.Predefined) == 0 {
|
||||
return rule{}, false, fmt.Errorf("%s holds values only for the keys of the list of predefined keys, and no "+
|
||||
"list is published yet (novox/hq ADR 0348 §7)", level)
|
||||
}
|
||||
p, ok := s.Predefined[key]
|
||||
if !ok {
|
||||
return rule{}, false, fmt.Errorf("%s holds values only for the predefined keys, and %q is not one; the "+
|
||||
"predefined keys are: %s (novox/hq ADR 0348 §7)", level, key, joinKeys(s.Predefined))
|
||||
}
|
||||
return p.rule(), p.Trusted, nil
|
||||
}
|
||||
|
||||
func assigned(s Sources, node, module string) bool {
|
||||
for _, a := range s.Assignments {
|
||||
if a.Node == node && a.Module == module {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func nodesOf(s Sources, module string) []string {
|
||||
var out []string
|
||||
for _, a := range s.Assignments {
|
||||
if a.Module == module {
|
||||
out = append(out, a.Node)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
if len(out) == 0 {
|
||||
return []string{"no node"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// record keeps a change in the history and raises its event; what could not be done is answered, not dropped.
|
||||
func (c *Config) record(key string, change Change) []string {
|
||||
var unsaid []string
|
||||
if err := c.Store.Append(HistoryLog, key, change); err != nil {
|
||||
unsaid = append(unsaid, "the history does not hold it: "+err.Error())
|
||||
}
|
||||
if err := c.Store.Emit(ConfigChanged, change); err != nil {
|
||||
unsaid = append(unsaid, "config-changed was not raised: "+err.Error())
|
||||
}
|
||||
for _, u := range unsaid {
|
||||
c.logf("[mesh-config] %s %s: %s", change.Action, key, u)
|
||||
}
|
||||
return unsaid
|
||||
}
|
||||
|
||||
func handActWhat(ch Change) string {
|
||||
what := fmt.Sprintf("config %s %s at %s", ch.Action, ch.Key, ch.Level)
|
||||
if ch.Action == "set" {
|
||||
what += " to " + string(ch.Value)
|
||||
}
|
||||
return what + ", by " + ch.By
|
||||
}
|
||||
|
||||
func said(ch Change) string {
|
||||
var b strings.Builder
|
||||
if ch.Action == "set" {
|
||||
fmt.Fprintf(&b, "%s is %s at %s", ch.Key, ch.Value, ch.Level)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%s is cleared at %s", ch.Key, ch.Level)
|
||||
}
|
||||
switch len(ch.Moved) {
|
||||
case 0:
|
||||
b.WriteString("; no module's value moved")
|
||||
case 1:
|
||||
m := ch.Moved[0]
|
||||
fmt.Fprintf(&b, "; %s on %s takes %s (from %s)", m.Module, m.Node, orUnset(m.To), m.Source)
|
||||
default:
|
||||
fmt.Fprintf(&b, "; %d values moved on %d node(s)", len(ch.Moved), len(nodeResults(ch.Moved)))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func orUnset(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return "no value (unset)"
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func nodeResults(moved []Moved) []NodeResult {
|
||||
byNode := map[string]map[string]bool{}
|
||||
for _, m := range moved {
|
||||
if byNode[m.Node] == nil {
|
||||
byNode[m.Node] = map[string]bool{}
|
||||
}
|
||||
byNode[m.Node][m.Module] = true
|
||||
}
|
||||
out := []NodeResult{}
|
||||
for _, node := range sortedKeys(byNode) {
|
||||
out = append(out, NodeResult{Node: node, Modules: sortedKeys(byNode[node]),
|
||||
Result: "resolved and written to mesh-config_resolved; the module takes it when its files are filled again"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Config) now() time.Time {
|
||||
if c.Now == nil {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return c.Now().UTC()
|
||||
}
|
||||
|
||||
func (c *Config) logf(format string, a ...any) {
|
||||
if c.Logf != nil {
|
||||
c.Logf(format, a...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// memStore is the bus as mesh-config reaches it, in memory: its states and the mirrors it reads, its history log,
|
||||
// the events it raised and the hand-acts it recorded.
|
||||
type memStore struct {
|
||||
mu sync.Mutex
|
||||
states map[string]map[string][]byte
|
||||
logs map[string][]LogEntry
|
||||
events []Change
|
||||
handActs []string
|
||||
// failHandAct makes the hand-act log refuse.
|
||||
failHandAct bool
|
||||
}
|
||||
|
||||
func newStore() *memStore {
|
||||
return &memStore{states: map[string]map[string][]byte{}, logs: map[string][]LogEntry{}}
|
||||
}
|
||||
|
||||
func (m *memStore) Get(state, key string) (*Entry, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
v, ok := m.states[state][key]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return &Entry{Key: key, Value: append([]byte(nil), v...)}, nil
|
||||
}
|
||||
|
||||
func (m *memStore) Put(state, key string, value any) error {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.states[state] == nil {
|
||||
m.states[state] = map[string][]byte{}
|
||||
}
|
||||
m.states[state][key] = raw
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) Delete(state, key string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.states[state], key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) Keys(state string) ([]string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var keys []string
|
||||
for k := range m.states[state] {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (m *memStore) Append(log, key string, value any) error {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.logs[log] = append(m.logs[log], LogEntry{Key: key, Value: raw, At: time.Now()})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) Read(log string) ([]LogEntry, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return append([]LogEntry(nil), m.logs[log]...), nil
|
||||
}
|
||||
|
||||
func (m *memStore) Emit(event string, body any) error {
|
||||
if event != ConfigChanged {
|
||||
return fmt.Errorf("mesh-config emits no %s", event)
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.events = append(m.events, body.(Change))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) HandAct(what, why, cause string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.failHandAct {
|
||||
return errors.New("the controller did not answer")
|
||||
}
|
||||
m.handActs = append(m.handActs, what+" | "+why+" | "+cause)
|
||||
return nil
|
||||
}
|
||||
|
||||
// mirror writes what the controller mirrors, in the shapes of its mirrors.
|
||||
func (m *memStore) mirror(t *testing.T, read, key, raw string) {
|
||||
t.Helper()
|
||||
if !json.Valid([]byte(raw)) {
|
||||
t.Fatalf("not JSON: %s", raw)
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.states[read] == nil {
|
||||
m.states[read] = map[string][]byte{}
|
||||
}
|
||||
m.states[read][key] = []byte(raw)
|
||||
}
|
||||
|
||||
func (m *memStore) assign(t *testing.T, node, module string) {
|
||||
m.mirror(t, AssignmentsRead, node+"."+module,
|
||||
fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T00:00:00Z"}`, node, module))
|
||||
}
|
||||
|
||||
func (m *memStore) resolved(t *testing.T, node, module, key string) Resolved {
|
||||
t.Helper()
|
||||
e, _ := m.Get(ResolvedState, ResolvedKey(node, module, key))
|
||||
if e == nil {
|
||||
t.Fatalf("%s holds no resolved value for %s on %s", key, module, node)
|
||||
}
|
||||
var r Resolved
|
||||
if err := json.Unmarshal(e.Value, &r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// dunstDeclared is dunst's declarations as the declared-config mirror on the feature branch writes them: kind, why
|
||||
// and default always written, so a config-form key without a default comes as kind "", why "" and default null.
|
||||
const dunstDeclared = `{"module":"dunst","settings":{
|
||||
"font-size":{"kind":"","default":10,"why":"","type":"number","description":"text size, in points","minimum":6,"maximum":72,"predefined":"font-size"},
|
||||
"width":{"kind":"","default":300,"why":"","type":"number","description":"the width of a notification, in pixels","minimum":100},
|
||||
"position":{"kind":"","default":"top-right","why":"","type":"string","description":"where notifications show","enum":["top-right","top-left"]},
|
||||
"origin":{"kind":"","default":null,"why":"","type":"string","description":"the operator's own origin","required":true,"pattern":"[a-z]+"}}}`
|
||||
|
||||
// theList is the controller's predefined mirror, as it writes it.
|
||||
const theList = `{"keys":{"agent-managed-settings":{"type":"object","description":"managed settings","trusted":true},
|
||||
"font-size":{"type":"number","description":"text size, in points","minimum":6,"maximum":72}},
|
||||
"repository":"mesh-catalog","commit":"aaaaaaaaaaaa","generation":5,"build":"b5"}`
|
||||
|
||||
func aMesh(t *testing.T) (*Config, *memStore) {
|
||||
t.Helper()
|
||||
m := newStore()
|
||||
m.mirror(t, DeclaredConfigRead, "dunst", dunstDeclared)
|
||||
m.mirror(t, DeclaredConfigRead, "claude-code", `{"module":"claude-code","settings":{
|
||||
"managed":{"kind":"","default":{},"why":"","type":"object","description":"managed settings","trusted":true,"predefined":"agent-managed-settings"}}}`)
|
||||
m.mirror(t, DeclaredConfigRead, "old", `{"module":"old","settings":{"workers":{"kind":"preference","default":4,"why":"two per core"}}}`)
|
||||
m.mirror(t, PredefinedRead, PredefinedMirrorKey, theList)
|
||||
for _, node := range []string{"g14", "novox"} {
|
||||
m.assign(t, node, "dunst")
|
||||
}
|
||||
m.assign(t, "g14", "claude-code")
|
||||
m.assign(t, "g14", "old")
|
||||
clock := time.Date(2026, 10, 14, 19, 0, 0, 0, time.UTC)
|
||||
c := &Config{Store: m, Now: func() time.Time { clock = clock.Add(time.Second); return clock }, Logf: t.Logf}
|
||||
if _, err := c.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.events = nil // the first resolution's
|
||||
return c, m
|
||||
}
|
||||
|
||||
func set(t *testing.T, c *Config, call Call) *SetAnswer {
|
||||
t.Helper()
|
||||
if call.Action == "" {
|
||||
call.Action = "set"
|
||||
}
|
||||
if call.Caller == "" {
|
||||
call.Caller = "g14.node-tools"
|
||||
}
|
||||
if call.Why == "" {
|
||||
call.Why = "the operator asked for it"
|
||||
}
|
||||
a, err := c.Change(call)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", call.Action, call.Key, err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func expect(t *testing.T, m *memStore, node string, value, source string) {
|
||||
t.Helper()
|
||||
r := m.resolved(t, node, "dunst", "font-size")
|
||||
if string(r.Value) != value || r.Source != source {
|
||||
t.Errorf("dunst's font-size on %s is %s from %s, want %s from %s", node, r.Value, r.Source, value, source)
|
||||
}
|
||||
}
|
||||
|
||||
// **The four sources, each in turn** (ADR 0348 §2, How it is checked): the manifest's default; mesh config for the
|
||||
// predefined key it falls back to; node config for that key on the node; the module's own value on that node. Each
|
||||
// cleared falls back to the one below.
|
||||
func TestEachOfTheFourSourcesResolvesInTurnAndEachClearedFallsBack(t *testing.T) {
|
||||
c, m := aMesh(t)
|
||||
expect(t, m, "g14", "10", SourceDefault)
|
||||
expect(t, m, "novox", "10", SourceDefault)
|
||||
|
||||
a := set(t, c, Call{Key: "font-size", Value: 12.0})
|
||||
expect(t, m, "g14", "12", SourceMesh)
|
||||
expect(t, m, "novox", "12", SourceMesh)
|
||||
if len(a.Moved) != 2 || a.Level.Kind != LevelMesh {
|
||||
t.Errorf("a mesh value moved %+v", a.Moved)
|
||||
}
|
||||
|
||||
a = set(t, c, Call{Key: "font-size", Value: 14.0, Node: "g14"})
|
||||
expect(t, m, "g14", "14", SourceNode)
|
||||
expect(t, m, "novox", "12", SourceMesh)
|
||||
if len(a.Moved) != 1 || a.Moved[0].Node != "g14" || string(a.Moved[0].From) != "12" || string(a.Moved[0].To) != "14" {
|
||||
t.Errorf("g14's node value moved %+v", a.Moved)
|
||||
}
|
||||
|
||||
a = set(t, c, Call{Key: "font-size", Value: 16.0, Node: "g14", Module: "dunst"})
|
||||
expect(t, m, "g14", "16", SourceModule)
|
||||
expect(t, m, "novox", "12", SourceMesh)
|
||||
if len(a.Moved) != 1 || a.Moved[0].Source != SourceModule {
|
||||
t.Errorf("dunst's own value moved %+v", a.Moved)
|
||||
}
|
||||
|
||||
// A value set below the module's own moves nothing it overrides.
|
||||
if a = set(t, c, Call{Key: "font-size", Value: 18.0, Node: "g14"}); len(a.Moved) != 0 {
|
||||
t.Errorf("a node value under a module's own moved %+v", a.Moved)
|
||||
}
|
||||
|
||||
set(t, c, Call{Action: "clear", Key: "font-size", Node: "g14", Module: "dunst"})
|
||||
expect(t, m, "g14", "18", SourceNode)
|
||||
set(t, c, Call{Action: "clear", Key: "font-size", Node: "g14"})
|
||||
expect(t, m, "g14", "12", SourceMesh)
|
||||
set(t, c, Call{Action: "clear", Key: "font-size"})
|
||||
expect(t, m, "g14", "10", SourceDefault)
|
||||
expect(t, m, "novox", "10", SourceDefault)
|
||||
}
|
||||
|
||||
// **A value naming no predefined key ignores mesh and node config** (ADR 0348 §2): it resolves from its default and
|
||||
// the module's own value only.
|
||||
func TestAValueNamingNoPredefinedKeyResolvesFromItsDefaultAndItsOwnValueOnly(t *testing.T) {
|
||||
c, m := aMesh(t)
|
||||
set(t, c, Call{Key: "font-size", Value: 12.0})
|
||||
set(t, c, Call{Key: "font-size", Value: 14.0, Node: "g14"})
|
||||
if r := m.resolved(t, "g14", "dunst", "width"); string(r.Value) != "300" || r.Source != SourceDefault {
|
||||
t.Errorf("width is %s from %s, want its default", r.Value, r.Source)
|
||||
}
|
||||
set(t, c, Call{Key: "width", Value: 400.0, Node: "g14", Module: "dunst"})
|
||||
if r := m.resolved(t, "g14", "dunst", "width"); string(r.Value) != "400" || r.Source != SourceModule {
|
||||
t.Errorf("width is %s from %s, want dunst's own value", r.Value, r.Source)
|
||||
}
|
||||
// A module not yet moved, in the settings form: its default and its own value.
|
||||
if r := m.resolved(t, "g14", "old", "workers"); string(r.Value) != "4" || r.Source != SourceDefault {
|
||||
t.Errorf("old's workers is %s from %s", r.Value, r.Source)
|
||||
}
|
||||
set(t, c, Call{Key: "workers", Value: 8.0, Node: "g14", Module: "old"})
|
||||
if r := m.resolved(t, "g14", "old", "workers"); string(r.Value) != "8" || r.Source != SourceModule {
|
||||
t.Errorf("old's workers is %s from %s", r.Value, r.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// **A required value nothing sets is answered unset** (ADR 0348 §4), and once set, it is the value.
|
||||
func TestARequiredValueNothingSetsIsAnsweredUnset(t *testing.T) {
|
||||
c, m := aMesh(t)
|
||||
r := m.resolved(t, "g14", "dunst", "origin")
|
||||
if r.Source != SourceUnset || !r.Required || len(r.Value) != 0 {
|
||||
t.Errorf("an unset required value resolves as %+v", r)
|
||||
}
|
||||
set(t, c, Call{Key: "origin", Value: "home", Node: "g14", Module: "dunst"})
|
||||
if r = m.resolved(t, "g14", "dunst", "origin"); r.Source != SourceModule || string(r.Value) != `"home"` {
|
||||
t.Errorf("a set required value resolves as %+v", r)
|
||||
}
|
||||
if r = m.resolved(t, "novox", "dunst", "origin"); r.Source != SourceUnset {
|
||||
t.Errorf("another node's required value resolves as %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// **The verb refuses** (ADR 0348 §3, §8, §10, How it is checked): a key not declared, naming the declared keys; a
|
||||
// module value without a node; a value failing validation; a trusted value without the terminal; a change without
|
||||
// why or a proven caller.
|
||||
func TestTheVerbRefusesWhatADeclarationDoesNotAllow(t *testing.T) {
|
||||
c, m := aMesh(t)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
call Call
|
||||
want string
|
||||
}{
|
||||
{"a key the module does not declare", Call{Key: "colour", Value: "red", Node: "g14", Module: "dunst"},
|
||||
"it declares: font-size, origin, position, width"},
|
||||
{"a key not on the predefined list, at the mesh", Call{Key: "width", Value: 400.0},
|
||||
"the predefined keys are: agent-managed-settings, font-size"},
|
||||
{"a key not on the predefined list, at a node", Call{Key: "width", Value: 400.0, Node: "g14"}, "is not one"},
|
||||
{"a module value without a node", Call{Key: "width", Value: 400.0, Module: "dunst"},
|
||||
"no module value for the whole mesh"},
|
||||
{"a module on a node it is not assigned to", Call{Key: "width", Value: 400.0, Node: "ace", Module: "dunst"},
|
||||
"it is assigned to: g14, novox"},
|
||||
{"a module not registered", Call{Key: "width", Value: 400.0, Node: "g14", Module: "nobody"}, "not a registered module"},
|
||||
{"a value of another type", Call{Key: "font-size", Value: "12"}, `"12" is not a number`},
|
||||
{"a value below its minimum", Call{Key: "font-size", Value: 2.0, Node: "g14"}, "below the minimum 6"},
|
||||
{"a value above its maximum", Call{Key: "font-size", Value: 99.0, Node: "g14", Module: "dunst"}, "above the maximum 72"},
|
||||
{"a value not in its enum", Call{Key: "position", Value: "middle", Node: "g14", Module: "dunst"}, "is not one of"},
|
||||
{"a value not matching its pattern", Call{Key: "origin", Value: "Home 1", Node: "g14", Module: "dunst"},
|
||||
"does not match the pattern"},
|
||||
{"a trusted value at the mesh, not from the terminal", Call{Key: "agent-managed-settings",
|
||||
Value: map[string]any{"a": 1.0}}, "set only at the controller's terminal"},
|
||||
{"a trusted module value, not from the terminal", Call{Key: "managed", Value: map[string]any{},
|
||||
Node: "g14", Module: "claude-code"}, "set only at the controller's terminal"},
|
||||
{"a change without why", Call{Key: "font-size", Value: 12.0, Why: " "}, "say why"},
|
||||
{"an unproven caller", Call{Key: "font-size", Value: 12.0, Caller: Unproven}, "no proven caller"},
|
||||
{"by from anyone but the controller", Call{Key: "font-size", Value: 12.0, By: "jochen"}, "only the controller says"},
|
||||
{"a clear of nothing set", Call{Action: "clear", Key: "font-size", Node: "g14"}, "nothing is set"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
call := tc.call
|
||||
if call.Action == "" {
|
||||
call.Action = "set"
|
||||
}
|
||||
if call.Caller == "" {
|
||||
call.Caller = "g14.node-tools"
|
||||
}
|
||||
if call.Why == "" {
|
||||
call.Why = "a test"
|
||||
}
|
||||
_, err := c.Change(call)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("refused with %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if keys, _ := m.Keys(ValuesState); len(keys) != 0 {
|
||||
t.Errorf("a refused call wrote %v", keys)
|
||||
}
|
||||
if len(m.events) != 0 || len(m.handActs) != 0 {
|
||||
t.Errorf("a refused call raised %d events and recorded %d hand-acts", len(m.events), len(m.handActs))
|
||||
}
|
||||
}
|
||||
|
||||
// **A trusted value from the controller's terminal is set**, with who gave it there (ADR 0348 §8).
|
||||
func TestATrustedValueFromTheTerminalIsSetWithWhoGaveIt(t *testing.T) {
|
||||
c, m := aMesh(t)
|
||||
a := set(t, c, Call{Key: "agent-managed-settings", Value: map[string]any{"b": 2.0, "a": 1.0},
|
||||
Caller: ControllerCaller, By: "jochen"})
|
||||
if string(a.Value) != `{"a":1,"b":2}` {
|
||||
t.Errorf("the value is written as %s", a.Value)
|
||||
}
|
||||
r := m.resolved(t, "g14", "claude-code", "managed")
|
||||
if r.Source != SourceMesh || !r.Trusted || string(r.Value) != `{"a":1,"b":2}` {
|
||||
t.Errorf("claude-code's managed settings resolve as %+v", r)
|
||||
}
|
||||
e, _ := m.Get(ValuesState, "mesh.agent-managed-settings")
|
||||
var v Value
|
||||
_ = json.Unmarshal(e.Value, &v)
|
||||
if v.By != "jochen" || v.Via != ControllerCaller {
|
||||
t.Errorf("the value says it was set by %s via %s", v.By, v.Via)
|
||||
}
|
||||
}
|
||||
|
||||
// **Every set and clear is in the history and the hand-act log with the proven caller and why, and raises
|
||||
// config-changed naming what moved** (ADR 0348 §9, §10).
|
||||
func TestEveryChangeIsKeptWithWhoAndWhyAndRaisesConfigChanged(t *testing.T) {
|
||||
c, m := aMesh(t)
|
||||
m.events = nil
|
||||
set(t, c, Call{Key: "font-size", Value: 12.0, Caller: "novox.node-tools", Why: "bigger on every desk"})
|
||||
set(t, c, Call{Action: "clear", Key: "font-size", Caller: "g14.node-tools", Why: "back to the default"})
|
||||
|
||||
history, err := c.History(Call{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var changes []HistoryEntry
|
||||
for _, h := range history {
|
||||
if h.Action == "set" || h.Action == "clear" {
|
||||
changes = append(changes, h)
|
||||
}
|
||||
}
|
||||
if len(changes) != 2 || changes[0].By != "novox.node-tools" || changes[0].Why != "bigger on every desk" ||
|
||||
changes[1].Action != "clear" || changes[1].By != "g14.node-tools" || string(changes[1].Was) != "12" {
|
||||
t.Fatalf("the history holds %+v", changes)
|
||||
}
|
||||
if len(m.handActs) != 2 || !strings.Contains(m.handActs[0], "config set font-size at mesh config to 12, by novox.node-tools | bigger on every desk") {
|
||||
t.Errorf("the hand-act log holds %q", m.handActs)
|
||||
}
|
||||
if len(m.events) != 2 || len(m.events[0].Moved) != 2 || m.events[0].Key != "font-size" || m.events[1].Action != "clear" {
|
||||
t.Errorf("config-changed was raised as %+v", m.events)
|
||||
}
|
||||
// Narrowed: g14's dunst sees both, through what they moved; a node that holds no dunst sees neither.
|
||||
if h, _ := c.History(Call{Module: "dunst", Node: "g14"}); len(h) < 2 {
|
||||
t.Errorf("dunst on g14's history holds %d changes", len(h))
|
||||
}
|
||||
if h, _ := c.History(Call{Node: "ace"}); len(h) != 0 {
|
||||
t.Errorf("ace's history holds %+v", h)
|
||||
}
|
||||
}
|
||||
|
||||
// **A hand-act the log refuses is said in the answer, never dropped**, and the value is set.
|
||||
func TestAChangeTheHandActLogRefusesIsSaid(t *testing.T) {
|
||||
c, m := aMesh(t)
|
||||
m.failHandAct = true
|
||||
a := set(t, c, Call{Key: "font-size", Value: 12.0})
|
||||
if len(a.Unsaid) != 1 || !strings.Contains(a.Unsaid[0], "hand-act log does not hold it") {
|
||||
t.Errorf("the answer says %q", a.Unsaid)
|
||||
}
|
||||
expect(t, m, "g14", "12", SourceMesh)
|
||||
}
|
||||
|
||||
// **The newest list of predefined keys is published, and an older one heard later is not** (ADR 0348 §7).
|
||||
func TestTheNewestListOfPredefinedKeysIsPublished(t *testing.T) {
|
||||
c, m := aMesh(t)
|
||||
published := func() map[string]Published {
|
||||
out := map[string]Published{}
|
||||
keys, _ := m.Keys(PredefinedState)
|
||||
for _, k := range keys {
|
||||
e, _ := m.Get(PredefinedState, k)
|
||||
var p Published
|
||||
_ = json.Unmarshal(e.Value, &p)
|
||||
out[k] = p
|
||||
}
|
||||
return out
|
||||
}
|
||||
if p := published(); len(p) != 2 || p["font-size"].Generation != 5 || !p["agent-managed-settings"].Trusted {
|
||||
t.Fatalf("published %+v", p)
|
||||
}
|
||||
m.mirror(t, PredefinedRead, PredefinedMirrorKey, `{"keys":{"font-size":{"type":"number","description":"x"},
|
||||
"theme":{"type":"string","description":"the theme"}},"commit":"bbbb","generation":7}`)
|
||||
if _, err := c.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p := published(); len(p) != 2 || p["theme"].Generation != 7 || p["agent-managed-settings"].Type != "" {
|
||||
t.Fatalf("a newer list published %+v", p)
|
||||
}
|
||||
m.mirror(t, PredefinedRead, PredefinedMirrorKey, theList) // generation 5, heard later
|
||||
if _, err := c.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p := published(); p["theme"].Generation != 7 {
|
||||
t.Fatalf("an older list replaced the newer one: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// **A list dropping a key that still holds a value is withheld** (ADR 0348 §7): the list published stands, and the
|
||||
// listing says why until the value is cleared.
|
||||
func TestAListDroppingAKeyThatHoldsAValueIsWithheldUntilItIsCleared(t *testing.T) {
|
||||
c, m := aMesh(t)
|
||||
set(t, c, Call{Key: "font-size", Value: 12.0, Node: "novox"})
|
||||
m.mirror(t, PredefinedRead, PredefinedMirrorKey, `{"keys":{"agent-managed-settings":{"type":"object",
|
||||
"description":"managed settings","trusted":true}},"commit":"cccccccccc","generation":9}`)
|
||||
if _, err := c.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listed, err := c.List(Call{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, kept := listed.Predefined["font-size"]; !kept ||
|
||||
!strings.Contains(listed.Withheld, "drops font-size (still set as node config on novox)") {
|
||||
t.Fatalf("the list was published over a value it drops: withheld %q, published %v", listed.Withheld,
|
||||
joinKeys(listed.Predefined))
|
||||
}
|
||||
set(t, c, Call{Action: "clear", Key: "font-size", Node: "novox"})
|
||||
if _, err := c.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listed, _ = c.List(Call{})
|
||||
if _, kept := listed.Predefined["font-size"]; kept || listed.Withheld != "" {
|
||||
t.Errorf("once cleared, the list is still withheld: %q, %v", listed.Withheld, joinKeys(listed.Predefined))
|
||||
}
|
||||
}
|
||||
|
||||
// **An assignment, a declaration or its removal moves the resolved values**, written only where they moved, and
|
||||
// said as config-changed; a pass over unchanged sources writes nothing.
|
||||
func TestAnAssignmentMovesTheResolvedValuesAndAQuietPassWritesNothing(t *testing.T) {
|
||||
c, m := aMesh(t)
|
||||
r, err := c.Reconcile()
|
||||
if err != nil || len(r.Moved) != 0 {
|
||||
t.Fatalf("a pass over unchanged sources moved %+v (%v)", r.Moved, err)
|
||||
}
|
||||
m.events = nil
|
||||
m.assign(t, "ace", "dunst")
|
||||
if r, _ = c.Reconcile(); len(r.Moved) != 4 {
|
||||
t.Errorf("an assignment moved %+v", r.Moved)
|
||||
}
|
||||
expect(t, m, "ace", "10", SourceDefault)
|
||||
if len(m.events) != 1 || m.events[0].Action != "resolve" {
|
||||
t.Errorf("an assignment raised %+v", m.events)
|
||||
}
|
||||
_ = m.Delete(AssignmentsRead, "ace.dunst")
|
||||
if r, _ = c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone {
|
||||
t.Errorf("an unassignment moved %+v", r.Moved)
|
||||
}
|
||||
if e, _ := m.Get(ResolvedState, ResolvedKey("ace", "dunst", "font-size")); e != nil {
|
||||
t.Error("an unassigned module keeps its resolved values")
|
||||
}
|
||||
}
|
||||
|
||||
// **The declared-config mirror's two shapes read the same** (the release lead's note on issue 652): kind, why and a
|
||||
// null default written for a config-form key are not given, and a mirror that leaves them out reads alike.
|
||||
func TestTheDeclaredConfigMirrorsTwoShapesReadTheSame(t *testing.T) {
|
||||
with, err := ParseModuleDeclarations([]byte(`{"module":"x","settings":{"k":{"kind":"","default":null,"why":"","type":"string","description":"d","required":true}}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
without, err := ParseModuleDeclarations([]byte(`{"module":"x","settings":{"k":{"type":"string","description":"d","required":true}}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, _ := json.Marshal(with)
|
||||
b, _ := json.Marshal(without)
|
||||
if string(a) != string(b) || with.Config["k"].HasDefault() {
|
||||
t.Errorf("the two shapes read as %s and %s", a, b)
|
||||
}
|
||||
if r := resolveOne(Sources{}, "n", "x", "k", with.Config["k"]); r.Source != SourceUnset {
|
||||
t.Errorf("a null default resolved as %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// **A listing narrows by module and node**, and says the mesh and node values its modules fall back to.
|
||||
func TestAListingNarrowsByModuleAndNode(t *testing.T) {
|
||||
c, _ := aMesh(t)
|
||||
set(t, c, Call{Key: "font-size", Value: 12.0})
|
||||
set(t, c, Call{Key: "font-size", Value: 14.0, Node: "novox"})
|
||||
l, err := c.List(Call{Module: "dunst", Node: "g14"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(l.Resolved) != 4 || len(l.Set) != 1 || l.Set[0].Level.Kind != LevelMesh || l.Predefined != nil {
|
||||
t.Errorf("dunst on g14 lists %d resolved and %+v", len(l.Resolved), l.Set)
|
||||
}
|
||||
l, _ = c.List(Call{Node: "novox"})
|
||||
if len(l.Resolved) != 4 || len(l.Set) != 2 {
|
||||
t.Errorf("novox lists %d resolved and %+v", len(l.Resolved), l.Set)
|
||||
}
|
||||
}
|
||||
|
||||
// **The verb's arguments are read strictly**: one it does not know is refused, naming what it takes.
|
||||
func TestTheVerbsArgumentsAreReadStrictly(t *testing.T) {
|
||||
if _, err := callOf(map[string]any{"action": "set", "level": "mesh"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "config takes no level") {
|
||||
t.Errorf("an unknown argument: %v", err)
|
||||
}
|
||||
if _, err := callOf(map[string]any{"key": 3.0}); err == nil {
|
||||
t.Error("a key that is not a string was read")
|
||||
}
|
||||
call, err := callOf(map[string]any{"action": "set", "key": "font-size", "value": 12.0, "why": " w "})
|
||||
if err != nil || call.Value != 12.0 || call.Why != "w" {
|
||||
t.Errorf("read %+v (%v)", call, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What mesh-config resolves from (novox/hq ADR 0348 §2, §4, §7): each module's declarations, the list of predefined
|
||||
// keys, and the assignments, as the controller mirrors them on the bus (design 57 F3, K3).
|
||||
|
||||
// The types a config value may have (ADR 0348 §4).
|
||||
const (
|
||||
TypeNumber = "number"
|
||||
TypeString = "string"
|
||||
TypeBoolean = "boolean"
|
||||
TypeObject = "object"
|
||||
TypeList = "list"
|
||||
)
|
||||
|
||||
// Declaration is one value a module declares, as the declared-config mirror holds it: the config form's fields,
|
||||
// or the settings form's kind, default and why for a module not yet moved (ADR 0348 §1). The mirror writes kind,
|
||||
// default and why for every key, so a config-form key comes as kind "", why "" and default null: an empty kind or
|
||||
// why and a null default are read as not given, never as an empty value or a null default.
|
||||
type Declaration struct {
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Default json.RawMessage `json:"default,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Enum []any `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"`
|
||||
}
|
||||
|
||||
// HasDefault is whether the declaration gives a default: present and not null.
|
||||
func (d Declaration) HasDefault() bool {
|
||||
return len(bytes.TrimSpace(d.Default)) > 0 && string(bytes.TrimSpace(d.Default)) != "null"
|
||||
}
|
||||
|
||||
// TypeOf is the value's type: the config form's, or for the settings form the type of its default.
|
||||
func (d Declaration) TypeOf() string {
|
||||
if d.Type != "" {
|
||||
return d.Type
|
||||
}
|
||||
if !d.HasDefault() {
|
||||
return ""
|
||||
}
|
||||
var v any
|
||||
if json.Unmarshal(d.Default, &v) != nil {
|
||||
return ""
|
||||
}
|
||||
return typeOfValue(v)
|
||||
}
|
||||
|
||||
// ModuleDeclarations is one module's value of the declared-config mirror.
|
||||
type ModuleDeclarations struct {
|
||||
Module string `json:"module"`
|
||||
Config map[string]Declaration `json:"settings"`
|
||||
}
|
||||
|
||||
// ParseModuleDeclarations reads one key of the declared-config mirror.
|
||||
func ParseModuleDeclarations(raw []byte) (ModuleDeclarations, error) {
|
||||
var m ModuleDeclarations
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return m, fmt.Errorf("not a module's declarations: %w", err)
|
||||
}
|
||||
if m.Module == "" {
|
||||
return m, fmt.Errorf("a module's declarations that name no module")
|
||||
}
|
||||
if m.Config == nil {
|
||||
m.Config = map[string]Declaration{}
|
||||
}
|
||||
for key, d := range m.Config {
|
||||
if !d.HasDefault() {
|
||||
d.Default = nil
|
||||
}
|
||||
m.Config[key] = d
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// PredefinedKey is one key of the list of predefined keys (ADR 0348 §7): its type, description, validation and
|
||||
// whether it is trusted. It has no default.
|
||||
type PredefinedKey struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Enum []any `json:"enum,omitempty"`
|
||||
Minimum *float64 `json:"minimum,omitempty"`
|
||||
Maximum *float64 `json:"maximum,omitempty"`
|
||||
Pattern string `json:"pattern,omitempty"`
|
||||
Trusted bool `json:"trusted,omitempty"`
|
||||
}
|
||||
|
||||
// PredefinedList is the list the controller kept, as its predefined mirror holds it under PredefinedMirrorKey.
|
||||
type PredefinedList struct {
|
||||
Keys map[string]PredefinedKey `json:"keys"`
|
||||
Repository string `json:"repository"`
|
||||
Commit string `json:"commit"`
|
||||
Generation int `json:"generation"`
|
||||
Build string `json:"build"`
|
||||
}
|
||||
|
||||
// PredefinedMirrorKey is the controller's predefined mirror's one key.
|
||||
const PredefinedMirrorKey = "list"
|
||||
|
||||
// Published is one key of mesh-config_predefined: the key's declaration, and the build of the list it came from.
|
||||
type Published struct {
|
||||
PredefinedKey
|
||||
Generation int `json:"generation"`
|
||||
Commit string `json:"commit"`
|
||||
}
|
||||
|
||||
// Assignment is one key of the assignments mirror: a module on a node.
|
||||
type Assignment struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
}
|
||||
|
||||
// rule is the validation a value is held to, from a module's declaration or a predefined key.
|
||||
type rule struct {
|
||||
Type string
|
||||
Enum []any
|
||||
Minimum *float64
|
||||
Maximum *float64
|
||||
Pattern string
|
||||
}
|
||||
|
||||
func (d Declaration) rule() rule {
|
||||
return rule{Type: d.TypeOf(), Enum: d.Enum, Minimum: d.Minimum, Maximum: d.Maximum, Pattern: d.Pattern}
|
||||
}
|
||||
|
||||
func (k PredefinedKey) rule() rule {
|
||||
return rule{Type: k.Type, Enum: k.Enum, Minimum: k.Minimum, Maximum: k.Maximum, Pattern: k.Pattern}
|
||||
}
|
||||
|
||||
// check is every way value breaks the rule, said in a sentence each; none for a value that holds.
|
||||
func (r rule) check(value any) []string {
|
||||
var problems []string
|
||||
if r.Type != "" && typeOfValue(value) != r.Type {
|
||||
return []string{fmt.Sprintf("%s is not a %s", show(value), r.Type)}
|
||||
}
|
||||
if len(r.Enum) > 0 && !slices.ContainsFunc(r.Enum, func(e any) bool { return sameValue(e, value) }) {
|
||||
problems = append(problems, fmt.Sprintf("%s is not one of %s", show(value), show(r.Enum)))
|
||||
}
|
||||
if n, ok := value.(float64); ok {
|
||||
if r.Minimum != nil && n < *r.Minimum {
|
||||
problems = append(problems, fmt.Sprintf("%s is below the minimum %s", show(n), show(*r.Minimum)))
|
||||
}
|
||||
if r.Maximum != nil && n > *r.Maximum {
|
||||
problems = append(problems, fmt.Sprintf("%s is above the maximum %s", show(n), show(*r.Maximum)))
|
||||
}
|
||||
if math.IsNaN(n) || math.IsInf(n, 0) {
|
||||
problems = append(problems, "a number is finite")
|
||||
}
|
||||
}
|
||||
if s, ok := value.(string); ok && r.Pattern != "" {
|
||||
pattern, err := regexp.Compile(`^(?:` + r.Pattern + `)$`)
|
||||
switch {
|
||||
case err != nil:
|
||||
problems = append(problems, fmt.Sprintf("its pattern %q cannot be read: %v", r.Pattern, err))
|
||||
case !pattern.MatchString(s):
|
||||
problems = append(problems, fmt.Sprintf("%q does not match the pattern %q", s, r.Pattern))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// typeOfValue is the config type of a value read from JSON.
|
||||
func typeOfValue(v any) string {
|
||||
switch v.(type) {
|
||||
case float64:
|
||||
return TypeNumber
|
||||
case string:
|
||||
return TypeString
|
||||
case bool:
|
||||
return TypeBoolean
|
||||
case map[string]any:
|
||||
return TypeObject
|
||||
case []any:
|
||||
return TypeList
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sameValue(a, b any) bool {
|
||||
ra, _ := json.Marshal(a)
|
||||
rb, _ := json.Marshal(b)
|
||||
return bytes.Equal(ra, rb)
|
||||
}
|
||||
|
||||
func show(v any) string {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// keyName is what a config key may be (the catalogue's rule): lower-case letters, digits, dots, dashes and
|
||||
// underscores, starting with a letter or digit.
|
||||
var keyName = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
|
||||
|
||||
// nodeOrModule is what a node or a module name may be: one token of a bucket's key.
|
||||
var nodeOrModule = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func joinKeys[V any](m map[string]V) string {
|
||||
keys := sortedKeys(m)
|
||||
if len(keys) == 0 {
|
||||
return "none"
|
||||
}
|
||||
return strings.Join(keys, ", ")
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Listing the values and reading their history, narrowed by module and node (ADR 0348 §1).
|
||||
|
||||
// Listed is what a listing answers.
|
||||
type Listed struct {
|
||||
// Resolved is each module's value on each node the call narrows to, with where it came from.
|
||||
Resolved []Resolved `json:"resolved"`
|
||||
// Set is every value set at a level the call reaches, with who set it and why.
|
||||
Set []SetValue `json:"set"`
|
||||
// Predefined is the published list of predefined keys, for a call that names no module.
|
||||
Predefined map[string]Published `json:"predefined,omitempty"`
|
||||
// Withheld is why the newest list of predefined keys is not published, when it is not.
|
||||
Withheld string `json:"withheld,omitempty"`
|
||||
}
|
||||
|
||||
// SetValue is one value set, at its level.
|
||||
type SetValue struct {
|
||||
Level Level `json:"level"`
|
||||
Key string `json:"key"`
|
||||
Value
|
||||
}
|
||||
|
||||
// List answers the resolved values and the values set, narrowed by module, node and key.
|
||||
func (c *Config) List(call Call) (*Listed, error) {
|
||||
for _, name := range []string{call.Node, call.Module} {
|
||||
if name != "" && !nodeOrModule.MatchString(name) {
|
||||
return nil, fmt.Errorf("%q is not a node's or a module's name", name)
|
||||
}
|
||||
}
|
||||
c.mu.Lock()
|
||||
withheld := c.withheld
|
||||
c.mu.Unlock()
|
||||
s, err := c.sources()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if call.Module != "" {
|
||||
if _, ok := s.Declared[call.Module]; !ok {
|
||||
return nil, fmt.Errorf("%s is not a registered module, so it declares no config", call.Module)
|
||||
}
|
||||
}
|
||||
out := &Listed{Resolved: []Resolved{}, Set: []SetValue{}, Withheld: withheld}
|
||||
resolved := Resolve(s)
|
||||
for _, k := range sortedKeys(resolved) {
|
||||
r := resolved[k]
|
||||
if (call.Module == "" || r.Module == call.Module) && (call.Node == "" || r.Node == call.Node) &&
|
||||
(call.Key == "" || r.Key == call.Key || r.Predefined == call.Key) {
|
||||
out.Resolved = append(out.Resolved, r)
|
||||
}
|
||||
}
|
||||
// The predefined keys the narrowed modules fall back to: their mesh and node values are listed too.
|
||||
fallsBack := map[string]bool{}
|
||||
for _, r := range out.Resolved {
|
||||
if r.Predefined != "" {
|
||||
fallsBack[r.Predefined] = true
|
||||
}
|
||||
}
|
||||
for _, k := range sortedKeys(s.Values) {
|
||||
level, key, ok := parseValueKey(k)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if call.Key != "" && key != call.Key {
|
||||
continue
|
||||
}
|
||||
reached := false
|
||||
switch level.Kind {
|
||||
case LevelMesh:
|
||||
reached = call.Module == "" || fallsBack[key]
|
||||
case LevelNode:
|
||||
reached = (call.Node == "" || level.Node == call.Node) && (call.Module == "" || fallsBack[key])
|
||||
case LevelModule:
|
||||
reached = (call.Node == "" || level.Node == call.Node) && (call.Module == "" || level.Module == call.Module)
|
||||
}
|
||||
if reached {
|
||||
out.Set = append(out.Set, SetValue{Level: level, Key: key, Value: s.Values[k]})
|
||||
}
|
||||
}
|
||||
if call.Module == "" {
|
||||
out.Predefined = s.Predefined
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// HistoryEntry is one change as the history keeps it.
|
||||
type HistoryEntry struct {
|
||||
Key string `json:"key"`
|
||||
Change
|
||||
}
|
||||
|
||||
// History is the changes the call narrows to, newest last, at most its limit (100 when it gives none).
|
||||
func (c *Config) History(call Call) ([]HistoryEntry, error) {
|
||||
entries, err := c.Store.Read(HistoryLog)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the history cannot be read: %w", err)
|
||||
}
|
||||
limit := call.Limit
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
out := []HistoryEntry{}
|
||||
for _, e := range entries {
|
||||
var ch Change
|
||||
if err := json.Unmarshal(e.Value, &ch); err != nil {
|
||||
continue
|
||||
}
|
||||
if !reaches(call, e.Key, ch) {
|
||||
continue
|
||||
}
|
||||
out = append(out, HistoryEntry{Key: e.Key, Change: ch})
|
||||
}
|
||||
if len(out) > limit {
|
||||
out = out[len(out)-limit:]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// reaches is whether a change concerns what the call narrows to: its level, or a value it moved.
|
||||
func reaches(call Call, key string, ch Change) bool {
|
||||
if call.Module == "" && call.Node == "" && call.Key == "" {
|
||||
return true
|
||||
}
|
||||
if level, k, ok := parseValueKey(key); ok {
|
||||
if (call.Key == "" || k == call.Key) && (call.Node == "" || level.Node == call.Node) &&
|
||||
(call.Module == "" || level.Module == call.Module) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// A change at another level reaches the call through what it moved there: mesh config moving a module's
|
||||
// value on the node named.
|
||||
for _, m := range ch.Moved {
|
||||
if (call.Module == "" || m.Module == call.Module) && (call.Node == "" || m.Node == call.Node) &&
|
||||
(call.Key == "" || m.Key == call.Key || ch.Key == call.Key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// mesh-config: the owner of every config value, holder of the mesh-config seat (novox/hq ADR 0348 §9, design 57
|
||||
// K3). A Go bundle served by a runtime of its own, on its own bus account (`runs-as`, ADR 0259 §8), so that its
|
||||
// account alone writes its buckets: mesh-config_values (each value set, at the mesh, a node, or a module on a
|
||||
// node), mesh-config_resolved (each module's value on each node, with its source), mesh-config_predefined (the
|
||||
// published list of predefined keys) and mesh-config_proposals (each value proposed for the operator's Approve,
|
||||
// asked under this module's own name, design 57 K4).
|
||||
// It resolves from the controller's mirrors of the assignments, the modules' declarations and the list it kept
|
||||
// (design 57 F3, K3). stdout is the MCP channel; what this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// Seat is the role this module holds, once for the mesh, and Verb its one verb.
|
||||
const (
|
||||
Seat = "mesh-config"
|
||||
Verb = "config"
|
||||
)
|
||||
|
||||
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
|
||||
// runtimeStore is the bus, through the runtime that launched this bundle.
|
||||
type runtimeStore struct{}
|
||||
|
||||
func (runtimeStore) Get(state, key string) (*Entry, error) {
|
||||
e, err := stdio.State(state).Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Entry{Key: e.Key, Value: e.Value, Revision: e.Revision}, nil
|
||||
}
|
||||
|
||||
func (runtimeStore) Put(state, key string, value any) error {
|
||||
_, err := stdio.State(state).Put(key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
func (runtimeStore) Delete(state, key string) error { return stdio.State(state).Delete(key) }
|
||||
|
||||
func (runtimeStore) Keys(state string) ([]string, error) { return stdio.State(state).Keys() }
|
||||
|
||||
func (runtimeStore) Append(log, key string, value any) error {
|
||||
_, err := stdio.Log(log).Append(key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
func (runtimeStore) Read(log string) ([]LogEntry, error) {
|
||||
var out []LogEntry
|
||||
for from := uint64(0); ; {
|
||||
page, err := stdio.Log(log).Read(from, "", 1000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range page.Entries {
|
||||
out = append(out, LogEntry{Key: e.Key, Value: e.Value, At: e.At})
|
||||
}
|
||||
if page.Next == 0 {
|
||||
return out, nil
|
||||
}
|
||||
from = page.Next
|
||||
}
|
||||
}
|
||||
|
||||
func (runtimeStore) Emit(event string, body any) error { return stdio.Emit(event, body) }
|
||||
|
||||
// HandAct records a change in the controller's hand-act log (to-be 45 §7) through its seat's verb.
|
||||
func (runtimeStore) HandAct(what, why, cause string) error {
|
||||
_, err := stdio.Ask("seat:mesh-controller.hand-act", map[string]any{"what": what, "why": why, "cause": cause})
|
||||
return err
|
||||
}
|
||||
|
||||
// runtimeAsks is the operator channel through the runtime: asks published under this module's own name, the
|
||||
// router's records of them read under that name alone (its `uses` of the seat, ADR 0259 §3), and the controller's
|
||||
// hand-act log for a warrant acted on.
|
||||
type runtimeAsks struct{}
|
||||
|
||||
func (runtimeAsks) Publish(subject string, body any, id string) error {
|
||||
return stdio.SeatPublish(subject, body, id)
|
||||
}
|
||||
func (runtimeAsks) Record(id string) (*stdio.StateEntry, error) { return stdio.SeatRecord("", id) }
|
||||
func (runtimeAsks) Kinds() ([]stdio.KindHeld, error) { return stdio.SeatKinds() }
|
||||
func (runtimeAsks) Warranted(ask string) error {
|
||||
_, err := stdio.Ask("seat:mesh-controller.warranted", map[string]any{"asker": Module, "ask": ask})
|
||||
return err
|
||||
}
|
||||
|
||||
func main() {
|
||||
c := &Config{Store: runtimeStore{}, Logf: logf, Asks: runtimeAsks{}}
|
||||
go resolving(c)
|
||||
go hearing(c)
|
||||
if err := stdio.Serve("", Tools(c)); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// resolving resolves at start and again whenever a source moves, and every five minutes besides, so a change
|
||||
// heard nowhere is still resolved. The runtime answers only once Serve runs, so the watches are asked again
|
||||
// until they are taken.
|
||||
func resolving(c *Config) {
|
||||
moved := make(chan struct{}, 1)
|
||||
poke := func(stdio.StateChange) error {
|
||||
select {
|
||||
case moved <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, read := range []string{AssignmentsRead, DeclaredConfigRead, PredefinedRead} {
|
||||
go func(read string) {
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
time.Sleep(wait)
|
||||
if err := stdio.State(read).Watch("", poke); err != nil {
|
||||
logf("[mesh-config] %s is not watched yet (%v); asking again in %s", read, err, wait)
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
}(read)
|
||||
}
|
||||
tick := time.NewTicker(5 * time.Minute)
|
||||
defer tick.Stop()
|
||||
time.Sleep(time.Second)
|
||||
for {
|
||||
r, err := c.Reconcile()
|
||||
switch {
|
||||
case err != nil:
|
||||
logf("[mesh-config] the values are not all resolved: %v", err)
|
||||
case len(r.Moved) > 0:
|
||||
logf("[mesh-config] resolved: %d value(s) moved", len(r.Moved))
|
||||
}
|
||||
if r.Published != "" {
|
||||
logf("[mesh-config] published %s", r.Published)
|
||||
}
|
||||
select {
|
||||
case <-moved:
|
||||
time.Sleep(500 * time.Millisecond) // a burst of writes is one resolution
|
||||
select {
|
||||
case <-moved:
|
||||
default:
|
||||
}
|
||||
case <-tick.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hearing takes the router's word on this module's asks, asked again until the runtime takes the subscription, and
|
||||
// reads the router's record of every waiting ask once a minute besides, so an answer heard nowhere is still taken.
|
||||
func hearing(c *Config) {
|
||||
go func() {
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
time.Sleep(wait)
|
||||
err := stdio.Subscribe(asks.Seat+".decided."+Module, func(e stdio.Envelope) error {
|
||||
var w asks.Warrant
|
||||
if json.Unmarshal(e.Body, &w) == nil {
|
||||
for _, s := range c.HeardEnd(w) {
|
||||
logf("[mesh-config] %s", s)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
logf("[mesh-config] hears the operator's answers")
|
||||
return
|
||||
}
|
||||
logf("[mesh-config] does not hear the operator's answers yet (%v); asking again in %s", err, wait)
|
||||
}
|
||||
}()
|
||||
tick := time.NewTicker(time.Minute)
|
||||
defer tick.Stop()
|
||||
for range tick.C {
|
||||
said, err := c.Decide()
|
||||
if err != nil {
|
||||
logf("[mesh-config] reading the operator's answers: %v", err)
|
||||
}
|
||||
for _, s := range said {
|
||||
logf("[mesh-config] %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tools is the seat's verb as the runtime serves it.
|
||||
func Tools(c *Config) []stdio.Tool {
|
||||
return []stdio.Tool{{
|
||||
Name: Seat + "." + Verb,
|
||||
Description: "Set, clear, list or read the history of config values (novox/hq ADR 0348). A value is set at " +
|
||||
"one level: mesh config (no module, no node) or node config (a node) for a predefined key, or a " +
|
||||
"module's own value on one node (module and node). Each set or clear needs why, is validated against " +
|
||||
"its declaration, names each module on each node whose value moves, and is kept in the history with " +
|
||||
"who and why. A trusted value is set only at the controller's terminal or on the operator's Approve: " +
|
||||
"propose asks the operator, under mesh-config's own name on a channel that proves who answered, and sets " +
|
||||
"the value on Approve if what it was shown against still stands; proposals lists them, or one with " +
|
||||
"proposal. list and history are narrowed by module, node and key.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"action": map[string]any{"type": "string",
|
||||
"enum": []string{"list", "set", "clear", "history", "propose", "proposals"},
|
||||
"description": "list when not given"},
|
||||
"key": map[string]any{"type": "string", "description": "the config key"},
|
||||
"value": map[string]any{"description": "for set: the value, of the key's declared type"},
|
||||
"module": map[string]any{"type": "string", "description": "a module: its own value, with node"},
|
||||
"node": map[string]any{"type": "string", "description": "a node"},
|
||||
"why": map[string]any{"type": "string", "description": "for set and clear: why, in a sentence"},
|
||||
"by": map[string]any{"type": "string",
|
||||
"description": "who gave the value at the controller's terminal: said by the controller alone"},
|
||||
"limit": map[string]any{"type": "number", "description": "for history: at most this many, newest"},
|
||||
"clear": map[string]any{"type": "boolean",
|
||||
"description": "for propose: propose clearing the value at its level instead of setting one"},
|
||||
"proposal": map[string]any{"type": "string", "description": "for proposals: one proposal's id, read whole"},
|
||||
}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
call, err := callOf(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
call.Caller = stdio.CallerOf(args)
|
||||
return c.Do(call)
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// callOf reads a call's arguments strictly: an argument it does not know is refused, not ignored.
|
||||
func callOf(args map[string]any) (Call, error) {
|
||||
known := map[string]bool{"action": true, "key": true, "value": true, "module": true, "node": true, "why": true,
|
||||
"by": true, "limit": true, "clear": true, "proposal": true}
|
||||
var unknown []string
|
||||
for k := range args {
|
||||
if !known[k] {
|
||||
unknown = append(unknown, k)
|
||||
}
|
||||
}
|
||||
if len(unknown) > 0 {
|
||||
return Call{}, fmt.Errorf("config takes no %s; it takes action, key, value, module, node, why, by, limit, "+
|
||||
"clear and proposal",
|
||||
strings.Join(unknown, ", "))
|
||||
}
|
||||
str := func(k string) (string, error) {
|
||||
v, ok := args[k]
|
||||
if !ok || v == nil {
|
||||
return "", nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s is a string", k)
|
||||
}
|
||||
return strings.TrimSpace(s), nil
|
||||
}
|
||||
var call Call
|
||||
var problems []error
|
||||
for k, into := range map[string]*string{"action": &call.Action, "key": &call.Key, "module": &call.Module,
|
||||
"node": &call.Node, "why": &call.Why, "by": &call.By, "proposal": &call.Proposal} {
|
||||
s, err := str(k)
|
||||
problems = append(problems, err)
|
||||
*into = s
|
||||
}
|
||||
if v, ok := args["limit"]; ok && v != nil {
|
||||
n, ok := v.(float64)
|
||||
if !ok || n < 0 || n != float64(int(n)) {
|
||||
problems = append(problems, errors.New("limit is a whole number"))
|
||||
}
|
||||
call.Limit = int(n)
|
||||
}
|
||||
if v, ok := args["clear"]; ok && v != nil {
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
problems = append(problems, errors.New("clear is true or false"))
|
||||
}
|
||||
call.Clear = b
|
||||
}
|
||||
if v, ok := args["value"]; ok {
|
||||
// The value as given, a JSON value: a number for a number, never its text; the validation judges it.
|
||||
call.Value = v
|
||||
}
|
||||
return call, errors.Join(problems...)
|
||||
}
|
||||
@@ -0,0 +1,928 @@
|
||||
package main
|
||||
|
||||
// A config value proposed for the operator's Approve (novox/hq ADR 0348 §8, ADR 0277's rules with mesh-config as
|
||||
// the asker; design 57 K4).
|
||||
//
|
||||
// Whoever the bus admits may PROPOSE a value at one level — mesh config, node config or a module's own value on a
|
||||
// node — a trusted one among them, which the verb sets only from the controller's terminal. A proposal changes
|
||||
// nothing. It is judged as a set judges it, then kept in mesh-config_proposals (which this module's account alone
|
||||
// writes) with the value whole, the value stored at that level when it was proposed (what the operator is shown
|
||||
// the change against), who proposed it, why and when. It is asked of the operator **under mesh-config's own name**
|
||||
// on the operator-channel seat — its own grant, from its manifest's `uses`, and its own cap of open asks — as an
|
||||
// ask whose two answers, Approve and Decline, are both at the level approve, so only a channel that proves who
|
||||
// answered carries either. The ask says the level, the key and the value, and that, approved, it takes effect at
|
||||
// once on every node it reaches; each option binds the proposal's level, key, the digest of the exact value and of
|
||||
// the value it was shown against.
|
||||
//
|
||||
// **On the answer** mesh-config reads the router's own record of its ask (which only the router writes, and this
|
||||
// module reads under its own name alone), composes the ask again from the proposal and holds the warrant to it:
|
||||
// the same ask digest, an option it offered, at its level, in time (asks.Warrant.For), and the act the option
|
||||
// bound is this proposal's (asks.Option.Performs). A record whose value or shown value changed after it was asked
|
||||
// is refused, and so is an Approve when the value stored at the level is no longer the one the operator was shown
|
||||
// the change against. Then the value is set exactly as a set at the terminal is, with the same judgement and
|
||||
// history, and who approved it is kept beside the value; the warrant is recorded in the controller's hand-act log.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/outward"
|
||||
)
|
||||
|
||||
// Module is this module's name: the one the bus grants its asks under.
|
||||
const Module = "mesh-config"
|
||||
|
||||
// ProposalVerb is the act a proposal's answers bind.
|
||||
const ProposalVerb = Seat + "." + Verb
|
||||
|
||||
// ProposalLasts is how long the operator has to answer: a day less ten minutes (ADR 0277 §9), under the router's
|
||||
// bound for an approval.
|
||||
const ProposalLasts = asks.ApproveLasts - 10*time.Minute
|
||||
|
||||
// How long a proposal waits for the router's word on its new ask, and how often it looks (ADR 0277 §20).
|
||||
const (
|
||||
RouterAnswersWithin = 8 * time.Second
|
||||
RouterAnswersEvery = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
// The two answers, both at the level approve.
|
||||
const (
|
||||
OptionApprove = "approve"
|
||||
OptionDecline = "decline"
|
||||
)
|
||||
|
||||
// Where a proposal stands.
|
||||
const (
|
||||
ProposalWaiting = "waiting"
|
||||
ProposalApproved = "approved"
|
||||
ProposalDeclined = "declined"
|
||||
ProposalEnded = "ended"
|
||||
ProposalRefused = "refused"
|
||||
ProposalUnasked = "not asked"
|
||||
)
|
||||
|
||||
// AskBus is the operator channel as this module reaches it: publish an ask under its own name, read the router's
|
||||
// record of one of its asks, the kinds of the channel bench with what each promises, and the controller's
|
||||
// hand-act log for a warrant acted on.
|
||||
type AskBus interface {
|
||||
Publish(subject string, body any, id string) error
|
||||
Record(id string) (*stdio.StateEntry, error)
|
||||
Kinds() ([]stdio.KindHeld, error)
|
||||
Warranted(ask string) error
|
||||
}
|
||||
|
||||
// Proposal is one value proposed, as mesh-config_proposals keeps it. Every field the ask is composed from is here,
|
||||
// so the ask is composed again from it alone to hold the warrant to.
|
||||
type Proposal struct {
|
||||
ID string `json:"id"`
|
||||
Action string `json:"action"`
|
||||
Level Level `json:"level"`
|
||||
Key string `json:"key"`
|
||||
// Value is the value proposed, whole, and Digest its digest (valueDigest); a clear carries none.
|
||||
Value json.RawMessage `json:"value,omitempty"`
|
||||
Digest string `json:"digest"`
|
||||
// Was is the value stored at the level when it was proposed, HadValue whether there was one, and WasDigest its
|
||||
// digest: what the operator is shown the change against, and what must still stand on Approve.
|
||||
Was json.RawMessage `json:"was,omitempty"`
|
||||
HadValue bool `json:"had-value"`
|
||||
WasDigest string `json:"was-digest"`
|
||||
Trusted bool `json:"trusted,omitempty"`
|
||||
// Reaches is each module on each node whose value it moves, as it was judged when proposed.
|
||||
Reaches []string `json:"reaches"`
|
||||
// From is who proposed it, as the bus proved the caller; Why is theirs; At is when.
|
||||
From string `json:"from"`
|
||||
Why string `json:"why"`
|
||||
At time.Time `json:"at"`
|
||||
|
||||
// State is where it stands, Outcome what came of it in words, Decided when; Answered is the warrant a person's
|
||||
// choice held on (Approve or Decline), and Approved who approved it.
|
||||
State string `json:"state"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Decided time.Time `json:"decided,omitempty"`
|
||||
Answered string `json:"answered,omitempty"`
|
||||
Approved string `json:"approved,omitempty"`
|
||||
// Recorded says the controller's hand-act log holds the warrant; Record is why not, while it does not.
|
||||
Recorded bool `json:"recorded,omitempty"`
|
||||
Record string `json:"record,omitempty"`
|
||||
}
|
||||
|
||||
// approval is what a decided proposal gives the set it makes: the warrant in words, and what the operator was
|
||||
// shown the change against.
|
||||
type approval struct {
|
||||
Proposal string
|
||||
By string
|
||||
Says string
|
||||
Was json.RawMessage
|
||||
HadValue bool
|
||||
WasDigest string
|
||||
}
|
||||
|
||||
// valueDigest is the digest a proposal binds of a value: SHA-256 over its one encoding (compact), "none" when
|
||||
// there is no value.
|
||||
func valueDigest(raw json.RawMessage, had bool) string {
|
||||
text := "none"
|
||||
if had {
|
||||
text = "value:" + string(compact(raw))
|
||||
}
|
||||
sum := sha256.Sum256([]byte(text))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func shownOrNone(raw json.RawMessage, had bool) string {
|
||||
if !had {
|
||||
return "no value set"
|
||||
}
|
||||
return string(compact(raw))
|
||||
}
|
||||
|
||||
// fingerprint is a digest as the operator is shown it: its first 24 hexadecimal digits in groups of four.
|
||||
func fingerprint(digest string) string {
|
||||
h := strings.TrimPrefix(digest, "sha256:")
|
||||
if len(h) < 24 {
|
||||
return h
|
||||
}
|
||||
var g []string
|
||||
for i := 0; i < 24; i += 4 {
|
||||
g = append(g, h[i:i+4])
|
||||
}
|
||||
return strings.Join(g, " ")
|
||||
}
|
||||
|
||||
// Fingerprint is the proposal as the operator is shown it: the value proposed, or for a clear the value it clears.
|
||||
func (p Proposal) Fingerprint() string {
|
||||
if p.Action == "clear" {
|
||||
return fingerprint(p.WasDigest)
|
||||
}
|
||||
return fingerprint(p.Digest)
|
||||
}
|
||||
|
||||
// about is what its ask is about: the value's place, so a newer proposal for it replaces an older one still open
|
||||
// (ADR 0277 §10, at a level, a key).
|
||||
func (p Proposal) about() string { return "config." + p.Level.ValueKey(p.Key) }
|
||||
|
||||
// Act is what an answer binds: this proposal's level, key, the digest of its exact value and of the value it was
|
||||
// shown against, who proposed it and when, and the answer.
|
||||
func (p Proposal) Act(option string) asks.Act {
|
||||
return asks.Act{"verb": ProposalVerb, "option": option, "proposal": p.ID, "action": p.Action,
|
||||
"level": p.Level.Kind, "node": p.Level.Node, "module": p.Level.Module, "key": p.Key, "value": p.Digest,
|
||||
"was": p.WasDigest, "had-value": strconv.FormatBool(p.HadValue), "from": p.From,
|
||||
"at": p.At.UTC().Format(time.RFC3339Nano)}
|
||||
}
|
||||
|
||||
// ---- what the operator reads --------------------------------------------------------------------------
|
||||
|
||||
// Markers for what a phone is not shown.
|
||||
const (
|
||||
markAddress = "‹address›"
|
||||
markPath = "‹path›"
|
||||
markWithheld = "‹withheld›"
|
||||
)
|
||||
|
||||
// sayable is a text as a channel that proves nothing is shown it, under the content rule (outward.Check): as it
|
||||
// is, or the marker of what it carries.
|
||||
func sayable(text string) (string, bool) {
|
||||
refusal, ok := outward.Check(text)
|
||||
if ok {
|
||||
return text, true
|
||||
}
|
||||
switch refusal.Class {
|
||||
case "address":
|
||||
return markAddress, false
|
||||
case "path":
|
||||
return markPath, false
|
||||
}
|
||||
return markWithheld, false
|
||||
}
|
||||
|
||||
// change is the change in one line, "+ key: value", "~ key: value (was: old)" or "- key (was: old)": each value
|
||||
// exact (for a channel that proves who answers, ADR 0277 §6 and issue 383) or under the content rule.
|
||||
func (p Proposal) change(exact bool) string {
|
||||
say := func(raw json.RawMessage) string {
|
||||
text := string(compact(raw))
|
||||
if exact {
|
||||
return text
|
||||
}
|
||||
s, _ := sayable(text)
|
||||
return s
|
||||
}
|
||||
switch {
|
||||
case p.Action == "clear":
|
||||
return fmt.Sprintf("- %s (was: %s)", p.Key, say(p.Was))
|
||||
case p.HadValue:
|
||||
return fmt.Sprintf("~ %s: %s (was: %s)", p.Key, say(p.Value), say(p.Was))
|
||||
}
|
||||
return fmt.Sprintf("+ %s: %s", p.Key, say(p.Value))
|
||||
}
|
||||
|
||||
// whole is whether every value is shown exactly under the content rule.
|
||||
func (p Proposal) whole() bool { return p.change(false) == p.change(true) }
|
||||
|
||||
// reaches is every node it reaches, in words.
|
||||
func (p Proposal) reaches() string {
|
||||
nodes := map[string]bool{}
|
||||
for _, r := range p.Reaches {
|
||||
if i := strings.LastIndex(r, " on "); i > 0 {
|
||||
nodes[r[i+4:]] = true
|
||||
}
|
||||
}
|
||||
switch len(nodes) {
|
||||
case 0:
|
||||
return "none today, as it moves no module's value"
|
||||
case 1:
|
||||
return sortedKeys(nodes)[0] + " (" + strings.Join(p.Reaches, ", ") + ")"
|
||||
}
|
||||
list := sortedKeys(nodes)
|
||||
return fmt.Sprintf("%d nodes, %s", len(list), strings.Join(list, ", "))
|
||||
}
|
||||
|
||||
// summary is the ask's first line: the level, the key and the value, and that it takes effect at once.
|
||||
func (p Proposal) summary() string {
|
||||
verb := "Sets " + p.Key
|
||||
if p.Action == "clear" {
|
||||
verb = "Clears " + p.Key
|
||||
}
|
||||
trusted := ""
|
||||
if p.Trusted {
|
||||
trusted = ", a trusted value,"
|
||||
}
|
||||
reach := p.reaches()
|
||||
s := fmt.Sprintf("%s%s at %s; approved, it takes effect at once on every node it reaches: %s.", verb, trusted,
|
||||
p.Level, reach)
|
||||
if len([]rune(s)) > asks.SummaryLength {
|
||||
s = fmt.Sprintf("%s%s at %s; approved, it takes effect at once on every node it reaches (%d modules).", verb,
|
||||
trusted, p.Level, len(p.Reaches))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// point is a line cut to what a phone shows at a glance, saying so.
|
||||
func point(line string) string {
|
||||
const cut = "… (cut: read it in the note)"
|
||||
if r := []rune(line); len(r) > asks.PointLength {
|
||||
return string(r[:asks.PointLength-len([]rune(cut))]) + cut
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// headline is the ask's headline, shortened when the names make it longer than the router allows (ADR 0277 §5).
|
||||
func (p Proposal) headline() string {
|
||||
verb := "Set"
|
||||
if p.Action == "clear" {
|
||||
verb = "Clear"
|
||||
}
|
||||
where := "for the whole mesh"
|
||||
switch p.Level.Kind {
|
||||
case LevelNode:
|
||||
where = "on " + p.Level.Node
|
||||
case LevelModule:
|
||||
where = "for " + p.Level.Module + " on " + p.Level.Node
|
||||
}
|
||||
for _, h := range []string{fmt.Sprintf("%s %s %s?", verb, p.Key, where), fmt.Sprintf("%s a config value %s?", verb,
|
||||
where), verb + " a config value?"} {
|
||||
if len([]rune(h)) <= asks.HeadlineLength {
|
||||
if s, ok := sayable(h); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return verb + " a config value?"
|
||||
}
|
||||
|
||||
// Ask is the proposal's ask, composed from it alone: its words, its two options each binding its act, who
|
||||
// answers, its expiry and what it is about.
|
||||
func (p Proposal) Ask() asks.Ask {
|
||||
why, _ := sayable("Why: " + p.Why)
|
||||
if why != "Why: "+p.Why {
|
||||
why = "Why: given, and read in full with the proposal"
|
||||
}
|
||||
compose := func(exact bool) string {
|
||||
return strings.Join([]string{p.summary(), point(p.change(exact)), point(why)}, "\n")
|
||||
}
|
||||
details := func(exact bool) string {
|
||||
var d strings.Builder
|
||||
d.WriteString(p.change(exact) + "\n")
|
||||
from, _ := sayable(p.From)
|
||||
fmt.Fprintf(&d, "Proposed by %s at %s UTC. Fingerprint %s.\n", from, p.At.UTC().Format("15:04 on 2 Jan"),
|
||||
p.Fingerprint())
|
||||
if !exact && !p.whole() {
|
||||
d.WriteString("Where who answers is not proven, its values are shown as ‹address›, ‹path› or ‹withheld›.\n")
|
||||
}
|
||||
fmt.Fprintf(&d, "Read it whole, with this fingerprint: mesh-config's config verb with action proposals and "+
|
||||
"proposal %s, through the mesh MCP server.", p.ID)
|
||||
return d.String()
|
||||
}
|
||||
a := asks.Ask{ID: p.ID, Headline: p.headline(), Explanation: compose(false), Who: asks.Operator,
|
||||
Expires: p.At.Add(ProposalLasts), OnExpiry: "the proposal is discarded; nothing changes", About: p.about(),
|
||||
Details: details(false)}
|
||||
if w := compose(true); w != a.Explanation {
|
||||
a.Whole = w
|
||||
}
|
||||
if w := details(true); w != a.Details {
|
||||
a.WholeDetails = w
|
||||
}
|
||||
does := "the value is set at once, and every node it reaches takes it"
|
||||
if p.Action == "clear" {
|
||||
does = "the value is cleared at once, and every node it reaches takes what is below it"
|
||||
}
|
||||
for _, o := range []struct{ id, label, does string }{
|
||||
{OptionApprove, "Approve", does},
|
||||
{OptionDecline, "Decline", "nothing changes; the proposal is discarded"},
|
||||
} {
|
||||
binds, _ := asks.ActDigest(p.Act(o.id))
|
||||
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve,
|
||||
Binds: binds})
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// problem says why a proposal as kept is not one this module made, or "": judged again before an Approve is
|
||||
// acted on, so a record whose value or shown value changed after it was asked is refused (ADR 0277 §12).
|
||||
func (p Proposal) problem() string {
|
||||
switch {
|
||||
case !asks.UsableID(p.ID):
|
||||
return "its id is not one"
|
||||
case p.Action != "set" && p.Action != "clear":
|
||||
return "its action is neither set nor clear"
|
||||
case !keyName.MatchString(p.Key):
|
||||
return "its key is not one"
|
||||
case p.At.IsZero():
|
||||
return "it says no time"
|
||||
case p.Action == "set" && valueDigest(p.Value, true) != p.Digest:
|
||||
return "its value is not the value its digest names: it changed after it was asked"
|
||||
case p.Action == "clear" && (len(p.Value) != 0 || p.Digest != valueDigest(nil, false)):
|
||||
return "a clear carries no value"
|
||||
case valueDigest(p.Was, p.HadValue) != p.WasDigest:
|
||||
return "the value it was shown against is not the one its digest names: it changed after it was asked"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---- proposing -----------------------------------------------------------------------------------------
|
||||
|
||||
// ProposeAnswer is what a proposal answers.
|
||||
type ProposeAnswer struct {
|
||||
Said string `json:"said"`
|
||||
Proposal string `json:"proposal"`
|
||||
Asked string `json:"asked"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Expires time.Time `json:"expires"`
|
||||
Router string `json:"router"`
|
||||
Reaches []string `json:"reaches"`
|
||||
Replaced []string `json:"replaced,omitempty"`
|
||||
}
|
||||
|
||||
// AtTheTerminalInstead names the other way, said whenever a proposal cannot be asked (ADR 0277 §19).
|
||||
const AtTheTerminalInstead = "the operator may set it at the controller's terminal instead, through the controller's " +
|
||||
"config line, which carries the value to mesh-config"
|
||||
|
||||
// approverReady says why no channel that proves who answered can carry an approval, when this module can tell
|
||||
// from the channel bench's kinds; an empty list says nothing, and the router is the judge (ADR 0259 §4).
|
||||
func approverReady(bus AskBus) string {
|
||||
kinds, err := bus.Kinds()
|
||||
if err != nil || len(kinds) == 0 {
|
||||
return ""
|
||||
}
|
||||
channels := 0
|
||||
for _, k := range kinds {
|
||||
if k.Seat != asks.ChannelSeat {
|
||||
continue
|
||||
}
|
||||
channels++
|
||||
if k.Promises("choice") && k.Promises("exact-render") && k.Promises("verified-sender") {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
if channels == 0 {
|
||||
return ""
|
||||
}
|
||||
return "no channel that proves who answered (one promising a verified sender, such as Telegram) is on the mesh"
|
||||
}
|
||||
|
||||
func newProposalID() string {
|
||||
var b [8]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return "cfg-" + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// endedWord is the router's word that an ask ended without a choice, heard while its proposer waits.
|
||||
type endedWord struct {
|
||||
Outcome asks.Outcome
|
||||
Words string
|
||||
}
|
||||
|
||||
func (c *Config) heardEnded(id string) (endedWord, bool) {
|
||||
c.endedMu.Lock()
|
||||
defer c.endedMu.Unlock()
|
||||
w, ok := c.ended[id]
|
||||
return w, ok
|
||||
}
|
||||
|
||||
func (c *Config) tellEnded(id string, w endedWord) {
|
||||
c.endedMu.Lock()
|
||||
defer c.endedMu.Unlock()
|
||||
if c.ended == nil {
|
||||
c.ended = map[string]endedWord{}
|
||||
}
|
||||
c.ended[id] = w
|
||||
}
|
||||
|
||||
// Propose judges a value as a set would, keeps it, and asks the operator under mesh-config's own name. Nothing is
|
||||
// set here: only the operator's Approve sets it (Decide).
|
||||
func (c *Config) Propose(call Call) (*ProposeAnswer, error) {
|
||||
refuse := func(format string, a ...any) (*ProposeAnswer, error) {
|
||||
return nil, errors.New(fmt.Sprintf(format, a...) + ". Nothing was proposed")
|
||||
}
|
||||
if c.Asks == nil {
|
||||
return refuse("the operator cannot be asked from here; %s", AtTheTerminalInstead)
|
||||
}
|
||||
if call.By != "" {
|
||||
return refuse("by names who gave a value at the controller's terminal; a proposal is asked of the operator")
|
||||
}
|
||||
if call.Proposal != "" {
|
||||
return refuse("proposal names a proposal to read, with action proposals")
|
||||
}
|
||||
action := "set"
|
||||
if call.Clear {
|
||||
action = "clear"
|
||||
if call.Value != nil {
|
||||
return refuse("a clear carries no value")
|
||||
}
|
||||
}
|
||||
c.mu.Lock()
|
||||
prop, ask, replaced, err := c.propose(call, action)
|
||||
c.mu.Unlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer := &ProposeAnswer{Proposal: prop.ID, Asked: ask.Headline, Fingerprint: prop.Fingerprint(),
|
||||
Expires: ask.Expires, Reaches: prop.Reaches, Replaced: replaced,
|
||||
Router: "the router has not said yet whether it took the ask; config's proposals shows it"}
|
||||
// The router's word, before answering (ADR 0277 §20): it refuses at once an ask no channel can carry.
|
||||
within, every := c.RouterWithin, c.RouterEvery
|
||||
if within <= 0 {
|
||||
within = RouterAnswersWithin
|
||||
}
|
||||
if every <= 0 {
|
||||
every = RouterAnswersEvery
|
||||
}
|
||||
for deadline := time.Now().Add(within); time.Now().Before(deadline); time.Sleep(every) {
|
||||
if w, ended := c.heardEnded(prop.ID); ended {
|
||||
return nil, fmt.Errorf("the router did not ask the operator: the ask %s (%s). The proposal is ended and "+
|
||||
"nothing is set; %s", w.Outcome, w.Words, AtTheTerminalInstead)
|
||||
}
|
||||
if e, err := c.Asks.Record(prop.ID); err == nil && e != nil {
|
||||
answer.Router = "the router took the ask and shows it on the channels that can carry it"
|
||||
break
|
||||
}
|
||||
}
|
||||
answer.Said = fmt.Sprintf("proposed %s of %s at %s (fingerprint %s): the operator is asked %q on a channel that "+
|
||||
"proves who answered. Nothing changes until they approve; on Approve it takes effect at once, and on Decline "+
|
||||
"or at %s UTC it is discarded", action, prop.Key, prop.Level, prop.Fingerprint(), ask.Headline,
|
||||
ask.Expires.UTC().Format("15:04 on 2 Jan"))
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// propose is Propose's judgement, its record and its ask; the caller holds the lock.
|
||||
func (c *Config) propose(call Call, action string) (Proposal, asks.Ask, []string, error) {
|
||||
fail := func(format string, a ...any) (Proposal, asks.Ask, []string, error) {
|
||||
return Proposal{}, asks.Ask{}, nil, errors.New(fmt.Sprintf(format, a...) + ". Nothing was proposed")
|
||||
}
|
||||
level, err := call.level()
|
||||
if err != nil {
|
||||
return fail("%v", err)
|
||||
}
|
||||
if !keyName.MatchString(call.Key) {
|
||||
return fail("name the key to propose: lower-case letters, digits, dots, dashes and underscores")
|
||||
}
|
||||
why := strings.TrimSpace(call.Why)
|
||||
if why == "" || strings.Contains(why, "\n") {
|
||||
return fail("say why, in one line: every change keeps its why (novox/hq ADR 0348 §9)")
|
||||
}
|
||||
if call.Caller == "" || call.Caller == Unproven {
|
||||
return fail("the call names no proven caller, and a proposal keeps who made it: call config through the mesh, " +
|
||||
"which names you")
|
||||
}
|
||||
s, err := c.sources()
|
||||
if err != nil {
|
||||
return Proposal{}, asks.Ask{}, nil, err
|
||||
}
|
||||
// **Judged as a set judges it, before anyone is asked** (ADR 0277 §4): no ask for a value the verb would refuse.
|
||||
r, trusted, err := c.declared(s, level, call.Key)
|
||||
if err != nil {
|
||||
return fail("%v", err)
|
||||
}
|
||||
vk := level.ValueKey(call.Key)
|
||||
was, had := s.Values[vk]
|
||||
now := c.now()
|
||||
prop := Proposal{ID: newProposalID(), Action: action, Level: level, Key: call.Key, HadValue: had,
|
||||
WasDigest: valueDigest(was.Value, had), Trusted: trusted, From: call.Caller, Why: why, At: now,
|
||||
State: ProposalWaiting, Digest: valueDigest(nil, false)}
|
||||
if had {
|
||||
prop.Was = compact(was.Value)
|
||||
}
|
||||
after := Sources{Assignments: s.Assignments, Declared: s.Declared, Predefined: s.Predefined,
|
||||
Values: map[string]Value{}}
|
||||
for k, v := range s.Values {
|
||||
after.Values[k] = v
|
||||
}
|
||||
switch action {
|
||||
case "set":
|
||||
if call.Value == nil {
|
||||
return fail("give the value to propose for %s", call.Key)
|
||||
}
|
||||
raw, err := json.Marshal(call.Value)
|
||||
if err != nil {
|
||||
return fail("the value cannot be read: %v", err)
|
||||
}
|
||||
var value any
|
||||
_ = json.Unmarshal(raw, &value)
|
||||
if problems := r.check(value); len(problems) > 0 {
|
||||
return fail("%s is refused for %s at %s: %s", show(value), call.Key, level, strings.Join(problems, "; "))
|
||||
}
|
||||
prop.Value, prop.Digest = compact(raw), valueDigest(raw, true)
|
||||
if had && string(compact(was.Value)) == string(prop.Value) {
|
||||
return fail("%s at %s already holds exactly %s", call.Key, level, prop.Value)
|
||||
}
|
||||
after.Values[vk] = Value{Value: prop.Value}
|
||||
case "clear":
|
||||
if !had {
|
||||
return fail("nothing is set for %s at %s, so there is nothing to clear", call.Key, level)
|
||||
}
|
||||
delete(after.Values, vk)
|
||||
}
|
||||
for _, m := range diff(Resolve(s), Resolve(after)) {
|
||||
prop.Reaches = append(prop.Reaches, m.Module+" on "+m.Node)
|
||||
}
|
||||
if prop.Reaches == nil {
|
||||
prop.Reaches = []string{}
|
||||
}
|
||||
// **The operator is shown exactly what they approve** where who answered is proven (ADR 0277 §6, §19): a value
|
||||
// shaped like a secret could be shown nowhere, so it is not proposed; config holds no secret.
|
||||
if refusal, ok := outward.CheckSecret(prop.change(true)); !ok {
|
||||
return fail("the change carries %s, which leaves the mesh nowhere, so the operator could not be shown what "+
|
||||
"they approve; config holds no secret, and %s", refusal, AtTheTerminalInstead)
|
||||
}
|
||||
// **Its own cap of open asks, and the same change once** (ADR 0277 §21); a newer proposal for the same place
|
||||
// replaces an older one still open (§10).
|
||||
all, err := c.proposals()
|
||||
if err != nil {
|
||||
return Proposal{}, asks.Ask{}, nil, err
|
||||
}
|
||||
var replace []Proposal
|
||||
open := 0
|
||||
for _, o := range all {
|
||||
if o.State != ProposalWaiting || now.After(o.At.Add(ProposalLasts)) {
|
||||
continue
|
||||
}
|
||||
if o.about() == prop.about() {
|
||||
if o.Action == prop.Action && o.Digest == prop.Digest && o.WasDigest == prop.WasDigest {
|
||||
return fail("the same change is proposed already, as %s, and waits for the operator's answer", o.ID)
|
||||
}
|
||||
replace = append(replace, o)
|
||||
continue
|
||||
}
|
||||
open++
|
||||
}
|
||||
if open >= asks.MostOpen {
|
||||
return fail("%d proposals already wait for the operator's answer, and mesh-config asks at most %d at once; "+
|
||||
"config's proposals lists them", open, asks.MostOpen)
|
||||
}
|
||||
if why := approverReady(c.Asks); why != "" {
|
||||
return fail("%s; %s", why, AtTheTerminalInstead)
|
||||
}
|
||||
ask := prop.Ask()
|
||||
if err := ask.Check(now); err != nil {
|
||||
return fail("%v", err)
|
||||
}
|
||||
if err := c.Store.Put(ProposalsState, prop.ID, prop); err != nil {
|
||||
return fail("the proposal could not be kept, so the operator was not asked: %v", err)
|
||||
}
|
||||
var replaced []string
|
||||
for _, o := range replace {
|
||||
o.State, o.Outcome, o.Decided = ProposalEnded, "replaced by the newer proposal "+prop.ID+"; nothing was set", now
|
||||
if err := c.Store.Put(ProposalsState, o.ID, o); err == nil {
|
||||
replaced = append(replaced, o.ID)
|
||||
}
|
||||
}
|
||||
if err := c.Asks.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
|
||||
prop.State, prop.Outcome = ProposalUnasked, "the ask could not be published: "+err.Error()
|
||||
_ = c.Store.Put(ProposalsState, prop.ID, prop)
|
||||
c.logf("[mesh-config] proposal %s could not be asked: %v", prop.ID, err)
|
||||
return Proposal{}, asks.Ask{}, nil, fmt.Errorf("the operator could not be asked (%v); the proposal is kept "+
|
||||
"and will never be set; %s", err, AtTheTerminalInstead)
|
||||
}
|
||||
c.logf("[mesh-config] proposal %s (%s %s at %s, fingerprint %s) asked of the operator", prop.ID, action, prop.Key,
|
||||
prop.Level, prop.Fingerprint())
|
||||
return prop, ask, replaced, nil
|
||||
}
|
||||
|
||||
// proposals is every proposal kept, oldest first.
|
||||
func (c *Config) proposals() ([]Proposal, error) {
|
||||
var out []Proposal
|
||||
err := c.each(ProposalsState, func(key string, raw []byte) error {
|
||||
var p Proposal
|
||||
if json.Unmarshal(raw, &p) != nil || p.ID != key {
|
||||
return nil // not one this module wrote: never asked, never acted on
|
||||
}
|
||||
out = append(out, p)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the proposals cannot be read: %w", err)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].At.Before(out[j].At) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- deciding --------------------------------------------------------------------------------------------
|
||||
|
||||
// routerRecord is the router's record of one ask, as far as this module reads it.
|
||||
type routerRecord struct {
|
||||
State string `json:"state"`
|
||||
Ask *asks.Ask `json:"ask"`
|
||||
Warrant *asks.Warrant `json:"warrant"`
|
||||
}
|
||||
|
||||
// Decide decides every waiting proposal whose ask the router has closed, oldest warrant first, each held to the
|
||||
// warrant as described at the top of this file; and asks the controller to record every warrant acted on that its
|
||||
// hand-act log does not hold yet. It answers a line for each decision.
|
||||
func (c *Config) Decide() ([]string, error) {
|
||||
if c.Asks == nil {
|
||||
return nil, nil
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
all, err := c.proposals()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := c.now()
|
||||
type closed struct {
|
||||
p Proposal
|
||||
w asks.Warrant
|
||||
}
|
||||
var ready []closed
|
||||
var said []string
|
||||
for _, p := range all {
|
||||
if p.State != ProposalWaiting {
|
||||
continue
|
||||
}
|
||||
e, err := c.Asks.Record(p.ID)
|
||||
if err == nil && e == nil && now.After(p.At.Add(ProposalLasts+time.Hour)) {
|
||||
// Ended, and the router keeps no record of it: no answer can be proven, so nothing is set.
|
||||
said = append(said, c.end(p, "the router keeps no record of its ask (refused before it was taken, or "+
|
||||
"forgotten); nothing was set"))
|
||||
continue
|
||||
}
|
||||
if err != nil || e == nil {
|
||||
continue // open, not yet recorded, or not readable now: read again on the next look
|
||||
}
|
||||
var rec routerRecord
|
||||
if json.Unmarshal(e.Value, &rec) != nil || rec.State == "open" || rec.Warrant == nil {
|
||||
continue
|
||||
}
|
||||
ready = append(ready, closed{p, *rec.Warrant})
|
||||
}
|
||||
sort.SliceStable(ready, func(i, j int) bool { return ready[i].w.At.Before(ready[j].w.At) })
|
||||
for _, r := range ready {
|
||||
said = append(said, c.decide(r.p, r.w))
|
||||
}
|
||||
c.recordWarrants(now)
|
||||
return said, nil
|
||||
}
|
||||
|
||||
// end records a proposal ended without the operator's choice.
|
||||
func (c *Config) end(p Proposal, why string) string {
|
||||
p.State, p.Outcome, p.Decided = ProposalEnded, why, c.now()
|
||||
if err := c.Store.Put(ProposalsState, p.ID, p); err != nil {
|
||||
return fmt.Sprintf("proposal %s: %s, and that could not be kept (%v)", p.ID, why, err)
|
||||
}
|
||||
return "proposal " + p.ID + ": " + why
|
||||
}
|
||||
|
||||
// decide holds one closed ask's warrant to its proposal and, on an Approve that holds, sets the value. The caller
|
||||
// holds the lock.
|
||||
func (c *Config) decide(p Proposal, w asks.Warrant) string {
|
||||
now := c.now()
|
||||
keep := func(state, outcome string) string {
|
||||
p.State, p.Outcome, p.Decided = state, outcome, now
|
||||
if err := c.Store.Put(ProposalsState, p.ID, p); err != nil {
|
||||
return fmt.Sprintf("proposal %s: %s, and that could not be kept (%v)", p.ID, outcome, err)
|
||||
}
|
||||
return "proposal " + p.ID + ": " + outcome
|
||||
}
|
||||
refused := func(why string) string {
|
||||
c.logf("[mesh-config] REFUSED the answer to proposal %s: %s", p.ID, why)
|
||||
return keep(ProposalRefused, "the answer is refused and nothing is set: "+why)
|
||||
}
|
||||
if w.Asker != Module || w.Ask != p.ID {
|
||||
return refused(fmt.Sprintf("the router's record is for %s's ask %s", w.Asker, w.Ask))
|
||||
}
|
||||
if w.Outcome != asks.OutcomeChosen {
|
||||
why := "the ask " + string(w.Outcome)
|
||||
if w.Words != "" {
|
||||
why += " (" + w.Words + ")"
|
||||
}
|
||||
return keep(ProposalEnded, why+"; nothing was set")
|
||||
}
|
||||
// **The record is held to what was asked** (ADR 0277 §11, §12): its value and the value it was shown against
|
||||
// still digest to what the ask bound, the warrant answers the ask composed again from it, and the act about to be
|
||||
// performed is the one the chosen option bound.
|
||||
if problem := p.problem(); problem != "" {
|
||||
return refused("the proposal is not the one asked: " + problem)
|
||||
}
|
||||
option, err := w.For(Module, p.Ask())
|
||||
if err == nil {
|
||||
err = option.Performs(p.Act(option.ID))
|
||||
}
|
||||
if err != nil {
|
||||
return refused(err.Error())
|
||||
}
|
||||
switch option.ID {
|
||||
case OptionDecline:
|
||||
p.Answered = w.Says()
|
||||
return keep(ProposalDeclined, "declined by the operator ("+w.Says()+"); nothing was set")
|
||||
case OptionApprove:
|
||||
p.Answered, p.Approved = w.Says(), approvedWords(p, w)
|
||||
default:
|
||||
return refused("the option " + option.ID + " is not one a proposal offers")
|
||||
}
|
||||
// Claimed before it is acted on: a warrant heard twice, or read again, sets nothing twice.
|
||||
p.State, p.Outcome, p.Decided = ProposalApproved, "approved by the operator; being set", now
|
||||
if err := c.Store.Put(ProposalsState, p.ID, p); err != nil {
|
||||
return fmt.Sprintf("proposal %s: approved by the operator, and not set: the decision could not be kept (%v); "+
|
||||
"it is read again on the next look", p.ID, err)
|
||||
}
|
||||
call := Call{Action: p.Action, Key: p.Key, Module: p.Level.Module, Node: p.Level.Node, Why: p.Why, Caller: p.From,
|
||||
approval: &approval{Proposal: p.ID, By: "the operator", Says: p.Approved, Was: p.Was, HadValue: p.HadValue,
|
||||
WasDigest: p.WasDigest}}
|
||||
if p.Action == "set" {
|
||||
var v any
|
||||
_ = json.Unmarshal(p.Value, &v)
|
||||
call.Value = v
|
||||
if v == nil {
|
||||
call.Value = json.RawMessage("null")
|
||||
}
|
||||
}
|
||||
a, err := c.change(call)
|
||||
if err != nil {
|
||||
return keep(ProposalRefused, "approved by the operator, and refused on Approve, so nothing is set: "+err.Error())
|
||||
}
|
||||
return keep(ProposalApproved, "approved by the operator and set: "+a.Said)
|
||||
}
|
||||
|
||||
// approvedWords is who approved it, as kept beside the value (ADR 0277 §15).
|
||||
func approvedWords(p Proposal, w asks.Warrant) string {
|
||||
who := "the operator"
|
||||
if w.By != nil {
|
||||
who = "the " + w.By.Who
|
||||
if w.By.Identity != "" {
|
||||
who += ", as " + w.By.Kind + " identity " + w.By.Identity
|
||||
}
|
||||
via := w.By.Kind
|
||||
if w.By.Verified != "" {
|
||||
via += " (" + w.By.Verified + ")"
|
||||
}
|
||||
who += ", via " + via
|
||||
}
|
||||
return fmt.Sprintf("approved by %s at %s, ask %s, proposed by %s", who, w.At.UTC().Format(time.RFC3339), p.ID, p.From)
|
||||
}
|
||||
|
||||
// recordWarrants asks the controller to record each warrant acted on (Approve or Decline) that its hand-act log
|
||||
// does not hold yet (ADR 0277 §17): the controller reads it from the router's own record. The caller holds the lock.
|
||||
func (c *Config) recordWarrants(now time.Time) {
|
||||
all, err := c.proposals()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, p := range all {
|
||||
if p.Recorded || p.Answered == "" || now.Sub(p.Decided) > 7*24*time.Hour {
|
||||
continue
|
||||
}
|
||||
if err := c.Asks.Warranted(p.ID); err != nil {
|
||||
p.Record = "not recorded in the hand-act log yet: " + err.Error()
|
||||
} else {
|
||||
p.Recorded, p.Record = true, ""
|
||||
}
|
||||
_ = c.Store.Put(ProposalsState, p.ID, p)
|
||||
}
|
||||
}
|
||||
|
||||
// HeardEnd takes the router's word on one of this module's asks. An ask ended without a choice ends its proposal
|
||||
// at once, and is told to a proposer waiting on it; nothing is set on such a word, so it needs no proof beyond its
|
||||
// subject. A choice is only ever taken from the router's record (Decide).
|
||||
func (c *Config) HeardEnd(w asks.Warrant) []string {
|
||||
if w.Asker != Module || !asks.UsableID(w.Ask) {
|
||||
return nil
|
||||
}
|
||||
if w.Outcome == asks.OutcomeChosen {
|
||||
said, err := c.Decide()
|
||||
if err != nil {
|
||||
said = append(said, "reading the operator's answers: "+err.Error())
|
||||
}
|
||||
return said
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
defer c.tellEnded(w.Ask, endedWord{Outcome: w.Outcome, Words: w.Words})
|
||||
e, err := c.Store.Get(ProposalsState, w.Ask)
|
||||
if err != nil || e == nil {
|
||||
return nil
|
||||
}
|
||||
var p Proposal
|
||||
if json.Unmarshal(e.Value, &p) != nil || p.ID != w.Ask || p.State != ProposalWaiting {
|
||||
return nil
|
||||
}
|
||||
why := "the ask " + string(w.Outcome)
|
||||
if w.Words != "" {
|
||||
why += " (" + w.Words + ")"
|
||||
}
|
||||
return []string{c.end(p, why+"; nothing was set")}
|
||||
}
|
||||
|
||||
// ---- the list ----------------------------------------------------------------------------------------------
|
||||
|
||||
// ProposalRow is one proposal as a listing says it.
|
||||
type ProposalRow struct {
|
||||
ID string `json:"id"`
|
||||
Action string `json:"action"`
|
||||
Level Level `json:"level"`
|
||||
Key string `json:"key"`
|
||||
State string `json:"state"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
From string `json:"from"`
|
||||
At time.Time `json:"at"`
|
||||
Expires time.Time `json:"expires"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Approved string `json:"approved,omitempty"`
|
||||
// What one proposal read whole says besides.
|
||||
Value json.RawMessage `json:"value,omitempty"`
|
||||
Was json.RawMessage `json:"was,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
Reaches []string `json:"reaches,omitempty"`
|
||||
Router string `json:"router,omitempty"`
|
||||
Phone string `json:"the phone,omitempty"`
|
||||
Warning string `json:"warning,omitempty"`
|
||||
}
|
||||
|
||||
// Proposals is every proposal, newest first; with a proposal's id, that one whole, with whether the router's ask
|
||||
// is the one composed from it.
|
||||
func (c *Config) Proposals(call Call) ([]ProposalRow, error) {
|
||||
all, err := c.proposals()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []ProposalRow{}
|
||||
for i := len(all) - 1; i >= 0; i-- {
|
||||
p := all[i]
|
||||
if call.Proposal != "" && p.ID != call.Proposal {
|
||||
continue
|
||||
}
|
||||
if (call.Key != "" && p.Key != call.Key) || (call.Node != "" && p.Level.Node != call.Node) ||
|
||||
(call.Module != "" && p.Level.Module != call.Module) {
|
||||
continue
|
||||
}
|
||||
row := ProposalRow{ID: p.ID, Action: p.Action, Level: p.Level, Key: p.Key, State: p.State, Outcome: p.Outcome,
|
||||
From: p.From, At: p.At, Expires: p.At.Add(ProposalLasts), Fingerprint: p.Fingerprint(), Approved: p.Approved}
|
||||
if p.State == ProposalWaiting && c.now().After(row.Expires) {
|
||||
row.State = "expired, not yet heard"
|
||||
}
|
||||
if call.Proposal != "" {
|
||||
row.Value, row.Was, row.Why, row.Reaches = p.Value, p.Was, p.Why, p.Reaches
|
||||
if problem := p.problem(); problem != "" {
|
||||
row.Warning = "this proposal is never set: " + problem
|
||||
}
|
||||
if c.Asks != nil {
|
||||
if e, err := c.Asks.Record(p.ID); err == nil && e != nil {
|
||||
var rec routerRecord
|
||||
if json.Unmarshal(e.Value, &rec) == nil {
|
||||
row.Router = rec.State
|
||||
switch {
|
||||
case rec.Ask == nil:
|
||||
row.Phone = "cannot be compared: the router's record keeps no ask"
|
||||
case rec.Ask.Digest() == p.Ask().Digest():
|
||||
row.Phone = "shows the ask composed from exactly this proposal (fingerprint " + p.Fingerprint() + ")"
|
||||
default:
|
||||
row.Phone = "shows ANOTHER ask than this proposal composes: do not approve it"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
if call.Proposal != "" && len(out) == 0 {
|
||||
return nil, errors.New("no proposal " + strconv.Quote(call.Proposal) + " is kept")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// standIn is the operator channel's router and a channel that proves who answered, in memory: it takes the asks
|
||||
// mesh-config publishes, keeps its own record of each under mesh-config's name, and records the operator's choice
|
||||
// as the router's warrant. Nothing of mesh-config's is trusted by it: it digests the ask it took.
|
||||
type standIn struct {
|
||||
mu sync.Mutex
|
||||
subjects []string
|
||||
asks map[string]asks.Ask
|
||||
records map[string][]byte
|
||||
kinds []stdio.KindHeld
|
||||
refuse error
|
||||
warranted []string
|
||||
// refuseWith makes the router refuse every ask it is published, as the router does for an ask no channel can
|
||||
// carry: no record, and its word on the decided subject (heard through hear).
|
||||
refuseWith string
|
||||
hear func(asks.Warrant)
|
||||
// noRecord keeps the router from recording an ask it took (an ask it has not taken yet).
|
||||
noRecord bool
|
||||
}
|
||||
|
||||
func newStandIn() *standIn {
|
||||
return &standIn{asks: map[string]asks.Ask{}, records: map[string][]byte{}, kinds: []stdio.KindHeld{
|
||||
{Seat: asks.ChannelSeat, Kind: "telegram", Module: "telegram", Node: "novox",
|
||||
Capabilities: []string{"choice", "exact-render", "verified-sender"}}}}
|
||||
}
|
||||
|
||||
func (r *standIn) Publish(subject string, body any, id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.refuse != nil {
|
||||
return r.refuse
|
||||
}
|
||||
a, ok := body.(asks.Ask)
|
||||
if !ok || a.ID != id {
|
||||
return errors.New("not an ask")
|
||||
}
|
||||
r.subjects = append(r.subjects, subject)
|
||||
r.asks[id] = a
|
||||
if r.refuseWith != "" {
|
||||
w := asks.Warrant{Ask: id, Asker: Module, About: a.About, Outcome: asks.OutcomeRefused, Words: r.refuseWith,
|
||||
At: time.Now()}
|
||||
go r.hear(w)
|
||||
return nil
|
||||
}
|
||||
if !r.noRecord {
|
||||
raw, _ := json.Marshal(map[string]any{"state": "open", "ask": a})
|
||||
r.records[id] = raw
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *standIn) Record(id string) (*stdio.StateEntry, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
raw, ok := r.records[id]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return &stdio.StateEntry{Key: Module + "." + id, Value: raw}, nil
|
||||
}
|
||||
|
||||
func (r *standIn) Kinds() ([]stdio.KindHeld, error) { return r.kinds, nil }
|
||||
|
||||
func (r *standIn) Warranted(ask string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.warranted = append(r.warranted, ask)
|
||||
return nil
|
||||
}
|
||||
|
||||
// choose is the operator choosing an option on a channel that proves who answered: the router's record of the ask
|
||||
// it took, closed with its warrant.
|
||||
func (r *standIn) choose(t *testing.T, id, option string, tweak func(*asks.Warrant)) asks.Warrant {
|
||||
t.Helper()
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
a, ok := r.asks[id]
|
||||
if !ok {
|
||||
t.Fatalf("the router took no ask %s", id)
|
||||
}
|
||||
o, ok := a.Option(option)
|
||||
if !ok {
|
||||
t.Fatalf("the ask %s offers no %s", id, option)
|
||||
}
|
||||
w := asks.Warrant{Ask: id, Asker: Module, About: a.About, Outcome: asks.OutcomeChosen, Option: o.ID, Label: o.Label,
|
||||
Level: o.Level, By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "4242", Verified: "user id verified"},
|
||||
Channel: "telegram", Proofs: []string{"P1"}, At: time.Date(2026, 10, 14, 19, 30, 0, 0, time.UTC),
|
||||
AskDigest: a.Digest()}
|
||||
if tweak != nil {
|
||||
tweak(&w)
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"state": "closed", "ask": a, "warrant": w})
|
||||
r.records[id] = raw
|
||||
return w
|
||||
}
|
||||
|
||||
// aProposingMesh is aMesh with the stand-in channel, its waits short.
|
||||
func aProposingMesh(t *testing.T) (*Config, *memStore, *standIn) {
|
||||
t.Helper()
|
||||
c, m := aMesh(t)
|
||||
r := newStandIn()
|
||||
r.hear = func(w asks.Warrant) { c.HeardEnd(w) }
|
||||
c.Asks, c.RouterWithin, c.RouterEvery = r, 300*time.Millisecond, 5*time.Millisecond
|
||||
return c, m, r
|
||||
}
|
||||
|
||||
func propose(t *testing.T, c *Config, call Call) *ProposeAnswer {
|
||||
t.Helper()
|
||||
call.Action = "propose"
|
||||
if call.Caller == "" {
|
||||
call.Caller = "g14.node-tools"
|
||||
}
|
||||
if call.Why == "" {
|
||||
call.Why = "the agents need the new rule"
|
||||
}
|
||||
a, err := c.Propose(call)
|
||||
if err != nil {
|
||||
t.Fatalf("propose %s: %v", call.Key, err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func storedProposal(t *testing.T, m *memStore, id string) Proposal {
|
||||
t.Helper()
|
||||
e, _ := m.Get(ProposalsState, id)
|
||||
if e == nil {
|
||||
t.Fatalf("no proposal %s is kept", id)
|
||||
}
|
||||
var p Proposal
|
||||
if err := json.Unmarshal(e.Value, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func storedValue(t *testing.T, m *memStore, key string) (Value, bool) {
|
||||
t.Helper()
|
||||
e, _ := m.Get(ValuesState, key)
|
||||
if e == nil {
|
||||
return Value{}, false
|
||||
}
|
||||
var v Value
|
||||
if err := json.Unmarshal(e.Value, &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// **A trusted value proposed end to end** (ADR 0348 §8, How it is checked: "A proposal's test"): mesh-config asks
|
||||
// under its own name; its ask names the level, the key and the value and says the change takes effect at once;
|
||||
// each option binds them; nothing is set until the operator's Approve; on Approve the value is set as a set is,
|
||||
// who approved it is kept beside it, and the warrant is recorded in the hand-act log.
|
||||
func TestATrustedValueIsProposedAskedUnderItsOwnNameAndSetOnTheOperatorsApprove(t *testing.T) {
|
||||
c, m, r := aProposingMesh(t)
|
||||
m.events, m.handActs = nil, nil
|
||||
value := map[string]any{"permissions": map[string]any{"deny": []any{"Bash(ssh:*)"}}}
|
||||
a := propose(t, c, Call{Key: "agent-managed-settings", Value: value})
|
||||
|
||||
// Nothing is set by a proposal.
|
||||
if _, set := storedValue(t, m, "mesh.agent-managed-settings"); set || len(m.events) != 0 || len(m.handActs) != 0 {
|
||||
t.Fatalf("a proposal set something: %d events, %d hand-acts", len(m.events), len(m.handActs))
|
||||
}
|
||||
if !strings.Contains(a.Router, "took the ask") || a.Proposal == "" || len(a.Reaches) != 1 ||
|
||||
a.Reaches[0] != "claude-code on g14" {
|
||||
t.Errorf("the proposal answered %+v", a)
|
||||
}
|
||||
// Asked under mesh-config's own name, on the operator channel.
|
||||
if len(r.subjects) != 1 || r.subjects[0] != asks.AskSubject("mesh-config") {
|
||||
t.Fatalf("asked on %v", r.subjects)
|
||||
}
|
||||
ask := r.asks[a.Proposal]
|
||||
if err := ask.Check(c.now()); err != nil {
|
||||
t.Fatalf("the ask: %v", err)
|
||||
}
|
||||
if err := asks.Glance(ask.Explanation); err != nil {
|
||||
t.Errorf("the ask does not read at a glance: %v\n%s", err, ask.Explanation)
|
||||
}
|
||||
for _, want := range []string{"agent-managed-settings", "mesh config", "takes effect at once", "claude-code",
|
||||
"a trusted value"} {
|
||||
if !strings.Contains(ask.Explanation, want) {
|
||||
t.Errorf("the ask's explanation does not say %q:\n%s", want, ask.Explanation)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(ask.Headline, "agent-managed-settings") || !strings.Contains(ask.Headline, "whole mesh") {
|
||||
t.Errorf("the headline is %q", ask.Headline)
|
||||
}
|
||||
// The value exact where who answers is proven; under the content rule elsewhere.
|
||||
exact := `+ agent-managed-settings: {"permissions":{"deny":["Bash(ssh:*)"]}}`
|
||||
shown := ask.Explanation
|
||||
if ask.Whole != "" {
|
||||
shown = ask.Whole
|
||||
}
|
||||
if !strings.Contains(shown, exact) || !strings.Contains(ask.Details+ask.WholeDetails, a.Proposal) {
|
||||
t.Errorf("the ask does not show the value whole:\n%s\n%s", shown, ask.WholeDetails)
|
||||
}
|
||||
// Each option binds the level, the key and the value.
|
||||
p := storedProposal(t, m, a.Proposal)
|
||||
for _, o := range ask.Options {
|
||||
if o.Level != asks.Approve {
|
||||
t.Errorf("the option %s is at %s", o.ID, o.Level)
|
||||
}
|
||||
act := p.Act(o.ID)
|
||||
want, _ := asks.ActDigest(act)
|
||||
if o.Binds != want || act["level"] != LevelMesh || act["key"] != "agent-managed-settings" ||
|
||||
act["value"] != valueDigest(json.RawMessage(`{"permissions":{"deny":["Bash(ssh:*)"]}}`), true) ||
|
||||
act["was"] != valueDigest(nil, false) {
|
||||
t.Errorf("the option %s binds %s for %v", o.ID, o.Binds, act)
|
||||
}
|
||||
}
|
||||
// Still nothing while the ask is open.
|
||||
if said, _ := c.Decide(); len(said) != 0 {
|
||||
t.Errorf("an open ask decided %v", said)
|
||||
}
|
||||
|
||||
r.choose(t, a.Proposal, OptionApprove, nil)
|
||||
said, err := c.Decide()
|
||||
if err != nil || len(said) != 1 || !strings.Contains(said[0], "approved by the operator and set") {
|
||||
t.Fatalf("the Approve decided %v (%v)", said, err)
|
||||
}
|
||||
v, set := storedValue(t, m, "mesh.agent-managed-settings")
|
||||
if !set || string(v.Value) != `{"permissions":{"deny":["Bash(ssh:*)"]}}` || v.By != "the operator" ||
|
||||
v.Via != "g14.node-tools" || !strings.Contains(v.Approved, "approved by the operator, as telegram identity 4242, "+
|
||||
"via telegram (user id verified)") || !strings.Contains(v.Approved, "ask "+a.Proposal) ||
|
||||
!strings.Contains(v.Approved, "proposed by g14.node-tools") {
|
||||
t.Fatalf("the value is %+v", v)
|
||||
}
|
||||
if res := m.resolved(t, "g14", "claude-code", "managed"); res.Source != SourceMesh ||
|
||||
string(res.Value) != string(v.Value) {
|
||||
t.Errorf("claude-code's managed settings resolve as %+v", res)
|
||||
}
|
||||
if len(m.events) != 1 || m.events[0].Approved != v.Approved || len(m.handActs) != 1 ||
|
||||
!strings.Contains(m.handActs[0], "the operator's Approve of proposal "+a.Proposal) {
|
||||
t.Errorf("the set raised %+v and recorded %q", m.events, m.handActs)
|
||||
}
|
||||
listed, _ := c.List(Call{Key: "agent-managed-settings"})
|
||||
if len(listed.Set) != 1 || listed.Set[0].Approved != v.Approved {
|
||||
t.Errorf("the listing says %+v", listed.Set)
|
||||
}
|
||||
if p = storedProposal(t, m, a.Proposal); p.State != ProposalApproved || !p.Recorded ||
|
||||
len(r.warranted) != 1 || r.warranted[0] != a.Proposal {
|
||||
t.Errorf("the proposal is %+v; warrants recorded %v", p, r.warranted)
|
||||
}
|
||||
// A warrant heard again, or read again, sets nothing twice.
|
||||
if said, _ = c.Decide(); len(said) != 0 || len(m.events) != 1 {
|
||||
t.Errorf("the warrant read again decided %v", said)
|
||||
}
|
||||
}
|
||||
|
||||
// **An Approve against a changed value is refused** (ADR 0348 §8, ADR 0277 §13; design 57 K4's proof): the value
|
||||
// stored at the level changed after the operator was shown the change against it, so nothing is set.
|
||||
func TestAnApproveIsRefusedWhenTheStoredValueChangedSince(t *testing.T) {
|
||||
c, m, r := aProposingMesh(t)
|
||||
a := propose(t, c, Call{Key: "agent-managed-settings", Value: map[string]any{"a": 1.0}})
|
||||
// Set at the terminal meanwhile.
|
||||
set(t, c, Call{Key: "agent-managed-settings", Value: map[string]any{"b": 2.0}, Caller: ControllerCaller, By: "jochen"})
|
||||
m.events = nil
|
||||
r.choose(t, a.Proposal, OptionApprove, nil)
|
||||
said, _ := c.Decide()
|
||||
if len(said) != 1 || !strings.Contains(said[0], "it changed since, so nothing is set") {
|
||||
t.Fatalf("the Approve decided %v", said)
|
||||
}
|
||||
if v, _ := storedValue(t, m, "mesh.agent-managed-settings"); string(v.Value) != `{"b":2}` || v.Approved != "" {
|
||||
t.Errorf("the value is %+v", v)
|
||||
}
|
||||
if len(m.events) != 0 {
|
||||
t.Errorf("a refused Approve raised %+v", m.events)
|
||||
}
|
||||
if p := storedProposal(t, m, a.Proposal); p.State != ProposalRefused {
|
||||
t.Errorf("the proposal is %s: %s", p.State, p.Outcome)
|
||||
}
|
||||
|
||||
// And a clear since: the value it was shown against (a value) is no longer stored.
|
||||
set(t, c, Call{Key: "font-size", Value: 12.0, Node: "g14"})
|
||||
a = propose(t, c, Call{Key: "font-size", Value: 14.0, Node: "g14"})
|
||||
set(t, c, Call{Action: "clear", Key: "font-size", Node: "g14"})
|
||||
r.choose(t, a.Proposal, OptionApprove, nil)
|
||||
if said, _ = c.Decide(); len(said) != 1 || !strings.Contains(said[0], "no value set") {
|
||||
t.Fatalf("the Approve after a clear decided %v", said)
|
||||
}
|
||||
if _, isSet := storedValue(t, m, "node.g14.font-size"); isSet {
|
||||
t.Error("an Approve against a cleared value set it")
|
||||
}
|
||||
}
|
||||
|
||||
// **A record that changed after it was asked is refused on Approve** (ADR 0277 §12): its value, or the value it was
|
||||
// shown against, no longer what the ask bound.
|
||||
func TestAnApproveIsRefusedWhenTheProposalChangedAfterItWasAsked(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
tamper func(p *Proposal)
|
||||
want string
|
||||
}{
|
||||
{"its value", func(p *Proposal) { p.Value = json.RawMessage(`{"evil":true}`) }, "its value is not the value"},
|
||||
{"its value and digest", func(p *Proposal) {
|
||||
p.Value = json.RawMessage(`{"evil":true}`)
|
||||
p.Digest = valueDigest(p.Value, true)
|
||||
}, "it was not the ask the person was shown"},
|
||||
{"the value it was shown against", func(p *Proposal) {
|
||||
p.Was, p.HadValue = json.RawMessage(`{"x":1}`), true
|
||||
}, "the value it was shown against"},
|
||||
{"its level", func(p *Proposal) { p.Level = Level{Kind: LevelNode, Node: "g14"} }, "not the ask the person was shown"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c, m, r := aProposingMesh(t)
|
||||
a := propose(t, c, Call{Key: "agent-managed-settings", Value: map[string]any{"a": 1.0}})
|
||||
r.choose(t, a.Proposal, OptionApprove, nil)
|
||||
p := storedProposal(t, m, a.Proposal)
|
||||
tc.tamper(&p)
|
||||
_ = m.Put(ProposalsState, p.ID, p)
|
||||
said, _ := c.Decide()
|
||||
if len(said) != 1 || !strings.Contains(said[0], tc.want) {
|
||||
t.Fatalf("decided %v, want %q", said, tc.want)
|
||||
}
|
||||
if keys, _ := m.Keys(ValuesState); len(keys) != 0 {
|
||||
t.Errorf("a changed proposal set %v", keys)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// **A warrant that is not the answer to the ask is refused** (ADR 0277 §11): another ask's digest, another level, an
|
||||
// answer after expiry, nobody verified.
|
||||
func TestAWarrantThatDoesNotAnswerTheAskIsRefused(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
tweak func(w *asks.Warrant)
|
||||
}{
|
||||
{"another ask's digest", func(w *asks.Warrant) { w.AskDigest = "sha256:00" }},
|
||||
{"another level", func(w *asks.Warrant) { w.Level = asks.Acknowledge }},
|
||||
{"after its expiry", func(w *asks.Warrant) { w.At = time.Date(2026, 10, 16, 0, 0, 0, 0, time.UTC) }},
|
||||
{"an option it did not offer", func(w *asks.Warrant) { w.Option = "force" }},
|
||||
{"no person", func(w *asks.Warrant) { w.By = nil }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c, m, r := aProposingMesh(t)
|
||||
a := propose(t, c, Call{Key: "agent-managed-settings", Value: map[string]any{"a": 1.0}})
|
||||
r.choose(t, a.Proposal, OptionApprove, tc.tweak)
|
||||
said, _ := c.Decide()
|
||||
if len(said) != 1 || !strings.Contains(said[0], "refused") {
|
||||
t.Fatalf("decided %v", said)
|
||||
}
|
||||
if keys, _ := m.Keys(ValuesState); len(keys) != 0 {
|
||||
t.Errorf("a warrant that does not hold set %v", keys)
|
||||
}
|
||||
if len(r.warranted) != 0 {
|
||||
t.Errorf("a refused warrant was recorded as acted on: %v", r.warranted)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// **On Decline, at expiry, or on the router's refusal nothing is set** (ADR 0277 §9, §16, §20), and a Decline is a
|
||||
// warrant the hand-act log holds.
|
||||
func TestDeclineExpiryAndTheRoutersRefusalSetNothing(t *testing.T) {
|
||||
c, m, r := aProposingMesh(t)
|
||||
a := propose(t, c, Call{Key: "agent-managed-settings", Value: map[string]any{"a": 1.0}})
|
||||
r.choose(t, a.Proposal, OptionDecline, nil)
|
||||
if said, _ := c.Decide(); len(said) != 1 || !strings.Contains(said[0], "declined by the operator") {
|
||||
t.Fatalf("the Decline decided %v", said)
|
||||
}
|
||||
if p := storedProposal(t, m, a.Proposal); p.State != ProposalDeclined || p.Approved != "" || !p.Recorded {
|
||||
t.Errorf("the declined proposal is %+v", p)
|
||||
}
|
||||
|
||||
// Ended without a choice: the router's word.
|
||||
a = propose(t, c, Call{Key: "font-size", Value: 12.0, Node: "g14"})
|
||||
c.HeardEnd(asks.Warrant{Ask: a.Proposal, Asker: Module, Outcome: asks.OutcomeExpired, At: time.Now()})
|
||||
if p := storedProposal(t, m, a.Proposal); p.State != ProposalEnded || !strings.Contains(p.Outcome, "expired") {
|
||||
t.Errorf("the expired proposal is %+v", p)
|
||||
}
|
||||
|
||||
// Ended, and the router never recorded it: discarded once its time is past.
|
||||
r.noRecord = true
|
||||
a = propose(t, c, Call{Key: "font-size", Value: 13.0, Node: "novox"})
|
||||
if !strings.Contains(a.Router, "has not said yet") {
|
||||
t.Errorf("an ask the router has not taken answered %q", a.Router)
|
||||
}
|
||||
later := time.Date(2026, 10, 16, 0, 0, 0, 0, time.UTC)
|
||||
c.Now = func() time.Time { return later }
|
||||
if said, _ := c.Decide(); len(said) != 1 || !strings.Contains(said[0], "keeps no record") {
|
||||
t.Errorf("an ask never recorded decided %v", said)
|
||||
}
|
||||
if keys, _ := m.Keys(ValuesState); len(keys) != 0 {
|
||||
t.Errorf("an unanswered proposal set %v", keys)
|
||||
}
|
||||
|
||||
// Refused by the router at once: said to the proposer.
|
||||
c2, m2, r2 := aProposingMesh(t)
|
||||
r2.refuseWith = "no channel can carry an approval"
|
||||
_, err := c2.Propose(Call{Action: "propose", Key: "font-size", Value: 12.0, Caller: "g14.node-tools", Why: "w"})
|
||||
if err == nil || !strings.Contains(err.Error(), "no channel can carry an approval") {
|
||||
t.Fatalf("the router's refusal answered %v", err)
|
||||
}
|
||||
all, _ := c2.proposals()
|
||||
if len(all) != 1 || all[0].State != ProposalEnded {
|
||||
t.Errorf("the refused proposal is %+v", all)
|
||||
}
|
||||
if keys, _ := m2.Keys(ValuesState); len(keys) != 0 {
|
||||
t.Errorf("a refused proposal set %v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
// **A proposal is judged as a set before anyone is asked, and refused at once when it cannot be asked** (ADR 0277 §4,
|
||||
// §19, §21): a value the verb would refuse; nothing that would change; a value shaped like a secret; no channel that
|
||||
// proves who answered; a publish that fails; the same change twice; a fourth open ask.
|
||||
func TestAProposalIsJudgedAndRefusedBeforeTheOperatorIsAsked(t *testing.T) {
|
||||
c, m, r := aProposingMesh(t)
|
||||
set(t, c, Call{Key: "font-size", Value: 12.0})
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
call Call
|
||||
want string
|
||||
}{
|
||||
{"a key not declared", Call{Key: "colour", Value: "red", Node: "g14", Module: "dunst"}, "it declares:"},
|
||||
{"a value failing validation", Call{Key: "font-size", Value: 99.0}, "above the maximum 72"},
|
||||
{"a module value without a node", Call{Key: "width", Value: 400.0, Module: "dunst"}, "no module value for the whole mesh"},
|
||||
{"nothing that would change", Call{Key: "font-size", Value: 12.0}, "already holds exactly 12"},
|
||||
{"a clear of nothing", Call{Key: "font-size", Clear: true, Node: "g14"}, "nothing to clear"},
|
||||
{"a clear with a value", Call{Key: "font-size", Clear: true, Value: 1.0}, "a clear carries no value"},
|
||||
{"a secret's shape", Call{Key: "agent-managed-settings", Value: map[string]any{"env": map[string]any{
|
||||
"API_TOKEN": "ghp_abcdefghijklmnopqrstuvwxyz0123456789"}}}, "leaves the mesh nowhere"},
|
||||
{"by", Call{Key: "font-size", Value: 13.0, By: "jochen"}, "by names who gave"},
|
||||
{"no why", Call{Key: "font-size", Value: 13.0, Why: " "}, "say why"},
|
||||
{"an unproven caller", Call{Key: "font-size", Value: 13.0, Caller: Unproven}, "no proven caller"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
call := tc.call
|
||||
call.Action = "propose"
|
||||
if call.Caller == "" {
|
||||
call.Caller = "g14.node-tools"
|
||||
}
|
||||
if call.Why == "" {
|
||||
call.Why = "a test"
|
||||
}
|
||||
if _, err := c.Propose(call); err == nil || !strings.Contains(err.Error(), tc.want) ||
|
||||
!strings.Contains(err.Error(), "Nothing was proposed") {
|
||||
t.Fatalf("refused with %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if len(r.subjects) != 0 {
|
||||
t.Fatalf("a refused proposal was asked: %v", r.subjects)
|
||||
}
|
||||
|
||||
r.kinds = []stdio.KindHeld{{Seat: asks.ChannelSeat, Kind: "desktop", Capabilities: []string{"choice"}}}
|
||||
if _, err := c.Propose(Call{Action: "propose", Key: "font-size", Value: 13.0, Caller: "g", Why: "w"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "no channel that proves who answered") {
|
||||
t.Errorf("no approving channel: %v", err)
|
||||
}
|
||||
r.kinds = newStandIn().kinds
|
||||
r.refuse = errors.New("the bus is away")
|
||||
if _, err := c.Propose(Call{Action: "propose", Key: "font-size", Value: 13.0, Caller: "g", Why: "w"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "could not be asked") {
|
||||
t.Errorf("a failed publish: %v", err)
|
||||
}
|
||||
r.refuse = nil
|
||||
|
||||
first := propose(t, c, Call{Key: "font-size", Value: 14.0, Node: "g14"})
|
||||
if _, err := c.Propose(Call{Action: "propose", Key: "font-size", Value: 14.0, Node: "g14", Caller: "g", Why: "w"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "proposed already, as "+first.Proposal) {
|
||||
t.Errorf("the same change twice: %v", err)
|
||||
}
|
||||
// A newer proposal for the same place replaces the older (ADR 0277 §10), and is not counted twice.
|
||||
newer := propose(t, c, Call{Key: "font-size", Value: 15.0, Node: "g14"})
|
||||
if len(newer.Replaced) != 1 || newer.Replaced[0] != first.Proposal ||
|
||||
storedProposal(t, m, first.Proposal).State != ProposalEnded {
|
||||
t.Errorf("the newer proposal replaced %v", newer.Replaced)
|
||||
}
|
||||
propose(t, c, Call{Key: "font-size", Value: 15.0, Node: "novox"})
|
||||
propose(t, c, Call{Key: "agent-managed-settings", Value: map[string]any{"a": 1.0}})
|
||||
if _, err := c.Propose(Call{Action: "propose", Key: "width", Value: 400.0, Node: "g14", Module: "dunst",
|
||||
Caller: "g", Why: "w"}); err == nil || !strings.Contains(err.Error(), "3 proposals already wait") {
|
||||
t.Errorf("a fourth open ask: %v", err)
|
||||
}
|
||||
if keys, _ := m.Keys(ValuesState); len(keys) != 1 {
|
||||
t.Errorf("proposals set %v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
// **A clear is proposed and approved the same way**, and a trusted value set or cleared through the verb is refused,
|
||||
// naming the proposal as the way (ADR 0277 §2).
|
||||
func TestAClearIsProposedAndATrustedSetNamesTheProposal(t *testing.T) {
|
||||
c, m, r := aProposingMesh(t)
|
||||
_, err := c.Change(Call{Action: "set", Key: "agent-managed-settings", Value: map[string]any{}, Caller: "g14.node-tools",
|
||||
Why: "w"})
|
||||
if err == nil || !strings.Contains(err.Error(), "propose it instead") {
|
||||
t.Errorf("a trusted set through the verb: %v", err)
|
||||
}
|
||||
set(t, c, Call{Key: "managed", Value: map[string]any{"a": 1.0}, Node: "g14", Module: "claude-code",
|
||||
Caller: ControllerCaller, By: "jochen"})
|
||||
a := propose(t, c, Call{Key: "managed", Clear: true, Node: "g14", Module: "claude-code"})
|
||||
ask := r.asks[a.Proposal]
|
||||
if !strings.HasPrefix(ask.Headline, "Clear managed for claude-code on g14") ||
|
||||
!strings.Contains(ask.Explanation, `- managed (was: {"a":1})`) {
|
||||
t.Errorf("the clear asks %q:\n%s", ask.Headline, ask.Explanation)
|
||||
}
|
||||
r.choose(t, a.Proposal, OptionApprove, nil)
|
||||
if said, _ := c.Decide(); len(said) != 1 || !strings.Contains(said[0], "is cleared") {
|
||||
t.Fatalf("the clear decided %v", said)
|
||||
}
|
||||
if _, isSet := storedValue(t, m, "module.g14.claude-code.managed"); isSet {
|
||||
t.Error("an approved clear left the value")
|
||||
}
|
||||
h, _ := c.History(Call{Key: "managed"})
|
||||
if last := h[len(h)-1]; last.Action != "clear" || !strings.Contains(last.Approved, "approved by the operator") {
|
||||
t.Errorf("the history keeps %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
// **The proposals are listed, and one is read whole** with whether the router's ask is the one composed from it.
|
||||
func TestTheProposalsAreListedAndOneIsReadWhole(t *testing.T) {
|
||||
c, m, _ := aProposingMesh(t)
|
||||
a := propose(t, c, Call{Key: "agent-managed-settings", Value: map[string]any{"a": 1.0}})
|
||||
b := propose(t, c, Call{Key: "font-size", Value: 12.0, Node: "g14"})
|
||||
rows, err := c.Do(Call{Action: "proposals"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l := rows.([]ProposalRow); len(l) != 2 || l[0].ID != b.Proposal || l[1].State != ProposalWaiting || l[0].Value != nil {
|
||||
t.Errorf("listed %+v", l)
|
||||
}
|
||||
one, err := c.Proposals(Call{Proposal: a.Proposal})
|
||||
if err != nil || len(one) != 1 || string(one[0].Value) != `{"a":1}` || one[0].Fingerprint != a.Fingerprint ||
|
||||
!strings.Contains(one[0].Phone, "exactly this proposal") {
|
||||
t.Fatalf("read whole %+v (%v)", one, err)
|
||||
}
|
||||
p := storedProposal(t, m, a.Proposal)
|
||||
p.Value, p.Digest = json.RawMessage(`{"a":2}`), valueDigest(json.RawMessage(`{"a":2}`), true)
|
||||
_ = m.Put(ProposalsState, p.ID, p)
|
||||
if one, _ = c.Proposals(Call{Proposal: a.Proposal}); !strings.Contains(one[0].Phone, "ANOTHER ask") {
|
||||
t.Errorf("a changed proposal reads %q", one[0].Phone)
|
||||
}
|
||||
if _, err := c.Proposals(Call{Proposal: "cfg-none"}); err == nil {
|
||||
t.Error("an unknown proposal was read")
|
||||
}
|
||||
}
|
||||
|
||||
// **The verb's new arguments are read strictly.**
|
||||
func TestTheProposalArgumentsAreReadStrictly(t *testing.T) {
|
||||
call, err := callOf(map[string]any{"action": "propose", "key": "k", "clear": true, "proposal": " cfg-1 "})
|
||||
if err != nil || !call.Clear || call.Proposal != "cfg-1" {
|
||||
t.Errorf("read %+v (%v)", call, err)
|
||||
}
|
||||
if _, err := callOf(map[string]any{"clear": "yes"}); err == nil {
|
||||
t.Error("a clear that is not a boolean was read")
|
||||
}
|
||||
c, _ := aMesh(t)
|
||||
if _, err := c.Do(Call{Action: "set", Key: "font-size", Value: 12.0, Clear: true, Caller: "g", Why: "w"}); err == nil {
|
||||
t.Error("a set took clear")
|
||||
}
|
||||
if _, err := c.Do(Call{Action: "propose", Key: "font-size", Value: 12.0, Caller: "g", Why: "w"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "cannot be asked from here") {
|
||||
t.Errorf("a proposal with no channel: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// A module only pulled in by another on a node, and handed to the provisioner there, has a value of its own in the
|
||||
// assignments mirror (mesh-controller internal/inventory AssignmentValues, issue 765): what requires it, its plan,
|
||||
// and no time it was assigned. Its values resolve there like an assigned module's, so the node's client can fill its
|
||||
// templates; they go when the value goes.
|
||||
func TestAModulePulledInHasItsValuesResolvedOnItsNode(t *testing.T) {
|
||||
c, m := aMesh(t)
|
||||
if _, err := c.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.mirror(t, AssignmentsRead, "ace.dunst", `{"node":"ace","module":"dunst","required-by":"desk",`+
|
||||
`"provisioning":{"templates":{"source":"x","digest":"y"},"files":[]}}`)
|
||||
if r, err := c.Reconcile(); err != nil || len(r.Moved) != 4 {
|
||||
t.Fatalf("a pulled-in module's value moved %+v (%v)", r.Moved, err)
|
||||
}
|
||||
expect(t, m, "ace", "10", SourceDefault)
|
||||
_ = m.Delete(AssignmentsRead, "ace.dunst")
|
||||
if r, _ := c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone {
|
||||
t.Errorf("a module no longer pulled in moved %+v", r.Moved)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The four sources (novox/hq ADR 0348 §2, §3, §9): a module's value on a node resolves, the most specific winning,
|
||||
// from the default in its manifest, mesh config for the predefined key it falls back to, node config for that key
|
||||
// on the node, and the module's own value on that node. A value naming no predefined key resolves from its default
|
||||
// and the module's own value only.
|
||||
|
||||
// The states this module keeps, and those it reads (its manifest's `state` and `reads`).
|
||||
const (
|
||||
ValuesState = "values"
|
||||
ResolvedState = "resolved"
|
||||
PredefinedState = "predefined"
|
||||
ProposalsState = "proposals"
|
||||
|
||||
AssignmentsRead = "mesh-controller.assignments"
|
||||
DeclaredConfigRead = "mesh-controller.declared-config"
|
||||
PredefinedRead = "mesh-controller.predefined"
|
||||
|
||||
// HistoryLog is the log of every set and clear, with the proven caller and why (ADR 0348 §9).
|
||||
HistoryLog = "history"
|
||||
)
|
||||
|
||||
// The sources a resolved value says it came from.
|
||||
const (
|
||||
SourceDefault = "default"
|
||||
SourceMesh = "mesh"
|
||||
SourceNode = "node"
|
||||
SourceModule = "module"
|
||||
// SourceUnset is a required value nothing sets: mesh-config answers it unset (ADR 0348 §4).
|
||||
SourceUnset = "unset"
|
||||
)
|
||||
|
||||
// The levels a value is set at, and how each is keyed in mesh-config_values (ADR 0348 §9).
|
||||
const (
|
||||
LevelMesh = "mesh"
|
||||
LevelNode = "node"
|
||||
LevelModule = "module"
|
||||
)
|
||||
|
||||
// Level is where a value is set: the mesh, a node, or a module on a node.
|
||||
type Level struct {
|
||||
Kind string `json:"level"`
|
||||
Node string `json:"node,omitempty"`
|
||||
Module string `json:"module,omitempty"`
|
||||
}
|
||||
|
||||
// ValueKey is the level's key for a config key in mesh-config_values.
|
||||
func (l Level) ValueKey(key string) string {
|
||||
switch l.Kind {
|
||||
case LevelNode:
|
||||
return "node." + l.Node + "." + key
|
||||
case LevelModule:
|
||||
return "module." + l.Node + "." + l.Module + "." + key
|
||||
}
|
||||
return "mesh." + key
|
||||
}
|
||||
|
||||
func (l Level) String() string {
|
||||
switch l.Kind {
|
||||
case LevelNode:
|
||||
return "node config on " + l.Node
|
||||
case LevelModule:
|
||||
return l.Module + "'s own value on " + l.Node
|
||||
}
|
||||
return "mesh config"
|
||||
}
|
||||
|
||||
// parseValueKey is the level and the config key a key of mesh-config_values names.
|
||||
func parseValueKey(k string) (Level, string, bool) {
|
||||
parts := strings.SplitN(k, ".", 4)
|
||||
switch {
|
||||
case len(parts) >= 2 && parts[0] == LevelMesh:
|
||||
return Level{Kind: LevelMesh}, strings.Join(parts[1:], "."), true
|
||||
case len(parts) >= 3 && parts[0] == LevelNode:
|
||||
return Level{Kind: LevelNode, Node: parts[1]}, strings.Join(parts[2:], "."), true
|
||||
case len(parts) == 4 && parts[0] == LevelModule:
|
||||
return Level{Kind: LevelModule, Node: parts[1], Module: parts[2]}, parts[3], true
|
||||
}
|
||||
return Level{}, "", false
|
||||
}
|
||||
|
||||
// Value is one key of mesh-config_values: the value set, who set it and why, and when.
|
||||
type Value struct {
|
||||
Value json.RawMessage `json:"value"`
|
||||
// By is who set it, as proven: the caller, or for a value given at the controller's terminal, who gave it there.
|
||||
By string `json:"by"`
|
||||
// Via is the bus account the call came from.
|
||||
Via string `json:"via"`
|
||||
Why string `json:"why"`
|
||||
// Approved is who approved it, for a value set on the operator's warrant (ADR 0348 §8, ADR 0277 §15): told from
|
||||
// one given at the terminal, which leaves it empty.
|
||||
Approved string `json:"approved,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// Resolved is one key of mesh-config_resolved, `<node>.<module>.<key>`: the value a module on a node takes for a
|
||||
// key, and where it came from. Built from the sources alone, so the same sources give the same bytes and a pass
|
||||
// over unchanged sources writes nothing.
|
||||
type Resolved struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Key string `json:"key"`
|
||||
Value json.RawMessage `json:"value,omitempty"`
|
||||
Source string `json:"source"`
|
||||
// Predefined is the predefined key the value falls back to, when it names one.
|
||||
Predefined string `json:"predefined,omitempty"`
|
||||
// Required says an unset value is the operator's own to set.
|
||||
Required bool `json:"required,omitempty"`
|
||||
Trusted bool `json:"trusted,omitempty"`
|
||||
}
|
||||
|
||||
// ResolvedKey is a resolved value's key in mesh-config_resolved.
|
||||
func ResolvedKey(node, module, key string) string { return node + "." + module + "." + key }
|
||||
|
||||
// Sources is everything a resolution reads: the assignments, every module's declarations, the published list of
|
||||
// predefined keys, and every value set.
|
||||
type Sources struct {
|
||||
Assignments []Assignment
|
||||
Declared map[string]ModuleDeclarations
|
||||
Predefined map[string]Published
|
||||
Values map[string]Value
|
||||
}
|
||||
|
||||
// Resolve is every assigned module's every declared value on its node, keyed as mesh-config_resolved is.
|
||||
func Resolve(s Sources) map[string]Resolved {
|
||||
out := map[string]Resolved{}
|
||||
for _, a := range s.Assignments {
|
||||
decl, ok := s.Declared[a.Module]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, key := range sortedKeys(decl.Config) {
|
||||
r := resolveOne(s, a.Node, a.Module, key, decl.Config[key])
|
||||
out[ResolvedKey(a.Node, a.Module, key)] = r
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveOne(s Sources, node, module, key string, d Declaration) Resolved {
|
||||
r := Resolved{Node: node, Module: module, Key: key, Source: SourceUnset, Predefined: d.Predefined,
|
||||
Required: d.Required, Trusted: d.Trusted}
|
||||
if d.HasDefault() {
|
||||
r.Value, r.Source = compact(d.Default), SourceDefault
|
||||
}
|
||||
if d.Predefined != "" {
|
||||
if p, ok := s.Predefined[d.Predefined]; ok && p.Trusted {
|
||||
r.Trusted = true
|
||||
}
|
||||
if v, ok := s.Values[Level{Kind: LevelMesh}.ValueKey(d.Predefined)]; ok {
|
||||
r.Value, r.Source = compact(v.Value), SourceMesh
|
||||
}
|
||||
if v, ok := s.Values[Level{Kind: LevelNode, Node: node}.ValueKey(d.Predefined)]; ok {
|
||||
r.Value, r.Source = compact(v.Value), SourceNode
|
||||
}
|
||||
}
|
||||
if v, ok := s.Values[Level{Kind: LevelModule, Node: node, Module: module}.ValueKey(key)]; ok {
|
||||
r.Value, r.Source = compact(v.Value), SourceModule
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// compact is a JSON value encoded the one way: objects' keys sorted, no spaces.
|
||||
func compact(raw json.RawMessage) json.RawMessage {
|
||||
var v any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return raw
|
||||
}
|
||||
out, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Moved is one resolved value that changed: a module on a node whose value for a key moved, from what to what.
|
||||
type Moved struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Key string `json:"key"`
|
||||
From json.RawMessage `json:"from,omitempty"`
|
||||
To json.RawMessage `json:"to,omitempty"`
|
||||
Source string `json:"source"`
|
||||
// Gone says the module no longer takes the key there: unassigned, or no longer declared.
|
||||
Gone bool `json:"gone,omitempty"`
|
||||
}
|
||||
|
||||
// diff is what moved between the resolved values held and those wanted, in key order.
|
||||
func diff(held, want map[string]Resolved) []Moved {
|
||||
var out []Moved
|
||||
for _, k := range sortedKeys(want) {
|
||||
w := want[k]
|
||||
h, had := held[k]
|
||||
if had && string(encodeResolved(h)) == string(encodeResolved(w)) {
|
||||
continue
|
||||
}
|
||||
m := Moved{Node: w.Node, Module: w.Module, Key: w.Key, To: w.Value, Source: w.Source}
|
||||
if had {
|
||||
m.From = h.Value
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
for _, k := range sortedKeys(held) {
|
||||
if _, ok := want[k]; !ok {
|
||||
h := held[k]
|
||||
out = append(out, Moved{Node: h.Node, Module: h.Module, Key: h.Key, From: h.Value, Gone: true})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
return ResolvedKey(out[i].Node, out[i].Module, out[i].Key) < ResolvedKey(out[j].Node, out[j].Module, out[j].Key)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func encodeResolved(r Resolved) []byte {
|
||||
raw, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("a resolved value cannot be encoded: %v", err))
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Reading the sources, writing the resolved values, and publishing the list of predefined keys.
|
||||
|
||||
// sources reads everything a resolution reads, from the bus as it is now.
|
||||
func (c *Config) sources() (Sources, error) {
|
||||
s := Sources{Declared: map[string]ModuleDeclarations{}, Predefined: map[string]Published{},
|
||||
Values: map[string]Value{}}
|
||||
var problems []string
|
||||
if err := c.each(AssignmentsRead, func(key string, raw []byte) error {
|
||||
var a Assignment
|
||||
if err := json.Unmarshal(raw, &a); err != nil || a.Node == "" || a.Module == "" {
|
||||
return fmt.Errorf("the assignment %s is not one: %s", key, raw)
|
||||
}
|
||||
s.Assignments = append(s.Assignments, a)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return s, fmt.Errorf("the assignments cannot be read: %w", err)
|
||||
}
|
||||
if err := c.each(DeclaredConfigRead, func(key string, raw []byte) error {
|
||||
m, err := ParseModuleDeclarations(raw)
|
||||
if err != nil {
|
||||
// One module's declarations unread leaves that module unresolved, said; the rest resolve.
|
||||
problems = append(problems, fmt.Sprintf("%s: %v", key, err))
|
||||
return nil
|
||||
}
|
||||
s.Declared[m.Module] = m
|
||||
return nil
|
||||
}); err != nil {
|
||||
return s, fmt.Errorf("the modules' declarations cannot be read: %w", err)
|
||||
}
|
||||
if err := c.each(PredefinedState, func(key string, raw []byte) error {
|
||||
var p Published
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
return fmt.Errorf("the published predefined key %s is not one: %w", key, err)
|
||||
}
|
||||
s.Predefined[key] = p
|
||||
return nil
|
||||
}); err != nil {
|
||||
return s, fmt.Errorf("the published list of predefined keys cannot be read: %w", err)
|
||||
}
|
||||
if err := c.each(ValuesState, func(key string, raw []byte) error {
|
||||
var v Value
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return fmt.Errorf("the value %s is not one: %w", key, err)
|
||||
}
|
||||
s.Values[key] = v
|
||||
return nil
|
||||
}); err != nil {
|
||||
return s, fmt.Errorf("the values cannot be read: %w", err)
|
||||
}
|
||||
for _, p := range problems {
|
||||
c.logf("[mesh-config] a module's declarations are not read, so its values are not resolved: %s", p)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// each hands every key of a state to visit.
|
||||
func (c *Config) each(state string, visit func(key string, raw []byte) error) error {
|
||||
keys, err := c.Store.Keys(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, k := range keys {
|
||||
e, err := c.Store.Get(state, k)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e == nil {
|
||||
continue // removed between the listing and the read
|
||||
}
|
||||
if err := visit(k, e.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// held is what mesh-config_resolved holds now.
|
||||
func (c *Config) held() (map[string]Resolved, error) {
|
||||
out := map[string]Resolved{}
|
||||
err := c.each(ResolvedState, func(key string, raw []byte) error {
|
||||
var r Resolved
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
// A value nobody can read is written again from the sources.
|
||||
out[key] = Resolved{Key: key, Source: "unreadable"}
|
||||
return nil
|
||||
}
|
||||
out[key] = r
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// writeResolved makes mesh-config_resolved hold what the sources resolve to, writing only what moved, and
|
||||
// answers what moved.
|
||||
func (c *Config) writeResolved(s Sources) ([]Moved, error) {
|
||||
held, err := c.held()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the resolved values cannot be read: %w", err)
|
||||
}
|
||||
want := Resolve(s)
|
||||
moved := diff(held, want)
|
||||
var problems []error
|
||||
for _, m := range moved {
|
||||
key := ResolvedKey(m.Node, m.Module, m.Key)
|
||||
if m.Gone {
|
||||
if err := c.Store.Delete(ResolvedState, key); err != nil {
|
||||
problems = append(problems, fmt.Errorf("removing %s: %w", key, err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := c.Store.Put(ResolvedState, key, want[key]); err != nil {
|
||||
problems = append(problems, fmt.Errorf("writing %s: %w", key, err))
|
||||
}
|
||||
}
|
||||
// A key that is no resolved value at all (written by an older mesh-config, or unreadable) is removed.
|
||||
for key, h := range held {
|
||||
if _, ok := want[key]; !ok && h.Source == "unreadable" {
|
||||
if err := c.Store.Delete(ResolvedState, key); err != nil {
|
||||
problems = append(problems, fmt.Errorf("removing %s: %w", key, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
return moved, errors.Join(problems...)
|
||||
}
|
||||
|
||||
// Resolution is what one pass of Reconcile did.
|
||||
type Resolution struct {
|
||||
Moved []Moved `json:"moved"`
|
||||
Published string `json:"published,omitempty"`
|
||||
Withheld string `json:"withheld,omitempty"`
|
||||
}
|
||||
|
||||
// Reconcile publishes the newest list of predefined keys and brings mesh-config_resolved to what the sources
|
||||
// resolve to: run at start, and whenever an assignment, a declaration or the list moves. A move it makes raises
|
||||
// config-changed like a set, with no level, naming what moved.
|
||||
func (c *Config) Reconcile() (Resolution, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
var r Resolution
|
||||
published, err := c.publish()
|
||||
if err != nil {
|
||||
c.logf("[mesh-config] the list of predefined keys is not published: %v", err)
|
||||
}
|
||||
r.Published, r.Withheld = published, c.withheld
|
||||
s, err := c.sources()
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
moved, err := c.writeResolved(s)
|
||||
r.Moved = moved
|
||||
if len(moved) > 0 {
|
||||
c.record("resolved", Change{Action: "resolve", Why: "an assignment, a declaration or the list of predefined " +
|
||||
"keys changed", At: c.now(), Moved: moved})
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
// publish makes mesh-config_predefined hold the list the controller kept, when it is newer than the one published
|
||||
// (ADR 0348 §7: the newest trunk build wins). **A list dropping a key that still holds a value is withheld**: the
|
||||
// list published stays, and why is said in every listing and in the log, because a value for a key no longer on
|
||||
// the list could neither be resolved nor cleared by name (§7: removing a key still holding a value is refused).
|
||||
// It answers what it published, empty when nothing changed.
|
||||
func (c *Config) publish() (string, error) {
|
||||
e, err := c.Store.Get(PredefinedRead, PredefinedMirrorKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the controller's list cannot be read: %w", err)
|
||||
}
|
||||
if e == nil {
|
||||
return "", nil // no list kept yet
|
||||
}
|
||||
var list PredefinedList
|
||||
if err := json.Unmarshal(e.Value, &list); err != nil || list.Keys == nil {
|
||||
return "", fmt.Errorf("the controller's list is not one: %s", e.Value)
|
||||
}
|
||||
current := map[string]Published{}
|
||||
if err := c.each(PredefinedState, func(key string, raw []byte) error {
|
||||
var p Published
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
current[key] = p
|
||||
return nil
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
generation := 0
|
||||
for _, p := range current {
|
||||
generation = max(generation, p.Generation)
|
||||
}
|
||||
if len(current) > 0 && generation >= list.Generation {
|
||||
c.withheld = ""
|
||||
return "", nil
|
||||
}
|
||||
held := map[string][]string{}
|
||||
if err := c.each(ValuesState, func(key string, _ []byte) error {
|
||||
level, k, ok := parseValueKey(key)
|
||||
if ok && level.Kind != LevelModule {
|
||||
if _, kept := list.Keys[k]; !kept {
|
||||
held[k] = append(held[k], level.String())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(held) > 0 {
|
||||
var why []string
|
||||
for _, k := range sortedKeys(held) {
|
||||
why = append(why, fmt.Sprintf("%s (still set as %s)", k, strings.Join(held[k], ", ")))
|
||||
}
|
||||
c.withheld = fmt.Sprintf("the list at %s (generation %d) is not published: it drops %s; clear those values "+
|
||||
"first, or keep the key (novox/hq ADR 0348 §7)", short(list.Commit), list.Generation, strings.Join(why, "; "))
|
||||
c.logf("[mesh-config] %s", c.withheld)
|
||||
return "", nil
|
||||
}
|
||||
c.withheld = ""
|
||||
var problems []error
|
||||
for _, k := range sortedKeys(list.Keys) {
|
||||
p := Published{PredefinedKey: list.Keys[k], Generation: list.Generation, Commit: list.Commit}
|
||||
if err := c.Store.Put(PredefinedState, k, p); err != nil {
|
||||
problems = append(problems, err)
|
||||
}
|
||||
}
|
||||
for k := range current {
|
||||
if _, kept := list.Keys[k]; !kept {
|
||||
if err := c.Store.Delete(PredefinedState, k); err != nil {
|
||||
problems = append(problems, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := errors.Join(problems...); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("the list at %s (generation %d): %s", short(list.Commit), list.Generation,
|
||||
joinKeys(list.Keys)), nil
|
||||
}
|
||||
|
||||
func short(commit string) string {
|
||||
if len(commit) > 8 {
|
||||
return commit[:8]
|
||||
}
|
||||
return commit
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module mesh-config
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 h1:I9rm8HhYRJ0+mzQQz+VAH0kgv2YJWkvctEc67Yd0PX8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"module": "mesh-config",
|
||||
"version": "1",
|
||||
"runs-as": "mesh-config",
|
||||
"claims": [
|
||||
{
|
||||
"name": "mesh-config",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"config"
|
||||
]
|
||||
}
|
||||
],
|
||||
"emits": [
|
||||
"config-changed"
|
||||
],
|
||||
"uses": [
|
||||
"operator-channel"
|
||||
],
|
||||
"invokes": [
|
||||
"seat:mesh-controller.hand-act",
|
||||
"seat:mesh-controller.warranted"
|
||||
],
|
||||
"reads": [
|
||||
"mesh-controller.assignments",
|
||||
"mesh-controller.declared-config",
|
||||
"mesh-controller.predefined"
|
||||
],
|
||||
"state": [
|
||||
"values",
|
||||
"resolved",
|
||||
"predefined",
|
||||
"proposals"
|
||||
],
|
||||
"logs": [
|
||||
"history"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:state}/broker"
|
||||
},
|
||||
"secrets-owner": "mesh-config",
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "mesh-config",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/mesh-config"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-config",
|
||||
"place": "."
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-config",
|
||||
"binary": "mesh-config",
|
||||
"loads": [
|
||||
"mesh-config"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user