Compare commits
9
Commits
52bcef159d
...
5b014e75b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b014e75b7 | ||
|
|
0d07efd395 | ||
|
|
deafd810b1 | ||
|
|
fffaface66 | ||
|
|
8ddf8b7005 | ||
|
|
8bbb1b19ae | ||
|
|
e32ca00aed | ||
|
|
1219ec8229 | ||
|
|
e324f5d821 |
@@ -18,8 +18,9 @@ package main
|
||||
// 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.
|
||||
// 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
|
||||
@@ -27,6 +28,13 @@ package main
|
||||
// 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.
|
||||
//
|
||||
// **On a channel that proves who answers, the values are shown WHOLE** (novox/hq issue 383, the operator's
|
||||
// decision of 2026-10-10): a mount point, a share name or a private address is the thing being approved and must
|
||||
// be readable, so the ask carries them whole beside the explanation (asks.Ask.Whole), the router shows that only
|
||||
// on a kind that verifies its sender, and only a value shaped like a secret is withheld there. The message is the
|
||||
// headline, one line per key, who proposed it and when; the fingerprint and how to read the proposal whole are
|
||||
// the Details answer's (asks.Ask.Details). The masking stays for conditions and for channels that prove nothing.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -47,6 +55,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -154,47 +163,48 @@ func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[strin
|
||||
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())
|
||||
// The message reads at a glance (issues 383 and 639): a summary of what changes where, then at most three
|
||||
// points, a key each; every key with its whole value, who proposed it, the fingerprint and how to read it at the
|
||||
// terminal are its Details, which the router notes after the answers.
|
||||
shown, shownAll, shownWhole := p.change(machines, false)
|
||||
whole, wholeAll, _ := p.change(machines, true)
|
||||
summary := p.summary()
|
||||
compose := func(points []string) string {
|
||||
return strings.Join(append([]string{summary}, points...), "\n")
|
||||
}
|
||||
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")
|
||||
// The Details: every setting it sets, one line each, uncut — whole where the sender is proven (WholeDetails),
|
||||
// masked elsewhere — then who proposed it, the fingerprint and how to read it at the terminal.
|
||||
details := func(lines []string) string {
|
||||
var d strings.Builder
|
||||
d.WriteString(strings.Join(lines, "\n") + "\n")
|
||||
d.WriteString(p.onApprove() + "\n")
|
||||
fmt.Fprintf(&d, "Proposed by %s at %s. Fingerprint %s.\n", sayable(p.From, machines), p.At.Local().Format("15:04 on 2 Jan"),
|
||||
fingerprint(p.Digest))
|
||||
if !shownWhole {
|
||||
d.WriteString("Where who answers is not proven, its values are shown as ‹address›, ‹path› or ‹withheld›.\n")
|
||||
}
|
||||
fmt.Fprintf(&d, "Read it whole, with this fingerprint: settings proposals %s at the controller's terminal, or the "+
|
||||
"controller's settings verb with proposal %s through the mesh MCP server.", id, id)
|
||||
return d.String()
|
||||
}
|
||||
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,
|
||||
q := asks.Ask{ID: id, Headline: headline, Explanation: compose(shown), Who: asks.Operator,
|
||||
Expires: p.At.Add(askApproveFor), OnExpiry: "the proposal is discarded; nothing changes",
|
||||
About: p.about()}
|
||||
About: p.about(), Details: details(shownAll)}
|
||||
// Only where a value was masked: an ask whose values all pass the content rule shows the same everywhere.
|
||||
if w := compose(whole); w != q.Explanation {
|
||||
q.Whole = w
|
||||
}
|
||||
if w := details(wholeAll); w != q.Details {
|
||||
q.WholeDetails = w
|
||||
}
|
||||
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"
|
||||
does := "the settings are set and sent to " + p.reaches() + " at once, each machine judged"
|
||||
if p.Clear {
|
||||
does = "the layer is removed; nothing is pushed yet"
|
||||
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"
|
||||
@@ -205,55 +215,99 @@ func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[strin
|
||||
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
|
||||
// summary is the proposal's first line, what changes where: how many settings, how many stay, and that Approve sends
|
||||
// the layer at once by a walk, one machine at a time (novox/hq ADR 0344); the rest of what Approve does
|
||||
// (onApprove) is the Details'.
|
||||
func (p settingsProposal) summary() string {
|
||||
if p.Clear {
|
||||
if !p.HadLayer {
|
||||
return "There is no layer to remove; approving changes nothing."
|
||||
}
|
||||
return fmt.Sprintf("Removes the %d setting(s) of %s set on %s; approved, the layer is removed and that is sent at once "+
|
||||
"by a walk to %s, one machine at a time, each judged.", len(leaves(p.Before, "")), p.Module, p.where(), p.reaches())
|
||||
}
|
||||
added, changed, removed := settingsChange(p.Before, p.Values)
|
||||
n := len(added) + len(changed) + len(removed)
|
||||
unchanged := len(leaves(p.Values, "")) - len(added) - len(changed)
|
||||
if n == 0 {
|
||||
return fmt.Sprintf("Nothing changes: the %d setting(s) of %s on %s are as they stand.", unchanged, p.Module, p.where())
|
||||
}
|
||||
stay := ""
|
||||
if unchanged > 0 {
|
||||
stay = fmt.Sprintf(", and %d stay as they are", unchanged)
|
||||
}
|
||||
return fmt.Sprintf("Changes %d setting(s) of %s on %s%s; approved, the layer is set and sent at once by a walk to %s, "+
|
||||
"one machine at a time, each judged.", n, p.Module, p.where(), stay, p.reaches())
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// change is what changes: as at most asks.MostPoints points for the glance, as every line whole for the note, and
|
||||
// whether every value was shown exactly: "+ key: value"
|
||||
// added, "~ key: value (was: old)" changed, "- key (was: old)" removed. Each value is shown under the content rule
|
||||
// (sayableValue), or — exact, for a channel that proves who answers — as it is, withheld only when shaped like a
|
||||
// secret (wholeValue). More changes than points: the first two, and the keys of the rest; a point longer than a
|
||||
// phone shows at a glance is cut and says so. The note carries every line uncut (the review of #304: Approve sets
|
||||
// exactly what the operator can read).
|
||||
func (p settingsProposal) change(machines []string, exact bool) ([]string, []string, bool) {
|
||||
whole := true
|
||||
say := func(v any) string {
|
||||
s, w := sayableValue(v, machines)
|
||||
if exact {
|
||||
s, w = wholeValue(v, machines)
|
||||
}
|
||||
whole = whole && w
|
||||
return s
|
||||
}
|
||||
var lines []string
|
||||
type line struct{ key, text string }
|
||||
var lines []line
|
||||
if p.Clear {
|
||||
was := leaves(p.Before, "")
|
||||
for _, k := range layerKeys(was) {
|
||||
lines = append(lines, fmt.Sprintf("- %s: %s", k, say(was[k])))
|
||||
lines = append(lines, line{k, 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])))
|
||||
lines = append(lines, line{k, 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])))
|
||||
lines = append(lines, line{k, 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))
|
||||
lines = append(lines, line{k, fmt.Sprintf("- %s (was: %s)", k, say(was[k]))})
|
||||
}
|
||||
}
|
||||
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
|
||||
all := make([]string, len(lines))
|
||||
for i, l := range lines {
|
||||
all[i] = l.text
|
||||
}
|
||||
return out, whole
|
||||
// What the note holds of a value: whole where the glance is the whole one (shown only where the sender is
|
||||
// proven, with the whole details beside it), under the content rule elsewhere — so the marker says no more than
|
||||
// the note it points to (the review of ADR 0347).
|
||||
inNote := "in the note"
|
||||
if exact {
|
||||
inNote = "whole in the note"
|
||||
}
|
||||
var out []string
|
||||
for i, l := range lines {
|
||||
if len(lines) > asks.MostPoints && i == asks.MostPoints-1 {
|
||||
var rest []string
|
||||
for _, r := range lines[i:] {
|
||||
rest = append(rest, r.key)
|
||||
}
|
||||
out = append(out, fmt.Sprintf("and %d more, each %s: %s", len(rest), inNote, strings.Join(rest, ", ")))
|
||||
break
|
||||
}
|
||||
out = append(out, l.text)
|
||||
}
|
||||
// A point too long for a glance is cut, and says so: its value is the note's.
|
||||
cut := "… (cut: " + inNote + ")"
|
||||
for i, l := range out {
|
||||
if r := []rune(l); len(r) > asks.PointLength {
|
||||
out[i] = string(r[:asks.PointLength-len([]rune(cut))]) + cut
|
||||
}
|
||||
}
|
||||
return out, all, whole
|
||||
}
|
||||
|
||||
func layerKeys(m map[string]any) []string {
|
||||
@@ -300,6 +354,27 @@ func sayableValue(v any, machines []string) (string, bool) {
|
||||
return out, out == text
|
||||
}
|
||||
|
||||
// wholeValue is a value as a channel that proves who answers is shown it (asks.Ask.Whole), and whether it was
|
||||
// shown whole: as it is, unless it is shaped like a secret (outward.Secret), which is withheld whole — never in
|
||||
// part, so no half of a key reaches the phone.
|
||||
func wholeValue(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
|
||||
}
|
||||
if _, ok := outward.Secret(text, machines...); !ok {
|
||||
return markWithheld, false
|
||||
}
|
||||
return text, true
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -432,6 +507,12 @@ func (pr proposer) propose(ctx context.Context, in proposeInput) (string, error)
|
||||
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))
|
||||
}
|
||||
// The whole words are shown only where the sender is proven, and never a secret's shape: wholeValue withholds
|
||||
// one, and the router would refuse the ask if one were left, so it is refused here first, in words.
|
||||
if refusal, ok := outward.Secret(q.Whole, machines...); !ok {
|
||||
return refuse("the change shown whole would carry %s, which may not leave the mesh on any channel; %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)
|
||||
@@ -519,15 +600,29 @@ func (pr proposer) propose(ctx context.Context, in proposeInput) (string, error)
|
||||
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))
|
||||
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
|
||||
}
|
||||
|
||||
func pushWord(node string) string {
|
||||
if node == "" {
|
||||
return "--behind"
|
||||
// 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 node
|
||||
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 {
|
||||
@@ -788,6 +883,22 @@ func setLayerIn(open *stores) setOnWarrant {
|
||||
changed = "nothing changed"
|
||||
}
|
||||
}
|
||||
return changed + " (ask " + askID + ")", nil
|
||||
// **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
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
@@ -116,24 +117,43 @@ func TestAProposalAsksAtTheLevelApproveWithTheExactChange(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
// The message reads at a glance (issues 383 and 639): a summary, then one point per key — and nothing else:
|
||||
// who proposed it, the fingerprint and the how-to are the Details, which the router notes after the answers.
|
||||
summary := "Changes 3 setting(s) of mounts on shanks; approved, the layer is set and sent at once by a walk to shanks, one machine at a time, each judged."
|
||||
if q.Explanation != summary+"\n+ sources: recalbox=‹address›@‹path›:ro\n~ shares: library=‹path› (was: none)\n- old (was: x)" {
|
||||
t.Errorf("the explanation:\n%s", q.Explanation)
|
||||
}
|
||||
if err := asks.Glance(q.Explanation); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
// Where the sender is proven, the values whole: the mount point and the share are what is approved.
|
||||
if q.Whole != summary+"\n+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro\n~ shares: library=/mnt/library (was: none)\n- old (was: x)" ||
|
||||
asks.Glance(q.Whole) != nil {
|
||||
t.Errorf("the whole words:\n%s", q.Whole)
|
||||
}
|
||||
for _, line := range []string{
|
||||
"Proposed by g14/claude-code, through the mesh-controller seat at " + r.now.Local().Format("15:04 on 2 Jan") +
|
||||
". Fingerprint " + fingerprint(layerDigest(mountsSources)) + ".",
|
||||
"Where who answers is not proven, its values are shown as ‹address›, ‹path› or ‹withheld›.",
|
||||
"Read it whole, with this fingerprint: settings proposals " + kept(r).ID + " at the controller's terminal",
|
||||
} {
|
||||
if !strings.Contains(q.Details, line) {
|
||||
t.Errorf("Details lack %q:\n%s", line, q.Details)
|
||||
}
|
||||
}
|
||||
if refusal, ok := outward.Check(q.Details, "anchor", "laptop", "shanks"); !ok {
|
||||
t.Errorf("the router would withhold a line of the details: %s", refusal)
|
||||
}
|
||||
for _, howTo := range []string{"ingerprint", "settings proposals", "settings verb", "not proven", "Proposed by"} {
|
||||
if strings.Contains(q.Explanation, howTo) || strings.Contains(q.Whole, howTo) {
|
||||
t.Errorf("the message carries the how-to %q", howTo)
|
||||
}
|
||||
}
|
||||
all := []string{q.Headline, q.Explanation, q.OnExpiry}
|
||||
for _, o := range q.Options {
|
||||
all = append(all, o.Label, o.Does)
|
||||
@@ -141,6 +161,9 @@ func TestAProposalAsksAtTheLevelApproveWithTheExactChange(t *testing.T) {
|
||||
if refusal, ok := outward.Check(strings.Join(all, "\n"), "anchor", "laptop", "shanks"); !ok {
|
||||
t.Errorf("the router would refuse the ask: %s", refusal)
|
||||
}
|
||||
if refusal, ok := outward.Secret(q.Whole, "anchor", "laptop", "shanks"); !ok {
|
||||
t.Errorf("the router would refuse the whole words: %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
|
||||
@@ -173,11 +196,98 @@ func TestAMeshWideLayerIsProposed(t *testing.T) {
|
||||
}
|
||||
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?") {
|
||||
q.Explanation != "Changes 1 setting(s) of notes on the whole mesh; approved, the layer is set and sent at once by a walk to every machine running notes, one machine at a time, each judged.\n+ x: 1" ||
|
||||
q.Whole != "" {
|
||||
t.Errorf("%+v", q)
|
||||
}
|
||||
}
|
||||
|
||||
// kept is the one proposal the rig keeps.
|
||||
func kept(r *proposerRig) asked {
|
||||
for _, a := range r.store {
|
||||
if a.Proposal != nil {
|
||||
return a
|
||||
}
|
||||
}
|
||||
return asked{}
|
||||
}
|
||||
|
||||
// The switch between the masked and the whole change (issue 383): the same change, under the content rule and
|
||||
// exact, differs in the masked values alone; a value shaped like a secret is withheld in both, whole, with its
|
||||
// key still named; and a change no value of which is masked carries no whole words of its own.
|
||||
func TestAProposalShowsItsValuesWholeOnlyWhereTheSenderIsProvenAndNeverASecret(t *testing.T) {
|
||||
machines := []string{"anchor", "laptop", "shanks"}
|
||||
r := newProposerRig(t)
|
||||
values := map[string]any{"shares": "media=/storage/media", "sources": "recalbox=smb://nas.lan/recalbox@/mnt/recalbox", "font": "Inter 13"}
|
||||
if _, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: values}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := r.askSent(t)
|
||||
masked, _, _ := kept(r).Proposal.change(machines, false)
|
||||
whole, _, _ := kept(r).Proposal.change(machines, true)
|
||||
if !strings.HasSuffix(q.Explanation, "\n"+strings.Join(masked, "\n")) || !strings.HasSuffix(q.Whole, "\n"+strings.Join(whole, "\n")) {
|
||||
t.Fatalf("the ask does not carry the change masked and whole:\n%s\n--\n%s", q.Explanation, q.Whole)
|
||||
}
|
||||
if want := []string{"+ font: Inter 13", "+ shares: media=‹path›", "+ sources: recalbox=‹address›@‹path›"}; strings.Join(masked, "\n") != strings.Join(want, "\n") {
|
||||
t.Errorf("masked: %q", masked)
|
||||
}
|
||||
if want := []string{"+ font: Inter 13", "+ shares: media=/storage/media", "+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox"}; strings.Join(whole, "\n") != strings.Join(want, "\n") {
|
||||
t.Errorf("whole: %q", whole)
|
||||
}
|
||||
if _, ok := outward.Secret(q.Whole, "shanks"); !ok {
|
||||
t.Error("the router would refuse the whole words")
|
||||
}
|
||||
// Mutation: the masked and the whole change differ in exactly the lines whose value was masked.
|
||||
differ := 0
|
||||
for i := range masked {
|
||||
if masked[i] != whole[i] {
|
||||
differ++
|
||||
if !strings.Contains(masked[i], "‹") || strings.Contains(whole[i], "‹") {
|
||||
t.Errorf("the lines differ otherwise than by the mask:\n%s\n%s", masked[i], whole[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
if differ != 2 {
|
||||
t.Errorf("%d lines differ, and the mask covered 2", differ)
|
||||
}
|
||||
// A value shaped like a secret is withheld in both, whole, with its key still named.
|
||||
r1 := newProposerRig(t)
|
||||
secrets := map[string]any{"github": "ghp_abcdefghijklmnopqrstuvwxyz0123456789", "cert": "-----BEGIN CERTIFICATE-----"}
|
||||
if _, err := r1.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: secrets}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q1 := r1.askSent(t)
|
||||
for _, line := range []string{"+ cert: ‹withheld›", "+ github: ‹withheld›"} {
|
||||
if !strings.Contains(q1.Explanation, line) {
|
||||
t.Errorf("lacks %q:\n%s", line, q1.Explanation)
|
||||
}
|
||||
}
|
||||
for _, secret := range []string{"ghp_", "BEGIN CERTIFICATE"} {
|
||||
if strings.Contains(q1.Explanation, secret) || strings.Contains(q1.Whole, secret) || strings.Contains(q1.Details, secret) {
|
||||
t.Errorf("the secret %q reaches the phone", secret)
|
||||
}
|
||||
}
|
||||
// More keys than points (issue 639): the first two, then the keys of the rest, read whole at the terminal.
|
||||
r3 := newProposerRig(t)
|
||||
five := map[string]any{"a": "1", "b": "2", "c": "3", "d": "4", "e": "5"}
|
||||
if _, err := r3.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: five}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if q3 := r3.askSent(t); !strings.HasSuffix(q3.Explanation, "\n+ a: 1\n+ b: 2\nand 3 more, each in the note: c, d, e") || asks.Glance(q3.Explanation) != nil ||
|
||||
!strings.Contains(q3.Details, "settings proposals") {
|
||||
t.Errorf("five keys:\n%s", q3.Explanation)
|
||||
}
|
||||
// No value masked: the message is the same everywhere, and the ask says no whole words.
|
||||
r2 := newProposerRig(t)
|
||||
if _, err := r2.pr.propose(context.Background(), proposeInput{module: "dunst", node: "laptop", values: map[string]any{"font-size": 13, "width": 500}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if q2 := r2.askSent(t); q2.Whole != "" || !strings.HasSuffix(q2.Explanation, "\n+ font-size: 13\n+ width: 500") ||
|
||||
strings.Contains(q2.Details, "not proven") {
|
||||
t.Errorf("%+v", q2)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -205,7 +315,8 @@ func TestAClearIsProposedAndShowsWhatItRemoves(t *testing.T) {
|
||||
}
|
||||
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›") {
|
||||
!strings.Contains(q.Explanation, "- places.data.path: ‹path›") || !strings.Contains(q.Whole, "- places.data.path: /srv/notes") ||
|
||||
!strings.HasPrefix(q.Explanation, "Removes the 2 setting(s) of notes set on laptop; approved, the layer is removed and that is sent at once by a walk to laptop, one machine at a time") {
|
||||
t.Errorf("%+v", q)
|
||||
}
|
||||
if p := r.theProposal(t).Proposal; !p.Clear || p.Digest != layerDigest(r.before) || len(r.judged) != 0 {
|
||||
@@ -744,3 +855,67 @@ func captureStdout(t *testing.T, f func()) string {
|
||||
_ = w.Close()
|
||||
return <-done
|
||||
}
|
||||
|
||||
// The review of #304: Approve sets exactly what the operator can read. A proposal of five keys, one value 300
|
||||
// characters long, shows two keys at a glance, says what it cut, and carries every key with its whole value in its
|
||||
// Details for a channel that proves who answers (WholeDetails), and every key masked for one that does not.
|
||||
func TestEveryKeyAndItsWholeValueReachTheOperator(t *testing.T) {
|
||||
var long strings.Builder
|
||||
long.WriteString("library=/storage")
|
||||
for i := 0; long.Len() < 300; i++ {
|
||||
long.WriteString(fmt.Sprintf("/album-%02d", i))
|
||||
}
|
||||
value := long.String()[:300]
|
||||
values := map[string]any{"a": "1", "b": "2", "c": "3", "d": "4", "shares": value}
|
||||
r := newProposerRig(t)
|
||||
if _, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: values}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := r.askSent(t)
|
||||
if err := q.Check(r.now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// What the operator reads on a proving channel: the whole words, then the note's whole details.
|
||||
visible := q.Whole + "\n" + q.WholeDetails
|
||||
if q.Whole == "" {
|
||||
visible = q.Explanation + "\n" + q.WholeDetails
|
||||
}
|
||||
for k, v := range values {
|
||||
if !strings.Contains(visible, "+ "+k+": "+v.(string)) {
|
||||
t.Errorf("the operator never reads %s whole:\n%s", k, visible)
|
||||
}
|
||||
}
|
||||
// The masked glance points to a note that holds the values masked, and says no more; the whole glance, shown only
|
||||
// beside the whole details, says they are whole there.
|
||||
if asks.Glance(q.Explanation) != nil || !strings.HasSuffix(q.Explanation, "\nand 3 more, each in the note: c, d, shares") ||
|
||||
strings.Contains(q.Explanation, "whole in the note") || !strings.HasSuffix(q.Whole, "\nand 3 more, each whole in the note: c, d, shares") {
|
||||
t.Errorf("the glance:\n%s", q.Explanation)
|
||||
}
|
||||
for k := range values {
|
||||
if !strings.Contains(q.Details, "+ "+k+": ") {
|
||||
t.Errorf("the masked details lack %s:\n%s", k, q.Details)
|
||||
}
|
||||
}
|
||||
if strings.Contains(q.Details, "/storage") {
|
||||
t.Errorf("the masked details show a path:\n%s", q.Details)
|
||||
}
|
||||
if refusal, ok := outward.Check(q.Details, "shanks"); !ok {
|
||||
t.Errorf("the router would withhold the masked details: %s", refusal)
|
||||
}
|
||||
if _, ok := outward.Secret(q.WholeDetails, "shanks"); !ok {
|
||||
t.Error("the router would refuse the whole details")
|
||||
}
|
||||
// A value too long for a glance is cut there, and says so.
|
||||
r2 := newProposerRig(t)
|
||||
if _, err := r2.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: map[string]any{"shares": value}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q2 := r2.askSent(t)
|
||||
// The cut point is 160 characters, its marker inside the bound.
|
||||
if last := q2.Whole[strings.LastIndex(q2.Whole, "\n")+1:]; len([]rune(last)) != asks.PointLength {
|
||||
t.Errorf("the cut point is %d characters, not %d: %q", len([]rune(last)), asks.PointLength, last)
|
||||
}
|
||||
if !strings.HasSuffix(q2.Whole, "… (cut: whole in the note)") || !strings.HasSuffix(q2.Explanation, "‹path›") && !strings.HasSuffix(q2.Explanation, "… (cut: in the note)") || !strings.Contains(q2.WholeDetails, "+ shares: "+value) || asks.Glance(q2.Whole) != nil {
|
||||
t.Errorf("the cut is not said, or the note lacks the whole:\n%s\n--\n%s", q2.Whole, q2.WholeDetails)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
@@ -83,6 +84,27 @@ func everyBuildKept(ctx context.Context, node string) bool {
|
||||
return on != "" && on == node
|
||||
}
|
||||
|
||||
type keepForSettingsKey struct{}
|
||||
|
||||
// keepingBuildsForSettings is a context whose sends compose every module of one machine — recorded or rolling out —
|
||||
// at the build that machine was last sent, as keepingEveryBuild does, for a settings walk's send (novox/hq ADR 0344):
|
||||
// the layer the operator approved, and nothing of any module's new code. A build held for a person, or waiting
|
||||
// for a gate, stays where it is; the walk that is its own carries it.
|
||||
func keepingBuildsForSettings(ctx context.Context, node string) context.Context {
|
||||
return context.WithValue(ctx, keepForSettingsKey{}, node)
|
||||
}
|
||||
|
||||
// errSettingsCannotKeep is a settings walk's send refused because a build of its machine cannot be kept: what the
|
||||
// machine was last sent is not known, or a build it runs is gone from the records. It does not clear by itself, so
|
||||
// the walk fails on it rather than trying again (ADR 0344).
|
||||
var errSettingsCannotKeep = errors.New("the settings cannot be sent with every build kept")
|
||||
|
||||
// settingsKeptOn is whether this context is a settings walk's send to this machine.
|
||||
func settingsKeptOn(ctx context.Context, node string) bool {
|
||||
on, _ := ctx.Value(keepForSettingsKey{}).(string)
|
||||
return on != "" && on == node
|
||||
}
|
||||
|
||||
// recordedKept is, for a send under keepingRecorded, every recorded module the machine was last sent a
|
||||
// build of that the mesh's build is not identical to: module → the commit it keeps. Nil when the context
|
||||
// keeps nothing, or when what the machine was last sent is not known (it is then held whole elsewhere —
|
||||
@@ -92,7 +114,7 @@ func everyBuildKept(ctx context.Context, node string) bool {
|
||||
// 490), and a machine whose last send is not known refuses the send: there is nothing to keep it at, and
|
||||
// composing the mesh's builds would be the very move the grants step must not make.
|
||||
func recordedKept(ctx context.Context, open *stores, node string) (map[string]string, error) {
|
||||
every := everyBuildKept(ctx, node)
|
||||
every := everyBuildKept(ctx, node) || settingsKeptOn(ctx, node)
|
||||
skip, on := keptExcept(ctx)
|
||||
if !on && !every {
|
||||
return nil, nil
|
||||
@@ -106,6 +128,9 @@ func recordedKept(ctx context.Context, open *stores, node string) (map[string]st
|
||||
return nil, err
|
||||
}
|
||||
if !known {
|
||||
if settingsKeptOn(ctx, node) {
|
||||
return nil, fmt.Errorf("%w: what %s was last sent is not known (novox/hq ADR 0344)", errSettingsCannotKeep, node)
|
||||
}
|
||||
if every {
|
||||
return nil, fmt.Errorf("what %s was last sent is not known, so the bus's user list cannot be sent "+
|
||||
"there alone with every build kept (novox/hq issue 490)", node)
|
||||
@@ -166,6 +191,10 @@ func keepRecorded(ctx context.Context, open *stores, node string, shelf map[stri
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found && settingsKeptOn(ctx, node) {
|
||||
return nil, fmt.Errorf("%w: %s runs %s's build %s, which the build records no longer hold (novox/hq ADR 0344)",
|
||||
errSettingsCannotKeep, node, m, short(kept[m]))
|
||||
}
|
||||
if !found && everyBuildKept(ctx, node) {
|
||||
return nil, fmt.Errorf("%s runs %s's build %s, which the build records no longer hold: the bus's user "+
|
||||
"list cannot be sent there alone with it kept (novox/hq issue 490)", node, m, short(kept[m]))
|
||||
|
||||
@@ -616,6 +616,10 @@ func releaseBacklog(ctx context.Context, open *stores, by string) (*inventory.Pl
|
||||
}
|
||||
for _, p := range plans {
|
||||
if p.Release != nil {
|
||||
if by != "" && p.Release.Settings != nil {
|
||||
return nil, fmt.Errorf("%s is sending %s; what waits is released once it ends — `plans %s` says where it is",
|
||||
p.ID, p.Repository, p.ID)
|
||||
}
|
||||
if by != "" {
|
||||
return nil, fmt.Errorf("%s is already releasing what waits; `plans %s` says where it is", p.ID, p.ID)
|
||||
}
|
||||
@@ -641,7 +645,9 @@ func releaseBacklog(ctx context.Context, open *stores, by string) (*inventory.Pl
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range recent {
|
||||
if p.Release == nil {
|
||||
// A settings walk that failed holds nothing back (novox/hq ADR 0344 §6): its failure is its module's
|
||||
// settings on one machine, said on its own, not a build no gate passed.
|
||||
if p.Release == nil || p.Release.Settings != nil {
|
||||
continue
|
||||
}
|
||||
if p.State == inventory.PlanFailed {
|
||||
@@ -702,6 +708,9 @@ func releaseBacklog(ctx context.Context, open *stores, by string) (*inventory.Pl
|
||||
// gate, or the machine being judged judged once more; the plan done when every machine is.
|
||||
func advanceRelease(ctx context.Context, open *stores, p *inventory.Plan) (bool, error) {
|
||||
r := p.Release
|
||||
if r.Settings != nil {
|
||||
return advanceSettingsWalk(ctx, open, p)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if r.Gate == nil {
|
||||
heard, err := releaseHeard(ctx, open)
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// An approved settings change is sent by a walk at once, and the operator's one Approve is its push (novox/hq ADR
|
||||
// 0344, issue 641).
|
||||
//
|
||||
// On 2026-10-13 the operator approved a betterbird proposal on Telegram and nothing happened: the warrant set the
|
||||
// layer, and the push stayed a separate act (ADR 0277 §18) that nobody took. Now the warrant that sets the layer
|
||||
// opens a **settings walk**: the walk ADR 0236 opens for builds waiting for a gate, with a layer in place of the
|
||||
// builds. Its order is every machine running the module that the layer reaches, the control node last; each is
|
||||
// sent and its module judged there, from the machine's report on that send, before the next is sent. The send is
|
||||
// the layer alone: every module there stays at the build its machine was last sent (keepingBuildsForSettings), so a
|
||||
// build held for a person (ADR 0242) or waiting for a gate stays where it is — a policy of `record` holds a build,
|
||||
// never a setting — and a send that would move a running module's data stops the walk (ADR 0217 §3). The walk says its start and end on `plan-moved` (ADR 0308), and the holder of the operator's
|
||||
// channel tells its end once. A failure stops the walk and leaves the layer as approved; it holds back nothing
|
||||
// else.
|
||||
|
||||
// settingsWalkPrefix names a settings walk's id.
|
||||
const settingsWalkPrefix = "settings-"
|
||||
|
||||
// settingsWalkFor is what a settings walk says it is for, where a merge's walk says its repository.
|
||||
func settingsWalkFor(s inventory.PlanSettings) string {
|
||||
where := s.Node
|
||||
if where == "" {
|
||||
where = "the whole mesh"
|
||||
}
|
||||
return fmt.Sprintf("the settings of %s on %s", s.Module, where)
|
||||
}
|
||||
|
||||
// settingsWalkOrder is the machines a layer reaches, in the order a settings walk sends them: every machine
|
||||
// running the module, or the one the layer names when it runs it there, sorted, the control node last. Pure.
|
||||
func settingsWalkOrder(node string, running, controllers []string) []string {
|
||||
var order, last []string
|
||||
for _, n := range running {
|
||||
if node != "" && n != node {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(controllers, n) {
|
||||
last = append(last, n)
|
||||
} else {
|
||||
order = append(order, n)
|
||||
}
|
||||
}
|
||||
sort.Strings(order)
|
||||
sort.Strings(last)
|
||||
return append(order, last...)
|
||||
}
|
||||
|
||||
// openSettingsWalk opens the walk that sends an approved layer, and answers the words of what it does: the
|
||||
// machines it sends, or that no machine runs the module and nothing is sent. Called on the operator's warrant,
|
||||
// after the layer is set.
|
||||
var openSettingsWalk = func(ctx context.Context, open *stores, s inventory.PlanSettings) (string, error) {
|
||||
inv := open.inventory
|
||||
running, err := inv.Running(ctx, s.Module)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
controllers, err := inv.Running(ctx, catalogue.ControllerSeatName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
order := settingsWalkOrder(s.Node, running, controllers)
|
||||
if len(order) == 0 {
|
||||
return fmt.Sprintf("no machine runs %s there, so there is nothing to send", s.Module), nil
|
||||
}
|
||||
release, err := inv.HoldPlans(ctx, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer release()
|
||||
now := time.Now().UTC()
|
||||
p := inventory.Plan{ID: fmt.Sprintf("%s%d", settingsWalkPrefix, now.UnixNano()), Repository: settingsWalkFor(s),
|
||||
Created: now, State: inventory.PlanRolling, Tiers: [][]string{{s.Module}}, Modules: map[string]*inventory.PlanModule{},
|
||||
Release: &inventory.PlanRelease{Order: order, Settings: &s},
|
||||
Note: fmt.Sprintf("approved (ask %s): sent to %s, one machine at a time, each judged", s.Ask,
|
||||
strings.Join(order, ", "))}
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Printf("%s: %s — %s\n", p.ID, p.Repository, p.Note)
|
||||
return fmt.Sprintf("%s sends it to %s, one machine at a time, each judged", p.ID, strings.Join(order, ", ")), nil
|
||||
}
|
||||
|
||||
// settingsDataMoves is what a settings walk's send to a machine would move of a running module's data: composed as
|
||||
// that send composes it, every build kept, and compared with what the machine was last sent (ADR 0217 §3).
|
||||
var settingsDataMoves = func(ctx context.Context, open *stores, node string) ([]dataMove, error) {
|
||||
keeping := keepingBuildsForSettings(keepingRecorded(ctx), node)
|
||||
gens, err := generators(keeping, open)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plan, settings, err := planFor(keeping, open, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
declared, err := declarationWith(keeping, open, node, plan, settings, gens, Reading)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := declared.Body()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return heldMoves(keeping, open.inventory, node, body, nil)
|
||||
}
|
||||
|
||||
// judgingOn is the open walk, other than the one named, whose gate is judging on a machine; empty when none is. A
|
||||
// settings walk sends a machine only when no other walk judges there: its send would set aside the declaration
|
||||
// that walk's gate reads the machine's report against.
|
||||
var judgingOn = func(ctx context.Context, open *stores, self, node string) (string, error) {
|
||||
plans, err := open.inventory.OpenPlans(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return judgingIn(plans, self, node), nil
|
||||
}
|
||||
|
||||
// judgingIn is judgingOn over plans read. Pure.
|
||||
func judgingIn(plans []inventory.Plan, self, node string) string {
|
||||
judging := func(g *inventory.PlanGate) bool {
|
||||
return g != nil && g.Verdict == "" && slices.Contains(g.Machines, node)
|
||||
}
|
||||
for _, p := range plans {
|
||||
if p.ID == self || !p.Open() {
|
||||
continue
|
||||
}
|
||||
if p.Release != nil {
|
||||
if judging(p.Release.Gate) {
|
||||
return p.ID
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, s := range p.Modules {
|
||||
if s != nil && judging(s.Gate) {
|
||||
return p.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// advanceSettingsWalk takes one step of a settings walk: the next machine sent and its module judged there, or the
|
||||
// machine being judged judged once more; done when every machine is.
|
||||
func advanceSettingsWalk(ctx context.Context, open *stores, p *inventory.Plan) (bool, error) {
|
||||
r, s := p.Release, p.Release.Settings
|
||||
now := time.Now().UTC()
|
||||
wait := func(note string) (bool, error) {
|
||||
changed := p.Note != note
|
||||
p.Note = note
|
||||
return changed, nil
|
||||
}
|
||||
if r.Gate == nil {
|
||||
heard, err := releaseHeard(ctx, open)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for r.Next < len(r.Order) {
|
||||
node := r.Order[r.Next]
|
||||
if !heard[node] {
|
||||
r.Skipped = append(r.Skipped, node)
|
||||
r.Next++
|
||||
continue
|
||||
}
|
||||
if id, err := judgingOn(ctx, open, p.ID, node); err != nil {
|
||||
return false, err
|
||||
} else if id != "" {
|
||||
return wait(fmt.Sprintf("waiting before %s: %s is judging there", node, id))
|
||||
}
|
||||
// **A running module's data that would move is a person's word** (ADR 0217 §3): the walk stops before
|
||||
// that machine, failed and said, and `push --move` sends it.
|
||||
held, err := settingsDataMoves(ctx, open, node)
|
||||
if errors.Is(err, errSettingsCannotKeep) {
|
||||
return failBefore(p, node, err.Error())
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("whether %s's data would move could not be read: %w", node, err)
|
||||
}
|
||||
if len(held) > 0 {
|
||||
var said strings.Builder
|
||||
sayDataHeld(&said, node, held)
|
||||
return failBefore(p, node, "a running module's data would move there — "+
|
||||
strings.Join(strings.Fields(said.String()), " "))
|
||||
}
|
||||
sent, grants, err := sendJudged(keepingBuildsForSettings(ctx, node), open, node)
|
||||
if errors.Is(err, errSettingsCannotKeep) {
|
||||
// It does not clear by itself: tried again on every tick, the walk would stay open and hold the walk
|
||||
// of waiting builds until a person came.
|
||||
return failBefore(p, node, err.Error())
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("sending %s %s: %w", node, settingsWalkFor(*s), err)
|
||||
}
|
||||
// **The module's build is the one the gate judges** (issue 641 review): kept by this send, it is what a
|
||||
// crossing send — a merge's walk sending the machine a new build of the module — supersedes, so this gate
|
||||
// never reads that send's report as its own.
|
||||
kept := ""
|
||||
if builds, known, err := open.inventory.SentBuilds(ctx, node); err == nil && known {
|
||||
kept = builds[s.Module]
|
||||
}
|
||||
r.Gate = &inventory.PlanGate{Component: coreComponent(s.Module), Machines: sent, Since: &now,
|
||||
From: kept, To: kept, Sent: sentNow(ctx, open.inventory, sent), Grants: grants}
|
||||
p.Note = fmt.Sprintf("sent %s %s (ask %s), every build there kept; judging %s there", node,
|
||||
settingsWalkFor(*s), s.Ask, s.Module)
|
||||
if said := grantsSaid(grants); said != "" {
|
||||
p.Note += "; " + said
|
||||
}
|
||||
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
||||
return true, nil
|
||||
}
|
||||
p.State, p.Tier = inventory.PlanDone, len(p.Tiers)
|
||||
p.Note = fmt.Sprintf("%s (ask %s) delivered on %s", settingsWalkFor(*s), s.Ask, strings.Join(r.Done, ", "))
|
||||
if len(r.Done) == 0 {
|
||||
// Sent nowhere is not delivered: said as the walk's failure, so the operator is not told it landed.
|
||||
p.State = inventory.PlanFailed
|
||||
p.Note = fmt.Sprintf("%s (ask %s) was sent to no machine", settingsWalkFor(*s), s.Ask)
|
||||
}
|
||||
if len(r.Skipped) > 0 {
|
||||
p.Note += "; not heard from, left as they were, their next send carries it: " + strings.Join(r.Skipped, ", ")
|
||||
}
|
||||
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
||||
return true, nil
|
||||
}
|
||||
g := r.Gate
|
||||
node := r.Order[r.Next]
|
||||
verdict, err := judgeMoves(ctx, open, g, []judged{{module: s.Module, node: node}}, now)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch verdict {
|
||||
case "":
|
||||
return wait(fmt.Sprintf("judging %s on %s: %s", s.Module, node, gateLine(g)))
|
||||
case inventory.GatePassed:
|
||||
r.Done = append(r.Done, node)
|
||||
r.Next++
|
||||
r.Gate = nil
|
||||
return true, nil
|
||||
case inventory.GateSuperseded:
|
||||
p.State = inventory.PlanSuperseded
|
||||
p.Note = fmt.Sprintf("superseded on %s: %s — nothing judged, nothing put back; the layer is set, and the "+
|
||||
"machines not yet sent take it at their next send", node, g.Why)
|
||||
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
||||
return true, nil
|
||||
}
|
||||
p.State = inventory.PlanFailed
|
||||
p.Note = fmt.Sprintf("%s failed its gate on %s: %s — the layer stays as the operator approved it, and the one it "+
|
||||
"replaced is in `settings %s` history", settingsWalkFor(*s), node, g.Why, s.Module) + unreached(r)
|
||||
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// failBefore ends a settings walk failed before it sent a machine, saying why, and that the layer stays.
|
||||
func failBefore(p *inventory.Plan, node, why string) (bool, error) {
|
||||
r, s := p.Release, p.Release.Settings
|
||||
p.State = inventory.PlanFailed
|
||||
p.Note = fmt.Sprintf("%s not sent to %s: %s; the layer stays as the operator approved it", settingsWalkFor(*s),
|
||||
node, why) + unreached(r)
|
||||
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// unreached is what a stopped settings walk left without the layer, in words: the machines after the one it stopped
|
||||
// at, and those passed over as away (ADR 0344 §6). Empty when there are none.
|
||||
func unreached(r *inventory.PlanRelease) string {
|
||||
var out string
|
||||
if r.Next+1 < len(r.Order) {
|
||||
out += "; not sent: " + strings.Join(r.Order[r.Next+1:], ", ")
|
||||
}
|
||||
if len(r.Skipped) > 0 {
|
||||
out += "; not heard from, left as they were: " + strings.Join(r.Skipped, ", ")
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/lease"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0344, issue 641: the operator's one Approve on a settings proposal is the act that makes it take
|
||||
// effect. The warrant sets the layer and opens a settings walk, which sends it to the machines it reaches one at a
|
||||
// time, judging the module on each; the ask says so before the Approve, and nothing names a push.
|
||||
|
||||
// The order: every machine running the module, or the one the layer names, the control node last.
|
||||
func TestASettingsWalkGoesToTheMachinesTheLayerReachesTheControlNodeLast(t *testing.T) {
|
||||
running := []string{"shanks", "novox", "g14", "ace"}
|
||||
for _, c := range []struct {
|
||||
node string
|
||||
want []string
|
||||
}{
|
||||
{"", []string{"ace", "g14", "shanks", "novox"}},
|
||||
{"g14", []string{"g14"}},
|
||||
{"novox", []string{"novox"}},
|
||||
{"elsewhere", nil},
|
||||
} {
|
||||
if got := settingsWalkOrder(c.node, running, []string{"novox"}); !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("a layer on %q goes to %v, want %v", c.node, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A settings walk sends a machine only when no other walk judges there.
|
||||
func TestASettingsWalkWaitsWhileAnotherWalkJudgesOnTheMachine(t *testing.T) {
|
||||
judging := &inventory.PlanGate{Machines: []string{"g14"}}
|
||||
passed := &inventory.PlanGate{Machines: []string{"g14"}, Verdict: inventory.GatePassed}
|
||||
plans := []inventory.Plan{
|
||||
{ID: "settings-1", State: inventory.PlanRolling, Release: &inventory.PlanRelease{Gate: judging}},
|
||||
{ID: "release-2", State: inventory.PlanRolling, Release: &inventory.PlanRelease{Gate: passed}},
|
||||
{ID: "plan-3", State: inventory.PlanRolling, Modules: map[string]*inventory.PlanModule{"app": {Gate: judging}}},
|
||||
{ID: "plan-4", State: inventory.PlanDone, Modules: map[string]*inventory.PlanModule{"app": {Gate: judging}}},
|
||||
}
|
||||
if id := judgingIn(plans, "settings-1", "g14"); id != "plan-3" {
|
||||
t.Errorf("on g14 %q judges, want plan-3 (itself, a passed gate and an ended walk are not judging)", id)
|
||||
}
|
||||
if id := judgingIn(plans[1:2], "x", "g14"); id != "" {
|
||||
t.Errorf("a passed gate judges: %q", id)
|
||||
}
|
||||
if id := judgingIn(plans, "x", "shanks"); id != "" {
|
||||
t.Errorf("on shanks %q judges", id)
|
||||
}
|
||||
}
|
||||
|
||||
// The ask says, before the Approve, that the layer is sent at once and judged, and that its end is told as every
|
||||
// walk's is; neither the ask nor the proposer's answer names a push any more.
|
||||
func TestTheAskSaysAnApprovedLayerIsSentAtOnce(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
in proposeInput
|
||||
where string
|
||||
had bool
|
||||
}{
|
||||
{proposeInput{module: "mounts", node: "shanks", values: mountsSources}, "shanks", false},
|
||||
{proposeInput{module: "notes", values: map[string]any{"x": "1"}}, "every machine running notes", true},
|
||||
{proposeInput{module: "notes", node: "laptop", clear: true}, "laptop", true},
|
||||
} {
|
||||
r := newProposerRig(t)
|
||||
if c.had {
|
||||
r.before, r.had = map[string]any{"x": "0"}, true
|
||||
}
|
||||
words, err := r.pr.propose(context.Background(), c.in)
|
||||
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)
|
||||
}
|
||||
// At a glance (ADR 0345): the summary says it is sent at once by a walk, one machine at a time; the rest of
|
||||
// what Approve does is the Details', which the router notes after the answers.
|
||||
if !strings.Contains(q.Explanation, "sent at once by a walk to "+c.where+", one machine at a time, each judged") ||
|
||||
!strings.Contains(q.Details, "and sent at once to "+c.where+", one machine at a time, each judged, "+
|
||||
"every build there kept; its end is told as every walk's is") {
|
||||
t.Errorf("the ask does not say the layer is sent at once to %s:\n%s", c.where, q.Explanation)
|
||||
}
|
||||
approve := q.Options[0]
|
||||
if approve.Label != "Approve" || !strings.Contains(approve.Does, "sent to "+c.where+" at once") {
|
||||
t.Errorf("Approve does %q", approve.Does)
|
||||
}
|
||||
if !strings.Contains(words, "a walk sends it to "+c.where+" at once") {
|
||||
t.Errorf("the proposer's answer:\n%s", words)
|
||||
}
|
||||
for _, said := range []string{q.Explanation, q.Whole, approve.Does, words} {
|
||||
if strings.Contains(said, "push") {
|
||||
t.Errorf("a push is still named, as if a second act were needed:\n%s", said)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- on the real stores -----------------------------------------------------------------------------
|
||||
|
||||
// The warrant sets the layer and opens the walk that sends it, naming the machines it reaches; a layer for a
|
||||
// machine that does not run the module opens none and says so.
|
||||
func TestAnApprovedLayerOpensTheWalkThatSendsIt(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",
|
||||
"content": "x = ${setting:x}\n"}}})
|
||||
if _, err := assign(ctx, open, "laptop", "notes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A layer set by hand opens no walk: it still waits for a push (ADR 0344 §7).
|
||||
if err := atTheTerminal(t, "settings", "set", "notes", `{"x":"0"}`, "--node", "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plans, _ := open.inventory.OpenPlans(ctx); len(plans) != 0 {
|
||||
t.Fatalf("a layer set at the terminal opened %d walk(s)", len(plans))
|
||||
}
|
||||
set := setLayerIn(open)
|
||||
now := time.Now()
|
||||
values := map[string]any{"x": "1"}
|
||||
p := settingsProposal{Module: "notes", Values: values, From: "g14/claude-code", At: now,
|
||||
Digest: layerDigest(values), BeforeDigest: layerDigest(nil)}
|
||||
words, err := set(ctx, p, "s9", "approved by the operator (ask s9)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(words, "+ x (ask s9); settings-") || !strings.Contains(words, "sends it to laptop, one machine at a time") {
|
||||
t.Errorf("the warrant's words: %q", words)
|
||||
}
|
||||
plans, err := open.inventory.OpenPlans(ctx)
|
||||
if err != nil || len(plans) != 1 {
|
||||
t.Fatalf("open walks: %+v %v", plans, err)
|
||||
}
|
||||
w := plans[0]
|
||||
if w.Release == nil || w.Release.Settings == nil || w.Release.Settings.Module != "notes" || w.Release.Settings.Ask != "s9" ||
|
||||
!reflect.DeepEqual(w.Release.Order, []string{"laptop"}) || w.Repository != "the settings of notes on the whole mesh" ||
|
||||
!reflect.DeepEqual(w.Tiers, [][]string{{"notes"}}) {
|
||||
t.Fatalf("the settings walk: %+v %+v", w, w.Release)
|
||||
}
|
||||
|
||||
// A machine's layer where the module does not run: set, and nothing to send.
|
||||
q := settingsProposal{Module: "notes", Node: "anchor", Values: values, From: "g14/claude-code", At: now,
|
||||
Digest: layerDigest(values), BeforeDigest: layerDigest(nil)}
|
||||
words, err = set(ctx, q, "s10", "approved by the operator (ask s10)")
|
||||
if err != nil || !strings.Contains(words, "no machine runs notes there, so there is nothing to send") {
|
||||
t.Fatalf("a layer nobody runs: %q %v", words, err)
|
||||
}
|
||||
if plans, _ := open.inventory.OpenPlans(ctx); len(plans) != 1 {
|
||||
t.Fatalf("a walk was opened for a machine that does not run the module: %d open", len(plans))
|
||||
}
|
||||
|
||||
// A walk that cannot be opened is the act's failure, said with the push that sends it by hand.
|
||||
was := openSettingsWalk
|
||||
openSettingsWalk = func(context.Context, *stores, inventory.PlanSettings) (string, error) {
|
||||
return "", fmt.Errorf("the plans are not to be had")
|
||||
}
|
||||
t.Cleanup(func() { openSettingsWalk = was })
|
||||
again := map[string]any{"x": "2"}
|
||||
byHand := map[string]any{"x": "0"}
|
||||
r := settingsProposal{Module: "notes", Node: "laptop", Values: again, Before: byHand, HadLayer: true,
|
||||
From: "g14/claude-code", At: now, Digest: layerDigest(again), BeforeDigest: layerDigest(byHand)}
|
||||
if _, err := set(ctx, r, "s11", "approved"); err == nil || !strings.Contains(err.Error(), "is set, and the walk that "+
|
||||
"sends it could not be opened: the plans are not to be had — `push laptop` sends it") {
|
||||
t.Errorf("an unopened walk: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// walkMesh is a mesh where `app` runs c1 on anchor and laptop and nothing waits for a gate: every send applied at
|
||||
// once, recorded, and judged from the reports, with a machine named broken failing what it was sent.
|
||||
type walkMesh struct {
|
||||
open *stores
|
||||
sent [][]string
|
||||
broken map[string]bool
|
||||
// kept says, per send, whether it kept every build of its machine; moves is the data a machine's send would move.
|
||||
kept []bool
|
||||
moves map[string][]dataMove
|
||||
heard map[string]bool
|
||||
}
|
||||
|
||||
func aSettledMesh(t *testing.T) *walkMesh {
|
||||
t.Helper()
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
w := &walkMesh{open: open, broken: map[string]bool{}, moves: map[string][]dataMove{},
|
||||
heard: map[string]bool{"anchor": true, "laptop": true}}
|
||||
withConditionsInMemory(t)
|
||||
was := doctorFrom
|
||||
doctorFrom = nil
|
||||
t.Cleanup(func() { doctorFrom = was })
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
manifest, _ := json.Marshal(catalogue.Manifest{Module: "app", Version: "1"})
|
||||
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-app-c1", Module: "app", Commit: "c1",
|
||||
Repository: "novox/mesh-catalog", Path: "modules/app", Manifest: manifest, Asked: old, At: old,
|
||||
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:app-1"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"}, inventory.Source{
|
||||
Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/app", BuiltFrom: "c1", Head: "c1", Asked: old}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, n := range []string{"anchor", "laptop"} {
|
||||
if _, err := inv.Assign(ctx, n, "app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSent(ctx, nodeID(t, open, n), "d-"+n, map[string]string{"app": "c1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
wasMoves, wasHeard, wasSend, wasGather, wasData := machineMoves, releaseHeard, sendRollout, gatherGateFacts, settingsDataMoves
|
||||
settingsDataMoves = func(_ context.Context, _ *stores, node string) ([]dataMove, error) { return w.moves[node], nil }
|
||||
machineMoves = func(ctx context.Context, open *stores, f moveFacts, node string, all bool) ([]inventory.CarriedMove, error) {
|
||||
modules, err := open.inventory.Assigned(ctx, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sent, known, err := open.inventory.SentBuilds(ctx, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.moves(node, modules, sent, known, all), nil
|
||||
}
|
||||
releaseHeard = func(context.Context, *stores) (map[string]bool, error) { return w.heard, nil }
|
||||
n := 0
|
||||
sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) {
|
||||
w.sent = append(w.sent, append([]string(nil), names...))
|
||||
w.kept = append(w.kept, len(names) == 1 && settingsKeptOn(ctx, names[0]))
|
||||
current, err := open.inventory.CurrentBuilds(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, node := range names {
|
||||
modules, _ := open.inventory.Assigned(ctx, node)
|
||||
carried := map[string]string{}
|
||||
for _, m := range modules {
|
||||
carried[m] = current[m].Commit
|
||||
}
|
||||
n++
|
||||
digest := fmt.Sprintf("d-%s-%d", node, n)
|
||||
if err := open.inventory.RecordSent(ctx, nodeID(t, open, node), digest, carried); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := open.inventory.RecordDoing(ctx, nodeID(t, open, node), inventory.Doing{Node: node,
|
||||
Outcome: inventory.OutcomeApplied, Declared: digest, Applied: 1, At: time.Now()}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
gatherGateFacts = func(ctx context.Context, open *stores, component string) (gateFacts, error) {
|
||||
f := gateFacts{now: time.Now(), reports: map[string]inventory.Reported{}, engines: map[string]string{},
|
||||
rolledBack: map[string][]lease.Rollback{}, served: map[string]served{}}
|
||||
reports, err := open.inventory.LastReports(ctx)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
for _, r := range reports {
|
||||
if w.broken[r.Node] {
|
||||
r.Outcome = inventory.OutcomeFailed
|
||||
}
|
||||
f.reports[r.Node] = r
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
wasSettle, wasEvery, wasBound := gateSettle, gateEvery, gateBound
|
||||
gateSettle, gateEvery = 0, time.Hour
|
||||
t.Cleanup(func() {
|
||||
machineMoves, releaseHeard, sendRollout, gatherGateFacts = wasMoves, wasHeard, wasSend, wasGather
|
||||
settingsDataMoves = wasData
|
||||
gateSettle, gateEvery, gateBound = wasSettle, wasEvery, wasBound
|
||||
})
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *walkMesh) walk(t *testing.T) inventory.Plan {
|
||||
t.Helper()
|
||||
plans, err := w.open.inventory.RecentPlans(t.Context(), 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range plans {
|
||||
if p.Release != nil && p.Release.Settings != nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
t.Fatal("no settings walk")
|
||||
return inventory.Plan{}
|
||||
}
|
||||
|
||||
// The walk sends the first machine alone and judges the module there before it sends the next, and ends
|
||||
// delivered on both; nothing waited for a gate, so each send carried the layer alone.
|
||||
func TestASettingsWalkSendsOneMachineAtATimeEachJudged(t *testing.T) {
|
||||
judgeLeavesAsTheCore(t)
|
||||
w := aSettledMesh(t)
|
||||
ctx := t.Context()
|
||||
words, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s1", Change: "~ x"})
|
||||
if err != nil || !strings.Contains(words, "sends it to anchor, laptop") {
|
||||
t.Fatalf("%q %v", words, err)
|
||||
}
|
||||
advancePlans(ctx, w.open)
|
||||
if !reflect.DeepEqual(w.sent, [][]string{{"anchor"}}) {
|
||||
t.Fatalf("sent %v: the first machine alone, before it is judged", w.sent)
|
||||
}
|
||||
p := w.walk(t)
|
||||
if p.Release.Gate == nil || p.Release.Next != 0 || !strings.Contains(p.Note, "judging app on anchor") {
|
||||
t.Fatalf("the walk after its first send: %s %+v", p.Note, p.Release)
|
||||
}
|
||||
gateEvery = 0
|
||||
for i := 0; i < 6; i++ {
|
||||
advancePlans(ctx, w.open)
|
||||
}
|
||||
if !reflect.DeepEqual(w.sent, [][]string{{"anchor"}, {"laptop"}}) {
|
||||
t.Fatalf("sent %v", w.sent)
|
||||
}
|
||||
p = w.walk(t)
|
||||
if p.State != inventory.PlanDone || !reflect.DeepEqual(p.Release.Done, []string{"anchor", "laptop"}) ||
|
||||
p.Outcome() != inventory.WalkDelivered || !strings.Contains(p.Note, "delivered on anchor, laptop") {
|
||||
t.Fatalf("the walk is %s: %s %+v", p.State, p.Note, p.Release)
|
||||
}
|
||||
// It said its start and its end, so the operator is told once (ADR 0308).
|
||||
if p.Times == nil || p.Times.Started == nil || p.Times.Ended == nil {
|
||||
t.Fatalf("the walk's start and end are not marked: %+v", p.Times)
|
||||
}
|
||||
// Nothing waits, and no walk of waiting builds opened beside it.
|
||||
if r, err := releaseBacklog(ctx, w.open, ""); err != nil || r != nil {
|
||||
t.Fatalf("%+v %v", r, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine that fails what it was sent stops the walk there: the next is not sent, the layer stays, and the
|
||||
// failure holds back no walk of waiting builds.
|
||||
func TestAFailedSettingsWalkStopsAndHoldsNothingBack(t *testing.T) {
|
||||
w := aSettledMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := w.open.inventory
|
||||
w.broken["anchor"] = true
|
||||
if err := inv.KeepSettings(ctx, "", "app", map[string]any{"x": "1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
advancePlans(ctx, w.open)
|
||||
}
|
||||
p := w.walk(t)
|
||||
if p.State != inventory.PlanFailed || p.Outcome() != inventory.WalkFailed ||
|
||||
!strings.Contains(p.Note, "the settings of app on the whole mesh failed its gate on anchor") ||
|
||||
!strings.Contains(p.Note, "the layer stays as the operator approved it") || !strings.Contains(p.Note, "not sent: laptop") {
|
||||
t.Fatalf("the walk is %s: %s", p.State, p.Note)
|
||||
}
|
||||
if !reflect.DeepEqual(w.sent, [][]string{{"anchor"}}) {
|
||||
t.Fatalf("sent %v: anchor alone, nothing to laptop", w.sent)
|
||||
}
|
||||
if layer, has, err := inv.Layer(ctx, "", "app"); err != nil || !has || layer["x"] != "1" {
|
||||
t.Fatalf("the layer after the failure: %v %v %v", layer, has, err)
|
||||
}
|
||||
// A build that waits for a gate is still walked: the failed settings walk holds nothing back.
|
||||
w.broken["anchor"] = false
|
||||
now := time.Now().Add(-time.Minute)
|
||||
manifest, _ := json.Marshal(catalogue.Manifest{Module: "app", Version: "1"})
|
||||
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-app-c2", Module: "app", Commit: "c2",
|
||||
Repository: "novox/mesh-catalog", Path: "modules/app", Manifest: manifest, Asked: now, At: now,
|
||||
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:app-2"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"}, inventory.Source{
|
||||
Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/app", BuiltFrom: "c2", Head: "c2", Asked: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r, err := releaseBacklog(ctx, w.open, ""); err != nil || r == nil {
|
||||
t.Fatalf("the waiting build is held back by a failed settings walk: %+v %v", r, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Each send of a settings walk keeps every build of its machine: the layer goes, and a build held for a person or
|
||||
// waiting for a gate stays where it is (ADR 0344 §3).
|
||||
func TestASettingsWalkSendsTheLayerAloneEveryBuildKept(t *testing.T) {
|
||||
w := aSettledMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := w.open.inventory
|
||||
gateEvery = 0
|
||||
if _, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s3"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
advancePlans(ctx, w.open)
|
||||
}
|
||||
if !reflect.DeepEqual(w.kept, []bool{true, true}) || w.walk(t).State != inventory.PlanDone {
|
||||
t.Fatalf("the sends kept every build: %v; the walk: %s", w.kept, w.walk(t).Note)
|
||||
}
|
||||
// What such a send keeps, on the store: a rolled-out build waiting there is kept at the one last sent.
|
||||
now := time.Now().Add(-time.Minute)
|
||||
manifest, _ := json.Marshal(catalogue.Manifest{Module: "app", Version: "1"})
|
||||
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-app-c2", Module: "app", Commit: "c2",
|
||||
Repository: "novox/mesh-catalog", Path: "modules/app", Manifest: manifest, Asked: now, At: now,
|
||||
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:app-2"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"}, inventory.Source{
|
||||
Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/app", BuiltFrom: "c2", Head: "c2", Asked: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kept, err := recordedKept(keepingBuildsForSettings(ctx, "anchor"), w.open, "anchor")
|
||||
if err != nil || kept["app"] != "c1" {
|
||||
t.Fatalf("a settings send to anchor keeps %v (%v), want app at c1", kept, err)
|
||||
}
|
||||
if kept, _ := recordedKept(ctx, w.open, "anchor"); kept["app"] != "" {
|
||||
t.Fatalf("an ordinary send keeps a rolled-out build: %v", kept)
|
||||
}
|
||||
// And so the ungated build waiting on anchor is no move of the settings send's: nothing for a gate to see.
|
||||
f, err := readMoveFacts(ctx, inv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waiting, _ := machineMoves(ctx, w.open, f, "anchor", false)
|
||||
if len(waiting) != 1 || waiting[0].Module != "app" {
|
||||
t.Fatalf("anchor waits for %+v, want app's c2", waiting)
|
||||
}
|
||||
if moved, err := withoutKept(keepingBuildsForSettings(ctx, "anchor"), w.open, "anchor", waiting); err != nil || len(moved) != 0 {
|
||||
t.Fatalf("a settings send to anchor would move %+v (%v)", moved, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine whose send would move a running module's data stops the walk there, failed and said with the push that
|
||||
// sends it on a person's word (ADR 0217 §3); one not heard from is passed over and named; and a walk that reached
|
||||
// no machine is not said delivered.
|
||||
func TestASettingsWalkStopsBeforeADataMoveAndPassesOverAMachineAway(t *testing.T) {
|
||||
w := aSettledMesh(t)
|
||||
ctx := t.Context()
|
||||
gateEvery = 0
|
||||
w.moves["laptop"] = []dataMove{{Container: "app-server", Inside: "/data", From: "/srv/a", To: "/srv/b"}}
|
||||
if _, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s4"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
advancePlans(ctx, w.open)
|
||||
}
|
||||
p := w.walk(t)
|
||||
if p.State != inventory.PlanFailed || !reflect.DeepEqual(w.sent, [][]string{{"anchor"}}) ||
|
||||
!strings.Contains(p.Note, "not sent to laptop: a running module's data would move there") ||
|
||||
!strings.Contains(p.Note, "push laptop --move app") {
|
||||
t.Fatalf("sent %v; the walk is %s: %s", w.sent, p.State, p.Note)
|
||||
}
|
||||
|
||||
v := aSettledMesh(t)
|
||||
v.heard = map[string]bool{"laptop": true}
|
||||
gateEvery = 0
|
||||
if _, err := openSettingsWalk(ctx, v.open, inventory.PlanSettings{Module: "app", Ask: "s5"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
advancePlans(ctx, v.open)
|
||||
}
|
||||
p = v.walk(t)
|
||||
if p.State != inventory.PlanDone || !reflect.DeepEqual(p.Release.Skipped, []string{"anchor"}) ||
|
||||
!strings.Contains(p.Note, "delivered on laptop; not heard from, left as they were, their next send carries it: anchor") {
|
||||
t.Fatalf("the walk with anchor away is %s: %s", p.State, p.Note)
|
||||
}
|
||||
|
||||
u := aSettledMesh(t)
|
||||
u.heard = map[string]bool{}
|
||||
if _, err := openSettingsWalk(ctx, u.open, inventory.PlanSettings{Module: "app", Ask: "s6"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
advancePlans(ctx, u.open)
|
||||
if p := u.walk(t); p.State != inventory.PlanFailed || p.Outcome() != inventory.WalkFailed ||
|
||||
!strings.Contains(p.Note, "was sent to no machine") || len(u.sent) != 0 {
|
||||
t.Fatalf("a walk that reached nobody is %s: %s", p.State, p.Note)
|
||||
}
|
||||
}
|
||||
|
||||
// The gate keeps the module's build its send kept, so a crossing send — a merge's walk sending the machine a new
|
||||
// build of the module while the settings gate judges there — supersedes it, and its report is never read as the
|
||||
// settings send's (issue 641 review, finding 1).
|
||||
func TestACrossingSendSupersedesASettingsGate(t *testing.T) {
|
||||
judgeLeavesAsTheCore(t)
|
||||
w := aSettledMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := w.open.inventory
|
||||
if _, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s7"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
advancePlans(ctx, w.open)
|
||||
p := w.walk(t)
|
||||
if g := p.Release.Gate; g == nil || g.To != "c1" || g.From != "c1" {
|
||||
t.Fatalf("the settings gate judges no build: %+v", p.Release.Gate)
|
||||
}
|
||||
// Another walk sends anchor app's c2 while the settings gate judges there.
|
||||
now := time.Now().Add(-time.Minute)
|
||||
manifest, _ := json.Marshal(catalogue.Manifest{Module: "app", Version: "1"})
|
||||
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-app-c2", Module: "app", Commit: "c2",
|
||||
Repository: "novox/mesh-catalog", Path: "modules/app", Manifest: manifest, Asked: now, At: now,
|
||||
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:app-2"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSent(ctx, nodeID(t, w.open, "anchor"), "d-merge", map[string]string{"app": "c2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.RecordDoing(ctx, nodeID(t, w.open, "anchor"), inventory.Doing{Node: "anchor",
|
||||
Outcome: inventory.OutcomeApplied, Declared: "d-merge", Applied: 1, At: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gateEvery = 0
|
||||
advancePlans(ctx, w.open)
|
||||
p = w.walk(t)
|
||||
if p.State != inventory.PlanSuperseded || p.Release.Gate == nil || p.Release.Gate.Verdict != inventory.GateSuperseded ||
|
||||
!strings.Contains(p.Note, "superseded on anchor") || len(p.Release.Done) != 0 {
|
||||
t.Fatalf("the settings walk after a crossing send is %s: %s %+v", p.State, p.Note, p.Release)
|
||||
}
|
||||
if !reflect.DeepEqual(w.sent, [][]string{{"anchor"}}) {
|
||||
t.Fatalf("sent %v: laptop was sent after a superseded judging", w.sent)
|
||||
}
|
||||
}
|
||||
|
||||
// A send refusal that cannot clear by itself — what the machine was last sent is not known, or a build it runs is
|
||||
// gone — fails the walk with why, rather than keeping it open and holding the walk of waiting builds; and a
|
||||
// failure names the machines passed over as away (issue 641 review, findings 2 and 3).
|
||||
func TestASettingsWalkFailsOnARefusalThatCannotClear(t *testing.T) {
|
||||
w := aSettledMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := w.open.inventory
|
||||
// What a send to a machine never sent can keep: nothing, and said as the refusal the walk fails on.
|
||||
if _, err := inv.AddNode(ctx, "fresh"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := recordedKept(keepingBuildsForSettings(ctx, "fresh"), w.open, "fresh"); !errors.Is(err, errSettingsCannotKeep) {
|
||||
t.Fatalf("a settings send to a machine never sent: %v", err)
|
||||
}
|
||||
w.heard = map[string]bool{"laptop": true}
|
||||
settingsDataMoves = func(_ context.Context, _ *stores, node string) ([]dataMove, error) {
|
||||
return nil, fmt.Errorf("%w: what %s was last sent is not known", errSettingsCannotKeep, node)
|
||||
}
|
||||
if _, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
advancePlans(ctx, w.open)
|
||||
p := w.walk(t)
|
||||
if p.State != inventory.PlanFailed || len(w.sent) != 0 ||
|
||||
!strings.Contains(p.Note, "not sent to laptop: the settings cannot be sent with every build kept: what laptop was last sent is not known") ||
|
||||
!strings.Contains(p.Note, "not heard from, left as they were: anchor") {
|
||||
t.Fatalf("the walk is %s: %s", p.State, p.Note)
|
||||
}
|
||||
// It is not open, so a build waiting for a gate is walked.
|
||||
now := time.Now().Add(-time.Minute)
|
||||
manifest, _ := json.Marshal(catalogue.Manifest{Module: "app", Version: "1"})
|
||||
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-app-c2", Module: "app", Commit: "c2",
|
||||
Repository: "novox/mesh-catalog", Path: "modules/app", Manifest: manifest, Asked: now, At: now,
|
||||
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:app-2"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"}, inventory.Source{
|
||||
Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/app", BuiltFrom: "c2", Head: "c2", Asked: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r, err := releaseBacklog(ctx, w.open, ""); err != nil || r == nil {
|
||||
t.Fatalf("the waiting build is held by the failed settings walk: %+v %v", r, err)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ module github.com/novox/mesh-controller
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
|
||||
@@ -2,6 +2,10 @@ git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f h1:8N5OW2mdTNIck
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
||||
@@ -433,6 +433,20 @@ type PlanRelease struct {
|
||||
Skipped []string `json:"skipped,omitempty"`
|
||||
// By is the person who released it, empty when the mesh did.
|
||||
By string `json:"by,omitempty"`
|
||||
// Settings is set on a settings walk (novox/hq ADR 0344): not the builds waiting for a gate, but a settings
|
||||
// layer the operator approved, sent to the machines it reaches one at a time, its module judged on each.
|
||||
Settings *PlanSettings `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
// PlanSettings is what a settings walk sends (novox/hq ADR 0344): the module whose layer the operator approved, the
|
||||
// layer's scope, the ask that approved it and what changed, in words.
|
||||
type PlanSettings struct {
|
||||
Module string `json:"module"`
|
||||
// Node is the machine the layer is for, empty for the whole mesh.
|
||||
Node string `json:"node,omitempty"`
|
||||
// Ask is the proposal the operator approved, and Change what its layer changed ("~ prefs").
|
||||
Ask string `json:"ask"`
|
||||
Change string `json:"change,omitempty"`
|
||||
}
|
||||
|
||||
// The states a plan passes through.
|
||||
|
||||
+56
-11
@@ -80,6 +80,57 @@ func Check(text string, machines ...string) (Refusal, bool) {
|
||||
return Refusal{"address", "a hardware address"}, false
|
||||
case reIPv6.MatchString(text):
|
||||
return Refusal{"address", "an IPv6 address"}, false
|
||||
}
|
||||
if refusal, ok := secretShape(text); !ok {
|
||||
return refusal, false
|
||||
}
|
||||
if reWinPath.MatchString(text) {
|
||||
return Refusal{"path", "a drive path"}, false
|
||||
}
|
||||
if refusal, ok := randomRun(text); !ok {
|
||||
return refusal, false
|
||||
}
|
||||
for _, word := range strings.FieldsFunc(text, isSeparator) {
|
||||
w := strings.TrimRight(word, ".:!?")
|
||||
if isPath(w) {
|
||||
return Refusal{"path", "a file path"}, false
|
||||
}
|
||||
if isHostName(w) {
|
||||
return Refusal{"address", "a host name"}, false
|
||||
}
|
||||
}
|
||||
return Refusal{}, true
|
||||
}
|
||||
|
||||
// Secret says whether a text carries a secret's shape, the one class that leaves the mesh nowhere — the
|
||||
// messenger's CheckSecret, one for one. It is what an ask's whole words (asks.Ask.Whole, novox/hq issue 383)
|
||||
// are held to: on a channel that proves who answers, a path or an address is what the operator approves and
|
||||
// is shown; a secret never is. A path is read as one: a run with a slash in it is judged piece by piece
|
||||
// between the slashes, so "/mnt/Photos_2024/Jochen" is a path and not a random string (the review of
|
||||
// 2026-10-10); the named shapes hold whatever the run holds.
|
||||
func Secret(text string, machines ...string) (Refusal, bool) {
|
||||
text = withoutMachines(text, machines)
|
||||
if refusal, ok := secretShape(text); !ok {
|
||||
return refusal, false
|
||||
}
|
||||
return randomRunOutsidePaths(text)
|
||||
}
|
||||
|
||||
// randomRunOutsidePaths is randomRun with each run that holds a slash judged by its pieces between the slashes.
|
||||
func randomRunOutsidePaths(text string) (Refusal, bool) {
|
||||
for _, run := range reRun.FindAllString(text, -1) {
|
||||
for _, piece := range strings.Split(run, "/") {
|
||||
if len(piece) >= 20 && looksRandom(piece) {
|
||||
return Refusal{"secret", "a long random-looking string"}, false
|
||||
}
|
||||
}
|
||||
}
|
||||
return Refusal{}, true
|
||||
}
|
||||
|
||||
// secretShape is the secret shapes a pattern names.
|
||||
func secretShape(text string) (Refusal, bool) {
|
||||
switch {
|
||||
case rePEM.MatchString(text):
|
||||
return Refusal{"secret", "a key block"}, false
|
||||
case reJWT.MatchString(text):
|
||||
@@ -92,23 +143,17 @@ func Check(text string, machines ...string) (Refusal, bool) {
|
||||
return Refusal{"secret", "a value given to a secret's name"}, false
|
||||
case reHex.MatchString(text):
|
||||
return Refusal{"secret", "a long hexadecimal string"}, false
|
||||
case reWinPath.MatchString(text):
|
||||
return Refusal{"path", "a drive path"}, false
|
||||
}
|
||||
return Refusal{}, true
|
||||
}
|
||||
|
||||
// randomRun is a long unbroken run of characters spread as a random string's are.
|
||||
func randomRun(text string) (Refusal, bool) {
|
||||
for _, run := range reRun.FindAllString(text, -1) {
|
||||
if looksRandom(run) {
|
||||
return Refusal{"secret", "a long random-looking string"}, false
|
||||
}
|
||||
}
|
||||
for _, word := range strings.FieldsFunc(text, isSeparator) {
|
||||
w := strings.TrimRight(word, ".:!?")
|
||||
if isPath(w) {
|
||||
return Refusal{"path", "a file path"}, false
|
||||
}
|
||||
if isHostName(w) {
|
||||
return Refusal{"address", "a host name"}, false
|
||||
}
|
||||
}
|
||||
return Refusal{}, true
|
||||
}
|
||||
|
||||
|
||||
@@ -86,3 +86,40 @@ func TestScrubAlwaysGivesWhatMayLeave(t *testing.T) {
|
||||
t.Errorf("a text that passes was changed: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Secret is the one class that leaves the mesh nowhere (novox/hq issue 383): it refuses every secret's shape
|
||||
// Check refuses, and nothing Check refuses as an address or a path — those an ask's whole words may carry.
|
||||
func TestSecretRefusesOnlyASecretsShape(t *testing.T) {
|
||||
for _, text := range []string{
|
||||
"-----BEGIN RSA PRIVATE KEY-----",
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc",
|
||||
"123456789:ABCdefGHIjklMNOpqrSTUvwxYZ0123456789ab",
|
||||
"ghp_abcdefghijklmnopqrstuvwxyz0123456789",
|
||||
"password=hunter2",
|
||||
"0123456789abcdef0123456789abcdef",
|
||||
"a key xK9mQ2vL8pR4tW7yB3nF6hJ1dG5sA0zC",
|
||||
} {
|
||||
r, ok := Secret(text)
|
||||
if ok || r.Class != "secret" {
|
||||
t.Errorf("not refused as a secret: %q (%s)", text, r)
|
||||
}
|
||||
}
|
||||
for _, text := range []string{
|
||||
"recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro",
|
||||
"library=/mnt/library",
|
||||
"photos=/mnt/Photos_2024/Jochen",
|
||||
"games=smb://nas.lan/Recalbox_Games2024",
|
||||
"/srv/media/Series_Archive/Season01",
|
||||
"10.77.0.9:53",
|
||||
"jochen@example.com",
|
||||
"C:\\Users\\jo",
|
||||
"anchor and the laptop",
|
||||
} {
|
||||
if r, ok := Secret(text, "anchor"); !ok {
|
||||
t.Errorf("refused as %s: %q", r, text)
|
||||
}
|
||||
if _, ok := Check(text, "anchor"); ok && text != "anchor and the laptop" {
|
||||
t.Errorf("Check lets %q leave, so Secret is not the narrower rule", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -9,7 +9,7 @@ a second place to clean besides the catalogue itself.
|
||||
|
||||
mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile
|
||||
mesh-host 7a41e120635b3f7b6cb94cca8b3fc4093e4e6110 examples/foundation-first-node.lock
|
||||
mesh-sdk 33917f91ac3cc213b879b32f3c70e2aa70395f98 conformance/events/module-event.json
|
||||
mesh-sdk f4745447ba6d0c4fd7dc6b1071e26ce012806233 conformance/events/module-event.json
|
||||
|
||||
To move them, from this repository's root, with the two repositories checked out beside it:
|
||||
|
||||
|
||||
+64
-4
@@ -174,6 +174,12 @@ func (a Ask) Digest() string {
|
||||
canonical(&b, a.Whole)
|
||||
canonical(&b, a.Details)
|
||||
}
|
||||
// WholeDetails (novox/hq issue 383, the review of mesh-controller #304) follows only when given, so every
|
||||
// ask without it digests as it did before it existed.
|
||||
if a.WholeDetails != "" {
|
||||
canonical(&b, "whole-details")
|
||||
canonical(&b, a.WholeDetails)
|
||||
}
|
||||
sum := sha256.Sum256([]byte(b.String()))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -202,9 +208,59 @@ type Ask struct {
|
||||
// The asker withholds a value shaped like a secret in it, and the router refuses the ask when one is left;
|
||||
// Explanation stays under the whole content rule everywhere else. Empty, Explanation is shown everywhere.
|
||||
Whole string `json:"whole,omitempty"`
|
||||
// Details is what the Details answer on the ask's message shows, line by line under the content rule: a
|
||||
// fingerprint, how to read the proposal whole at the terminal. Empty, an ask's own message offers no Details.
|
||||
// Details are the ask's secondary words, line by line under the content rule: who proposed it, a
|
||||
// fingerprint, how to read it whole. The router shows them in the note of the ask's own message
|
||||
// (Message.Note), after what the ask's options do and when it expires — never in its body (novox/hq
|
||||
// issue 639).
|
||||
Details string `json:"details,omitempty"`
|
||||
// WholeDetails are the Details with their exact values whole, shown in place of Details where Whole is shown
|
||||
// in place of the explanation (a kind that proves who answers, given the ask's answers): every value an
|
||||
// approval sets, whole, so the person reads exactly what they approve (novox/hq issue 383). Held, like
|
||||
// Whole, to the secret rule alone; the router refuses the ask when a secret's shape is left in it. Empty,
|
||||
// Details are shown everywhere.
|
||||
WholeDetails string `json:"whole-details,omitempty"`
|
||||
}
|
||||
|
||||
// What an ask's explanation is held to, so it reads at a glance on a phone (novox/hq issue 639): a summary
|
||||
// of two or three short sentences on its first line — what changes, why, what it costs or risks — then at
|
||||
// most three points, a line each, only where they add something. Whole is held to the same. Who asked, when,
|
||||
// the fingerprint and how to read it whole are Details, never the explanation.
|
||||
const (
|
||||
SummaryLength = 300
|
||||
MostPoints = 3
|
||||
PointLength = 160
|
||||
)
|
||||
|
||||
// Glance says why an explanation does not read at a glance, or nil: its first line, the summary, longer than
|
||||
// SummaryLength; more than MostPoints lines after it; a point longer than PointLength. Blank lines are not
|
||||
// counted. An asker holds its asks to it in its own tests; the router says, without refusing, an ask that
|
||||
// does not hold to it.
|
||||
func Glance(explanation string) error {
|
||||
var lines []string
|
||||
for _, l := range strings.Split(explanation, "\n") {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
lines = append(lines, strings.TrimSpace(l))
|
||||
}
|
||||
}
|
||||
var problems []string
|
||||
switch {
|
||||
case len(lines) == 0:
|
||||
problems = append(problems, "it has no summary")
|
||||
case len([]rune(lines[0])) > SummaryLength:
|
||||
problems = append(problems, fmt.Sprintf("its summary is %d characters, more than %d", len([]rune(lines[0])), SummaryLength))
|
||||
}
|
||||
if len(lines) > 1+MostPoints {
|
||||
problems = append(problems, fmt.Sprintf("it has %d points after its summary, more than %d", len(lines)-1, MostPoints))
|
||||
}
|
||||
for i, l := range lines {
|
||||
if i > 0 && len([]rune(l)) > PointLength {
|
||||
problems = append(problems, fmt.Sprintf("its point %d is %d characters, more than %d", i, len([]rune(l)), PointLength))
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return errors.New("the explanation does not read at a glance: " + strings.Join(problems, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// The bounds of an ask (novox/hq ADR 0234 §8, ADR 0259 §4).
|
||||
@@ -435,8 +491,12 @@ type Message struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Buttons []Button `json:"buttons,omitempty"`
|
||||
Urgent bool `json:"urgent,omitempty"`
|
||||
Silent bool `json:"silent,omitempty"`
|
||||
// Note is the message's secondary words, after its body and its buttons: who asked and when, what each
|
||||
// answer does, when it expires, and the ask's Details. A channel shows it smaller, collapsed or set apart
|
||||
// where it can, and after the body where it cannot; it is never left out (novox/hq issue 639).
|
||||
Note string `json:"note,omitempty"`
|
||||
Urgent bool `json:"urgent,omitempty"`
|
||||
Silent bool `json:"silent,omitempty"`
|
||||
// Reply is the Choice or LinkAsked this answers, by its ID: the channel shows it where that was made.
|
||||
Reply string `json:"reply,omitempty"`
|
||||
// To is the account linked as the operator on this kind, for a channel that verifies its sender: where
|
||||
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c
|
||||
# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d
|
||||
## explicit; go 1.22
|
||||
git.novox.be/novox/mesh-sdk/go/asks
|
||||
# github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op
|
||||
|
||||
Reference in New Issue
Block a user