mesh/merge-gate head 1219ec822943 merged into trunk 6f1a05366c1d — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head 1219ec822943 merged into trunk 6f1a05366c1d — pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
A judged send carries what waits on its machine, which would have released builds held after a failed walk without a person's word. The settings walk now keeps every build of its machine, refuses a send that would move a running module's data, and never says delivered when it reached no machine.
822 lines
32 KiB
Go
822 lines
32 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. No push is left for
|
||
// afterwards: the warrant that sets the layer opens the walk that sends it (novox/hq ADR 0344, settings_walk.go),
|
||
// so the operator's one Approve is the act that makes the change take effect.
|
||
//
|
||
// **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/inventory"
|
||
"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 + ".")
|
||
}
|
||
b.WriteString(" " + p.onApprove())
|
||
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 and sent to " + p.reaches() + " at once, each machine judged"
|
||
if p.Clear {
|
||
does = "the layer is removed and that is sent to " + p.reaches() + " at once, each machine judged"
|
||
}
|
||
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, a walk sends it to %s at once, one machine at "+
|
||
"a time, each judged, and its end is told as every walk's is (novox/hq ADR 0344)", id, p.reaches())
|
||
return b.String(), nil
|
||
}
|
||
|
||
// reaches is the machines an approved layer is sent to, in words: the one it names, or every machine running the
|
||
// module.
|
||
func (p settingsProposal) reaches() string {
|
||
if p.Node == "" {
|
||
return "every machine running " + p.Module
|
||
}
|
||
return p.Node
|
||
}
|
||
|
||
// onApprove is what the ask says Approve does (novox/hq ADR 0344 §4): the one Approve sets the layer and sends it.
|
||
func (p settingsProposal) onApprove() string {
|
||
what := "the layer is set"
|
||
if p.Clear {
|
||
what = "the layer is removed"
|
||
}
|
||
return fmt.Sprintf("Approved, %s and sent at once to %s, one machine at a time, each judged, every build there "+
|
||
"kept; its end is told as every walk's is. A module that reads its settings only when it starts takes them at "+
|
||
"its next start.", what, p.reaches())
|
||
}
|
||
|
||
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, conn),
|
||
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"
|
||
}
|
||
}
|
||
// **And sends it** (novox/hq ADR 0344): the layer the operator approved is walked to the machines it reaches
|
||
// at once. A walk that cannot be opened is said as the act's failure — the layer is set and nothing sends it.
|
||
walked, err := openSettingsWalk(ctx, open, inventory.PlanSettings{Module: p.Module, Node: p.Node, Ask: askID,
|
||
Change: changed})
|
||
if err != nil {
|
||
return "", fmt.Errorf("%s (ask %s) is set, and the walk that sends it could not be opened: %v — `push %s` sends it",
|
||
changed, askID, err, pushTo(p.Node))
|
||
}
|
||
return changed + " (ask " + askID + "); " + walked, nil
|
||
}
|
||
}
|
||
|
||
// pushTo is the push that sends a layer by hand when its walk could not: the machine, or every one behind.
|
||
func pushTo(node string) string {
|
||
if node == "" {
|
||
return "--behind"
|
||
}
|
||
return node
|
||
}
|