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.
794 lines
31 KiB
Go
794 lines
31 KiB
Go
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
|
||
}
|
||
}
|