Merge pull request 'A trusted setting is proposed through the settings verb and set only on the operator's warrant (hq ADR 0277)' (#199) from feat/0277-settings-propose into main
This commit was merged in pull request #199.
This commit is contained in:
@@ -114,8 +114,14 @@ type asked struct {
|
||||
// Rehearsal is an ask started at the controller's terminal (rehearse.go): about no condition, its answers
|
||||
// perform nothing, and the reconciling of conditions leaves it alone.
|
||||
Rehearsal bool `json:"rehearsal,omitempty"`
|
||||
// Proposal is a settings layer proposed through a verb (proposals.go, novox/hq ADR 0277): about no
|
||||
// condition, set on Approve by the serving controller itself, and left alone by the reconciling of conditions.
|
||||
Proposal *settingsProposal `json:"proposal,omitempty"`
|
||||
}
|
||||
|
||||
// ofACondition says an ask is one of a condition's: not a rehearsal, not a proposal.
|
||||
func (r asked) ofACondition() bool { return !r.Rehearsal && r.Proposal == nil }
|
||||
|
||||
// partKey is an ask's place among what is asked: its condition and its part.
|
||||
func partKey(condition, part string) string { return condition + "#" + part }
|
||||
|
||||
@@ -184,6 +190,8 @@ type asker struct {
|
||||
publish func(ctx context.Context, subject string, body []byte, id string) error
|
||||
// call performs an action's verb with its arguments, as the controller.
|
||||
call func(ctx context.Context, a conditions.Action, args map[string]string) error
|
||||
// setLayer sets a proposed settings layer on the operator's warrant (novox/hq ADR 0277); nil cannot.
|
||||
setLayer setOnWarrant
|
||||
// record writes the hand-act log.
|
||||
record func(ctx context.Context, act link.HandAct) error
|
||||
// routerRecord reads the router's record of an ask for a warrant missed; nil reads nothing.
|
||||
@@ -324,17 +332,45 @@ func (a *asker) reconcile(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
byCondition := map[string]asked{} // by partKey
|
||||
// What is open and not a condition's — a rehearsal, a proposal — still counts toward what the router holds
|
||||
// open for the controller (askMostOpen); expired unanswered, it is kept so, as the router says it too.
|
||||
otherOpen := 0
|
||||
for _, r := range all {
|
||||
if r.State == askOpen && !r.Rehearsal {
|
||||
if r.State == askOpen && !r.ofACondition() && !now.Before(r.Ask.Expires) {
|
||||
if _, err := a.store.Change(ctx, r.ID, func(x *asked) bool {
|
||||
if x.State != askOpen || x.Acted != "" {
|
||||
return false
|
||||
}
|
||||
x.State, x.Ended, x.Acted = string(asks.OutcomeExpired), now, "nothing: the ask expired unanswered"
|
||||
return true
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if r.State == askOpen && !r.ofACondition() {
|
||||
otherOpen++
|
||||
}
|
||||
if r.State == askOpen && r.ofACondition() {
|
||||
k := partKey(r.Condition, r.Part)
|
||||
if prior, held := byCondition[k]; !held || r.Opened.After(prior.Opened) {
|
||||
byCondition[k] = r
|
||||
}
|
||||
}
|
||||
}
|
||||
// A warrant missed while away, read from the router's record.
|
||||
// A warrant missed while away, read from the router's record: for a condition's ask, and for a proposal's or a
|
||||
// rehearsal's alike.
|
||||
if a.routerRecord != nil {
|
||||
var lookedUp []asked
|
||||
for _, r := range byCondition {
|
||||
lookedUp = append(lookedUp, r)
|
||||
}
|
||||
for _, r := range all {
|
||||
if r.State == askOpen && !r.ofACondition() {
|
||||
lookedUp = append(lookedUp, r)
|
||||
}
|
||||
}
|
||||
for _, r := range lookedUp {
|
||||
if now.Sub(r.Opened) < askCatchUpAfter {
|
||||
continue
|
||||
}
|
||||
@@ -350,7 +386,7 @@ func (a *asker) reconcile(ctx context.Context) error {
|
||||
}
|
||||
byCondition = map[string]asked{}
|
||||
for _, r := range all {
|
||||
if r.State == askOpen && !r.Rehearsal {
|
||||
if r.State == askOpen && r.ofACondition() {
|
||||
byCondition[partKey(r.Condition, r.Part)] = r
|
||||
}
|
||||
}
|
||||
@@ -409,7 +445,7 @@ func (a *asker) reconcile(ctx context.Context) error {
|
||||
}
|
||||
return open[i].Key < open[j].Key
|
||||
})
|
||||
openNow := 0
|
||||
openNow := otherOpen
|
||||
for _, c := range open {
|
||||
if !wants(c, now) {
|
||||
continue
|
||||
@@ -773,7 +809,7 @@ func (a *asker) Decided(ctx context.Context, body []byte) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stillOpen := r.Rehearsal // a rehearsal is about no condition
|
||||
stillOpen := r.Rehearsal || r.Proposal != nil // a rehearsal and a proposal are about no condition
|
||||
for _, c := range open {
|
||||
stillOpen = stillOpen || c.Key == r.Condition
|
||||
}
|
||||
@@ -820,15 +856,23 @@ func (a *asker) Decided(ctx context.Context, body []byte) error {
|
||||
args["why"] = why
|
||||
}
|
||||
var acted error
|
||||
outcome := "done"
|
||||
switch {
|
||||
case r.Rehearsal && act.Verb == rehearsalVerb:
|
||||
// A rehearsal's answer performs nothing: it is recorded below as the operator's decision.
|
||||
case r.Proposal != nil && act.Verb == proposalVerb:
|
||||
// A proposed settings layer, set by this controller itself on Approve (novox/hq ADR 0277): the act's
|
||||
// digest of the values is held to the record's own values before anything is set.
|
||||
outcome, acted = a.decideProposal(ctx, *r, act, w)
|
||||
if acted == nil && outcome != "" && !strings.HasPrefix(outcome, "nothing") {
|
||||
outcome = "done: " + outcome
|
||||
}
|
||||
case act.Arguments["silence"] != "":
|
||||
acted = a.silence(ctx, act.Arguments["silence"], conditions.MaxSilence, byWords(w), why)
|
||||
default:
|
||||
acted = a.call(ctx, act, args)
|
||||
}
|
||||
ended, outcome := a.now(), "done"
|
||||
ended := a.now()
|
||||
if acted != nil {
|
||||
outcome = "failed: " + acted.Error()
|
||||
}
|
||||
|
||||
@@ -314,6 +314,8 @@ func startAsking(ctx context.Context, open *stores, server *link.Server, conn *n
|
||||
return err
|
||||
},
|
||||
call: callAction(conn),
|
||||
// A proposed settings layer is set by this controller itself on the warrant (novox/hq ADR 0277).
|
||||
setLayer: setLayerIn(open),
|
||||
record: func(ctx context.Context, act link.HandAct) error {
|
||||
_, err := link.RecordHandAct(ctx, conn, act)
|
||||
return err
|
||||
|
||||
@@ -10,10 +10,12 @@ import (
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/overlay"
|
||||
)
|
||||
|
||||
@@ -397,8 +399,9 @@ func assignCommand(ctx context.Context, verb string, args []string) error {
|
||||
func settingsCommand(ctx context.Context, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("settings show <module> [--node <node>] [--history], settings set <module> <file> " +
|
||||
"[--node <node>] [--replace], settings clear <module> [--node <node>], or settings preferences " +
|
||||
"[<module>] [--node <node>]")
|
||||
"[--node <node>] [--replace], settings clear <module> [--node <node>], settings preferences " +
|
||||
"[<module>] [--node <node>], settings propose <module> <file | --clear> [--node <node>] [--replace], " +
|
||||
"or settings proposals [<id>]")
|
||||
}
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
@@ -412,12 +415,38 @@ func settingsCommand(ctx context.Context, args []string) error {
|
||||
// **What a set removes is refused unless meant** (novox/hq ADR 0217). A layer is replaced whole,
|
||||
// and on 2026-10-05 setting one placement dropped a machine's whole layer for a module without a
|
||||
// word (novox/hq issue 304). Adding and changing keys needs nothing; removing one needs this.
|
||||
replace := set.Bool("replace", false, "for set: remove the keys the new layer does not name")
|
||||
replace := set.Bool("replace", false, "for set and propose: remove the keys the new layer does not name")
|
||||
history := set.Bool("history", false, "for show: the layers this one replaced, the latest first")
|
||||
clear := set.Bool("clear", false, "for propose: propose that the layer be removed")
|
||||
positionals, err := parseAround(set, args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch args[0] {
|
||||
case "propose":
|
||||
// A trusted setting, proposed by anyone and set only on the operator's warrant (novox/hq ADR 0277):
|
||||
// the stores are opened there, so the proposal and the verb's refusals below never meet.
|
||||
if len(positionals) < 1 || len(positionals) > 2 {
|
||||
return errors.New("settings propose <module> <settings.json | {…}> [--node <node>] [--replace], or settings propose <module> --clear [--node <node>]")
|
||||
}
|
||||
values := ""
|
||||
if len(positionals) == 2 {
|
||||
values = positionals[1]
|
||||
}
|
||||
return proposeCommand(ctx, positionals[0], *node, values, *clear, *replace)
|
||||
case "proposals":
|
||||
if len(positionals) > 1 || *node != "" || *clear || *replace || *history {
|
||||
return errors.New("settings proposals [<id>]")
|
||||
}
|
||||
id := ""
|
||||
if len(positionals) == 1 {
|
||||
id = positionals[0]
|
||||
}
|
||||
return proposalsCommand(ctx, id)
|
||||
}
|
||||
if *clear {
|
||||
return errors.New("--clear is for settings propose; a layer is cleared with settings clear")
|
||||
}
|
||||
|
||||
where := "the whole mesh"
|
||||
if *node != "" {
|
||||
@@ -455,7 +484,7 @@ func settingsCommand(ctx context.Context, args []string) error {
|
||||
"removal is meant (novox/hq ADR 0217). Nothing was changed",
|
||||
positionals[0], where, strings.Join(removed, ", "), positionals[0], nodeFlag(*node))
|
||||
}
|
||||
if err := inv.SetSettings(ctx, *node, positionals[0], values); err != nil {
|
||||
if err := inv.SetSettingsBy(ctx, *node, positionals[0], values, setByWords()); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s on %s:\n", positionals[0], where)
|
||||
@@ -496,8 +525,12 @@ func settingsCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
for _, p := range past {
|
||||
shown, _ := json.MarshalIndent(p.Values, " ", " ")
|
||||
fmt.Printf("%s on %s, until %s (%s):\n %s\n", positionals[0], where,
|
||||
p.ReplacedAt.Local().Format("2006-01-02 15:04:05"), p.ReplacedBy, shown)
|
||||
by := ""
|
||||
if p.SetBy != "" {
|
||||
by = "; " + p.SetBy
|
||||
}
|
||||
fmt.Printf("%s on %s, until %s (%s%s):\n %s\n", positionals[0], where,
|
||||
p.ReplacedAt.Local().Format("2006-01-02 15:04:05"), p.ReplacedBy, by, shown)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -513,6 +546,14 @@ func settingsCommand(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(shown))
|
||||
// And who set it (novox/hq ADR 0277): a layer the operator approved on their phone says so.
|
||||
if setBy, setAt, has, err := inv.LayerOrigin(ctx, *node, positionals[0]); err != nil {
|
||||
return err
|
||||
} else if has && setBy != "" {
|
||||
fmt.Printf(" %s\n", setBy)
|
||||
} else if has {
|
||||
fmt.Printf(" set at %s; who set it was not kept\n", setAt.Local().Format("2006-01-02 15:04"))
|
||||
}
|
||||
}
|
||||
// Every value the module gives a default or a layer sets, and where it came from (novox/hq
|
||||
// ADR 0262): the default, the mesh's layer, or this node's. Said after the layer, which stays
|
||||
@@ -606,17 +647,26 @@ func settingsCommand(ctx context.Context, args []string) error {
|
||||
if err := refuseTerminalSettingsThroughAVerb(ctx, inv, before, nil, positionals[0], where); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := inv.ClearSettings(ctx, *node, positionals[0]); err != nil {
|
||||
if err := inv.ClearSettingsBy(ctx, *node, positionals[0], setByWords()); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s on %s is back to what the module says\n", positionals[0], where)
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("settings has no %q; it has show, set, clear and preferences", args[0])
|
||||
return fmt.Errorf("settings has no %q; it has show, set, clear, preferences, propose and proposals", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
// setByWords is who sets a layer from this process, as the layer keeps it (novox/hq ADR 0277): the caller at the
|
||||
// controller's terminal, or through which verb.
|
||||
func setByWords() string {
|
||||
if verb, through := throughAVerb(); through {
|
||||
return "set by " + link.Caller() + " through " + verb + " at " + time.Now().Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
return "set at the controller's terminal by " + link.Caller() + " at " + time.Now().Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
// describeEffective says each setting's value on a machine or the whole mesh, where it came from, and
|
||||
// the module's default when a layer overrides it.
|
||||
func describeEffective(module, where string, values []catalogue.SettingSource) string {
|
||||
@@ -1107,10 +1157,13 @@ func refuseTerminalSettingsThroughAVerb(ctx context.Context, inv *inventory.Inve
|
||||
}
|
||||
// A trusted mergeable file takes any key, so its module's whole layer is the terminal's (novox/hq issue 340).
|
||||
if files := catalogue.TrustedMergeable(shelf[module]); len(files) > 0 && !sameLayer(before, after) {
|
||||
return fmt.Errorf("the settings of %s on %s are set at the controller's terminal only (`mesh-cli` on the control-node), never through a verb (this "+
|
||||
"line came through %q): %s merges whatever key a layer sets into a file root or a consumer trusts, so "+
|
||||
"any key could point the module at a listener of the caller's, and whoever may call a verb includes "+
|
||||
"agents (novox/hq issue 340; a file nothing trusts says \"trusted\": false). Nothing was changed",
|
||||
return fmt.Errorf("the settings of %s on %s are set at the controller's terminal (`mesh-cli` on the control-node) or on "+
|
||||
"the operator's warrant, never through a verb alone (this line came through %q): %s merges whatever key a "+
|
||||
"layer sets into a file root or a consumer trusts, so any key could point the module at a listener of the "+
|
||||
"caller's, and whoever may call a verb includes agents (novox/hq issue 340; a file nothing trusts says "+
|
||||
"\"trusted\": false). Propose it instead: the settings verb with propose puts the exact values to the "+
|
||||
"operator on a channel that proves who answers, and the layer is set on their Approve (novox/hq ADR 0277). "+
|
||||
"Nothing was changed",
|
||||
module, where, verb, strings.Join(files, ", "))
|
||||
}
|
||||
for _, key := range catalogue.TerminalKeys(shelf[module]) {
|
||||
@@ -1119,11 +1172,13 @@ func refuseTerminalSettingsThroughAVerb(ctx context.Context, inv *inventory.Inve
|
||||
if string(was) == string(now) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("%s of %s on %s is set at the controller's terminal only (`mesh-cli` on the control-node), never through a verb (this "+
|
||||
"line came through %q): it says where root creates and owns a module's directories, which of "+
|
||||
"the machine's paths are mounted into its container, what the mesh's consumers trust, or what a file "+
|
||||
"root or a person's session obeys takes, and whoever may call a verb includes agents (novox/hq issue 339; "+
|
||||
"issue 340 for a mergeable file's own keys). Nothing was changed", key, module, where, verb)
|
||||
return fmt.Errorf("%s of %s on %s is set at the controller's terminal (`mesh-cli` on the control-node) or on the "+
|
||||
"operator's warrant, never through a verb alone (this line came through %q): it says where root creates and "+
|
||||
"owns a module's directories, which of the machine's paths are mounted into its container, what the mesh's "+
|
||||
"consumers trust, or what a file root or a person's session obeys takes, and whoever may call a verb "+
|
||||
"includes agents (novox/hq issue 339; issue 340 for a mergeable file's own keys). Propose it instead: the "+
|
||||
"settings verb with propose puts the exact values to the operator on a channel that proves who answers, and "+
|
||||
"the layer is set on their Approve (novox/hq ADR 0277). Nothing was changed", key, module, where, verb)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
package main
|
||||
|
||||
// A trusted setting proposed through a verb and set only on the operator's warrant (novox/hq ADR 0277).
|
||||
//
|
||||
// mesh-controller settings propose <module> <values.json | {…}> [--node <node>] [--replace]
|
||||
// mesh-controller settings propose <module> --clear [--node <node>]
|
||||
// mesh-controller settings proposals [<id>]
|
||||
//
|
||||
// Whoever the bus admits may PROPOSE a settings layer — the keys issue 339 made the terminal's (`places`,
|
||||
// `accesses`, what a provider serves, what a trusted file asks for) among them. A proposal changes nothing: it is
|
||||
// kept in the controller's own asks (broker.AskedBucket, written by the controller alone) and asked of the
|
||||
// operator through the operator channel as an ask whose two answers, Approve and Decline, are both at the level
|
||||
// approve, so only a channel that proves who answered (Telegram, today) carries either. The serving controller
|
||||
// acts on the warrant as on any other of its asks (asker.Decided): once, for the ask it holds, the option it
|
||||
// offered, and only when the act about to be performed — the module, the machine, the digest of the exact values,
|
||||
// the layer they replace, whether a removal is meant — is the one the option bound when the operator was shown
|
||||
// it. Then it sets the layer as `settings set` at the terminal does, with the same judgement, keeps who approved
|
||||
// it beside the layer (`settings` says it back), and records the warrant in the hand-act log on the bus, where a
|
||||
// person's decisions are read; the router edits the ask on every channel to its outcome. No seat event of its
|
||||
// own: nothing consumes one, and the installer's first user list in the node-engine's repository names every
|
||||
// controller event, so one would cost a node-engine change for a fact without a reader. The push afterwards is a
|
||||
// separate act, as it is for a layer set at the terminal.
|
||||
//
|
||||
// **What the operator reads** is the module, the machine, each key with its exact new value and, for a changed
|
||||
// key, the value it replaces. A value that may not leave the mesh (an address, a path, a secret's shape: the
|
||||
// router's content rule, outward.Check) is shown with that part replaced by ‹address›, ‹path› or ‹withheld›, the
|
||||
// ask says so, and the whole is read with `settings proposals <id>` — whose fingerprint must be the one on the
|
||||
// phone. Fail closed: a proposal nothing can carry to the operator is refused at once, in words, and never left
|
||||
// waiting for an answer that cannot come.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/outward"
|
||||
)
|
||||
|
||||
// settingsProposal is one proposed change to a module's settings layer, as the controller's ask keeps it
|
||||
// (asked.Proposal). Every field the ask is composed from is here, so the serving controller composes the same ask
|
||||
// the proposer did, and the warrant's digest holds to it.
|
||||
type settingsProposal struct {
|
||||
Module string `json:"module"`
|
||||
// Node is the machine, or empty for the whole mesh.
|
||||
Node string `json:"node,omitempty"`
|
||||
// Values is the layer proposed, whole; Clear says the layer is removed instead.
|
||||
Values map[string]any `json:"values,omitempty"`
|
||||
Clear bool `json:"clear,omitempty"`
|
||||
// Replace says the keys the layer had and Values do not name are meant to go (novox/hq ADR 0217).
|
||||
Replace bool `json:"replace,omitempty"`
|
||||
// Before is the layer as it stood when the proposal was made, and HadLayer whether there was one: what the
|
||||
// operator was shown the change against, and what must still stand when the warrant is acted on.
|
||||
Before map[string]any `json:"before,omitempty"`
|
||||
HadLayer bool `json:"had-layer"`
|
||||
// From is who proposed it, as the bus named the caller; At is when.
|
||||
From string `json:"from"`
|
||||
At time.Time `json:"at"`
|
||||
// Digest is the digest of Values (layerDigest), BeforeDigest of Before.
|
||||
Digest string `json:"digest"`
|
||||
BeforeDigest string `json:"before-digest"`
|
||||
}
|
||||
|
||||
// proposalVerb is the verb a proposal's answers bind: the controller's own settings, performed by itself.
|
||||
const proposalVerb = askerName + ".settings"
|
||||
|
||||
// The two answers, both at the level approve.
|
||||
const (
|
||||
answerApprove = "approve"
|
||||
answerDecline = "decline"
|
||||
)
|
||||
|
||||
// layerDigest is the digest a proposal binds: SHA-256 over the layer's canonical JSON (Go sorts a map's keys), an
|
||||
// absent layer and an empty one alike.
|
||||
func layerDigest(values map[string]any) string {
|
||||
if values == nil {
|
||||
values = map[string]any{}
|
||||
}
|
||||
raw, _ := json.Marshal(values)
|
||||
sum := sha256.Sum256(raw)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// fingerprint is a digest as the operator is shown it: its first 24 hexadecimal digits in groups of four, so no
|
||||
// word of it is long enough for the router to take for a secret.
|
||||
func fingerprint(digest string) string {
|
||||
hexed := strings.TrimPrefix(digest, "sha256:")
|
||||
if len(hexed) < 24 {
|
||||
return hexed
|
||||
}
|
||||
var groups []string
|
||||
for i := 0; i < 24; i += 4 {
|
||||
groups = append(groups, hexed[i:i+4])
|
||||
}
|
||||
return strings.Join(groups, " ")
|
||||
}
|
||||
|
||||
// where is the layer in words: "shanks" or "the whole mesh".
|
||||
func (p settingsProposal) where() string {
|
||||
if p.Node == "" {
|
||||
return "the whole mesh"
|
||||
}
|
||||
return p.Node
|
||||
}
|
||||
|
||||
// about is what the ask is about, a key without spaces: the module's layer on the machine, or on the mesh.
|
||||
func (p settingsProposal) about() string {
|
||||
if p.Node == "" {
|
||||
return "settings." + p.Module + ".mesh"
|
||||
}
|
||||
return "settings." + p.Module + "." + p.Node
|
||||
}
|
||||
|
||||
// actions are the proposal's two answers as the controller keeps them (asked.Actions): each binds the exact act
|
||||
// through boundAct — the verb, the machine, the level and every argument, the values' digest among them.
|
||||
func (p settingsProposal) actions(id string) []conditions.Action {
|
||||
args := func(answer string) map[string]string {
|
||||
return map[string]string{"proposal": id, "answer": answer, "module": p.Module, "node": p.Node,
|
||||
"values": p.Digest, "before": p.BeforeDigest, "had-layer": strconv.FormatBool(p.HadLayer),
|
||||
"replace": strconv.FormatBool(p.Replace), "clear": strconv.FormatBool(p.Clear), "from": p.From,
|
||||
"at": p.At.UTC().Format(time.RFC3339Nano)}
|
||||
}
|
||||
return []conditions.Action{
|
||||
{Label: "Approve", Verb: proposalVerb, Machine: p.Node, Level: conditions.LevelApprove, Arguments: args(answerApprove)},
|
||||
{Label: "Decline", Verb: proposalVerb, Machine: p.Node, Level: conditions.LevelApprove, Arguments: args(answerDecline)},
|
||||
}
|
||||
}
|
||||
|
||||
// ask is the proposal's ask, composed from it alone, as the router is sent it: the headline, the explanation
|
||||
// with the change shown under the content rule, and the two options with their bound acts.
|
||||
func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[string]int) {
|
||||
verb := "Set"
|
||||
if p.Clear {
|
||||
verb = "Clear"
|
||||
}
|
||||
headline := fmt.Sprintf("%s %s on %s?", verb, p.Module, p.where())
|
||||
if len([]rune(headline)) > asks.HeadlineLength {
|
||||
headline = fmt.Sprintf("%s settings on %s?", verb, p.where())
|
||||
}
|
||||
if len([]rune(headline)) > asks.HeadlineLength {
|
||||
headline = verb + " settings?"
|
||||
}
|
||||
shown, whole := p.change(machines)
|
||||
var b strings.Builder
|
||||
if p.Clear {
|
||||
fmt.Fprintf(&b, "Clear the settings of %s on %s, back to what the module says?\n\n", p.Module, p.where())
|
||||
} else {
|
||||
fmt.Fprintf(&b, "Set the settings of %s on %s to these values?\n\n", p.Module, p.where())
|
||||
}
|
||||
fmt.Fprintf(&b, "Proposed by %s, at %s. ", sayable(p.From, machines), p.At.Local().Format("15:04 on 2 Jan"))
|
||||
switch {
|
||||
case p.Clear && p.HadLayer:
|
||||
b.WriteString("The layer it removes:\n\n")
|
||||
case p.Clear:
|
||||
b.WriteString("There is no layer to remove; approving changes nothing.")
|
||||
case !p.HadLayer:
|
||||
b.WriteString("There is no layer yet; this is the whole of it:\n\n")
|
||||
default:
|
||||
b.WriteString("The layer is replaced whole; what changes against it:\n\n")
|
||||
}
|
||||
b.WriteString(shown)
|
||||
fmt.Fprintf(&b, "\n\nFingerprint %s.", fingerprint(p.Digest))
|
||||
if !whole {
|
||||
b.WriteString(" Parts shown as ‹address›, ‹path› or ‹withheld› may not leave the mesh: read it whole, with " +
|
||||
"this fingerprint, through the mesh MCP server (mesh-controller.settings, proposal " + id + ") or with " +
|
||||
"mesh-cli settings proposals " + id + ".")
|
||||
}
|
||||
if p.Clear {
|
||||
b.WriteString(" Approved, the layer is removed at once and the machine takes it at its next push.")
|
||||
} else {
|
||||
b.WriteString(" Approved, the layer is set at once and the machine takes it at its next push.")
|
||||
}
|
||||
q := asks.Ask{ID: id, Headline: headline, Explanation: b.String(), Who: asks.Operator,
|
||||
Expires: p.At.Add(askApproveFor), OnExpiry: "the proposal is discarded; nothing changes",
|
||||
About: p.about()}
|
||||
options := map[string]int{}
|
||||
for i, act := range p.actions(id) {
|
||||
binds, _ := asks.ActDigest(boundAct(act))
|
||||
oid := optionID(act.Label)
|
||||
options[oid] = i
|
||||
does := "the settings are set; nothing is pushed yet"
|
||||
if p.Clear {
|
||||
does = "the layer is removed; nothing is pushed yet"
|
||||
}
|
||||
if act.Arguments["answer"] == answerDecline {
|
||||
does = "nothing changes; the proposal is discarded"
|
||||
}
|
||||
q.Options = append(q.Options, asks.Option{ID: oid, Label: act.Label, Does: does, Level: asks.Level(act.Level),
|
||||
Binds: binds})
|
||||
}
|
||||
return q, options
|
||||
}
|
||||
|
||||
// shownMost is the most of a change the phone is shown, in bytes; the rest is read whole with `settings proposals`.
|
||||
const shownMost = 1400
|
||||
|
||||
// change is what changes, line by line, each value shown under the content rule, and whether every value was
|
||||
// shown whole: "+ key: value" added, "~ key: value (was: old)" changed, "- key (was: old)" removed, and the
|
||||
// count of keys unchanged.
|
||||
func (p settingsProposal) change(machines []string) (string, bool) {
|
||||
whole := true
|
||||
say := func(v any) string {
|
||||
s, w := sayableValue(v, machines)
|
||||
whole = whole && w
|
||||
return s
|
||||
}
|
||||
var lines []string
|
||||
if p.Clear {
|
||||
was := leaves(p.Before, "")
|
||||
for _, k := range layerKeys(was) {
|
||||
lines = append(lines, fmt.Sprintf("- %s: %s", k, say(was[k])))
|
||||
}
|
||||
} else {
|
||||
added, changed, removed := settingsChange(p.Before, p.Values)
|
||||
was, now := leaves(p.Before, ""), leaves(p.Values, "")
|
||||
for _, k := range added {
|
||||
lines = append(lines, fmt.Sprintf("+ %s: %s", k, say(now[k])))
|
||||
}
|
||||
for _, k := range changed {
|
||||
lines = append(lines, fmt.Sprintf("~ %s: %s (was: %s)", k, say(now[k]), say(was[k])))
|
||||
}
|
||||
for _, k := range removed {
|
||||
lines = append(lines, fmt.Sprintf("- %s (was: %s)", k, say(was[k])))
|
||||
}
|
||||
unchanged := len(now) - len(added) - len(changed)
|
||||
switch {
|
||||
case len(lines) == 0 && unchanged > 0:
|
||||
lines = append(lines, fmt.Sprintf("= nothing changes: the %d key(s) are as they stand", unchanged))
|
||||
case unchanged > 0:
|
||||
lines = append(lines, fmt.Sprintf("= %d key(s) unchanged", unchanged))
|
||||
}
|
||||
}
|
||||
out := strings.Join(lines, "\n")
|
||||
if len(out) > shownMost {
|
||||
cut := shownMost
|
||||
for cut > 0 && out[cut] != '\n' {
|
||||
cut--
|
||||
}
|
||||
out = out[:cut] + fmt.Sprintf("\n… NOT SHOWN IN FULL here: %d more bytes", len(strings.Join(lines, "\n"))-cut)
|
||||
whole = false
|
||||
}
|
||||
return out, whole
|
||||
}
|
||||
|
||||
func layerKeys(m map[string]any) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// The shapes a value is shown without, in place: an address with what follows it up to a separator, and a
|
||||
// path. Replaced in place rather than word by word, so "recalbox=smb://host/share@/mnt/recalbox" is shown as
|
||||
// "recalbox=‹address›@‹path›" and keeps its shape.
|
||||
var (
|
||||
shownURL = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^\s@"',;)\]}]*`)
|
||||
shownIPv4 = regexp.MustCompile(`\b\d{1,3}(\.\d{1,3}){3}(:\d+)?\b`)
|
||||
shownEmail = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}`)
|
||||
shownPath = regexp.MustCompile(`(^|[\s=@,;:"'(\[{])((?:~|\.{1,2})?/[^\s"',;:)\]}]*)`)
|
||||
)
|
||||
|
||||
// Markers for what is not shown.
|
||||
const (
|
||||
markAddress = "‹address›"
|
||||
markPath = "‹path›"
|
||||
markWithheld = "‹withheld›"
|
||||
)
|
||||
|
||||
// sayableValue is a value as the phone is shown it, and whether it was shown whole. A string is shown bare; any
|
||||
// other value as JSON.
|
||||
func sayableValue(v any, machines []string) (string, bool) {
|
||||
text, ok := v.(string)
|
||||
if !ok {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return markWithheld, false
|
||||
}
|
||||
text = string(raw)
|
||||
}
|
||||
if text == "" {
|
||||
return `""`, true
|
||||
}
|
||||
out := sayable(text, machines)
|
||||
return out, out == text
|
||||
}
|
||||
|
||||
// sayable is a text with what may not leave the mesh replaced in place by a marker; what the markers cannot make
|
||||
// pass is withheld whole.
|
||||
func sayable(text string, machines []string) string {
|
||||
if _, ok := outward.Check(text, machines...); ok {
|
||||
return text
|
||||
}
|
||||
out := shownURL.ReplaceAllString(text, markAddress)
|
||||
out = shownEmail.ReplaceAllString(out, markAddress)
|
||||
out = shownIPv4.ReplaceAllString(out, markAddress)
|
||||
out = shownPath.ReplaceAllString(out, "${1}"+markPath)
|
||||
// Then word by word, as the router reads them: a host name, a path the shapes above missed, a secret's shape.
|
||||
words := strings.FieldsFunc(out, func(r rune) bool {
|
||||
return r == ' ' || r == '\t' || r == '\n' || strings.ContainsRune("\"'`()[]{}<>,;|", r)
|
||||
})
|
||||
for _, w := range words {
|
||||
if refusal, ok := outward.Check(w, machines...); !ok {
|
||||
mark := markWithheld
|
||||
switch refusal.Class {
|
||||
case "address":
|
||||
mark = markAddress
|
||||
case "path":
|
||||
mark = markPath
|
||||
}
|
||||
out = strings.ReplaceAll(out, w, mark)
|
||||
}
|
||||
}
|
||||
if _, ok := outward.Check(out, machines...); ok {
|
||||
return out
|
||||
}
|
||||
out = strings.ReplaceAll(outward.Scrub(out, markWithheld, machines...), "(withheld)", markWithheld)
|
||||
if _, ok := outward.Check(out, machines...); ok {
|
||||
return out
|
||||
}
|
||||
return markWithheld
|
||||
}
|
||||
|
||||
// ---- proposing ---------------------------------------------------------------------------------------
|
||||
|
||||
// proposer is the propose command's reaches, given so a test needs no store and no bus.
|
||||
type proposer struct {
|
||||
// layer reads a module's layer as it stands.
|
||||
layer func(ctx context.Context, node, module string) (map[string]any, bool, error)
|
||||
// judge judges the layer as SetSettings would, keeping nothing.
|
||||
judge func(ctx context.Context, node, module string, values map[string]any) error
|
||||
// machines are the mesh's machine names: allowed in the ask's words.
|
||||
machines func(ctx context.Context) ([]string, error)
|
||||
store askedStore
|
||||
publish func(ctx context.Context, subject string, body []byte, id string) error
|
||||
// routerHere and grantHeld are the asker's own judgements of whether an ask can be carried; nil is yes.
|
||||
routerHere func(ctx context.Context) (bool, error)
|
||||
grantHeld func(ctx context.Context) (bool, string, error)
|
||||
// routerRecord reads the router's record of an ask: its state, or "" for none.
|
||||
routerRecord func(ctx context.Context, id string) (string, error)
|
||||
now func() time.Time
|
||||
caller string
|
||||
// waitFor and waitEvery bound how long propose waits for the router's word on the new ask.
|
||||
waitFor time.Duration
|
||||
waitEvery time.Duration
|
||||
}
|
||||
|
||||
// proposeInput is what is proposed.
|
||||
type proposeInput struct {
|
||||
module string
|
||||
node string
|
||||
values map[string]any
|
||||
clear bool
|
||||
replace bool
|
||||
}
|
||||
|
||||
// atTheTerminalInstead names the other way, said whenever a proposal is refused for want of a channel.
|
||||
func atTheTerminalInstead(in proposeInput) string {
|
||||
if in.clear {
|
||||
return "the operator may clear it at the controller's terminal instead: mesh-cli settings clear " + in.module + nodeFlag(in.node)
|
||||
}
|
||||
return "the operator may set it at the controller's terminal instead: mesh-cli settings set " + in.module + " '{…}'" + nodeFlag(in.node)
|
||||
}
|
||||
|
||||
// propose keeps a proposal in the controller's asks and asks the operator; it answers the words said to the
|
||||
// caller. Nothing is set here.
|
||||
func (pr proposer) propose(ctx context.Context, in proposeInput) (string, error) {
|
||||
refuse := func(format string, args ...any) (string, error) {
|
||||
return "", fmt.Errorf(format+". Nothing was proposed", args...)
|
||||
}
|
||||
if in.module == "" {
|
||||
return refuse("a proposal names a module")
|
||||
}
|
||||
if !in.clear && in.values == nil {
|
||||
return refuse("a proposal gives the values, or says --clear")
|
||||
}
|
||||
now := pr.now()
|
||||
before, had, err := pr.layer(ctx, in.node, in.module)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p := settingsProposal{Module: in.module, Node: in.node, Values: in.values, Clear: in.clear, Replace: in.replace,
|
||||
Before: before, HadLayer: had, From: pr.caller, At: now, BeforeDigest: layerDigest(before)}
|
||||
if in.clear {
|
||||
// A clear binds the layer it removes: its digest is the fingerprint the operator reads.
|
||||
p.Values, p.Digest = nil, layerDigest(before)
|
||||
} else {
|
||||
// Judged now, as SetSettings judges, so the operator is never asked about a layer the mesh would refuse —
|
||||
// and judged again when the warrant is acted on.
|
||||
if err := pr.judge(ctx, in.node, in.module, in.values); err != nil {
|
||||
return refuse("%v", err)
|
||||
}
|
||||
_, _, removed := settingsChange(before, in.values)
|
||||
if len(removed) > 0 && !in.replace {
|
||||
return refuse("%s on %s: this layer would no longer set %s. A layer is replaced whole; read it with "+
|
||||
"`settings show %s%s` and include what should stay, or add --replace if the removal is meant "+
|
||||
"(novox/hq ADR 0217)", in.module, p.where(), strings.Join(removed, ", "), in.module, nodeFlag(in.node))
|
||||
}
|
||||
p.Digest = layerDigest(in.values)
|
||||
}
|
||||
var machines []string
|
||||
if pr.machines != nil {
|
||||
if machines, err = pr.machines(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
id := newProposalID()
|
||||
q, options := p.ask(id, machines)
|
||||
if err := q.Check(now); err != nil {
|
||||
return refuse("%v", err)
|
||||
}
|
||||
words := []string{q.Headline, q.Explanation, q.OnExpiry}
|
||||
for _, o := range q.Options {
|
||||
words = append(words, o.Label, o.Does)
|
||||
}
|
||||
if refusal, ok := outward.Check(strings.Join(words, "\n"), machines...); !ok {
|
||||
return refuse("the ask's words would carry %s, which may not leave the mesh, and the change could not be "+
|
||||
"shown without it; %s", refusal, atTheTerminalInstead(in))
|
||||
}
|
||||
// Fail closed, before anything is kept: no router, no grant, no channel means no ask.
|
||||
if pr.routerHere != nil {
|
||||
here, err := pr.routerHere(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !here {
|
||||
return refuse("no router takes the controller's asks (no module holding the operator channel is assigned), "+
|
||||
"so the operator cannot be asked; %s", atTheTerminalInstead(in))
|
||||
}
|
||||
}
|
||||
if pr.grantHeld != nil {
|
||||
held, why, err := pr.grantHeld(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !held {
|
||||
return refuse("the operator cannot be asked yet: %s; %s", why, atTheTerminalInstead(in))
|
||||
}
|
||||
}
|
||||
all, err := pr.store.All(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
open := 0
|
||||
for _, r := range all {
|
||||
if r.State != askOpen || !now.Before(r.Ask.Expires) {
|
||||
continue
|
||||
}
|
||||
if r.Proposal != nil && r.Proposal.Module == p.Module && r.Proposal.Node == p.Node && r.Proposal.Digest == p.Digest &&
|
||||
r.Proposal.Clear == p.Clear {
|
||||
return refuse("the same change is already proposed as %s and waits for the operator's answer until %s; "+
|
||||
"`settings proposals` lists it", r.ID, r.Ask.Expires.Local().Format("15:04"))
|
||||
}
|
||||
open++
|
||||
}
|
||||
if open >= askMostOpen {
|
||||
return refuse("%d questions already wait for the operator's answer, and the operator is asked at most %d at "+
|
||||
"once; `settings proposals` lists the proposals among them", open, askMostOpen)
|
||||
}
|
||||
// Kept before it is published, as the asker keeps every ask, so a warrant always finds it.
|
||||
if err := pr.store.Create(ctx, asked{ID: id, Condition: q.About, Ask: q, Actions: p.actions(id), Options: options,
|
||||
State: askOpen, Opened: now, Proposal: &p}); err != nil {
|
||||
return "", fmt.Errorf("the proposal could not be kept in the controller's asks, so the operator was not asked: %w", err)
|
||||
}
|
||||
body, err := json.Marshal(q)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := pr.publish(ctx, asks.AskSubject(askerName), body, "ask."+id); err != nil {
|
||||
_, _ = pr.store.Change(ctx, id, func(x *asked) bool {
|
||||
if x.State != askOpen || x.Acted != "" {
|
||||
return false
|
||||
}
|
||||
x.State, x.Ended, x.Acted = askUnsent, pr.now(), "nothing: it could not be published: "+err.Error()
|
||||
return true
|
||||
})
|
||||
return "", fmt.Errorf("the operator could not be asked (%v); the proposal %s is kept and will never become "+
|
||||
"active. Propose it again, or %s", err, id, atTheTerminalInstead(in))
|
||||
}
|
||||
// The router's word, before answering: it refuses at once an ask no channel can carry (the serving controller
|
||||
// hears that and ends the ask here), and records one it took.
|
||||
taken := "the router has not said yet whether it took the ask; `settings proposals` shows where it stands"
|
||||
for deadline := time.Now().Add(pr.waitFor); time.Now().Before(deadline); {
|
||||
if r, err := pr.store.Get(ctx, id); err == nil && r != nil && r.State != askOpen {
|
||||
why := r.Acted
|
||||
if r.Warrant != nil && r.Warrant.Words != "" {
|
||||
why = r.Warrant.Words
|
||||
}
|
||||
return "", fmt.Errorf("the router did not ask the operator: the ask %s %s (%s). Nothing changes; %s",
|
||||
id, r.State, why, atTheTerminalInstead(in))
|
||||
}
|
||||
if pr.routerRecord != nil {
|
||||
if state, err := pr.routerRecord(ctx, id); err == nil && state != "" {
|
||||
taken = "the router took the ask and shows it on the channels that can carry it"
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(pr.waitEvery)
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "proposal %s: %s\n", id, q.Headline)
|
||||
fmt.Fprintf(&b, " %s\n", taken)
|
||||
fmt.Fprintf(&b, " the operator is asked on a channel that proves who answers, and reads the change with fingerprint %s\n",
|
||||
fingerprint(p.Digest))
|
||||
fmt.Fprintf(&b, " until %s nothing changes: the layer is set only on Approve, and discarded on Decline or at expiry\n",
|
||||
q.Expires.Local().Format("2006-01-02 15:04"))
|
||||
fmt.Fprintf(&b, " `settings proposals %s` shows it whole; once approved, `push %s` sends it", id, pushWord(p.Node))
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func pushWord(node string) string {
|
||||
if node == "" {
|
||||
return "--behind"
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func newProposalID() string {
|
||||
return "s" + strings.TrimPrefix(newAskID(), "c")
|
||||
}
|
||||
|
||||
// How long propose waits for the router's word on a new ask, and how often it looks.
|
||||
const (
|
||||
routerAnswersWithin = 8 * time.Second
|
||||
routerAnswersEvery = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
// proposeCommand is `settings propose`, on this controller's stores and bus.
|
||||
func proposeCommand(ctx context.Context, module, node, valuesArg string, clear, replace bool) error {
|
||||
var values map[string]any
|
||||
if !clear {
|
||||
if valuesArg == "" {
|
||||
return errors.New("settings propose <module> <settings.json | {…}> [--node <node>] [--replace], or settings propose <module> --clear [--node <node>]")
|
||||
}
|
||||
var raw []byte
|
||||
var err error
|
||||
if strings.HasPrefix(strings.TrimSpace(valuesArg), "{") {
|
||||
raw = []byte(valuesArg)
|
||||
} else if raw, err = os.ReadFile(valuesArg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(raw, &values); err != nil {
|
||||
return fmt.Errorf("%s is not a settings file: %w", valuesArg, err)
|
||||
}
|
||||
} else if valuesArg != "" {
|
||||
return errors.New("settings propose: --clear takes no values")
|
||||
}
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer open.Close()
|
||||
js, err := aBus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer js.Close()
|
||||
conn := js.Conn()
|
||||
pr := proposer{
|
||||
layer: open.inventory.Layer,
|
||||
judge: open.inventory.JudgeSettings,
|
||||
machines: func(ctx context.Context) ([]string, error) {
|
||||
nodes, err := open.inventory.Nodes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var names []string
|
||||
for _, n := range nodes {
|
||||
names = append(names, n.Name)
|
||||
}
|
||||
return names, nil
|
||||
},
|
||||
store: busAsked{conn: conn},
|
||||
publish: func(ctx context.Context, subject string, body []byte, id string) error {
|
||||
_, err := js.Context().Publish(subject, body, nats.MsgId(id), nats.Context(ctx))
|
||||
return err
|
||||
},
|
||||
routerHere: routerHereIn(open.inventory),
|
||||
grantHeld: grantHeldIn(open),
|
||||
routerRecord: func(ctx context.Context, id string) (string, error) {
|
||||
bucket, err := asksRecords(ctx, open.inventory)
|
||||
if err != nil || bucket == "" {
|
||||
return "", err
|
||||
}
|
||||
state, _, err := readRouterRecord(ctx, conn, bucket, askerName, id)
|
||||
return state, err
|
||||
},
|
||||
now: time.Now,
|
||||
caller: link.Caller(),
|
||||
waitFor: routerAnswersWithin, waitEvery: routerAnswersEvery,
|
||||
}
|
||||
words, err := pr.propose(ctx, proposeInput{module: module, node: node, values: values, clear: clear, replace: replace})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(words)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- listing ---------------------------------------------------------------------------------------
|
||||
|
||||
// proposalsCommand is `settings proposals [<id>]`: every proposal, newest first, and where each stands; with an
|
||||
// id, the proposal whole — its values, the layer it was shown against, and its digest.
|
||||
func proposalsCommand(ctx context.Context, id string) error {
|
||||
return onTheBus(func(conn *nats.Conn) error {
|
||||
all, err := busAsked{conn: conn}.All(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var proposals []asked
|
||||
for _, r := range all {
|
||||
if r.Proposal != nil {
|
||||
proposals = append(proposals, r)
|
||||
}
|
||||
}
|
||||
sort.Slice(proposals, func(i, j int) bool { return proposals[i].Opened.After(proposals[j].Opened) })
|
||||
if id != "" {
|
||||
for _, r := range proposals {
|
||||
if r.ID == id {
|
||||
fmt.Print(describeProposal(r, time.Now()))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("no proposal %s is kept", id)
|
||||
}
|
||||
if len(proposals) == 0 {
|
||||
fmt.Println("no settings have been proposed")
|
||||
return nil
|
||||
}
|
||||
for _, r := range proposals {
|
||||
fmt.Print(proposalLine(r, time.Now()))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// proposalState is where a proposal stands, in a word or two.
|
||||
func proposalState(r asked, now time.Time) string {
|
||||
switch {
|
||||
case r.State == askOpen && now.Before(r.Ask.Expires):
|
||||
return "waiting for the operator until " + r.Ask.Expires.Local().Format("2006-01-02 15:04")
|
||||
case r.State == askOpen:
|
||||
return "expired unanswered; discarded"
|
||||
case r.Acted != "":
|
||||
return r.State + ": " + r.Acted
|
||||
}
|
||||
return r.State
|
||||
}
|
||||
|
||||
func proposalLine(r asked, now time.Time) string {
|
||||
p := *r.Proposal
|
||||
what := "set"
|
||||
if p.Clear {
|
||||
what = "clear"
|
||||
}
|
||||
keys := strings.Join(layerKeys(p.Values), ", ")
|
||||
if p.Clear {
|
||||
keys = "the whole layer"
|
||||
}
|
||||
return fmt.Sprintf("%s %s %s on %s (%s)\n by %s at %s; fingerprint %s\n %s\n", r.ID, what, p.Module, p.where(),
|
||||
keys, p.From, p.At.Local().Format("2006-01-02 15:04"), fingerprint(p.Digest), proposalState(r, now))
|
||||
}
|
||||
|
||||
func describeProposal(r asked, now time.Time) string {
|
||||
p := *r.Proposal
|
||||
var b strings.Builder
|
||||
b.WriteString(proposalLine(r, now))
|
||||
fmt.Fprintf(&b, " digest %s; the layer it was shown against %s\n", p.Digest, p.BeforeDigest)
|
||||
shownValues, _ := json.MarshalIndent(p.Values, " ", " ")
|
||||
if p.Clear {
|
||||
fmt.Fprintf(&b, " clears the layer\n")
|
||||
} else {
|
||||
fmt.Fprintf(&b, " values:\n %s\n", shownValues)
|
||||
}
|
||||
if p.HadLayer {
|
||||
shownBefore, _ := json.MarshalIndent(p.Before, " ", " ")
|
||||
fmt.Fprintf(&b, " the layer as it stood:\n %s\n", shownBefore)
|
||||
} else {
|
||||
b.WriteString(" there was no layer\n")
|
||||
}
|
||||
if r.Warrant != nil && r.Warrant.By != nil {
|
||||
fmt.Fprintf(&b, " answered: %s, through %s, at %s\n", r.Warrant.Says(), viaWords(*r.Warrant),
|
||||
r.Warrant.At.Local().Format("2006-01-02 15:04"))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ---- acting on the warrant --------------------------------------------------------------------------
|
||||
|
||||
// setOnWarrant performs an approved proposal: the layer set or cleared as `settings set` and `settings clear` at the
|
||||
// terminal do, with who approved it kept beside the layer. It answers the words of what changed.
|
||||
type setOnWarrant func(ctx context.Context, p settingsProposal, askID, setBy string) (string, error)
|
||||
|
||||
// decideProposal is what the asker does with a warrant for a proposal (asker.Decided): nothing on Decline, and on
|
||||
// Approve the act only when it is the one the option bound — the digest of the exact values kept here is the one
|
||||
// in the act, and so the one the ask's option bound and the warrant's ask digest covers.
|
||||
func (a *asker) decideProposal(ctx context.Context, r asked, act conditions.Action, w asks.Warrant) (string, error) {
|
||||
p := *r.Proposal
|
||||
if act.Arguments["answer"] != answerApprove {
|
||||
return "nothing: the operator declined; the layer is unchanged", nil
|
||||
}
|
||||
if a.setLayer == nil {
|
||||
return "", errors.New("this controller cannot set a layer on a warrant")
|
||||
}
|
||||
// The record's own proposal against the act the option bound: the act is composed again from the record —
|
||||
// its module, machine, values (digested again), the layer they replace, whether a removal is meant, a clear,
|
||||
// who proposed it and when — and must digest to what the option bound when the operator was shown it. A
|
||||
// record changed after the ask, in any field, is refused and nothing is set.
|
||||
again := p
|
||||
again.Digest, again.BeforeDigest = layerDigest(p.Values), layerDigest(p.Before)
|
||||
if p.Clear {
|
||||
again.Digest = layerDigest(p.Before)
|
||||
}
|
||||
recomposed := again.actions(r.ID)
|
||||
chosen := -1
|
||||
for i, candidate := range recomposed {
|
||||
if candidate.Arguments["answer"] == act.Arguments["answer"] {
|
||||
chosen = i
|
||||
}
|
||||
}
|
||||
option, offered := r.Ask.Option(w.Option)
|
||||
if chosen < 0 || !offered {
|
||||
return "", fmt.Errorf("the proposal %s offers no answer %q: nothing is set", r.ID, act.Arguments["answer"])
|
||||
}
|
||||
if err := option.Performs(boundAct(recomposed[chosen])); err != nil {
|
||||
return "", fmt.Errorf("the proposal kept for %s is not the one the operator was shown: %v", r.ID, err)
|
||||
}
|
||||
setBy := fmt.Sprintf("approved by %s via %s at %s (ask %s, proposed by %s)", byWords(w), viaWords(w),
|
||||
w.At.Local().Format("2006-01-02 15:04"), r.ID, p.From)
|
||||
return a.setLayer(ctx, p, r.ID, setBy)
|
||||
}
|
||||
|
||||
// setLayerIn is setOnWarrant on this controller's stores: the layer must still be the one the operator was shown
|
||||
// the change against (to-be 46 §10, step 7), then it is set with the same judgement as at the terminal.
|
||||
func setLayerIn(open *stores) setOnWarrant {
|
||||
return func(ctx context.Context, p settingsProposal, askID, setBy string) (string, error) {
|
||||
inv := open.inventory
|
||||
before, had, err := inv.Layer(ctx, p.Node, p.Module)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if layerDigest(before) != p.BeforeDigest || had != p.HadLayer {
|
||||
return "", fmt.Errorf("the layer of %s on %s changed since the operator was shown the change: it is not set; "+
|
||||
"propose it again", p.Module, p.where())
|
||||
}
|
||||
var changed string
|
||||
if p.Clear {
|
||||
if err := inv.ClearSettingsBy(ctx, p.Node, p.Module, setBy); err != nil {
|
||||
return "", err
|
||||
}
|
||||
changed = "the layer is removed"
|
||||
} else {
|
||||
added, altered, removed := settingsChange(before, p.Values)
|
||||
if len(removed) > 0 && !p.Replace {
|
||||
return "", fmt.Errorf("the layer would no longer set %s, and the removal was not meant: nothing is set",
|
||||
strings.Join(removed, ", "))
|
||||
}
|
||||
if err := inv.SetSettingsBy(ctx, p.Node, p.Module, p.Values, setBy); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var parts []string
|
||||
for _, k := range added {
|
||||
parts = append(parts, "+ "+k)
|
||||
}
|
||||
for _, k := range altered {
|
||||
parts = append(parts, "~ "+k)
|
||||
}
|
||||
for _, k := range removed {
|
||||
parts = append(parts, "- "+k)
|
||||
}
|
||||
changed = strings.Join(parts, ", ")
|
||||
if changed == "" {
|
||||
changed = "nothing changed"
|
||||
}
|
||||
}
|
||||
return changed + " (ask " + askID + ")", nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/outward"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0277: a trusted setting is proposed through a verb by anyone the bus admits, asked of the
|
||||
// operator at the level approve with the exact change, and set only on the operator's warrant — once, for the
|
||||
// exact values the option bound; declined, expired or refused, it is discarded; nothing is asked when nothing
|
||||
// can carry the ask.
|
||||
|
||||
// aProposer is the propose path with its reaches faked: a layer as it stands, a judge that records what it
|
||||
// judged, a memory store, and what was published.
|
||||
type proposerRig struct {
|
||||
pr proposer
|
||||
store memAskedStore
|
||||
sent []published
|
||||
judged []map[string]any
|
||||
before map[string]any
|
||||
had bool
|
||||
refusedBy string
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func newProposerRig(t *testing.T) *proposerRig {
|
||||
r := &proposerRig{store: memAskedStore{}, now: time.Date(2026, 10, 10, 14, 5, 0, 0, time.UTC)}
|
||||
r.pr = proposer{
|
||||
layer: func(_ context.Context, node, module string) (map[string]any, bool, error) {
|
||||
return r.before, r.had, nil
|
||||
},
|
||||
judge: func(_ context.Context, node, module string, values map[string]any) error {
|
||||
r.judged = append(r.judged, values)
|
||||
if r.refusedBy != "" {
|
||||
return errors.New(r.refusedBy)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
machines: func(context.Context) ([]string, error) { return []string{"anchor", "laptop", "shanks"}, nil },
|
||||
store: r.store,
|
||||
publish: func(_ context.Context, subject string, body []byte, id string) error {
|
||||
r.sent = append(r.sent, published{subject, id, body})
|
||||
return nil
|
||||
},
|
||||
now: func() time.Time { return r.now },
|
||||
caller: "g14/claude-code, through the mesh-controller seat",
|
||||
waitFor: time.Millisecond,
|
||||
waitEvery: time.Millisecond,
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *proposerRig) askSent(t *testing.T) asks.Ask {
|
||||
t.Helper()
|
||||
if len(r.sent) != 1 || r.sent[0].subject != asks.AskSubject("mesh-controller") {
|
||||
t.Fatalf("published %+v", r.sent)
|
||||
}
|
||||
var q asks.Ask
|
||||
if err := json.Unmarshal(r.sent[0].body, &q); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func (r *proposerRig) theProposal(t *testing.T) asked {
|
||||
t.Helper()
|
||||
for _, a := range r.store {
|
||||
if a.Proposal != nil {
|
||||
return a
|
||||
}
|
||||
}
|
||||
t.Fatal("no proposal is kept")
|
||||
return asked{}
|
||||
}
|
||||
|
||||
var mountsSources = map[string]any{"sources": "recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro", "shares": "library=/mnt/library"}
|
||||
|
||||
// The ask: at the level approve on both answers, each binding the proposal's act, with every key and its exact new
|
||||
// value as far as the content rule lets it leave the mesh, the layer it was shown against, and nothing set.
|
||||
func TestAProposalAsksAtTheLevelApproveWithTheExactChange(t *testing.T) {
|
||||
r := newProposerRig(t)
|
||||
r.before, r.had = map[string]any{"shares": "none", "old": "x"}, true
|
||||
words, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: mountsSources, replace: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := r.askSent(t)
|
||||
if err := q.Check(r.now); err != nil {
|
||||
t.Fatalf("the ask is refused: %v", err)
|
||||
}
|
||||
if q.Headline != "Set mounts on shanks?" || q.About != "settings.mounts.shanks" || q.Who != asks.Operator ||
|
||||
!q.Expires.Equal(r.now.Add(askApproveFor)) {
|
||||
t.Errorf("the ask: %+v", q)
|
||||
}
|
||||
if len(q.Options) != 2 {
|
||||
t.Fatalf("options %+v", q.Options)
|
||||
}
|
||||
for _, o := range q.Options {
|
||||
if o.Level != asks.Approve || !strings.HasPrefix(o.Binds, "sha256:") {
|
||||
t.Errorf("the option %s is %s and binds %q", o.ID, o.Level, o.Binds)
|
||||
}
|
||||
}
|
||||
if q.Options[0].Binds == q.Options[1].Binds {
|
||||
t.Error("Approve and Decline bind the same act")
|
||||
}
|
||||
for _, line := range []string{
|
||||
"Set the settings of mounts on shanks to these values?",
|
||||
"Proposed by g14/claude-code, through the mesh-controller seat, at " + r.now.Local().Format("15:04 on 2 Jan") + ".",
|
||||
"+ sources: recalbox=‹address›@‹path›:ro",
|
||||
"~ shares: library=‹path› (was: none)",
|
||||
"- old (was: x)",
|
||||
"Fingerprint " + fingerprint(layerDigest(mountsSources)) + ".",
|
||||
"read it whole, with this fingerprint",
|
||||
} {
|
||||
if !strings.Contains(q.Explanation, line) {
|
||||
t.Errorf("the explanation lacks %q:\n%s", line, q.Explanation)
|
||||
}
|
||||
}
|
||||
for _, leak := range []string{"nas.lan", "/mnt/recalbox", "/mnt/library", "smb://"} {
|
||||
if strings.Contains(q.Explanation, leak) {
|
||||
t.Errorf("the explanation carries %q, which may not leave the mesh", leak)
|
||||
}
|
||||
}
|
||||
all := []string{q.Headline, q.Explanation, q.OnExpiry}
|
||||
for _, o := range q.Options {
|
||||
all = append(all, o.Label, o.Does)
|
||||
}
|
||||
if refusal, ok := outward.Check(strings.Join(all, "\n"), "anchor", "laptop", "shanks"); !ok {
|
||||
t.Errorf("the router would refuse the ask: %s", refusal)
|
||||
}
|
||||
// Kept as the controller's own ask, about no condition, with the proposal whole and its digests.
|
||||
kept := r.theProposal(t)
|
||||
p := kept.Proposal
|
||||
if kept.State != askOpen || kept.Ask.Digest() != q.Digest() || p.Module != "mounts" || p.Node != "shanks" ||
|
||||
p.Digest != layerDigest(mountsSources) || p.BeforeDigest != layerDigest(r.before) || !p.Replace || !p.HadLayer ||
|
||||
p.From != r.pr.caller || kept.ofACondition() {
|
||||
t.Errorf("kept %+v / %+v", kept, p)
|
||||
}
|
||||
// Each option binds exactly the act the controller will perform (boundAct over the kept action).
|
||||
for i, act := range kept.Actions {
|
||||
binds, _ := asks.ActDigest(boundAct(act))
|
||||
if q.Options[i].Binds != binds || act.Arguments["values"] != p.Digest || act.Arguments["before"] != p.BeforeDigest ||
|
||||
act.Verb != proposalVerb || act.Level != conditions.LevelApprove {
|
||||
t.Errorf("the option %s does not bind the kept act: %+v", q.Options[i].ID, act)
|
||||
}
|
||||
}
|
||||
if len(r.judged) != 1 || r.judged[0]["sources"] != mountsSources["sources"] {
|
||||
t.Errorf("judged %v", r.judged)
|
||||
}
|
||||
if !strings.Contains(words, "nothing changes") || !strings.Contains(words, kept.ID) {
|
||||
t.Errorf("the caller is told: %s", words)
|
||||
}
|
||||
}
|
||||
|
||||
// A layer for the whole mesh is proposed too.
|
||||
func TestAMeshWideLayerIsProposed(t *testing.T) {
|
||||
r := newProposerRig(t)
|
||||
if _, err := r.pr.propose(context.Background(), proposeInput{module: "notes", values: map[string]any{"x": "1"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := r.askSent(t)
|
||||
if q.Headline != "Set notes on the whole mesh?" || q.About != "settings.notes.mesh" ||
|
||||
!strings.Contains(q.Explanation, "Set the settings of notes on the whole mesh to these values?") {
|
||||
t.Errorf("%+v", q)
|
||||
}
|
||||
}
|
||||
|
||||
// A proposal expired unanswered is kept so by the reconciling, and a warrant for it afterwards sets nothing.
|
||||
func TestAnExpiredProposalIsKeptExpired(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
calls := withSetLayer(r)
|
||||
a := aProposalAsked(t, r, "s7", aProposal(r.now))
|
||||
r.now = r.now.Add(askApproveFor + time.Minute)
|
||||
if err := r.a.reconcile(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := r.store["s7"]; got.State != string(asks.OutcomeExpired) || !strings.HasPrefix(got.Acted, "nothing") {
|
||||
t.Errorf("kept as %+v", got)
|
||||
}
|
||||
answerWith(t, r, warrantOn(a, "approve", r.now.Add(-2*time.Minute)))
|
||||
if len(*calls) != 0 {
|
||||
t.Errorf("set after expiry: %+v", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
// A clear is proposed too, and shows the layer it removes.
|
||||
func TestAClearIsProposedAndShowsWhatItRemoves(t *testing.T) {
|
||||
r := newProposerRig(t)
|
||||
r.before, r.had = map[string]any{"places": map[string]any{"data": map[string]any{"path": "/srv/notes", "owner": "1001:1001"}}}, true
|
||||
if _, err := r.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", clear: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := r.askSent(t)
|
||||
if q.Headline != "Clear notes on laptop?" || !strings.Contains(q.Explanation, "- places.data.owner: 1001:1001") ||
|
||||
!strings.Contains(q.Explanation, "- places.data.path: ‹path›") {
|
||||
t.Errorf("%+v", q)
|
||||
}
|
||||
if p := r.theProposal(t).Proposal; !p.Clear || p.Digest != layerDigest(r.before) || len(r.judged) != 0 {
|
||||
t.Errorf("a clear: %+v, judged %v", p, r.judged)
|
||||
}
|
||||
}
|
||||
|
||||
// What the phone is shown keeps a value's shape and passes the content rule: an address, a path, a secret's
|
||||
// shape each replaced in place; a value every word of which may leave the mesh shown whole.
|
||||
func TestAValueIsShownInItsShapeAndPassesTheContentRule(t *testing.T) {
|
||||
for in, want := range map[any]string{
|
||||
"recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro": "recalbox=‹address›@‹path›:ro",
|
||||
"library=/mnt/library": "library=‹path›",
|
||||
"none": "none",
|
||||
"Inter 13": "Inter 13",
|
||||
"10.77.0.9:53": "‹address›",
|
||||
"jochen@example.com": "‹address›",
|
||||
"nas.lan": "‹address›",
|
||||
"anchor": "anchor",
|
||||
"-----BEGIN CERTIFICATE-----": "‹withheld›",
|
||||
42: "42",
|
||||
true: "true",
|
||||
} {
|
||||
got, whole := sayableValue(in, []string{"anchor"})
|
||||
if got != want {
|
||||
t.Errorf("%v shown as %q, want %q", in, got, want)
|
||||
}
|
||||
if whole != (got == want && !strings.Contains(want, "‹")) {
|
||||
t.Errorf("%v: whole %v", in, whole)
|
||||
}
|
||||
if _, ok := outward.Check(got, "anchor"); !ok {
|
||||
t.Errorf("%v shown as %q, which the router refuses", in, got)
|
||||
}
|
||||
}
|
||||
for _, v := range []any{[]any{"a", "/etc/x"}, map[string]any{"path": "/srv/x", "owner": "1001:1001"}} {
|
||||
got, _ := sayableValue(v, nil)
|
||||
if _, ok := outward.Check(got); !ok || strings.Contains(got, "/srv") || strings.Contains(got, "/etc") {
|
||||
t.Errorf("%v shown as %q", v, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A proposal that the mesh would refuse to set, or that would silently remove a key, is refused before anybody is
|
||||
// asked (ADR 0217 holds for a proposal as for a set).
|
||||
func TestAProposalTheMeshWouldRefuseIsNotAsked(t *testing.T) {
|
||||
r := newProposerRig(t)
|
||||
r.before, r.had = map[string]any{"a": 1, "b": 2}, true
|
||||
_, err := r.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: map[string]any{"a": 1}})
|
||||
if err == nil || !strings.Contains(err.Error(), "would no longer set b") || !strings.Contains(err.Error(), "ADR 0217") {
|
||||
t.Errorf("a silent removal: %v", err)
|
||||
}
|
||||
r.refusedBy = "refused: notes on laptop cannot compose"
|
||||
_, err = r.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: map[string]any{"a": 1, "b": 3}})
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot compose") {
|
||||
t.Errorf("a layer the mesh refuses: %v", err)
|
||||
}
|
||||
if len(r.sent) != 0 || len(r.store) != 0 {
|
||||
t.Errorf("asked anyway: %+v %+v", r.sent, r.store)
|
||||
}
|
||||
}
|
||||
|
||||
// Fail closed: no router, no grant, a publish that fails, or the router's refusal each leave nothing waiting for
|
||||
// an answer that cannot come, and name the terminal's line.
|
||||
func TestAProposalFailsClosedWhenNothingCanCarryIt(t *testing.T) {
|
||||
r := newProposerRig(t)
|
||||
in := proposeInput{module: "mounts", node: "shanks", values: mountsSources}
|
||||
r.pr.routerHere = func(context.Context) (bool, error) { return false, nil }
|
||||
if _, err := r.pr.propose(context.Background(), in); err == nil || !strings.Contains(err.Error(), "no router") ||
|
||||
!strings.Contains(err.Error(), "mesh-cli settings set mounts") {
|
||||
t.Errorf("without a router: %v", err)
|
||||
}
|
||||
r.pr.routerHere = nil
|
||||
r.pr.grantHeld = func(context.Context) (bool, string, error) { return false, "the bus's user list is behind", nil }
|
||||
if _, err := r.pr.propose(context.Background(), in); err == nil || !strings.Contains(err.Error(), "user list is behind") {
|
||||
t.Errorf("without the grant: %v", err)
|
||||
}
|
||||
if len(r.sent) != 0 || len(r.store) != 0 {
|
||||
t.Fatalf("asked anyway: %+v %+v", r.sent, r.store)
|
||||
}
|
||||
r.pr.grantHeld = nil
|
||||
r.pr.publish = func(context.Context, string, []byte, string) error { return errors.New("the bus is away") }
|
||||
if _, err := r.pr.propose(context.Background(), in); err == nil || !strings.Contains(err.Error(), "the bus is away") ||
|
||||
!strings.Contains(err.Error(), "never become active") {
|
||||
t.Errorf("a publish that fails: %v", err)
|
||||
}
|
||||
if kept := r.theProposal(t); kept.State != askUnsent {
|
||||
t.Errorf("an unpublished proposal is %s", kept.State)
|
||||
}
|
||||
// The router's refusal, heard by the serving controller and kept here, is said to the caller.
|
||||
r = newProposerRig(t)
|
||||
r.pr.publish = func(_ context.Context, _ string, _ []byte, id string) error {
|
||||
ask := strings.TrimPrefix(id, "ask.")
|
||||
r.store[ask] = func() asked {
|
||||
a := r.store[ask]
|
||||
a.State, a.Acted = string(asks.OutcomeRefused), "nothing: the ask refused: no channel can carry any of its answers now"
|
||||
return a
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
if _, err := r.pr.propose(context.Background(), in); err == nil || !strings.Contains(err.Error(), "did not ask the operator") ||
|
||||
!strings.Contains(err.Error(), "no channel can carry") {
|
||||
t.Errorf("the router's refusal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The bounds: at most three asks open for the controller, and the same change not proposed twice.
|
||||
func TestAProposalIsBounded(t *testing.T) {
|
||||
r := newProposerRig(t)
|
||||
in := proposeInput{module: "mounts", node: "shanks", values: mountsSources}
|
||||
if _, err := r.pr.propose(context.Background(), in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.pr.propose(context.Background(), in); err == nil || !strings.Contains(err.Error(), "already proposed") {
|
||||
t.Errorf("the same change twice: %v", err)
|
||||
}
|
||||
for _, node := range []string{"laptop", "anchor"} {
|
||||
if _, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: node, values: mountsSources}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_, err := r.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: map[string]any{"x": 1}})
|
||||
if err == nil || !strings.Contains(err.Error(), "3 questions already wait") {
|
||||
t.Errorf("a fourth: %v", err)
|
||||
}
|
||||
if len(r.sent) != 3 {
|
||||
t.Errorf("published %d", len(r.sent))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the warrant ------------------------------------------------------------------------------------
|
||||
|
||||
// aProposalAsked keeps a proposal's ask in the asker rig's store, as propose keeps it.
|
||||
func aProposalAsked(t *testing.T, r *askerRig, id string, p settingsProposal) asked {
|
||||
t.Helper()
|
||||
q, options := p.ask(id, nil)
|
||||
if err := q.Check(r.now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := asked{ID: id, Condition: q.About, Ask: q, Actions: p.actions(id), Options: options, State: askOpen, Opened: r.now, Proposal: &p}
|
||||
r.store[id] = a
|
||||
return a
|
||||
}
|
||||
|
||||
func aProposal(now time.Time) settingsProposal {
|
||||
before := map[string]any{"shares": "none"}
|
||||
return settingsProposal{Module: "mounts", Node: "shanks", Values: mountsSources, Replace: true, Before: before, HadLayer: true,
|
||||
From: "g14/claude-code", At: now, Digest: layerDigest(mountsSources), BeforeDigest: layerDigest(before)}
|
||||
}
|
||||
|
||||
// warrantOn is the router's warrant for a kept ask, choosing an option by id.
|
||||
func warrantOn(a asked, option string, now time.Time) asks.Warrant {
|
||||
o, _ := a.Ask.Option(option)
|
||||
return asks.Warrant{Ask: a.ID, Asker: "mesh-controller", About: a.Ask.About, Outcome: asks.OutcomeChosen, Option: o.ID,
|
||||
Label: o.Label, Level: o.Level, Channel: "telegram", Proofs: []string{"P1"}, At: now, AskDigest: a.Ask.Digest(),
|
||||
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}
|
||||
}
|
||||
|
||||
type setCall struct {
|
||||
p settingsProposal
|
||||
ask string
|
||||
setBy string
|
||||
}
|
||||
|
||||
func withSetLayer(r *askerRig) *[]setCall {
|
||||
var calls []setCall
|
||||
r.a.setLayer = func(_ context.Context, p settingsProposal, askID, setBy string) (string, error) {
|
||||
calls = append(calls, setCall{p, askID, setBy})
|
||||
return "+ sources, ~ shares", nil
|
||||
}
|
||||
return &calls
|
||||
}
|
||||
|
||||
// On Approve the layer is set once, with who approved it and through which channel kept beside it, and the warrant
|
||||
// is recorded as the operator's decision; heard again, nothing more happens.
|
||||
func TestTheLayerIsSetOnceOnTheOperatorsApproval(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
calls := withSetLayer(r)
|
||||
a := aProposalAsked(t, r, "s1", aProposal(r.now))
|
||||
if err := r.a.reconcile(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := r.store["s1"]; got.State != askOpen {
|
||||
t.Fatalf("the reconciling of conditions ended the proposal: %+v", got)
|
||||
}
|
||||
w := warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
answerWith(t, r, w)
|
||||
answerWith(t, r, w) // heard again, or replayed
|
||||
if len(*calls) != 1 {
|
||||
t.Fatalf("set %d time(s): %+v", len(*calls), *calls)
|
||||
}
|
||||
c := (*calls)[0]
|
||||
if c.ask != "s1" || c.p.Digest != layerDigest(mountsSources) ||
|
||||
!strings.HasPrefix(c.setBy, "approved by the operator, as telegram identity 42 via telegram (telegram), user id verified at ") ||
|
||||
!strings.Contains(c.setBy, "(ask s1, proposed by g14/claude-code)") {
|
||||
t.Errorf("set by %q for %+v", c.setBy, c.p)
|
||||
}
|
||||
if len(r.called)+len(r.silenced) != 0 {
|
||||
t.Errorf("a verb was called: %v %v", r.called, r.silenced)
|
||||
}
|
||||
if len(r.acts) != 1 {
|
||||
t.Fatalf("hand-acts %+v", r.acts)
|
||||
}
|
||||
act := r.acts[0]
|
||||
if act.Verb != handActWarrant || act.By != "the operator, as telegram identity 42" || act.Ask != "s1" ||
|
||||
!slices.Contains(act.Args, "answer=approve") || !slices.Contains(act.Args, "values="+layerDigest(mountsSources)) ||
|
||||
!strings.HasPrefix(act.Outcome, "done") || !personsDecision(act) {
|
||||
t.Errorf("the record: %+v", act)
|
||||
}
|
||||
if got := r.store["s1"]; got.State != string(asks.OutcomeChosen) || !strings.HasPrefix(got.Acted, "done") {
|
||||
t.Errorf("kept as %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Decline, expiry, the router's refusal, a cancel: nothing is set, and the proposal is recorded as ended.
|
||||
func TestDeclineExpiryAndRefusalDiscardAProposal(t *testing.T) {
|
||||
for name, outcome := range map[string]asks.Outcome{"declined": asks.OutcomeChosen, "expired": asks.OutcomeExpired,
|
||||
"refused": asks.OutcomeRefused, "cancelled": asks.OutcomeCancelled, "replaced": asks.OutcomeReplaced} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
calls := withSetLayer(r)
|
||||
a := aProposalAsked(t, r, "s2", aProposal(r.now))
|
||||
w := warrantOn(a, "decline", r.now.Add(time.Hour))
|
||||
if outcome != asks.OutcomeChosen {
|
||||
w = asks.Warrant{Ask: a.ID, Asker: "mesh-controller", Outcome: outcome, Words: "its time passed", At: r.now.Add(time.Hour)}
|
||||
}
|
||||
answerWith(t, r, w)
|
||||
if len(*calls) != 0 {
|
||||
t.Fatalf("set: %+v", *calls)
|
||||
}
|
||||
got := r.store["s2"]
|
||||
if got.State != string(outcome) || got.Acted == "" || !strings.HasPrefix(got.Acted, "nothing") {
|
||||
t.Errorf("kept as %+v", got)
|
||||
}
|
||||
if outcome == asks.OutcomeChosen && (len(r.acts) != 1 || !strings.Contains(r.acts[0].Outcome, "declined")) {
|
||||
t.Errorf("a decline is a decision too: %+v", r.acts)
|
||||
}
|
||||
// An approval after it ended is refused.
|
||||
answerWith(t, r, warrantOn(a, "approve", r.now.Add(2*time.Hour)))
|
||||
if len(*calls) != 0 {
|
||||
t.Fatalf("set after the end: %+v", *calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The warrant binds the exact values: a record whose values changed after the ask, an action changed, a warrant for
|
||||
// another ask's digest, at another level, or after expiry each set nothing.
|
||||
func TestAWarrantSetsOnlyTheExactValuesTheOperatorWasShown(t *testing.T) {
|
||||
cases := map[string]func(r *askerRig, a asked) asks.Warrant{
|
||||
"the values changed in the record": func(r *askerRig, a asked) asks.Warrant {
|
||||
a.Proposal.Values = map[string]any{"sources": "recalbox=smb://evil/recalbox@/mnt/recalbox", "shares": "library=/mnt/library"}
|
||||
r.store[a.ID] = a
|
||||
return warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
},
|
||||
"the layer it was shown against changed in the record": func(r *askerRig, a asked) asks.Warrant {
|
||||
a.Proposal.Before = map[string]any{"shares": "other"}
|
||||
r.store[a.ID] = a
|
||||
return warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
},
|
||||
"the module changed in the record": func(r *askerRig, a asked) asks.Warrant {
|
||||
a.Proposal.Module = "sshd"
|
||||
r.store[a.ID] = a
|
||||
return warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
},
|
||||
"the machine changed in the record": func(r *askerRig, a asked) asks.Warrant {
|
||||
a.Proposal.Node = "anchor"
|
||||
r.store[a.ID] = a
|
||||
return warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
},
|
||||
"the removal became meant in the record": func(r *askerRig, a asked) asks.Warrant {
|
||||
a.Proposal.Replace = !a.Proposal.Replace
|
||||
r.store[a.ID] = a
|
||||
return warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
},
|
||||
"the set became a clear in the record": func(r *askerRig, a asked) asks.Warrant {
|
||||
a.Proposal.Clear = true
|
||||
r.store[a.ID] = a
|
||||
return warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
},
|
||||
"the action's digest was changed": func(r *askerRig, a asked) asks.Warrant {
|
||||
a.Actions[0].Arguments["values"] = layerDigest(map[string]any{"sources": "x"})
|
||||
r.store[a.ID] = a
|
||||
return warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
},
|
||||
"another ask's digest": func(r *askerRig, a asked) asks.Warrant {
|
||||
w := warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
w.AskDigest = "sha256:0000"
|
||||
return w
|
||||
},
|
||||
"at the level acknowledge": func(r *askerRig, a asked) asks.Warrant {
|
||||
w := warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
w.Level = asks.Acknowledge
|
||||
return w
|
||||
},
|
||||
"after expiry": func(r *askerRig, a asked) asks.Warrant {
|
||||
return warrantOn(a, "approve", r.now.Add(askApproveFor+time.Minute))
|
||||
},
|
||||
"nobody chose": func(r *askerRig, a asked) asks.Warrant {
|
||||
w := warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
w.By = nil
|
||||
return w
|
||||
},
|
||||
"another asker's": func(r *askerRig, a asked) asks.Warrant {
|
||||
w := warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
w.Asker = "claude-code"
|
||||
return w
|
||||
},
|
||||
}
|
||||
for name, tamper := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
calls := withSetLayer(r)
|
||||
a := aProposalAsked(t, r, "s3", aProposal(r.now))
|
||||
answerWith(t, r, tamper(r, a))
|
||||
if len(*calls) != 0 {
|
||||
t.Fatalf("set: %+v", *calls)
|
||||
}
|
||||
if got := r.store["s3"]; strings.HasPrefix(got.Acted, "done") {
|
||||
t.Errorf("kept as done: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A proposal counts toward what the controller holds open, so a fourth ask is not attempted while three are.
|
||||
func TestOpenProposalsCountTowardTheAsksHeldOpen(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
for _, id := range []string{"s4", "s5", "s6"} {
|
||||
aProposalAsked(t, r, id, aProposal(r.now))
|
||||
}
|
||||
r.open = []conditions.Condition{heldCondition()}
|
||||
if err := r.a.reconcile(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(r.sent) != 0 {
|
||||
t.Errorf("asked beyond the bound: %d", len(r.sent))
|
||||
}
|
||||
for _, id := range []string{"s4", "s5", "s6"} {
|
||||
if r.store[id].State != askOpen {
|
||||
t.Errorf("%s was ended by the reconciling of conditions: %+v", id, r.store[id])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the verb ---------------------------------------------------------------------------------------
|
||||
|
||||
func TestTheSettingsVerbComposesProposeAndProposals(t *testing.T) {
|
||||
for name, c := range map[string]struct {
|
||||
args map[string]any
|
||||
want string
|
||||
}{
|
||||
"propose on a machine": {map[string]any{"module": "mounts", "node": "shanks", "values": `{"sources":"x"}`, "propose": "true"},
|
||||
"settings propose mounts {\"sources\":\"x\"} --node shanks"},
|
||||
"propose with replace": {map[string]any{"module": "mounts", "values": `{"a":1}`, "propose": "true", "replace": "true"},
|
||||
"settings propose mounts {\"a\":1} --replace"},
|
||||
"propose a clear": {map[string]any{"module": "mounts", "node": "shanks", "propose": "true", "clear": "true"},
|
||||
"settings propose mounts --clear --node shanks"},
|
||||
"the proposals": {map[string]any{"proposals": "true"}, "settings proposals"},
|
||||
"one proposal": {map[string]any{"proposal": "s1"}, "settings proposals s1"},
|
||||
} {
|
||||
argv, err := argvFor("settings", c.args)
|
||||
if err != nil || strings.Join(argv, " ") != c.want {
|
||||
t.Errorf("%s: %v %v", name, argv, err)
|
||||
}
|
||||
}
|
||||
for name, args := range map[string]map[string]any{
|
||||
"propose without a module": {"values": `{"a":1}`, "propose": "true"},
|
||||
"propose without values": {"module": "mounts", "propose": "true"},
|
||||
"propose values and clear": {"module": "mounts", "values": `{"a":1}`, "clear": "true", "propose": "true"},
|
||||
"proposals with a module": {"proposals": "true", "module": "mounts"},
|
||||
"proposals and a proposal": {"proposals": "true", "proposal": "s1"},
|
||||
"a proposal with values": {"proposal": "s1", "values": `{"a":1}`},
|
||||
"proposals with a listing": {"proposals": "true", "list": "preferences"},
|
||||
} {
|
||||
if _, err := argvFor("settings", args); err == nil {
|
||||
t.Errorf("%s was composed", name)
|
||||
}
|
||||
}
|
||||
// The generic command verb proposes nothing (it is the settings verb's), and lists proposals (a read).
|
||||
if err := refusedAsTheGenericCommand([]string{"settings", "propose", "mounts", "{}", "--node", "shanks"}); err == nil {
|
||||
t.Error("the generic command proposed")
|
||||
}
|
||||
if err := refusedAsTheGenericCommand([]string{"settings", "proposals"}); err != nil {
|
||||
t.Errorf("the generic command may not list proposals: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- on the real stores -----------------------------------------------------------------------------
|
||||
|
||||
// setLayerIn sets the layer as the terminal does — judged, the removal meant, the history kept — with who approved it
|
||||
// beside it; and refuses once the layer is no longer the one the operator was shown the change against.
|
||||
func TestSetOnAWarrantJudgesTheLayerAndKeepsWhoApprovedIt(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
|
||||
// y is a preference with a default, so a layer may leave it out; x is the operator's own (ADR 0262).
|
||||
Settings: map[string]catalogue.SettingDeclaration{"y": {Kind: "preference", Default: "10", Why: "a size"}},
|
||||
Resources: []map[string]any{{"id": "data", "type": "directory", "mode": "0755"},
|
||||
// A trusted file (unmarked), so its keys are the terminal's — and now the warrant's (novox/hq ADR 0277).
|
||||
{"id": "rc", "type": "file", "path": "/etc/notes.conf", "mode": "0644", "content": "x = ${setting:x}\ny = ${setting:y}\n"}}})
|
||||
if _, err := assign(ctx, open, "laptop", "notes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set := setLayerIn(open)
|
||||
now := time.Now()
|
||||
first := map[string]any{"x": "1", "y": "2"}
|
||||
p := settingsProposal{Module: "notes", Node: "laptop", Values: first, From: "g14/claude-code", At: now,
|
||||
Digest: layerDigest(first), BeforeDigest: layerDigest(nil)}
|
||||
changed, err := set(ctx, p, "s9", "approved by the operator, as telegram identity 42 via telegram at 14:05 (ask s9)")
|
||||
if err != nil || !strings.Contains(changed, "+ x") {
|
||||
t.Fatalf("%q %v", changed, err)
|
||||
}
|
||||
layer, has, err := open.inventory.Layer(ctx, "laptop", "notes")
|
||||
if err != nil || !has || layer["x"] != "1" {
|
||||
t.Fatalf("the layer: %v %v %v", layer, has, err)
|
||||
}
|
||||
setBy, _, has, err := open.inventory.LayerOrigin(ctx, "laptop", "notes")
|
||||
if err != nil || !has || !strings.HasPrefix(setBy, "approved by the operator, as telegram identity 42 via telegram") {
|
||||
t.Fatalf("who set it: %q %v", setBy, err)
|
||||
}
|
||||
// Shown by `settings show`, at the terminal and through the verb.
|
||||
out := captureStdout(t, func() {
|
||||
if err := atTheTerminal(t, "settings", "show", "notes", "--node", "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "approved by the operator, as telegram identity 42 via telegram") {
|
||||
t.Errorf("settings show says:\n%s", out)
|
||||
}
|
||||
// The same proposal again: the layer is no longer the one the operator was shown it against.
|
||||
if _, err := set(ctx, p, "s9", "approved …"); err == nil || !strings.Contains(err.Error(), "changed since the operator was shown") {
|
||||
t.Errorf("a stale proposal: %v", err)
|
||||
}
|
||||
// A removal not meant is refused; one meant is taken, and the history says who had set the layer.
|
||||
second := map[string]any{"x": "1"}
|
||||
q := settingsProposal{Module: "notes", Node: "laptop", Values: second, Before: first, HadLayer: true,
|
||||
From: "g14/claude-code", At: now, Digest: layerDigest(second), BeforeDigest: layerDigest(first)}
|
||||
if _, err := set(ctx, q, "s10", "approved …"); err == nil || !strings.Contains(err.Error(), "removal was not meant") {
|
||||
t.Errorf("a silent removal: %v", err)
|
||||
}
|
||||
// A layer the mesh refuses is refused here too, with the same words as at the terminal.
|
||||
bad := q
|
||||
bad.Values, bad.Digest = map[string]any{"x": "a\nb", "y": "2"}, layerDigest(map[string]any{"x": "a\nb", "y": "2"})
|
||||
if _, err := set(ctx, bad, "s11", "approved …"); err == nil || !strings.Contains(err.Error(), "line break") {
|
||||
t.Errorf("a line break on a warrant: %v", err)
|
||||
}
|
||||
if layer, _, _ := open.inventory.Layer(ctx, "laptop", "notes"); layer["y"] != "2" {
|
||||
t.Fatalf("a refused act changed the layer: %v", layer)
|
||||
}
|
||||
q.Replace = true
|
||||
if _, err := set(ctx, q, "s10", "approved later"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
past, err := open.inventory.SettingsHistory(ctx, "laptop", "notes")
|
||||
if err != nil || len(past) != 1 || !strings.HasPrefix(past[0].SetBy, "approved by the operator") {
|
||||
t.Errorf("the history: %+v %v", past, err)
|
||||
}
|
||||
// And a clear, keeping who cleared it with the copy.
|
||||
c := settingsProposal{Module: "notes", Node: "laptop", Clear: true, Before: second, HadLayer: true, From: "x", At: now,
|
||||
Digest: layerDigest(second), BeforeDigest: layerDigest(second)}
|
||||
if _, err := set(ctx, c, "s12", "approved by the operator at 15:00"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, has, _ := open.inventory.Layer(ctx, "laptop", "notes"); has {
|
||||
t.Error("the layer was not cleared")
|
||||
}
|
||||
past, _ = open.inventory.SettingsHistory(ctx, "laptop", "notes")
|
||||
if len(past) != 2 || !strings.Contains(past[0].SetBy, "cleared approved by the operator at 15:00") {
|
||||
t.Errorf("the history after a clear: %+v", past)
|
||||
}
|
||||
}
|
||||
|
||||
// A layer set at the terminal says so, and one set through a verb names the verb (novox/hq ADR 0277).
|
||||
func TestALayerSaysWhoSetIt(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
|
||||
Resources: []map[string]any{{"id": "rc", "type": "file", "path": "/etc/notes.conf", "mode": "0644", "trusted": false,
|
||||
"content": "x = ${setting:x}\n"}}})
|
||||
if _, err := assign(ctx, open, "laptop", "notes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := atTheTerminal(t, "settings", "set", "notes", `{"x":"1"}`, "--node", "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setBy, _, _, _ := open.inventory.LayerOrigin(ctx, "laptop", "notes")
|
||||
if !strings.HasPrefix(setBy, "set at the controller's terminal by ") {
|
||||
t.Errorf("at the terminal: %q", setBy)
|
||||
}
|
||||
if err := throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop", "values": `{"x":"2"}`}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setBy, _, _, _ = open.inventory.LayerOrigin(ctx, "laptop", "notes")
|
||||
if !strings.HasPrefix(setBy, "set by ") || !strings.Contains(setBy, "through settings") {
|
||||
t.Errorf("through the verb: %q", setBy)
|
||||
}
|
||||
}
|
||||
|
||||
// A trusted setting is still refused through the settings verb (novox/hq issue 339), and the refusal now names the
|
||||
// proposal as the way.
|
||||
func TestATrustedSettingThroughAVerbNamesTheProposal(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
|
||||
Resources: []map[string]any{{"id": "data", "type": "directory", "mode": "0755"}}})
|
||||
if _, err := assign(ctx, open, "laptop", "notes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop",
|
||||
"values": `{"places":{"data":{"path":"/srv/notes","owner":"1001:1001"}}}`})
|
||||
if err == nil || !strings.Contains(err.Error(), "issue 339") || !strings.Contains(err.Error(), "propose") {
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
if _, has, _ := open.inventory.Layer(ctx, "laptop", "notes"); has {
|
||||
t.Error("a layer was kept")
|
||||
}
|
||||
}
|
||||
|
||||
// captureStdout runs f and answers what it printed to standard output.
|
||||
func captureStdout(t *testing.T, f func()) string {
|
||||
t.Helper()
|
||||
before := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = w
|
||||
done := make(chan string)
|
||||
go func() {
|
||||
var b strings.Builder
|
||||
_, _ = io.Copy(&b, r)
|
||||
done <- b.String()
|
||||
}()
|
||||
f()
|
||||
os.Stdout = before
|
||||
_ = w.Close()
|
||||
return <-done
|
||||
}
|
||||
@@ -260,10 +260,10 @@ func refusedAsTheGenericCommand(argv []string) error {
|
||||
// A layer is written through the settings verb, never the generic one (novox/hq issue 339): the
|
||||
// settings verb is where what a verb may not set is refused, and one route is one set of words.
|
||||
// The command refuses places and accesses through any verb as well; this says so before it runs.
|
||||
if argv[0] == "settings" && slices.ContainsFunc(argv[1:], func(w string) bool { return w == "set" || w == "clear" }) {
|
||||
return &heldAtTheTerminal{msg: "settings are set and cleared through the settings verb, not the " +
|
||||
"generic command; and places and accesses only at the controller's terminal (novox/hq issue 339). " +
|
||||
"Nothing was done"}
|
||||
if argv[0] == "settings" && slices.ContainsFunc(argv[1:], func(w string) bool { return w == "set" || w == "clear" || w == "propose" }) {
|
||||
return &heldAtTheTerminal{msg: "settings are set, cleared and proposed through the settings verb, not the " +
|
||||
"generic command; and places and accesses are set at the controller's terminal or on the operator's " +
|
||||
"warrant (novox/hq issue 339, ADR 0277). Nothing was done"}
|
||||
}
|
||||
// The generic verb only reads (novox/hq ADR 0266): what writes has a named verb that composes its own
|
||||
// line, or is the operator's at the controller's terminal.
|
||||
@@ -835,6 +835,21 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
if str("module") == "" && on("clear") {
|
||||
return nil, errors.New("settings: a module is needed to clear a layer; name it with module")
|
||||
}
|
||||
// The proposals, listed or one whole (novox/hq ADR 0277): a read, needing no module.
|
||||
if on("proposals") || str("proposal") != "" {
|
||||
if str("module") != "" || str("values") != "" || str("node") != "" || on("clear") || on("replace") ||
|
||||
on("history") || str("list") != "" || on("propose") || (on("proposals") && str("proposal") != "") {
|
||||
return nil, errors.New("settings: proposals lists what was proposed and proposal shows one; either takes nothing else")
|
||||
}
|
||||
argv := []string{"settings", "proposals"}
|
||||
if id := str("proposal"); id != "" {
|
||||
argv = append(argv, id)
|
||||
}
|
||||
return argv, nil
|
||||
}
|
||||
if str("module") == "" && on("propose") {
|
||||
return nil, errors.New("settings: a module is needed to propose a layer; name it with module")
|
||||
}
|
||||
if list := str("list"); list != "" || str("module") == "" {
|
||||
if list != "" && list != "preferences" {
|
||||
return nil, fmt.Errorf("settings lists %q only; %q is not a listing", "preferences", list)
|
||||
@@ -855,6 +870,22 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
}
|
||||
var argv []string
|
||||
switch {
|
||||
case on("propose"):
|
||||
// A proposal: the values, or clear, put to the operator (novox/hq ADR 0277). Nothing is set here.
|
||||
argv = []string{"settings", "propose", str("module")}
|
||||
switch {
|
||||
case on("clear") && str("values") != "":
|
||||
return nil, errors.New("settings: a proposal gives values or says clear, not both")
|
||||
case on("clear"):
|
||||
argv = append(argv, "--clear")
|
||||
case str("values") != "":
|
||||
argv = append(argv, str("values"))
|
||||
if on("replace") {
|
||||
argv = append(argv, "--replace")
|
||||
}
|
||||
default:
|
||||
return nil, errors.New("settings: a proposal gives the values, or says clear")
|
||||
}
|
||||
case on("clear"):
|
||||
argv = []string{"settings", "clear", str("module")}
|
||||
case str("values") != "":
|
||||
@@ -1447,7 +1478,7 @@ var commandReadForms = map[string]func(rest []string) bool{
|
||||
"conditions": func(r []string) bool { return flagsOnly(r) || subIn(r, "list", "show", "history") },
|
||||
"node": func(r []string) bool { return subIn(r, "list", "show") },
|
||||
"module": func(r []string) bool { return subIn(r, "list") },
|
||||
"settings": func(r []string) bool { return subIn(r, "show", "preferences") },
|
||||
"settings": func(r []string) bool { return subIn(r, "show", "preferences", "proposals") },
|
||||
"retire": func(r []string) bool { return subIn(r, "list") },
|
||||
"cleanup": func(r []string) bool { return subIn(r, "list") },
|
||||
"delivery": func(r []string) bool { return subIn(r, "plan", "walks") },
|
||||
|
||||
@@ -268,17 +268,26 @@ var ControllerVerbs = []Verb{
|
||||
"with history, the layers it replaced. Setting replaces that layer whole and answers each key it adds (+), " +
|
||||
"changes (~) and removes (-); a set that would remove a key is refused unless replace says it is meant " +
|
||||
"(novox/hq ADR 0217). Takes effect at the next push. With clear, removes the layer and the module is back to " +
|
||||
"what its definition says; a cleared or replaced layer is kept in the history.",
|
||||
"what its definition says; a cleared or replaced layer is kept in the history. A trusted setting — " +
|
||||
"places, accesses, what a provider serves, what a trusted file asks for, the whole layer of a module " +
|
||||
"with a trusted mergeable file — is refused through this verb (novox/hq issue 339, 340) and PROPOSED " +
|
||||
"instead (novox/hq ADR 0277): with propose, the values (or clear) are put to the operator on a channel " +
|
||||
"that proves who answers, with every key and its exact new value, and the layer is set only on their " +
|
||||
"Approve; the answer names the proposal, its fingerprint and when it expires, and nothing changes until " +
|
||||
"then. With proposals, every proposal and where each stands; with proposal, one whole.",
|
||||
Input: schema(map[string]string{
|
||||
"module": "the module's name; with list, only that module's",
|
||||
"values": "the settings as a JSON object, for set",
|
||||
"values": "the settings as a JSON object, for set or propose",
|
||||
"node": "one machine; the whole mesh when absent",
|
||||
"clear": "\"true\" to remove the layer instead of setting it; not with values",
|
||||
"replace": "\"true\": with values, the set is meant to remove the keys the layer had and it does not name",
|
||||
"clear": "\"true\" to remove the layer instead of setting it, or to propose its removal; not with values",
|
||||
"replace": "\"true\": with values, the set (or the proposal) is meant to remove the keys the layer had and it does not name",
|
||||
"history": "\"true\": without values or clear, the layers this one replaced, the latest first",
|
||||
"list": "\"preferences\": every module's preferences — key, default and why — and the value on each " +
|
||||
"machine it is assigned to with where it comes from; module and node narrow it (novox/hq ADR 0262)",
|
||||
}, nil, "clear", "replace", "history")},
|
||||
"propose": "\"true\": propose the values (or clear) to the operator instead of setting them (novox/hq ADR 0277)",
|
||||
"proposals": "\"true\": every settings proposal, newest first, and where each stands",
|
||||
"proposal": "a proposal's id: that proposal whole, its values and the layer it was shown against",
|
||||
}, nil, "clear", "replace", "history", "propose", "proposals")},
|
||||
{Name: "command", Description: "Run one reading command line of the controller's own, as you would type it at " +
|
||||
"its shell — `node show ace`, `module list`, `plans`, `conditions show <key>` — and answer what it " +
|
||||
"printed. The generic verb beside the named ones (novox/hq ADR 0154), and since ADR 0266 it only reads: " +
|
||||
|
||||
@@ -799,7 +799,35 @@ func profileFrom(raw []byte) ([]Capability, error) {
|
||||
// this is a statement of the whole layer, so removing a key is done by leaving it out, which is
|
||||
// the only way removing one could work at all.
|
||||
func (i *Inventory) SetSettings(ctx context.Context, nodeName, module string, values map[string]any) error {
|
||||
return i.setSettings(ctx, nodeName, module, values, true)
|
||||
return i.setSettings(ctx, nodeName, module, values, true, "")
|
||||
}
|
||||
|
||||
// SetSettingsBy is SetSettings with who set the layer kept beside it (novox/hq ADR 0277): "approved by the
|
||||
// operator via telegram …" for a layer set on a warrant, the caller at the controller's terminal otherwise.
|
||||
// `settings` says it back, so a layer the operator approved on their phone is told from one typed.
|
||||
func (i *Inventory) SetSettingsBy(ctx context.Context, nodeName, module string, values map[string]any, setBy string) error {
|
||||
return i.setSettings(ctx, nodeName, module, values, true, setBy)
|
||||
}
|
||||
|
||||
// JudgeSettings judges a layer as SetSettings would, and keeps nothing: what a proposal is held to before the
|
||||
// operator is asked (novox/hq ADR 0277), so an ask is never raised for a layer the mesh would refuse.
|
||||
func (i *Inventory) JudgeSettings(ctx context.Context, nodeName, module string, values map[string]any) error {
|
||||
if err := i.judgeSettings(ctx, nodeName, module, values); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
given, err := givenIn(module, raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if nodeName == "" && len(given) > 0 {
|
||||
return fmt.Errorf("%s: %s is given per node — a port is a fact about one machine; "+
|
||||
"set it with --node", module, catalogue.PortsSetting)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// KeepSettings records a layer the mesh already holds, as it holds it, without judging it alone: for a
|
||||
@@ -807,10 +835,10 @@ func (i *Inventory) SetSettings(ctx context.Context, nodeName, module string, va
|
||||
// mesh-wide layer that needs a machine's own value to compose would be refused before that machine's
|
||||
// layer is there — though the mesh keeps both and composes. Composition still judges every layer.
|
||||
func (i *Inventory) KeepSettings(ctx context.Context, nodeName, module string, values map[string]any) error {
|
||||
return i.setSettings(ctx, nodeName, module, values, false)
|
||||
return i.setSettings(ctx, nodeName, module, values, false, "")
|
||||
}
|
||||
|
||||
func (i *Inventory) setSettings(ctx context.Context, nodeName, module string, values map[string]any, judge bool) error {
|
||||
func (i *Inventory) setSettings(ctx context.Context, nodeName, module string, values map[string]any, judge bool, setBy string) error {
|
||||
raw, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -848,9 +876,9 @@ func (i *Inventory) setSettings(ctx context.Context, nodeName, module string, va
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`insert into settings (node, module, values) values (null, $1, $2)
|
||||
`insert into settings (node, module, values, set_by) values (null, $1, $2, $3)
|
||||
on conflict (module) where node is null
|
||||
do update set values = excluded.values, set_at = now()`, module, raw); err != nil {
|
||||
do update set values = excluded.values, set_at = now(), set_by = excluded.set_by`, module, raw, nullable(setBy)); err != nil {
|
||||
return wrapModule(err, module)
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
@@ -878,9 +906,9 @@ func (i *Inventory) setSettings(ctx context.Context, nodeName, module string, va
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx,
|
||||
`insert into settings (node, module, values) values ($1, $2, $3)
|
||||
`insert into settings (node, module, values, set_by) values ($1, $2, $3, $4)
|
||||
on conflict (node, module) where node is not null
|
||||
do update set values = excluded.values, set_at = now()`, node.ID, module, raw)
|
||||
do update set values = excluded.values, set_at = now(), set_by = excluded.set_by`, node.ID, module, raw, nullable(setBy))
|
||||
if err != nil {
|
||||
return wrapModule(err, module)
|
||||
}
|
||||
@@ -1069,6 +1097,11 @@ func wrapModule(err error, module string) error {
|
||||
|
||||
// ClearSettings removes a layer.
|
||||
func (i *Inventory) ClearSettings(ctx context.Context, nodeName, module string) error {
|
||||
return i.ClearSettingsBy(ctx, nodeName, module, "")
|
||||
}
|
||||
|
||||
// ClearSettingsBy removes a layer, and keeps who cleared it with the history copy (novox/hq ADR 0277).
|
||||
func (i *Inventory) ClearSettingsBy(ctx context.Context, nodeName, module, clearedBy string) error {
|
||||
nodeID, err := i.layerNode(ctx, nodeName)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1082,6 +1115,17 @@ func (i *Inventory) ClearSettings(ctx context.Context, nodeName, module string)
|
||||
if _, err := tx.Exec(ctx, keepReplacedSQL, module, nodeID, "clear"); err != nil {
|
||||
return err
|
||||
}
|
||||
if clearedBy != "" {
|
||||
// The history copy says who cleared it, beside who had set it.
|
||||
if _, err := tx.Exec(ctx,
|
||||
`update settings_history set set_by = coalesce(set_by, '') || ' — cleared ' || $3
|
||||
where module = $1 and node is not distinct from $2::uuid
|
||||
and replaced_at = (select max(replaced_at) from settings_history
|
||||
where module = $1 and node is not distinct from $2::uuid)`,
|
||||
module, nodeID, clearedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`delete from settings where module = $1 and node is not distinct from $2::uuid`, module, nodeID); err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- A trusted setting set on the operator's warrant (novox/hq ADR 0277): a layer says who set it.
|
||||
--
|
||||
-- Until now a layer carried only when it was set: a layer the operator approved on their phone and one typed
|
||||
-- at the controller's terminal looked the same, and `settings` could not say "approved by the operator via
|
||||
-- telegram". Kept with the layer, and with the history copy of it, so the provenance of a replaced layer is
|
||||
-- read back beside its values.
|
||||
alter table settings add column set_by text;
|
||||
alter table settings_history add column set_by text;
|
||||
@@ -44,6 +44,31 @@ type PastLayer struct {
|
||||
ReplacedAt time.Time
|
||||
// ReplacedBy is "set" or "clear".
|
||||
ReplacedBy string
|
||||
// SetBy is who had set the layer, when it was kept (novox/hq ADR 0277); empty for one set before that was kept.
|
||||
SetBy string
|
||||
}
|
||||
|
||||
// LayerOrigin is who set a layer and when (novox/hq ADR 0277): empty words for a layer set before who set it was
|
||||
// kept, and has false where there is no layer.
|
||||
func (i *Inventory) LayerOrigin(ctx context.Context, nodeName, module string) (setBy string, setAt time.Time, has bool, err error) {
|
||||
nodeID, err := i.layerNode(ctx, nodeName)
|
||||
if err != nil {
|
||||
return "", time.Time{}, false, err
|
||||
}
|
||||
var by *string
|
||||
err = i.store.Pool().QueryRow(ctx,
|
||||
`select set_by, set_at from settings where module = $1 and node is not distinct from $2::uuid`,
|
||||
module, nodeID).Scan(&by, &setAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", time.Time{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", time.Time{}, false, err
|
||||
}
|
||||
if by != nil {
|
||||
setBy = *by
|
||||
}
|
||||
return setBy, setAt, true, nil
|
||||
}
|
||||
|
||||
// SettingsHistory is every layer of one module on one machine — the whole mesh's when nodeName is
|
||||
@@ -54,7 +79,7 @@ func (i *Inventory) SettingsHistory(ctx context.Context, nodeName, module string
|
||||
return nil, err
|
||||
}
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select values, set_at, replaced_at, replaced_by from settings_history
|
||||
`select values, set_at, replaced_at, replaced_by, set_by from settings_history
|
||||
where module = $1 and node is not distinct from $2::uuid order by replaced_at desc`,
|
||||
module, nodeID)
|
||||
if err != nil {
|
||||
@@ -65,9 +90,13 @@ func (i *Inventory) SettingsHistory(ctx context.Context, nodeName, module string
|
||||
for rows.Next() {
|
||||
var raw []byte
|
||||
var p PastLayer
|
||||
if err := rows.Scan(&raw, &p.SetAt, &p.ReplacedAt, &p.ReplacedBy); err != nil {
|
||||
var by *string
|
||||
if err := rows.Scan(&raw, &p.SetAt, &p.ReplacedAt, &p.ReplacedBy, &by); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if by != nil {
|
||||
p.SetBy = *by
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p.Values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -78,8 +107,8 @@ func (i *Inventory) SettingsHistory(ctx context.Context, nodeName, module string
|
||||
|
||||
// keepReplacedSQL copies a layer into the history before it is replaced or cleared; run in the same
|
||||
// transaction as the write where there is one, so a write that fails leaves no history of it.
|
||||
const keepReplacedSQL = `insert into settings_history (node, module, values, set_at, replaced_by)
|
||||
select node, module, values, set_at, $3 from settings
|
||||
const keepReplacedSQL = `insert into settings_history (node, module, values, set_at, replaced_by, set_by)
|
||||
select node, module, values, set_at, $3, set_by from settings
|
||||
where module = $1 and node is not distinct from $2::uuid`
|
||||
|
||||
// layerNode is the node id of a layer, nil for the whole mesh's.
|
||||
|
||||
Reference in New Issue
Block a user