Compare commits

..
2 Commits
Author SHA1 Message Date
jochen 52bcef159d A settings proposal carries every key with its whole value in its Details (issue 383)
mesh/delivery-group group fix/383-an-ask-reads-at-a-glance checking: composed check out of date: novox/mesh-controller@52bcef159d95 was judged against trunk 6f…
mesh/merge-gate out of date: judged against trunk 6f1a05366c1d, the trunk is now 8ddf8b700558; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 6f1a05366c1d, the trunk is now 8ddf8b700558; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
The review of #304: with more than three keys the ask showed only the names of the rest, a long value was
cut without a word, and the Details held no key, so Approve bound values the operator never saw. The
Details now list every key uncut — masked, and whole in WholeDetails for a channel that proves who
answers — before who proposed it and the fingerprint; the glance says what it cut. Vendors mesh-sdk at
f474544 and captures its conformance events there.
2026-10-13 17:04:48 +02:00
jochen 35232c8534 A settings proposal reads at a glance, its values whole where the sender is proven (issues 383 and 639)
mesh/merge-gate head 35232c853483 merged into trunk 6f1a05366c1d — pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus…
mesh/repo-check head 35232c853483 merged into trunk 6f1a05366c1d — pass: its merge-check.sh passed
mesh/delivery-group group fix/383-an-ask-reads-at-a-glance checking: composed check out of date: novox/mesh-catalog@ea1dfdce5bc7 was judged against trunk 5f491…
mesh/delivery superseded: a newer head of the same pull request
The operator could not read the settings proposals on the phone: the change, the fingerprint and a how-to
paragraph in one wall of plain text, and the values masked as ‹path› and ‹address›, so the approval was
blind.

The ask is now a summary of what changes where ("Changes 3 setting(s) of mounts on shanks; approved, the
layer is set at once and shanks takes it at its next push."), then at most three points, one key each (more
keys: the first two and the names of the rest). Who proposed it, the fingerprint and how to read it whole
are its Details, which the router notes after the answers. The change is carried twice: under the content
rule, and whole (asks.Ask.Whole), shown only on a channel that proves who answers, a value shaped like a
secret withheld in both (from the earlier work on issue 383, #202).

Vendors mesh-sdk at 9588871 (fix/639-an-ask-reads-at-a-glance: Message.Note, asks.Glance), and captures its
conformance events at that commit.
2026-10-13 16:33:52 +02:00
7 changed files with 31 additions and 979 deletions
+23 -60
View File
@@ -18,9 +18,8 @@ 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. 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.
// controller event, so one would cost a node-engine change for a fact without a reader. The push afterwards is a
// separate act, as it is for a layer set at the terminal.
//
// **What the operator reads** is the module, the machine, each key with its exact new value and, for a changed
// key, the value it replaces. A value that may not leave the mesh (an address, a path, a secret's shape: the
@@ -55,7 +54,6 @@ 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"
)
@@ -177,7 +175,6 @@ func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[strin
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 {
@@ -202,9 +199,9 @@ func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[strin
binds, _ := asks.ActDigest(boundAct(act))
oid := optionID(act.Label)
options[oid] = i
does := "the settings are set and sent to " + p.reaches() + " at once, each machine judged"
does := "the settings are set; nothing is pushed yet"
if p.Clear {
does = "the layer is removed and that is sent to " + p.reaches() + " at once, each machine judged"
does = "the layer is removed; nothing is pushed yet"
}
if act.Arguments["answer"] == answerDecline {
does = "nothing changes; the proposal is discarded"
@@ -215,16 +212,19 @@ func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[strin
return q, options
}
// 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'.
// summary is the proposal's first line, what changes where: how many settings, how many stay, and that the machine
// takes the layer at its next push.
func (p settingsProposal) summary() string {
takes := p.Node
if p.Node == "" {
takes = "every machine"
}
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())
return fmt.Sprintf("Removes the %d setting(s) of %s set on %s; approved, the layer goes at once and %s takes it at its next push.",
len(leaves(p.Before, "")), p.Module, p.where(), takes)
}
added, changed, removed := settingsChange(p.Before, p.Values)
n := len(added) + len(changed) + len(removed)
@@ -236,8 +236,8 @@ func (p settingsProposal) summary() string {
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())
return fmt.Sprintf("Changes %d setting(s) of %s on %s%s; approved, the layer is set at once and %s takes it at its next push.",
n, p.Module, p.where(), stay, takes)
}
// change is what changes: as at most asks.MostPoints points for the glance, as every line whole for the note, and
@@ -281,13 +281,6 @@ func (p settingsProposal) change(machines []string, exact bool) ([]string, []str
for i, l := range lines {
all[i] = l.text
}
// 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 {
@@ -295,13 +288,13 @@ func (p settingsProposal) change(machines []string, exact bool) ([]string, []str
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, ", ")))
out = append(out, fmt.Sprintf("and %d more, each whole in the note: %s", len(rest), 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 + ")"
// A point too long for a glance is cut, and says so: its whole value is the note's.
const cut = "… (cut: whole in the note)"
for i, l := range out {
if r := []rune(l); len(r) > asks.PointLength {
out[i] = string(r[:asks.PointLength-len([]rune(cut))]) + cut
@@ -600,29 +593,15 @@ 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, 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())
fmt.Fprintf(&b, " `settings proposals %s` shows it whole; once approved, `push %s` sends it", id, pushWord(p.Node))
return b.String(), nil
}
// reaches is the machines an approved layer is sent to, in words: the one it names, or every machine running the
// module.
func (p settingsProposal) reaches() string {
if p.Node == "" {
return "every machine running " + p.Module
func pushWord(node string) string {
if node == "" {
return "--behind"
}
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())
return node
}
func newProposalID() string {
@@ -883,22 +862,6 @@ func setLayerIn(open *stores) setOnWarrant {
changed = "nothing changed"
}
}
// **And sends it** (novox/hq ADR 0344): the layer the operator approved is walked to the machines it reaches
// at once. A walk that cannot be opened is said as the act's failure — the layer is set and nothing sends it.
walked, err := openSettingsWalk(ctx, open, inventory.PlanSettings{Module: p.Module, Node: p.Node, Ask: askID,
Change: changed})
if err != nil {
return "", fmt.Errorf("%s (ask %s) is set, and the walk that sends it could not be opened: %v — `push %s` sends it",
changed, askID, err, pushTo(p.Node))
}
return changed + " (ask " + askID + "); " + walked, nil
return changed + " (ask " + askID + ")", 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
}
+6 -13
View File
@@ -119,7 +119,7 @@ func TestAProposalAsksAtTheLevelApproveWithTheExactChange(t *testing.T) {
}
// 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."
summary := "Changes 3 setting(s) of mounts on shanks; approved, the layer is set at once and shanks takes it at its next push."
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)
}
@@ -196,7 +196,7 @@ func TestAMeshWideLayerIsProposed(t *testing.T) {
}
q := r.askSent(t)
if q.Headline != "Set notes on the whole mesh?" || q.About != "settings.notes.mesh" ||
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.Explanation != "Changes 1 setting(s) of notes on the whole mesh; approved, the layer is set at once and every machine takes it at its next push.\n+ x: 1" ||
q.Whole != "" {
t.Errorf("%+v", q)
}
@@ -273,7 +273,7 @@ func TestAProposalShowsItsValuesWholeOnlyWhereTheSenderIsProvenAndNeverASecret(t
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 ||
if q3 := r3.askSent(t); !strings.HasSuffix(q3.Explanation, "\n+ a: 1\n+ b: 2\nand 3 more, each whole 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)
}
@@ -316,7 +316,7 @@ 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.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") {
!strings.HasPrefix(q.Explanation, "Removes the 2 setting(s) of notes set on laptop; approved, the layer goes at once") {
t.Errorf("%+v", q)
}
if p := r.theProposal(t).Proposal; !p.Clear || p.Digest != layerDigest(r.before) || len(r.judged) != 0 {
@@ -885,10 +885,7 @@ func TestEveryKeyAndItsWholeValueReachTheOperator(t *testing.T) {
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") {
if asks.Glance(q.Explanation) != nil || !strings.HasSuffix(q.Explanation, "\nand 3 more, each whole in the note: c, d, shares") {
t.Errorf("the glance:\n%s", q.Explanation)
}
for k := range values {
@@ -911,11 +908,7 @@ func TestEveryKeyAndItsWholeValueReachTheOperator(t *testing.T) {
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 {
if !strings.HasSuffix(q2.Whole, "… (cut: whole 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)
}
}
+1 -30
View File
@@ -2,7 +2,6 @@ package main
import (
"context"
"errors"
"fmt"
"sort"
@@ -84,27 +83,6 @@ 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 —
@@ -114,7 +92,7 @@ func settingsKeptOn(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) || settingsKeptOn(ctx, node)
every := everyBuildKept(ctx, node)
skip, on := keptExcept(ctx)
if !on && !every {
return nil, nil
@@ -128,9 +106,6 @@ 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)
@@ -191,10 +166,6 @@ 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]))
+1 -10
View File
@@ -616,10 +616,6 @@ 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)
}
@@ -645,9 +641,7 @@ func releaseBacklog(ctx context.Context, open *stores, by string) (*inventory.Pl
return nil, err
}
for _, p := range recent {
// 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 {
if p.Release == nil {
continue
}
if p.State == inventory.PlanFailed {
@@ -708,9 +702,6 @@ 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)
-284
View File
@@ -1,284 +0,0 @@
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
}
-568
View File
@@ -1,568 +0,0 @@
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)
}
}
-14
View File
@@ -433,20 +433,6 @@ 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.