A trusted setting is proposed through the settings verb and set only on the operator's warrant (hq ADR 0277)
Issue 339 made every trusted setting the controller's terminal's alone, so an agent could only hand the operator a line to type at the control node. Now anyone the bus admits may PROPOSE a layer: settings propose keeps the proposal in the controller's own asks (the asked bucket, which the controller alone writes), judged as settings set judges, and asks the operator on the operator channel at the level approve with every key, its exact new value (in its shape where a path or an address may not leave the mesh), the was of a changed key, the removed keys and the layer's fingerprint. The serving controller sets the layer on the warrant alone: once, for the ask it holds, only when the record's values still digest to what the option bound and the layer is still the one shown, with the terminal's judgement and history, and keeps who approved it beside the layer, which settings says back (migration 0090). Decline, expiry, a cancel, a replacement or the router's refusal discard it; nothing is asked when no router, no grant or no channel can carry it. settings proposals lists them. The push afterwards is a separate act.
This commit is contained in:
@@ -91,8 +91,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 }
|
||||
|
||||
@@ -161,6 +167,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.
|
||||
@@ -301,17 +309,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
|
||||
}
|
||||
@@ -327,7 +363,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
|
||||
}
|
||||
}
|
||||
@@ -360,7 +396,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
|
||||
@@ -711,7 +747,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
|
||||
}
|
||||
@@ -758,15 +794,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") },
|
||||
|
||||
Reference in New Issue
Block a user