Compare commits
99
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
690b75f659 | ||
|
|
d52de218c8 | ||
|
|
6188e6b1da | ||
|
|
798738cc12 | ||
|
|
babfef4f3a | ||
|
|
96b0966ad8 | ||
|
|
4d30b5de13 | ||
|
|
c97942d591 | ||
|
|
9c69b9da17 | ||
|
|
00065dbdaf | ||
|
|
16362e1bbd | ||
|
|
3496b58f66 | ||
|
|
d1cc9b4e20 | ||
|
|
cc11de2513 | ||
|
|
04fcce2fcc | ||
|
|
b9ceeace41 | ||
|
|
2e0a7d1cbd | ||
|
|
3ced96fc6f | ||
|
|
d3b4549611 | ||
|
|
065cfa0d1d | ||
|
|
0e0e143055 | ||
|
|
2887691414 | ||
|
|
e1499d4196 | ||
|
|
96fc4209d3 | ||
|
|
8a53532d89 | ||
|
|
d0161fac52 | ||
|
|
887de9b5f2 | ||
|
|
1560c498b6 | ||
|
|
df6d72aec2 | ||
|
|
a6bc0936e1 | ||
|
|
513ebd0577 | ||
|
|
88e84a4dd7 | ||
|
|
7b5c063cd3 | ||
|
|
abd3078491 | ||
|
|
0e5aed1253 | ||
|
|
6d3523ff10 | ||
|
|
bd35b1c06c | ||
|
|
150f038ff8 | ||
|
|
6c616838a5 | ||
|
|
de55c63e12 | ||
|
|
abe5f7dc17 | ||
|
|
4d1b81b6cb | ||
|
|
9517f590ac | ||
|
|
9cef820117 | ||
|
|
272ca2a578 | ||
|
|
7160d95323 | ||
|
|
9551bc2380 | ||
|
|
cdaba36eca | ||
|
|
eaf5195998 | ||
|
|
a6f831a633 | ||
|
|
a138c045bb | ||
|
|
988e501ccc | ||
|
|
19eefb66c6 | ||
|
|
081d9244d6 | ||
|
|
b55a38ca9f | ||
|
|
747734687e | ||
|
|
9006c82393 | ||
|
|
0c8c9ffae9 | ||
|
|
1c7c385839 | ||
|
|
65ff6159ad | ||
|
|
e6e1e3bc89 | ||
|
|
07e59c535e | ||
|
|
ba97297f66 | ||
|
|
e6b00e2e51 | ||
|
|
fa19a2d718 | ||
|
|
3e5086a2f6 | ||
|
|
ed331eb972 | ||
|
|
be59f29f46 | ||
|
|
5689553406 | ||
|
|
0559b80887 | ||
|
|
74d35600a6 | ||
|
|
d19c9ed5b7 | ||
|
|
799eec0a5a | ||
|
|
ad406e81b8 | ||
|
|
646c5e53db | ||
|
|
2190e2c664 | ||
|
|
0909d7b125 | ||
|
|
744b0b9162 | ||
|
|
8de4dc7951 | ||
|
|
2913c54c29 | ||
|
|
ef26d4cb0f | ||
|
|
d9a730307c | ||
|
|
9ca7952d5e | ||
|
|
58cb586c37 | ||
|
|
c3c56a69e2 | ||
|
|
63b87b6f7b | ||
|
|
997a4925b0 | ||
|
|
077ddf0eb8 | ||
|
|
c544c2a17b | ||
|
|
5866b2db94 | ||
|
|
983bf65141 | ||
|
|
0e46b8302d | ||
|
|
4c375dafed | ||
|
|
e2a45b18ba | ||
|
|
5fa8e40667 | ||
|
|
b3fd360ddb | ||
|
|
9372e80cec | ||
|
|
c9be75b13e | ||
|
|
f5f315cc95 |
@@ -306,6 +306,9 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
|
||||
for _, r := range built.Read {
|
||||
result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref})
|
||||
}
|
||||
for _, s := range built.Sources {
|
||||
result.Sources = append(result.Sources, link.BuildSource{Repository: s.Repository, Ref: s.Ref, Paths: s.Paths})
|
||||
}
|
||||
say("built", built.Manifest.Module+" from "+short(built.Commit))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -179,7 +180,21 @@ func (a *actor) release() {
|
||||
// holderOf is this process as the lease's holder.
|
||||
func holderOf(instance string) lease.Holder {
|
||||
host, _ := os.Hostname()
|
||||
return lease.Holder{Instance: instance, Host: host, Build: version}
|
||||
build := runningBuild()
|
||||
if build == "" {
|
||||
build = version
|
||||
}
|
||||
return lease.Holder{Instance: instance, Host: host, Build: build}
|
||||
}
|
||||
|
||||
// RunningBuildVar is where the declaration tells this process which build it is (module.json, the
|
||||
// controller process's env): the version its bundle is delivered as, `${version}` composed by the
|
||||
// catalogue from the bundle's digest. Empty for a process placed by hand.
|
||||
const RunningBuildVar = "MESH_CONTROLLER_VERSION"
|
||||
|
||||
// runningBuild is the version of the build this process is, or empty when the declaration did not say.
|
||||
func runningBuild() string {
|
||||
return strings.TrimSpace(os.Getenv(RunningBuildVar))
|
||||
}
|
||||
|
||||
// serveUnderTheLease takes the lease for the serving controller, waiting while another holds it, and
|
||||
|
||||
@@ -48,7 +48,8 @@ const agentAccountProbe = "DA"
|
||||
//
|
||||
// The one judgement: `node show`, the self-check, and ADR 0259's router honouring a verified sender read
|
||||
// it here.
|
||||
func agentConfined(ctx context.Context, inv *inventory.Inventory, node string) (named, confined bool, why string, err error) {
|
||||
// now is the judging clock, threaded so a caller judging several things at one instant judges them all at it.
|
||||
func agentConfined(ctx context.Context, inv *inventory.Inventory, node string, now time.Time) (named, confined bool, why string, err error) {
|
||||
n, err := inv.NodeByName(ctx, node)
|
||||
if err != nil {
|
||||
return false, false, "", err
|
||||
@@ -61,7 +62,7 @@ func agentConfined(ctx context.Context, inv *inventory.Inventory, node string) (
|
||||
if err != nil {
|
||||
return true, false, "", err
|
||||
}
|
||||
confined, why = judgedConfined(n.AgentAccount, h, had, time.Now())
|
||||
confined, why = judgedConfined(n.AgentAccount, h, had, now)
|
||||
return true, confined, why, nil
|
||||
}
|
||||
|
||||
@@ -118,9 +119,11 @@ func judgedConfined(agent string, h inventory.NodeHealth, had bool, now time.Tim
|
||||
}
|
||||
|
||||
// searchQuietFor is how long the controller lets an agent account's verdict wait for the node-engine's setuid
|
||||
// search before that is itself the urgent condition: the engine's bound on one search (link.RootSearchBound, the
|
||||
// engine's own value), counted from when this controller first saw it waiting, never from the engine's start.
|
||||
const searchQuietFor = link.RootSearchBound
|
||||
// search before that is itself the urgent condition: the longest a search is expected to take (link.RootSearchQuiet,
|
||||
// the engine's own value; novox/hq issue 361 — the search runs to completion, with no bound of its own), counted
|
||||
// from when this controller first saw it waiting, never from the engine's start. Quiet raises nothing; it never
|
||||
// makes the agent account confined, which only a healthy verdict from a complete, fresh search does.
|
||||
const searchQuietFor = link.RootSearchQuiet
|
||||
|
||||
// The kinds of an agent account's verdict, for the quiet a search earns.
|
||||
const (
|
||||
@@ -206,10 +209,10 @@ func probeAgentAccounts(ctx context.Context, d *doctor) ([]conditions.Observatio
|
||||
if confined {
|
||||
continue
|
||||
}
|
||||
// Not judged yet only because the first search since the node-engine started is still running: not the
|
||||
// Not judged yet only because the node-engine's search runs and no complete, fresh one judges: not the
|
||||
// urgent condition after every restart. The agent is still not confined — ADR 0259's router reads
|
||||
// agentConfined, not this — and `node show` still says not judged. Loud again once the search fails,
|
||||
// runs out its bound, or the statement goes stale.
|
||||
// waits past searchQuietFor, or the statement goes stale.
|
||||
if quiet {
|
||||
continue
|
||||
}
|
||||
@@ -228,7 +231,7 @@ func agentAccountLines(ctx context.Context, inv *inventory.Inventory, n inventor
|
||||
return []string{fmt.Sprintf(" agents run as the operator account (%s); no agent account is named",
|
||||
orNoneKnown(n.Account))}
|
||||
}
|
||||
_, confined, why, err := agentConfined(ctx, inv, n.Name)
|
||||
_, confined, why, err := agentConfined(ctx, inv, n.Name, time.Now())
|
||||
if err != nil {
|
||||
return []string{fmt.Sprintf(" agents run as %s (home %s); whether it can become root could NOT be read: %v",
|
||||
n.AgentAccount, n.AgentHome(), err)}
|
||||
|
||||
@@ -106,10 +106,10 @@ func TestTheSelfCheckSaysAnAgentAccountThatCanBecomeRoot(t *testing.T) {
|
||||
if found, err = probeAgentAccounts(ctx, d); err != nil || len(onlyMachine(found, "anchor")) != 0 {
|
||||
t.Fatalf("a judged agent account still fails: %+v %v", found, err)
|
||||
}
|
||||
if named, confined, why, err := agentConfined(ctx, inv, "anchor"); err != nil || !named || !confined {
|
||||
if named, confined, why, err := agentConfined(ctx, inv, "anchor", time.Now()); err != nil || !named || !confined {
|
||||
t.Fatalf("agentConfined on anchor: %v %v %q %v", named, confined, why, err)
|
||||
}
|
||||
if named, _, why, err := agentConfined(ctx, inv, "laptop"); err != nil || named ||
|
||||
if named, _, why, err := agentConfined(ctx, inv, "laptop", time.Now()); err != nil || named ||
|
||||
!strings.Contains(why, "operator account") {
|
||||
t.Fatalf("agentConfined on a machine naming none: %v %q %v", named, why, err)
|
||||
}
|
||||
@@ -199,14 +199,20 @@ func TestTheNodeVerbOnlyShows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// After every node-engine restart its search for setuid programs runs for up to its bound, and the agent account
|
||||
// is not judged until it ends. DA does not raise that as urgent while the search is within its bound, counted from
|
||||
// when this controller first saw it waiting — never from the engine's own "since", which a restart resets, so an
|
||||
// engine restarted in a loop does not keep it quiet. The account is still not confined, and `node show` still
|
||||
// says not judged; a search that failed, or a way to root found, is urgent at once.
|
||||
// Until a complete search for setuid programs judges — none since the node-engine's state was kept, or the last
|
||||
// older than the engine lets one judge — the agent account is not judged, and the search runs to its end with no
|
||||
// bound (novox/hq issue 361). DA does not raise that as urgent within searchQuietFor, counted from when this
|
||||
// controller first saw it waiting — never from the engine's own "since", which a restart resets, so an engine
|
||||
// restarted in a loop does not keep it quiet. The account is still not confined, and `node show` still says not
|
||||
// judged; a search that failed, or a way to root found, is urgent at once.
|
||||
func TestASearchStillRunningAfterARestartIsNotUrgent(t *testing.T) {
|
||||
if searchQuietFor != rootsearch.Bound {
|
||||
t.Fatalf("the quiet is %s and the node-engine's bound %s: they are one value", searchQuietFor, rootsearch.Bound)
|
||||
if searchQuietFor != rootsearch.Quiet {
|
||||
t.Fatalf("the quiet is %s and the node-engine's %s: they are one value", searchQuietFor, rootsearch.Quiet)
|
||||
}
|
||||
// A daily search that finishes within the quiet never leaves the account unjudged between two of them.
|
||||
if rootsearch.FreshFor < rootsearch.Every+rootsearch.Quiet {
|
||||
t.Fatalf("a complete search judges for %s, less than a day's search (%s) and the quiet (%s)",
|
||||
rootsearch.FreshFor, rootsearch.Every, rootsearch.Quiet)
|
||||
}
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
@@ -246,7 +252,7 @@ func TestASearchStillRunningAfterARestartIsNotUrgent(t *testing.T) {
|
||||
if found := say(link.StateUnknown, running); len(found) != 0 {
|
||||
t.Fatalf("a search first seen now was raised: %+v", found)
|
||||
}
|
||||
if _, confined, why, _ := agentConfined(ctx, inv, "anchor"); confined || !strings.Contains(why, "not judged") {
|
||||
if _, confined, why, _ := agentConfined(ctx, inv, "anchor", time.Now()); confined || !strings.Contains(why, "not judged") {
|
||||
t.Fatalf("an account whose search runs was read as confined: %q", why)
|
||||
}
|
||||
// The engine restarted again and again, each statement's own since fresh: the controller's clock runs on.
|
||||
|
||||
@@ -0,0 +1,937 @@
|
||||
package main
|
||||
|
||||
// The controller asks, and acts on the operator's warrant (novox/hq ADR 0259 §6). It holds no channel, no
|
||||
// identity and no factor: it asks the router like any other module, and performs the answer chosen with its
|
||||
// own grant.
|
||||
//
|
||||
// - **For every open, unsilenced condition that needs the operator and names its answers**, one ask is
|
||||
// published on the `operator-channel` seat under the controller's own name: the condition's words, its
|
||||
// actions as options at their levels (Silence acknowledges; Release, Stop, Start and Restart approve),
|
||||
// answered by the operator, expiring after a day (a week when every option only acknowledges). A
|
||||
// condition that clears, is silenced, or changes its answers has its ask cancelled; an ask that expired
|
||||
// unanswered is asked again while the condition lasts; one the router refused is asked again after a wait
|
||||
// that grows with each refusal in a row, at most half an hour, so a refusal is never believed longer. Each ask is kept in the controller's bucket
|
||||
// `asked`, so a restart neither asks twice nor forgets.
|
||||
// - **On a warrant**, heard on the seat's event under the controller's own name (which only the router may
|
||||
// say), the controller acts once per ask: only for an ask it holds, only for the option it offered at
|
||||
// that option's level, and only while the condition is still open. It performs the action as itself —
|
||||
// a silence through its own conditions, any other through the verb the action names — with the warrant's
|
||||
// words as its why, and records it in the hand-act log as the operator's decision, naming the channel,
|
||||
// the ask and the proofs. An ask that ended without a choice is recorded and nothing is done.
|
||||
// - **A warrant it missed** while away is read from the router's record of its asks, under its own name.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// The asker's name on the seat: the controller's module.
|
||||
const askerName = broker.ControllerSeat
|
||||
|
||||
// How long an ask lasts: a day when an answer approves, a week when every answer only acknowledges.
|
||||
const (
|
||||
// askApproveFor is a day less a margin, so an ask is never refused at the router for lasting a day and
|
||||
// a moment (the SDK's bound is a day).
|
||||
askApproveFor = 24*time.Hour - 10*time.Minute
|
||||
askAcknowledgeFor = 7 * 24 * time.Hour
|
||||
// askEvery is how often what is open is asked about again, beside every change.
|
||||
askEvery = time.Minute
|
||||
// askCatchUpAfter is how old an open ask is before the router's record of it is read: a warrant heard
|
||||
// on the event needs no reading.
|
||||
askCatchUpAfter = 2 * time.Minute
|
||||
// askAgainAfterAnswer is how long a condition the operator answered is not asked about again with the
|
||||
// same answers: what was chosen takes a while to clear it, and asking again at once would ask twice.
|
||||
askAgainAfterAnswer = time.Hour
|
||||
// askMostOpen is how many asks the controller holds open at once (the router refuses a fourth): the
|
||||
// most urgent conditions first, then the oldest.
|
||||
askMostOpen = asks.MostOpen
|
||||
// askRefusedRetryMost is the longest a refusal is believed without asking again (novox/hq issue 369): the
|
||||
// router refused while its channels had not yet said they could send, the channels could send twenty
|
||||
// minutes later, and the controller repeated that refusal for eleven hours. A refused ask is asked again
|
||||
// after askEvery, then twice as long after each refusal in a row, never longer than this — and at once when
|
||||
// the channels change.
|
||||
askRefusedRetryMost = 30 * time.Minute
|
||||
// askRefusedCounted is how far back refusals in a row are counted for that wait.
|
||||
askRefusedCounted = 6 * time.Hour
|
||||
// askVerdictWait is how long an ask made again after a refusal waits for the router's word before it is
|
||||
// taken as taken: the router refuses an ask as it reads it, so a refusal comes within seconds. Meanwhile
|
||||
// the condition saying it was not delivered stands as it was, neither cleared nor raised again.
|
||||
askVerdictWait = 2 * time.Minute
|
||||
)
|
||||
|
||||
// refusedRetryAfter is how long a part refused n times in a row waits before it is asked again.
|
||||
func refusedRetryAfter(n int) time.Duration {
|
||||
wait := askEvery
|
||||
for i := 1; i < n && wait < askRefusedRetryMost; i++ {
|
||||
wait *= 2
|
||||
}
|
||||
return min(wait, askRefusedRetryMost)
|
||||
}
|
||||
|
||||
// What became of an ask, as the controller keeps it.
|
||||
const (
|
||||
askOpen = "open"
|
||||
askCancelled = "cancelled"
|
||||
)
|
||||
|
||||
// asked is one ask the controller made, as it keeps it.
|
||||
type asked struct {
|
||||
ID string `json:"id"`
|
||||
Condition string `json:"condition"`
|
||||
// Channels is what the channels were when it was asked (asker.channels): an ask the router refused is asked
|
||||
// again at once when the condition's answers or the channels change, and otherwise after a wait that grows
|
||||
// with each refusal in a row (refusedRetryAfter, novox/hq issue 369).
|
||||
Channels string `json:"channels,omitempty"`
|
||||
Ask asks.Ask `json:"ask"`
|
||||
Actions []conditions.Action `json:"actions"`
|
||||
// Options are the actions by option id.
|
||||
Options map[string]int `json:"options"`
|
||||
State string `json:"state"`
|
||||
Opened time.Time `json:"opened"`
|
||||
Ended time.Time `json:"ended,omitempty"`
|
||||
Warrant *asks.Warrant `json:"warrant,omitempty"`
|
||||
// Acted is what the controller did on the warrant: empty before it did anything, "acting" while it acts,
|
||||
// then "done", "failed: …" or "nothing: …". Anything but empty is never acted on again.
|
||||
Acted string `json:"acted,omitempty"`
|
||||
// Part is which ask of its condition this is (askPart): empty for the one that carries the condition's
|
||||
// answers, or the authorising ones where it has both; "acknowledge" for its acknowledging answers asked
|
||||
// apart (the review of 2026-10-09, M1).
|
||||
Part string `json:"part,omitempty"`
|
||||
// Rehearsal is an ask started at the controller's terminal (rehearse.go): about no condition, its answers
|
||||
// perform nothing, and the reconciling of conditions leaves it alone.
|
||||
Rehearsal bool `json:"rehearsal,omitempty"`
|
||||
// Proposal is a settings layer proposed through a verb (proposals.go, novox/hq ADR 0277): about no
|
||||
// condition, set on Approve by the serving controller itself, and left alone by the reconciling of conditions.
|
||||
Proposal *settingsProposal `json:"proposal,omitempty"`
|
||||
}
|
||||
|
||||
// ofACondition says an ask is one of a condition's: not a rehearsal, not a proposal.
|
||||
func (r asked) ofACondition() bool { return !r.Rehearsal && r.Proposal == nil }
|
||||
|
||||
// partKey is an ask's place among what is asked: its condition and its part.
|
||||
func partKey(condition, part string) string { return condition + "#" + part }
|
||||
|
||||
// partAcknowledge is the part of a condition asked apart for its acknowledging answers.
|
||||
const partAcknowledge = "acknowledge"
|
||||
|
||||
// askPart is one ask a condition is asked with: its part, what it is about, and its answers.
|
||||
type askPart struct {
|
||||
name string
|
||||
about string
|
||||
actions []conditions.Action
|
||||
}
|
||||
|
||||
// levelOf is an action's level as an option offers it: one that says none is never taken for less than
|
||||
// approve.
|
||||
func levelOf(act conditions.Action) asks.Level {
|
||||
if act.Level == "" {
|
||||
return asks.Approve
|
||||
}
|
||||
return asks.Level(act.Level)
|
||||
}
|
||||
|
||||
// partsOf is the asks a condition is asked with (the review of 2026-10-09, M1): one, when its answers are all
|
||||
// of one kind; else its authorising answers (Release, Stop, Restart) in one ask, about the condition, and its
|
||||
// acknowledging ones (Silence) in another. **An acknowledgement never shares an ask with an approval**: a
|
||||
// channel that only acknowledges would otherwise answer the ask, and end the approval with it.
|
||||
func partsOf(c conditions.Condition) []askPart {
|
||||
var ack, auth []conditions.Action
|
||||
for _, act := range c.Actions {
|
||||
if levelOf(act) == asks.Acknowledge {
|
||||
ack = append(ack, act)
|
||||
} else {
|
||||
auth = append(auth, act)
|
||||
}
|
||||
}
|
||||
if len(ack) == 0 || len(auth) == 0 {
|
||||
return []askPart{{about: c.Key, actions: c.Actions}}
|
||||
}
|
||||
return []askPart{{about: c.Key, actions: auth},
|
||||
{name: partAcknowledge, about: c.Key + "." + partAcknowledge, actions: ack}}
|
||||
}
|
||||
|
||||
// askedStore keeps the asks (broker.AskedBucket). **Every write after the first is a compare-and-set** (the
|
||||
// review of 2026-10-09, L2): an ask is created once, and changed only over the revision it was read at, the
|
||||
// change decided again on what is read — so two controllers, or two deliveries of one warrant, never write
|
||||
// over each other, and of two that would act only the one whose write stands does.
|
||||
type askedStore interface {
|
||||
Get(ctx context.Context, id string) (*asked, error)
|
||||
// Create keeps a new ask, and refuses one already kept under its id.
|
||||
Create(ctx context.Context, a asked) error
|
||||
// Change applies change to the ask kept under id, by compare-and-set, and says whether its write stood.
|
||||
// change says whether to write at all; on a write that came between, it is asked again on what is read.
|
||||
Change(ctx context.Context, id string, change func(*asked) bool) (bool, error)
|
||||
All(ctx context.Context) ([]asked, error)
|
||||
}
|
||||
|
||||
// askChangeTries is how often a change is read and tried again when another write came between.
|
||||
const askChangeTries = 5
|
||||
|
||||
// asker is the controller asking the operator and acting on the answer.
|
||||
type asker struct {
|
||||
open func(ctx context.Context) ([]conditions.Condition, error)
|
||||
silence func(ctx context.Context, key string, d time.Duration, by, why string) error
|
||||
store askedStore
|
||||
// publish puts a message on a subject's stream, de-duplicated by id.
|
||||
publish func(ctx context.Context, subject string, body []byte, id string) error
|
||||
// call performs an action's verb with its arguments, as the controller.
|
||||
call func(ctx context.Context, a conditions.Action, args map[string]string) error
|
||||
// setLayer sets a proposed settings layer on the operator's warrant (novox/hq ADR 0277); nil cannot.
|
||||
setLayer setOnWarrant
|
||||
// record writes the hand-act log.
|
||||
record func(ctx context.Context, act link.HandAct) error
|
||||
// routerRecord reads the router's record of an ask for a warrant missed; nil reads nothing.
|
||||
routerRecord func(ctx context.Context, id string) (*asks.Warrant, error)
|
||||
// routerHere says whether a router holds the seat and takes asks under the asker's name; nil is yes.
|
||||
routerHere func(ctx context.Context) (bool, error)
|
||||
// grantHeld says whether the bus holds the controller's grant to ask: the user list the bus's machine was
|
||||
// last sent is the one the mesh composes now (novox/hq issue 353). The grant is composed from the router's
|
||||
// assignment and reaches the bus only when that machine is next pushed, so between `assign` and `push`
|
||||
// the record says a router is here and the bus refuses every ask. why says what to do; nil is yes.
|
||||
grantHeld func(ctx context.Context) (held bool, why string, err error)
|
||||
// channels is what the channels are now, as a fingerprint: who holds which kind, promising what.
|
||||
channels func(ctx context.Context) string
|
||||
// raise keeps the asker's own condition (sourceAsker): which conditions needing the operator could not be
|
||||
// asked, and why. Nil raises nothing (a test that does not look).
|
||||
raise func(ctx context.Context, obs []conditions.Observation) error
|
||||
now func() time.Time
|
||||
logf func(string, ...any)
|
||||
|
||||
saidNoRouter bool
|
||||
saidNoGrant bool
|
||||
|
||||
mu sync.Mutex
|
||||
nudged chan struct{}
|
||||
}
|
||||
|
||||
func (a *asker) nudge() {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
if a.nudged == nil {
|
||||
a.nudged = make(chan struct{}, 1)
|
||||
}
|
||||
ch := a.nudged
|
||||
a.mu.Unlock()
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// keep asks until ctx ends: now, on every change of a condition, and every askEvery.
|
||||
func (a *asker) keep(ctx context.Context) {
|
||||
a.nudge()
|
||||
tick := time.NewTicker(askEvery)
|
||||
defer tick.Stop()
|
||||
a.mu.Lock()
|
||||
nudged := a.nudged
|
||||
a.mu.Unlock()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
case <-nudged:
|
||||
}
|
||||
if err := a.reconcile(ctx); err != nil {
|
||||
a.logf("what the operator is asked could not be brought up to date: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wants says whether a condition is one to ask about now.
|
||||
func wants(c conditions.Condition, now time.Time) bool {
|
||||
return len(c.Actions) > 0 && c.Needs != "" && !c.SilencedAt(now)
|
||||
}
|
||||
|
||||
func sameAsked(a []conditions.Action, b []conditions.Action) bool {
|
||||
x, _ := json.Marshal(a)
|
||||
y, _ := json.Marshal(b)
|
||||
return string(x) == string(y)
|
||||
}
|
||||
|
||||
// reconcile brings what is asked in line with what is open.
|
||||
func (a *asker) reconcile(ctx context.Context) error {
|
||||
now := a.now()
|
||||
if a.routerHere != nil {
|
||||
here, err := a.routerHere(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !here {
|
||||
if !a.saidNoRouter {
|
||||
a.logf("no router takes asks under the controller's name (a module declaring %s with its ask "+
|
||||
"named by its caller, assigned): the operator is asked nothing until one is", broker.AsksSeat)
|
||||
a.saidNoRouter = true
|
||||
}
|
||||
open, err := a.open(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var unasked []conditions.Condition
|
||||
for _, c := range open {
|
||||
if wants(c, now) {
|
||||
unasked = append(unasked, c)
|
||||
}
|
||||
}
|
||||
return a.sayUnasked(ctx, unasked, "no router takes the controller's asks: no module holding "+
|
||||
broker.AsksSeat+" that takes an ask under its asker's name is assigned")
|
||||
}
|
||||
a.saidNoRouter = false
|
||||
}
|
||||
if a.grantHeld != nil {
|
||||
held, why, err := a.grantHeld(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !held {
|
||||
if !a.saidNoGrant {
|
||||
a.logf("the bus does not hold the controller's grant to ask yet: %s; the operator is asked nothing until it does", why)
|
||||
a.saidNoGrant = true
|
||||
}
|
||||
open, err := a.open(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var unasked []conditions.Condition
|
||||
for _, c := range open {
|
||||
if wants(c, now) {
|
||||
unasked = append(unasked, c)
|
||||
}
|
||||
}
|
||||
return a.sayUnasked(ctx, unasked, "the bus does not hold the controller's grant to ask yet: "+why)
|
||||
}
|
||||
a.saidNoGrant = false
|
||||
}
|
||||
channels := ""
|
||||
if a.channels != nil {
|
||||
channels = a.channels(ctx)
|
||||
}
|
||||
open, err := a.open(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
all, err := a.store.All(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
byCondition := map[string]asked{} // by partKey
|
||||
// What is open and not a condition's — a rehearsal, a proposal — still counts toward what the router holds
|
||||
// open for the controller (askMostOpen); expired unanswered, it is kept so, as the router says it too.
|
||||
otherOpen := 0
|
||||
for _, r := range all {
|
||||
if r.State == askOpen && !r.ofACondition() && !now.Before(r.Ask.Expires) {
|
||||
if _, err := a.store.Change(ctx, r.ID, func(x *asked) bool {
|
||||
if x.State != askOpen || x.Acted != "" {
|
||||
return false
|
||||
}
|
||||
x.State, x.Ended, x.Acted = string(asks.OutcomeExpired), now, "nothing: the ask expired unanswered"
|
||||
return true
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if r.State == askOpen && !r.ofACondition() {
|
||||
otherOpen++
|
||||
}
|
||||
if r.State == askOpen && r.ofACondition() {
|
||||
k := partKey(r.Condition, r.Part)
|
||||
if prior, held := byCondition[k]; !held || r.Opened.After(prior.Opened) {
|
||||
byCondition[k] = r
|
||||
}
|
||||
}
|
||||
}
|
||||
// A warrant missed while away, read from the router's record: for a condition's ask, and for a proposal's or a
|
||||
// rehearsal's alike.
|
||||
if a.routerRecord != nil {
|
||||
var lookedUp []asked
|
||||
for _, r := range byCondition {
|
||||
lookedUp = append(lookedUp, r)
|
||||
}
|
||||
for _, r := range all {
|
||||
if r.State == askOpen && !r.ofACondition() {
|
||||
lookedUp = append(lookedUp, r)
|
||||
}
|
||||
}
|
||||
for _, r := range lookedUp {
|
||||
if now.Sub(r.Opened) < askCatchUpAfter {
|
||||
continue
|
||||
}
|
||||
if w, err := a.routerRecord(ctx, r.ID); err == nil && w != nil {
|
||||
body, _ := json.Marshal(w)
|
||||
if err := a.Decided(ctx, body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if all, err = a.store.All(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
byCondition = map[string]asked{}
|
||||
for _, r := range all {
|
||||
if r.State == askOpen && r.ofACondition() {
|
||||
byCondition[partKey(r.Condition, r.Part)] = r
|
||||
}
|
||||
}
|
||||
}
|
||||
// What the operator answered lately, by condition: not asked again at once; and what the router refused,
|
||||
// newest first: not asked again until the answers or the channels change.
|
||||
answered, refused := map[string]asked{}, map[string]asked{}
|
||||
for _, r := range all {
|
||||
k := partKey(r.Condition, r.Part)
|
||||
if r.State == string(asks.OutcomeChosen) && now.Sub(r.Ended) < askAgainAfterAnswer {
|
||||
answered[k] = r
|
||||
}
|
||||
if r.State == string(asks.OutcomeRefused) {
|
||||
if prior, has := refused[k]; !has || r.Opened.After(prior.Opened) {
|
||||
refused[k] = r
|
||||
}
|
||||
}
|
||||
}
|
||||
// Refusals in a row, by part: those since the last ask that was not refused, within askRefusedCounted.
|
||||
// superseded: a part asked since its last refusal, by an ask the router did not refuse — open, answered,
|
||||
// expired or cancelled. Its refusal is history then, never said again (the review of PR 198: an answered
|
||||
// retry brought the refusal back).
|
||||
inRow, superseded := map[string]int{}, map[string]asked{}
|
||||
for k, last := range refused {
|
||||
for _, r := range all {
|
||||
if partKey(r.Condition, r.Part) == k && r.State != string(asks.OutcomeRefused) && r.State != askUnsent &&
|
||||
r.Opened.After(last.Opened) && r.Opened.After(superseded[k].Opened) {
|
||||
superseded[k] = r
|
||||
}
|
||||
}
|
||||
var since time.Time
|
||||
for _, r := range all {
|
||||
if partKey(r.Condition, r.Part) == k && r.State != string(asks.OutcomeRefused) && r.State != askUnsent &&
|
||||
!r.Opened.After(last.Opened) && r.Opened.After(since) {
|
||||
since = r.Opened
|
||||
}
|
||||
}
|
||||
for _, r := range all {
|
||||
if partKey(r.Condition, r.Part) == k && r.State == string(asks.OutcomeRefused) && r.Opened.After(since) &&
|
||||
now.Sub(r.Opened) < askRefusedCounted {
|
||||
inRow[k]++
|
||||
}
|
||||
}
|
||||
}
|
||||
wanted := map[string]bool{}
|
||||
var unasked []conditions.Condition // refused by the router, and nothing it was refused for changed
|
||||
var refusedWords []string
|
||||
// The most urgent first, then the oldest: those are asked when no more than askMostOpen may be.
|
||||
sort.SliceStable(open, func(i, j int) bool {
|
||||
ui, uj := open[i].Severity == conditions.Urgent, open[j].Severity == conditions.Urgent
|
||||
if ui != uj {
|
||||
return ui
|
||||
}
|
||||
if !open[i].Raised.Equal(open[j].Raised) {
|
||||
return open[i].Raised.Before(open[j].Raised)
|
||||
}
|
||||
return open[i].Key < open[j].Key
|
||||
})
|
||||
openNow := otherOpen
|
||||
for _, c := range open {
|
||||
if !wants(c, now) {
|
||||
continue
|
||||
}
|
||||
for _, p := range partsOf(c) {
|
||||
if r, held := byCondition[partKey(c.Key, p.name)]; held && sameAsked(r.Actions, p.actions) && now.Before(r.Ask.Expires) {
|
||||
openNow++
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, c := range open {
|
||||
if !wants(c, now) {
|
||||
continue
|
||||
}
|
||||
saidUnasked := false
|
||||
for _, p := range partsOf(c) {
|
||||
key := partKey(c.Key, p.name)
|
||||
wanted[key] = true
|
||||
if r, was := refused[key]; was && sameAsked(r.Actions, p.actions) {
|
||||
cur, held := byCondition[key]
|
||||
ended := r.Ended
|
||||
if ended.IsZero() {
|
||||
ended = r.Opened
|
||||
}
|
||||
later, asked := superseded[key]
|
||||
waiting := !held && !asked && r.Channels == channels && now.Sub(ended) < refusedRetryAfter(inRow[key])
|
||||
// Asked again now (the wait over), or lately and the router's word not in yet: said as it was until
|
||||
// that word, so the condition neither clears nor is raised again at each try.
|
||||
retrying := !held && !asked && !waiting
|
||||
verdictDue := held && asked && later.ID == cur.ID && now.Sub(cur.Opened) < askVerdictWait
|
||||
if waiting || retrying || verdictDue {
|
||||
if !saidUnasked {
|
||||
unasked, saidUnasked = append(unasked, c), true
|
||||
}
|
||||
if r.Warrant != nil && r.Warrant.Words != "" {
|
||||
refusedWords = append(refusedWords, r.Warrant.Words)
|
||||
}
|
||||
}
|
||||
if waiting {
|
||||
continue // refused lately, and nothing it was refused for has changed: asked again after the wait
|
||||
}
|
||||
}
|
||||
if r, done := answered[key]; done && sameAsked(r.Actions, p.actions) {
|
||||
if _, held := byCondition[key]; !held {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if r, held := byCondition[key]; held {
|
||||
switch {
|
||||
case !sameAsked(r.Actions, p.actions):
|
||||
if err := a.cancel(ctx, r, "its answers changed"); err != nil {
|
||||
return err
|
||||
}
|
||||
case !now.Before(r.Ask.Expires):
|
||||
// Expired unanswered: the router says so too; asked again below while it lasts.
|
||||
if _, err := a.store.Change(ctx, r.ID, func(x *asked) bool {
|
||||
if x.State != askOpen {
|
||||
return false
|
||||
}
|
||||
x.State, x.Ended = string(asks.OutcomeExpired), now
|
||||
return true
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
openNow--
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
if openNow >= askMostOpen {
|
||||
continue // asked when one of the open ones ends, most urgent first
|
||||
}
|
||||
if err := a.ask(ctx, c, p, channels); err != nil {
|
||||
a.logf("the operator could not be asked about %s: %v", c.Key, err)
|
||||
continue
|
||||
}
|
||||
openNow++
|
||||
}
|
||||
}
|
||||
stillOpen := map[string]conditions.Condition{}
|
||||
for _, c := range open {
|
||||
stillOpen[c.Key] = c
|
||||
}
|
||||
for key, r := range byCondition {
|
||||
if wanted[key] {
|
||||
continue
|
||||
}
|
||||
// **A silence never takes an approval back** (the confirmation review of 2026-10-09, M1). Silence is an
|
||||
// acknowledgement — anyone at the desk may give it — so a condition silenced while its approval is asked
|
||||
// keeps that ask open, unchanged, until it is answered on a channel that proves who answered, or expires.
|
||||
// It is not asked again once it ends, while the silence lasts.
|
||||
if c, open := stillOpen[r.Condition]; open && c.SilencedAt(now) && r.Ask.Highest() != asks.Acknowledge &&
|
||||
now.Before(r.Ask.Expires) && keepsItsAnswers(c, r) {
|
||||
continue
|
||||
}
|
||||
if err := a.cancel(ctx, r, "the condition ended, was silenced or needs nothing now"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
why := "the router refused the ask"
|
||||
if len(refusedWords) > 0 {
|
||||
why += ": " + refusedWords[0]
|
||||
}
|
||||
return a.sayUnasked(ctx, unasked, why)
|
||||
}
|
||||
|
||||
// keepsItsAnswers says a condition still offers the answers an ask kept was asked with.
|
||||
func keepsItsAnswers(c conditions.Condition, r asked) bool {
|
||||
for _, p := range partsOf(c) {
|
||||
if partKey(c.Key, p.name) == partKey(r.Condition, r.Part) {
|
||||
return sameAsked(r.Actions, p.actions)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sourceAsker raises the asker's own condition.
|
||||
const sourceAsker = "asker"
|
||||
|
||||
// sayUnasked keeps the asker's one condition: while a condition that needs the operator could not be asked
|
||||
// on any channel, said loudly (failure must be loud), cleared when every one could be.
|
||||
func (a *asker) sayUnasked(ctx context.Context, unasked []conditions.Condition, why string) error {
|
||||
if a.raise == nil {
|
||||
return nil
|
||||
}
|
||||
var obs []conditions.Observation
|
||||
if len(unasked) > 0 {
|
||||
keys := make([]string, 0, len(unasked))
|
||||
severity := conditions.Warning
|
||||
for _, c := range unasked {
|
||||
keys = append(keys, c.Key)
|
||||
if c.Severity == conditions.Urgent {
|
||||
severity = conditions.Urgent
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
obs = append(obs, conditions.Observation{Scope: conditions.ScopeSeat, ID: broker.AsksSeat, Token: "unasked",
|
||||
Kind: "asks-undelivered", Severity: severity, Source: sourceAsker,
|
||||
Summary: fmt.Sprintf("%d condition(s) that need the operator could not be asked on any channel: %s; %s",
|
||||
len(keys), strings.Join(keys, ", "), why),
|
||||
Headline: "Questions for you not delivered",
|
||||
Explanation: "The mesh could not send you its questions on any channel.",
|
||||
Needs: "answer them from the mesh MCP server, and check why no channel carries them.",
|
||||
Resolved: "The mesh can ask you again"})
|
||||
}
|
||||
if err := a.raise(ctx, obs); err != nil {
|
||||
a.logf("whether the operator could be asked could not be kept as a condition: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// optionID is an action's label as an option's id: "Silence for a week" is silence-for-a-week.
|
||||
func optionID(label string) string {
|
||||
var b strings.Builder
|
||||
dash := false
|
||||
for _, r := range strings.ToLower(label) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
dash = false
|
||||
case !dash && b.Len() > 0:
|
||||
b.WriteByte('-')
|
||||
dash = true
|
||||
}
|
||||
}
|
||||
return strings.TrimSuffix(b.String(), "-")
|
||||
}
|
||||
|
||||
// doesWords is what an action does, in the words an option says it with.
|
||||
func doesWords(act conditions.Action) string {
|
||||
switch {
|
||||
case act.Arguments["silence"] != "":
|
||||
return "nothing more is said of it for a week"
|
||||
case act.Verb == "mesh-delivery.release":
|
||||
return "the delivery goes on"
|
||||
case act.Verb == "mesh-delivery.stop":
|
||||
return "the delivery ends"
|
||||
case act.Verb == broker.ControllerSeat+".plans" && act.Arguments["go"] != "":
|
||||
return "the delivery starts"
|
||||
case act.Verb == broker.ControllerSeat+".plans" && act.Arguments["stop"] != "":
|
||||
return "the delivery is stopped"
|
||||
case strings.HasSuffix(act.Verb, ".restart"):
|
||||
return "its service is restarted on " + act.Machine
|
||||
}
|
||||
return strings.ToLower(act.Label)
|
||||
}
|
||||
|
||||
// askText is a condition's words as an ask says them: without where an answer is given when no channel can
|
||||
// give it (FromMeshMCPServer), since the ask is answered on a channel and the router says where else.
|
||||
func askText(s string) string {
|
||||
for _, with := range []string{", " + FromMeshMCPServer, " " + FromMeshMCPServer} {
|
||||
s = strings.ReplaceAll(s, with, ".")
|
||||
}
|
||||
return strings.ReplaceAll(s, "..", ".")
|
||||
}
|
||||
|
||||
// askOf is the ask one part of a condition is asked with.
|
||||
func askOf(id string, c conditions.Condition, p askPart, now time.Time) (asks.Ask, map[string]int) {
|
||||
q := asks.Ask{ID: id, Headline: c.Headline, Explanation: askText(c.Explanation), Who: asks.Operator,
|
||||
OnExpiry: "nothing is done, and you are asked again while it lasts", About: p.about,
|
||||
Urgent: c.Severity == conditions.Urgent}
|
||||
options := map[string]int{}
|
||||
approves := false
|
||||
for i, act := range p.actions {
|
||||
level := levelOf(act) // an action that says nothing of its level is never taken for less than approve
|
||||
approves = approves || level != asks.Acknowledge
|
||||
oid := optionID(act.Label)
|
||||
options[oid] = i
|
||||
// Every option binds the exact act it stands for (novox/hq ADR 0259 §6): the verb, the machine and
|
||||
// every argument. The warrant then authorises that act and no other.
|
||||
binds, _ := asks.ActDigest(boundAct(act))
|
||||
q.Options = append(q.Options, asks.Option{ID: oid, Label: act.Label, Does: doesWords(act), Level: level,
|
||||
Binds: binds})
|
||||
}
|
||||
q.Expires = now.Add(askAcknowledgeFor)
|
||||
if approves {
|
||||
q.Expires = now.Add(askApproveFor)
|
||||
}
|
||||
return q, options
|
||||
}
|
||||
|
||||
// boundAct is what an option's Binds digests: the act exactly as the controller will perform it — its verb,
|
||||
// machine, level, and each argument as "arg.<name>" — and never its label or words.
|
||||
func boundAct(act conditions.Action) asks.Act {
|
||||
out := asks.Act{"verb": act.Verb, "machine": act.Machine, "level": act.Level}
|
||||
for k, v := range act.Arguments {
|
||||
out["arg."+k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newAskID() string {
|
||||
var b [8]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return "c" + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// askUnsent is an ask kept and never published: asked again at the next look.
|
||||
const askUnsent = "unsent"
|
||||
|
||||
// ask publishes one ask about a part of a condition, kept before it is published (the review of 2026-10-09,
|
||||
// L3): a warrant for it then always finds it, and one whose publishing failed is marked so and asked again.
|
||||
func (a *asker) ask(ctx context.Context, c conditions.Condition, p askPart, channels string) error {
|
||||
now := a.now()
|
||||
id := newAskID()
|
||||
q, options := askOf(id, c, p, now)
|
||||
if err := q.Check(now); err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := json.Marshal(q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.store.Create(ctx, asked{ID: id, Condition: c.Key, Part: p.name, Ask: q, Actions: p.actions,
|
||||
Options: options, State: askOpen, Opened: now, Channels: channels}); err != nil {
|
||||
return fmt.Errorf("the ask could not be kept, so it was not asked: %w", err)
|
||||
}
|
||||
if err := a.publish(ctx, asks.AskSubject(askerName), body, "ask."+id); err != nil {
|
||||
if _, cerr := a.store.Change(ctx, id, func(x *asked) bool {
|
||||
if x.State != askOpen || x.Acted != "" {
|
||||
return false
|
||||
}
|
||||
x.State, x.Ended, x.Acted = askUnsent, a.now(), "nothing: it could not be published: "+err.Error()
|
||||
return true
|
||||
}); cerr != nil {
|
||||
a.logf("the ask %s could not be published, and could not be marked so: %v", id, cerr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
a.logf("asked the operator about %s (%s): %d answer(s)", c.Key, id, len(q.Options))
|
||||
return nil
|
||||
}
|
||||
|
||||
// cancel takes an ask back: kept cancelled first, so a warrant that comes after is refused, then said to the
|
||||
// router; a cancel the router did not hear leaves the ask to expire there, and nothing is done on it here.
|
||||
func (a *asker) cancel(ctx context.Context, r asked, why string) error {
|
||||
stood, err := a.store.Change(ctx, r.ID, func(x *asked) bool {
|
||||
if x.State != askOpen {
|
||||
return false
|
||||
}
|
||||
x.State, x.Ended = askCancelled, a.now()
|
||||
return true
|
||||
})
|
||||
if err != nil || !stood {
|
||||
return err
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"id": r.ID})
|
||||
if err := a.publish(ctx, asks.CancelSubject(askerName), body, "cancel."+r.ID); err != nil {
|
||||
a.logf("the ask %s about %s is taken back here, and the router could not be told (%v): it expires there, "+
|
||||
"and no answer to it is acted on", r.ID, r.Condition, err)
|
||||
return nil
|
||||
}
|
||||
a.logf("took back the ask %s about %s: %s", r.ID, r.Condition, why)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decided takes the router's word on one of the controller's asks (link.Decider). An error is returned only
|
||||
// when what was decided could not be kept, so the word is held and heard again.
|
||||
func (a *asker) Decided(ctx context.Context, body []byte) error {
|
||||
var w asks.Warrant
|
||||
if err := json.Unmarshal(body, &w); err != nil {
|
||||
a.logf("the router's word on an ask could not be read; ignored: %v", err)
|
||||
return nil
|
||||
}
|
||||
if w.Asker != askerName {
|
||||
a.logf("REFUSED a warrant for %s's ask %s: the controller acts only on its own", w.Asker, w.Ask)
|
||||
return nil
|
||||
}
|
||||
r, err := a.store.Get(ctx, w.Ask)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r == nil {
|
||||
a.logf("REFUSED a warrant for the ask %s, which the controller does not hold", w.Ask)
|
||||
return nil
|
||||
}
|
||||
if r.Acted != "" {
|
||||
return nil // heard again: acted on once
|
||||
}
|
||||
now := a.now()
|
||||
if w.Outcome != asks.OutcomeChosen {
|
||||
acted := "nothing: the ask " + string(w.Outcome)
|
||||
if w.Words != "" {
|
||||
acted += ": " + w.Words
|
||||
}
|
||||
if _, err := a.store.Change(ctx, r.ID, func(x *asked) bool {
|
||||
if x.Acted != "" {
|
||||
return false
|
||||
}
|
||||
x.State, x.Ended, x.Warrant, x.Acted = string(w.Outcome), now, &w, acted
|
||||
return true
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
a.logf("the ask %s about %s ended %s; nothing is done", r.ID, r.Condition, w.Outcome)
|
||||
return nil
|
||||
}
|
||||
if r.State != askOpen {
|
||||
// Cancelled, replaced or expired in the controller's own record: no answer to it is acted on.
|
||||
a.logf("REFUSED a warrant for the ask %s, which is %s in the controller's own record", r.ID, r.State)
|
||||
return nil
|
||||
}
|
||||
option, err := w.For(askerName, r.Ask)
|
||||
if err != nil {
|
||||
a.logf("REFUSED a warrant for the ask %s: %v", r.ID, err)
|
||||
return nil
|
||||
}
|
||||
index, offered := r.Options[option.ID]
|
||||
if !offered || index >= len(r.Actions) {
|
||||
a.logf("REFUSED a warrant for the ask %s: it chose %s, which no action stands for", r.ID, option.ID)
|
||||
return nil
|
||||
}
|
||||
act := r.Actions[index]
|
||||
// The act about to be performed is the one the option bound when the controller asked: a record changed
|
||||
// since is refused, never performed.
|
||||
if err := option.Performs(boundAct(act)); err != nil {
|
||||
a.logf("REFUSED a warrant for the ask %s: %v", r.ID, err)
|
||||
return nil
|
||||
}
|
||||
open, err := a.open(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stillOpen := r.Rehearsal || r.Proposal != nil // a rehearsal and a proposal are about no condition
|
||||
for _, c := range open {
|
||||
stillOpen = stillOpen || c.Key == r.Condition
|
||||
}
|
||||
if !stillOpen {
|
||||
// The asker checks the state is still what it asked about before it acts (to-be 46 §10, step 7).
|
||||
if _, err := a.store.Change(ctx, r.ID, func(x *asked) bool {
|
||||
if x.State != askOpen || x.Acted != "" {
|
||||
return false
|
||||
}
|
||||
x.State, x.Warrant, x.Ended, x.Acted = string(asks.OutcomeChosen), &w, now,
|
||||
"nothing: the condition ended before the answer"
|
||||
return true
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
a.logf("%s, for %s, which ended meanwhile: nothing is done", w.Says(), r.Condition)
|
||||
return nil
|
||||
}
|
||||
// Claimed before acting, by compare-and-set: only the delivery whose write stands acts (security review
|
||||
// of 2026-10-08, finding 9). Not by the warrant's message id, which another publisher could take first:
|
||||
// the controller's own record decides.
|
||||
claimed, err := a.store.Change(ctx, r.ID, func(x *asked) bool {
|
||||
if x.State != askOpen || x.Acted != "" {
|
||||
return false
|
||||
}
|
||||
x.State, x.Warrant, x.Acted = string(asks.OutcomeChosen), &w, "acting"
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !claimed {
|
||||
a.logf("the warrant for the ask %s was already taken by another delivery; nothing more is done", r.ID)
|
||||
return nil
|
||||
}
|
||||
r.Acted = "acting"
|
||||
|
||||
why := fmt.Sprintf("%s (ask %s)", w.Says(), r.ID)
|
||||
args := map[string]string{}
|
||||
for k, v := range act.Arguments {
|
||||
args[k] = v
|
||||
}
|
||||
if v, takes := args["why"]; takes && v == "" {
|
||||
args["why"] = why
|
||||
}
|
||||
var acted error
|
||||
outcome := "done"
|
||||
switch {
|
||||
case r.Rehearsal && act.Verb == rehearsalVerb:
|
||||
// A rehearsal's answer performs nothing: it is recorded below as the operator's decision.
|
||||
case r.Proposal != nil && act.Verb == proposalVerb:
|
||||
// A proposed settings layer, set by this controller itself on Approve (novox/hq ADR 0277): the act's
|
||||
// digest of the values is held to the record's own values before anything is set.
|
||||
outcome, acted = a.decideProposal(ctx, *r, act, w)
|
||||
if acted == nil && outcome != "" && !strings.HasPrefix(outcome, "nothing") {
|
||||
outcome = "done: " + outcome
|
||||
}
|
||||
case act.Arguments["silence"] != "":
|
||||
acted = a.silence(ctx, act.Arguments["silence"], conditions.MaxSilence, byWords(w), why)
|
||||
default:
|
||||
acted = a.call(ctx, act, args)
|
||||
}
|
||||
ended := a.now()
|
||||
if acted != nil {
|
||||
outcome = "failed: " + acted.Error()
|
||||
}
|
||||
r.Ended, r.Acted = ended, outcome
|
||||
if _, err := a.store.Change(ctx, r.ID, func(x *asked) bool {
|
||||
if x.Acted != "acting" {
|
||||
return false
|
||||
}
|
||||
x.Ended, x.Acted = ended, outcome
|
||||
return true
|
||||
}); err != nil {
|
||||
a.logf("%s was acted on (%s), and how it ended could NOT be kept: %v", r.ID, outcome, err)
|
||||
}
|
||||
verbArgs := []string{act.Verb}
|
||||
if act.Machine != "" {
|
||||
verbArgs = append(verbArgs, "on "+act.Machine)
|
||||
}
|
||||
keys := make([]string, 0, len(args))
|
||||
for k := range args {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if k != "why" {
|
||||
verbArgs = append(verbArgs, k+"="+args[k])
|
||||
}
|
||||
}
|
||||
if err := a.record(ctx, link.HandAct{Verb: handActWarrant, Args: verbArgs, Why: why, By: byWords(w),
|
||||
Cause: conditions.CauseOperatorAnswer, Condition: r.Condition, Via: viaWords(w), Ask: r.ID,
|
||||
Proofs: w.Proofs, RequestedBy: r.Condition, Outcome: r.Acted}); err != nil {
|
||||
a.logf("%s was done, and could NOT be recorded in the hand-act log: %v", why, err)
|
||||
}
|
||||
a.logf("%s: %s", why, r.Acted)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handActWarrant is the verb an act the operator chose on a warrant is recorded under: a person's decision,
|
||||
// never a repair (handActVerbs).
|
||||
const handActWarrant = "warrant"
|
||||
|
||||
// byWords is who chose, as the hand-act log says it: "the operator, as telegram identity 42".
|
||||
func byWords(w asks.Warrant) string {
|
||||
if w.By == nil {
|
||||
return "the operator"
|
||||
}
|
||||
return fmt.Sprintf("the %s, as %s identity %s", w.By.Who, w.By.Kind, w.By.Identity)
|
||||
}
|
||||
|
||||
// viaWords is the channel an answer came through: its module and kind, and how the sender was known.
|
||||
func viaWords(w asks.Warrant) string {
|
||||
if w.By == nil {
|
||||
return w.Channel
|
||||
}
|
||||
via := w.Channel + " (" + w.By.Kind + ")"
|
||||
if w.By.Verified != "" {
|
||||
via += ", " + w.By.Verified
|
||||
}
|
||||
return via
|
||||
}
|
||||
|
||||
// errNotGranted is an action whose verb the controller's grant does not name.
|
||||
var errNotGranted = errors.New("the controller's grant does not name this verb")
|
||||
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
)
|
||||
|
||||
// busAsker is an asker on a real bus's `asked` bucket, counting what it performs: two of them are two
|
||||
// controllers sharing one record.
|
||||
type busAskerRig struct {
|
||||
mu sync.Mutex
|
||||
called int
|
||||
acts int
|
||||
open []conditions.Condition
|
||||
sent [][]byte
|
||||
}
|
||||
|
||||
func (rig *busAskerRig) asker(t *testing.T, conn *nats.Conn, now time.Time) *asker {
|
||||
return &asker{
|
||||
open: func(context.Context) ([]conditions.Condition, error) {
|
||||
rig.mu.Lock()
|
||||
defer rig.mu.Unlock()
|
||||
return rig.open, nil
|
||||
},
|
||||
silence: func(context.Context, string, time.Duration, string, string) error { return nil },
|
||||
store: busAsked{conn: conn},
|
||||
publish: func(_ context.Context, subject string, body []byte, _ string) error {
|
||||
rig.mu.Lock()
|
||||
defer rig.mu.Unlock()
|
||||
if subject == asks.AskSubject(askerName) {
|
||||
rig.sent = append(rig.sent, body)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
call: func(context.Context, conditions.Action, map[string]string) error {
|
||||
time.Sleep(20 * time.Millisecond) // long enough for the other delivery to arrive meanwhile
|
||||
rig.mu.Lock()
|
||||
defer rig.mu.Unlock()
|
||||
rig.called++
|
||||
return nil
|
||||
},
|
||||
record: func(context.Context, link.HandAct) error {
|
||||
rig.mu.Lock()
|
||||
defer rig.mu.Unlock()
|
||||
rig.acts++
|
||||
return nil
|
||||
},
|
||||
now: func() time.Time { return now },
|
||||
logf: t.Logf,
|
||||
}
|
||||
}
|
||||
|
||||
func askedBus(t *testing.T) *nats.Conn {
|
||||
t.Helper()
|
||||
conn, err := nats.Connect(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(conn.Close)
|
||||
js, err := jetstream.New(conn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: broker.AskedBucket}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return conn
|
||||
}
|
||||
|
||||
// The review of 2026-10-09 (L7): two deliveries of one warrant, to two controllers at once, perform its act
|
||||
// exactly once and record it once — the record's compare-and-set decides, never the warrant's message id.
|
||||
func TestTwoAnswersAtOnceActOnce(t *testing.T) {
|
||||
conn := askedBus(t)
|
||||
now := time.Date(2026, 10, 9, 14, 0, 0, 0, time.UTC)
|
||||
rig := &busAskerRig{open: []conditions.Condition{heldCondition()}}
|
||||
first, second := rig.asker(t, conn, now), rig.asker(t, conn, now)
|
||||
if err := first.reconcile(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rig.sent) != 1 {
|
||||
t.Fatalf("asked %d times", len(rig.sent))
|
||||
}
|
||||
var q asks.Ask
|
||||
_ = json.Unmarshal(rig.sent[0], &q)
|
||||
release, _ := q.Option("release")
|
||||
w := asks.Warrant{Ask: q.ID, Asker: askerName, About: q.About, Outcome: asks.OutcomeChosen, Option: release.ID,
|
||||
Label: release.Label, Level: release.Level, Channel: "telegram", Proofs: []string{"P1"}, At: now,
|
||||
AskDigest: q.Digest(), By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}
|
||||
body, _ := json.Marshal(w)
|
||||
var wg sync.WaitGroup
|
||||
for _, a := range []*asker{first, second, first, second} {
|
||||
wg.Add(1)
|
||||
go func(a *asker) {
|
||||
defer wg.Done()
|
||||
if err := a.Decided(context.Background(), body); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}(a)
|
||||
}
|
||||
wg.Wait()
|
||||
if rig.called != 1 || rig.acts != 1 {
|
||||
t.Fatalf("performed %d time(s), recorded %d time(s)", rig.called, rig.acts)
|
||||
}
|
||||
got, err := busAsked{conn: conn}.Get(context.Background(), q.ID)
|
||||
if err != nil || got == nil || got.Acted != "done" {
|
||||
t.Fatalf("kept as %+v (%v)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The review of 2026-10-09 (L2): a write decided on a record read earlier never lands over one made since. A
|
||||
// cancel read before the answer was acted on leaves the act's record as it is.
|
||||
func TestAStaleCancelDoesNotWriteOverAnAct(t *testing.T) {
|
||||
conn := askedBus(t)
|
||||
now := time.Date(2026, 10, 9, 14, 0, 0, 0, time.UTC)
|
||||
rig := &busAskerRig{open: []conditions.Condition{heldCondition()}}
|
||||
a := rig.asker(t, conn, now)
|
||||
if err := a.reconcile(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var q asks.Ask
|
||||
_ = json.Unmarshal(rig.sent[0], &q)
|
||||
stale, _ := busAsked{conn: conn}.Get(context.Background(), q.ID)
|
||||
release, _ := q.Option("release")
|
||||
w := asks.Warrant{Ask: q.ID, Asker: askerName, About: q.About, Outcome: asks.OutcomeChosen, Option: release.ID,
|
||||
Label: release.Label, Level: release.Level, Channel: "telegram", At: now, AskDigest: q.Digest(),
|
||||
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}
|
||||
body, _ := json.Marshal(w)
|
||||
if err := a.Decided(context.Background(), body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.cancel(context.Background(), *stale, "the condition ended"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := busAsked{conn: conn}.Get(context.Background(), q.ID)
|
||||
if got.State != string(asks.OutcomeChosen) || got.Acted != "done" {
|
||||
t.Errorf("a stale cancel wrote over the act: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0259 §6: the controller asks the operator for the answers its conditions name, and performs
|
||||
// the one chosen on the router's warrant — once, for its own ask, the option offered, at its level.
|
||||
|
||||
type memAskedStore map[string]asked
|
||||
|
||||
// memAskedMu guards every memAskedStore: Change is a compare-and-set as the bus's is.
|
||||
var memAskedMu sync.Mutex
|
||||
|
||||
func (m memAskedStore) Get(_ context.Context, id string) (*asked, error) {
|
||||
memAskedMu.Lock()
|
||||
defer memAskedMu.Unlock()
|
||||
r, ok := m[id]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
func (m memAskedStore) Create(_ context.Context, r asked) error {
|
||||
memAskedMu.Lock()
|
||||
defer memAskedMu.Unlock()
|
||||
if _, kept := m[r.ID]; kept {
|
||||
return errors.New("an ask is kept under that id")
|
||||
}
|
||||
m[r.ID] = r
|
||||
return nil
|
||||
}
|
||||
func (m memAskedStore) Change(_ context.Context, id string, change func(*asked) bool) (bool, error) {
|
||||
memAskedMu.Lock()
|
||||
defer memAskedMu.Unlock()
|
||||
r, ok := m[id]
|
||||
if !ok || !change(&r) {
|
||||
return false, nil
|
||||
}
|
||||
m[id] = r
|
||||
return true, nil
|
||||
}
|
||||
func (m memAskedStore) All(context.Context) ([]asked, error) {
|
||||
memAskedMu.Lock()
|
||||
defer memAskedMu.Unlock()
|
||||
var out []asked
|
||||
for _, r := range m {
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type published struct {
|
||||
subject, id string
|
||||
body []byte
|
||||
}
|
||||
|
||||
type askerRig struct {
|
||||
a *asker
|
||||
open []conditions.Condition
|
||||
store memAskedStore
|
||||
sent []published
|
||||
called []string
|
||||
silenced []string
|
||||
acts []link.HandAct
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func newAskerRig(t *testing.T) *askerRig {
|
||||
r := &askerRig{store: memAskedStore{}, now: time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC)}
|
||||
r.a = &asker{
|
||||
open: func(context.Context) ([]conditions.Condition, error) { return r.open, nil },
|
||||
silence: func(_ context.Context, key string, d time.Duration, by, why string) error {
|
||||
r.silenced = append(r.silenced, key+" for "+d.String()+" by "+by+" because "+why)
|
||||
// As the controller's conditions do (the confirmation review of 2026-10-09, M1): the condition is
|
||||
// silenced from now on, so what is asked next sees it silenced.
|
||||
for i := range r.open {
|
||||
if r.open[i].Key == key {
|
||||
r.open[i].Silenced = &conditions.Silence{Until: r.now.Add(d), By: by, Why: why, Since: r.now}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
store: r.store,
|
||||
publish: func(_ context.Context, subject string, body []byte, id string) error {
|
||||
r.sent = append(r.sent, published{subject, id, body})
|
||||
return nil
|
||||
},
|
||||
call: func(_ context.Context, a conditions.Action, args map[string]string) error {
|
||||
raw, _ := json.Marshal(args)
|
||||
r.called = append(r.called, a.Verb+"@"+a.Machine+" "+string(raw))
|
||||
return nil
|
||||
},
|
||||
record: func(_ context.Context, act link.HandAct) error { r.acts = append(r.acts, act); return nil },
|
||||
now: func() time.Time { return r.now },
|
||||
logf: t.Logf,
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func heldCondition() conditions.Condition {
|
||||
o := stalledObservations([]stalledLine{{ID: "novox/hq@055550802096", State: "held", For: "36h2m6s",
|
||||
Bound: "24h0m0s", H2: "none: the state is the operator's"}})[0]
|
||||
return conditions.Condition{Key: o.Key(), Kind: o.Kind, Severity: conditions.Warning, Headline: o.Headline,
|
||||
Explanation: conditions.Verdict(o.Needs, o.Explanation), Needs: o.Needs, Actions: o.Actions}
|
||||
}
|
||||
|
||||
func unitsCondition() conditions.Condition {
|
||||
key := "machine.shanks.units"
|
||||
return conditions.Condition{Key: key, Kind: "machine-units", Severity: conditions.Warning,
|
||||
Headline: "3 failed services on shanks", Explanation: "Needs you: mend or remove them on shanks, or silence this.",
|
||||
Needs: "mend or remove them on shanks, or silence this.", Actions: []conditions.Action{conditions.SilenceAction(key)}}
|
||||
}
|
||||
|
||||
func (r *askerRig) asksSent(t *testing.T) []asks.Ask {
|
||||
t.Helper()
|
||||
var out []asks.Ask
|
||||
for _, p := range r.sent {
|
||||
if p.subject != asks.AskSubject("mesh-controller") {
|
||||
continue
|
||||
}
|
||||
var q asks.Ask
|
||||
if err := json.Unmarshal(p.body, &q); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, q)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAnAskIsMadeForEachConditionThatNamesItsAnswers(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
quiet := conditions.Condition{Key: "machine.ace.silent", Headline: "ace silent", Explanation: "Nothing for you to do. x"}
|
||||
r.open = []conditions.Condition{heldCondition(), unitsCondition(), quiet}
|
||||
if err := r.a.reconcile(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent := r.asksSent(t)
|
||||
if len(sent) != 2 {
|
||||
t.Fatalf("asked %d times: %+v", len(sent), sent)
|
||||
}
|
||||
byAbout := map[string]asks.Ask{}
|
||||
for _, q := range sent {
|
||||
byAbout[q.About] = q
|
||||
if err := q.Check(r.now); err != nil {
|
||||
t.Errorf("%s: %v", q.About, err)
|
||||
}
|
||||
}
|
||||
held := byAbout[heldCondition().Key]
|
||||
if len(held.Options) != 2 || held.Options[0].Label != "Release" || held.Options[0].Level != asks.Approve ||
|
||||
held.Options[1].ID != "stop" || held.Expires != r.now.Add(askApproveFor) || held.Who != asks.Operator ||
|
||||
held.OnExpiry == "" {
|
||||
t.Errorf("the held delivery is asked %+v", held)
|
||||
}
|
||||
units := byAbout["machine.shanks.units"]
|
||||
if len(units.Options) != 1 || units.Options[0].Level != asks.Acknowledge || units.Expires != r.now.Add(askAcknowledgeFor) {
|
||||
t.Errorf("the failed units are asked %+v", units)
|
||||
}
|
||||
// No second ask while one is open.
|
||||
r.now = r.now.Add(time.Minute)
|
||||
_ = r.a.reconcile(context.Background())
|
||||
if n := len(r.asksSent(t)); n != 2 {
|
||||
t.Errorf("asked again while open: %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAskIsTakenBackWhenItsConditionEndsAndAskedAgainAfterItExpires(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
r.open = []conditions.Condition{heldCondition(), unitsCondition()}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
// The units are silenced, the held delivery lasts past its ask's day.
|
||||
units := unitsCondition()
|
||||
units.Silenced = &conditions.Silence{Until: r.now.Add(48 * time.Hour)}
|
||||
r.open = []conditions.Condition{heldCondition(), units}
|
||||
r.now = r.now.Add(askApproveFor)
|
||||
if err := r.a.reconcile(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cancels int
|
||||
for _, p := range r.sent {
|
||||
if p.subject == asks.CancelSubject("mesh-controller") {
|
||||
cancels++
|
||||
}
|
||||
}
|
||||
if cancels != 1 {
|
||||
t.Errorf("cancels %d, want the silenced one's", cancels)
|
||||
}
|
||||
if sent := r.asksSent(t); len(sent) != 3 || sent[2].About != heldCondition().Key {
|
||||
t.Errorf("the expired ask was not asked again: %+v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
// warrantFor is the router's warrant for the open ask about a condition, choosing an option by label.
|
||||
func (r *askerRig) warrantFor(t *testing.T, condition, label string) asks.Warrant {
|
||||
t.Helper()
|
||||
for _, a := range r.store {
|
||||
if a.Condition != condition || a.State != askOpen {
|
||||
continue
|
||||
}
|
||||
for _, o := range a.Ask.Options {
|
||||
if o.Label == label {
|
||||
return asks.Warrant{Ask: a.ID, Asker: "mesh-controller", About: condition, Outcome: asks.OutcomeChosen,
|
||||
Option: o.ID, Label: o.Label, Level: o.Level, Channel: "telegram", Proofs: []string{"P1"}, At: r.now,
|
||||
AskDigest: a.Ask.Digest(),
|
||||
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("no open ask about %s offers %s", condition, label)
|
||||
return asks.Warrant{}
|
||||
}
|
||||
|
||||
func answerWith(t *testing.T, r *askerRig, w asks.Warrant) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(w)
|
||||
if err := r.a.Decided(context.Background(), body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWarrantIsActedOnOnce(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
r.open = []conditions.Condition{heldCondition()}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
w := r.warrantFor(t, heldCondition().Key, "Release")
|
||||
answerWith(t, r, w)
|
||||
answerWith(t, r, w) // heard again
|
||||
if len(r.called) != 1 {
|
||||
t.Fatalf("called %v", r.called)
|
||||
}
|
||||
want := `mesh-delivery.release@ {"id":"novox/hq@055550802096","why":"the operator, via telegram (user id verified), chose Release (ask ` + w.Ask + `)"}`
|
||||
if r.called[0] != want {
|
||||
t.Errorf("called\n %s\nwant\n %s", r.called[0], want)
|
||||
}
|
||||
if len(r.acts) != 1 {
|
||||
t.Fatalf("hand-acts %+v", r.acts)
|
||||
}
|
||||
act := r.acts[0]
|
||||
if act.Verb != handActWarrant || act.By != "the operator, as telegram identity 42" ||
|
||||
act.Via != "telegram (telegram), user id verified" || act.Ask != w.Ask || strings.Join(act.Proofs, ",") != "P1" ||
|
||||
act.Cause != conditions.CauseOperatorAnswer || act.Condition != heldCondition().Key || act.Outcome != "done" {
|
||||
t.Errorf("the hand-act %+v", act)
|
||||
}
|
||||
if !personsDecision(act) {
|
||||
t.Error("an act on a warrant counts as a repair")
|
||||
}
|
||||
if got := r.store[w.Ask]; got.State != string(asks.OutcomeChosen) || got.Acted != "done" {
|
||||
t.Errorf("kept %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWarrantThatIsNotForItsOwnAskIsRefused(t *testing.T) {
|
||||
for name, change := range map[string]func(*asks.Warrant){
|
||||
"another asker": func(w *asks.Warrant) { w.Asker = "mesh-delivery" },
|
||||
"an ask not held": func(w *asks.Warrant) { w.Ask = "c0000000000000000" },
|
||||
"an option not offered": func(w *asks.Warrant) { w.Option = "delete" },
|
||||
"another level": func(w *asks.Warrant) { w.Level = asks.Acknowledge },
|
||||
"no person": func(w *asks.Warrant) { w.By = nil },
|
||||
"another ask's digest": func(w *asks.Warrant) { w.AskDigest = "sha256:0000" },
|
||||
"no ask's digest": func(w *asks.Warrant) { w.AskDigest = "" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
r.open = []conditions.Condition{heldCondition()}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
w := r.warrantFor(t, heldCondition().Key, "Stop")
|
||||
change(&w)
|
||||
answerWith(t, r, w)
|
||||
if len(r.called)+len(r.acts)+len(r.silenced) != 0 {
|
||||
t.Errorf("acted on it: %v %v %v", r.called, r.acts, r.silenced)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachAnswerCallsExactlyItsVerb(t *testing.T) {
|
||||
plan := "plan-1791454185265004861"
|
||||
waiting := conditions.Condition{Key: "plan." + plan + ".waiting", Severity: conditions.Urgent,
|
||||
Headline: "openrazer delivery waiting to start", Needs: "start it, or stop it.",
|
||||
Explanation: "Needs you: start it, or stop it.", Actions: waitingActions(plan, conditions.Urgent)}
|
||||
module := conditions.Condition{Key: "module.openrazer.g14.unhealthy", Severity: conditions.Warning,
|
||||
Headline: "openrazer not working on g14", Needs: "restart its service openrazer-daemon on g14.",
|
||||
Explanation: "Needs you: restart it.", Actions: []conditions.Action{{Label: "Restart",
|
||||
Verb: "node-service-manager.restart", Machine: "g14", Level: conditions.LevelApprove,
|
||||
Arguments: map[string]string{"unit": "openrazer-daemon.service", "scope": "user"}}}}
|
||||
for _, tc := range []struct {
|
||||
c conditions.Condition
|
||||
label string
|
||||
want string
|
||||
}{
|
||||
{waiting, "Start", `mesh-controller.plans@ {"cause":"operator-answer","go":"` + plan + `","why":"`},
|
||||
{waiting, "Stop", `mesh-controller.plans@ {"cause":"operator-answer","stop":"` + plan + `","why":"`},
|
||||
{module, "Restart", `node-service-manager.restart@g14 {"scope":"user","unit":"openrazer-daemon.service"}`},
|
||||
} {
|
||||
r := newAskerRig(t)
|
||||
r.open = []conditions.Condition{tc.c}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
answerWith(t, r, r.warrantFor(t, tc.c.Key, tc.label))
|
||||
if len(r.called) != 1 || !strings.HasPrefix(r.called[0], tc.want) {
|
||||
t.Errorf("%s: called %v, want %s…", tc.label, r.called, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestASilenceChosenIsTheControllersOwnAndAnAnswerToAnAsk(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
r.open = []conditions.Condition{unitsCondition()}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
w := r.warrantFor(t, "machine.shanks.units", "Silence for a week")
|
||||
w.Level, w.Proofs = asks.Acknowledge, nil
|
||||
w.By = &asks.Person{Who: asks.Operator, Kind: "desktop", Identity: "g14",
|
||||
Verified: "a desk click: whoever was at the operator's session on g14"}
|
||||
w.Channel = "desk-channel"
|
||||
answerWith(t, r, w)
|
||||
if len(r.called) != 0 || len(r.silenced) != 1 || !strings.HasPrefix(r.silenced[0], "machine.shanks.units for 168h0m0s by the operator, as desktop identity g14") {
|
||||
t.Fatalf("silenced %v, called %v", r.silenced, r.called)
|
||||
}
|
||||
if len(r.acts) != 1 || r.acts[0].Cause != conditions.CauseOperatorAnswer || len(r.acts[0].Proofs) != 0 {
|
||||
t.Errorf("%+v", r.acts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAskThatEndedWithoutAChoiceDoesNothing(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
r.open = []conditions.Condition{heldCondition()}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
w := r.warrantFor(t, heldCondition().Key, "Release")
|
||||
w.Outcome, w.Option, w.Label, w.Level, w.By, w.Words = asks.OutcomeExpired, "", "", "", nil, "nobody answered in time"
|
||||
answerWith(t, r, w)
|
||||
if len(r.called)+len(r.acts) != 0 || r.store[w.Ask].State != string(asks.OutcomeExpired) ||
|
||||
!strings.HasPrefix(r.store[w.Ask].Acted, "nothing") {
|
||||
t.Errorf("called %v acts %v kept %+v", r.called, r.acts, r.store[w.Ask])
|
||||
}
|
||||
// And a choice for a condition that ended meanwhile does nothing either.
|
||||
r2 := newAskerRig(t)
|
||||
r2.open = []conditions.Condition{heldCondition()}
|
||||
_ = r2.a.reconcile(context.Background())
|
||||
w2 := r2.warrantFor(t, heldCondition().Key, "Release")
|
||||
r2.open = nil
|
||||
answerWith(t, r2, w2)
|
||||
if len(r2.called) != 0 || r2.store[w2.Ask].Acted != "nothing: the condition ended before the answer" {
|
||||
t.Errorf("%v %+v", r2.called, r2.store[w2.Ask])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWarrantMissedWhileAwayIsReadFromTheRoutersRecord(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
r.open = []conditions.Condition{heldCondition()}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
w := r.warrantFor(t, heldCondition().Key, "Stop")
|
||||
r.a.routerRecord = func(_ context.Context, id string) (*asks.Warrant, error) {
|
||||
if id != w.Ask {
|
||||
return nil, errors.New("another ask")
|
||||
}
|
||||
return &w, nil
|
||||
}
|
||||
r.now = r.now.Add(askCatchUpAfter)
|
||||
if err := r.a.reconcile(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(r.called) != 1 || !strings.HasPrefix(r.called[0], "mesh-delivery.stop@") {
|
||||
t.Errorf("called %v", r.called)
|
||||
}
|
||||
if n := len(r.asksSent(t)); n != 1 {
|
||||
t.Errorf("asked again after the answer: %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// After review (2026-10-08): a refused ask is not asked again at once; issue 369: nor is the refusal believed
|
||||
// for ever — it is asked again after a wait that doubles with each refusal in a row, and at once when the
|
||||
// channels change.
|
||||
func TestAnAskTheRouterRefusedIsAskedAgainAfterAGrowingWait(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
channels := "channel/telegram=telegram@anchor[choice]own:true"
|
||||
r.a.channels = func(context.Context) string { return channels }
|
||||
r.open = []conditions.Condition{heldCondition()}
|
||||
refuse := func() {
|
||||
t.Helper()
|
||||
sent := r.asksSent(t)
|
||||
refusal, _ := json.Marshal(asks.Warrant{Ask: sent[len(sent)-1].ID, Asker: "mesh-controller",
|
||||
Outcome: asks.OutcomeRefused, Words: "no channel can carry any of its answers now", At: r.now})
|
||||
if err := r.a.Decided(context.Background(), refusal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
first := r.asksSent(t)[0]
|
||||
refuse()
|
||||
if got := r.store[first.ID]; got.State != string(asks.OutcomeRefused) || !strings.Contains(got.Acted, "nothing") {
|
||||
t.Fatalf("the refusal was kept as %+v", got)
|
||||
}
|
||||
// Each wait: nothing asked before it ends, asked once when it has.
|
||||
for i, wait := range []time.Duration{time.Minute, 2 * time.Minute, 4 * time.Minute, 8 * time.Minute,
|
||||
16 * time.Minute, 30 * time.Minute, 30 * time.Minute} {
|
||||
before := len(r.asksSent(t))
|
||||
r.now = r.now.Add(wait - 10*time.Second)
|
||||
_ = r.a.reconcile(context.Background())
|
||||
if n := len(r.asksSent(t)); n != before {
|
||||
t.Fatalf("refusal %d: asked again before %s", i+1, wait)
|
||||
}
|
||||
r.now = r.now.Add(10 * time.Second)
|
||||
_ = r.a.reconcile(context.Background())
|
||||
if n := len(r.asksSent(t)); n != before+1 {
|
||||
t.Fatalf("refusal %d: not asked again after %s (%d asks)", i+1, wait, n)
|
||||
}
|
||||
refuse()
|
||||
}
|
||||
before := len(r.asksSent(t))
|
||||
channels = "channel/telegram=telegram@anchor[choice,verified-sender]own:true"
|
||||
_ = r.a.reconcile(context.Background())
|
||||
if n := len(r.asksSent(t)); n != before+1 {
|
||||
t.Errorf("not asked again once the channels changed: %d", n-before)
|
||||
}
|
||||
}
|
||||
|
||||
// After review: at most three asks open at once, the most urgent first, then the oldest.
|
||||
func TestAtMostThreeAsksAreOpenTheMostUrgentFirst(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
var open []conditions.Condition
|
||||
for i := 0; i < 4; i++ {
|
||||
c := unitsCondition()
|
||||
c.Key = "machine.m" + string(rune('a'+i)) + ".units"
|
||||
c.Actions = []conditions.Action{conditions.SilenceAction(c.Key)}
|
||||
c.Raised = r.now.Add(-time.Duration(10-i) * time.Hour)
|
||||
open = append(open, c)
|
||||
}
|
||||
urgent := heldCondition()
|
||||
urgent.Severity, urgent.Raised = conditions.Urgent, r.now.Add(-time.Minute)
|
||||
r.open = append(open, urgent)
|
||||
_ = r.a.reconcile(context.Background())
|
||||
sent := r.asksSent(t)
|
||||
if len(sent) != askMostOpen || sent[0].About != urgent.Key || sent[1].About != "machine.ma.units" || sent[2].About != "machine.mb.units" {
|
||||
var about []string
|
||||
for _, q := range sent {
|
||||
about = append(about, q.About)
|
||||
}
|
||||
t.Fatalf("asked %v", about)
|
||||
}
|
||||
}
|
||||
|
||||
// After review: nothing is asked while no router takes asks under the controller's name, and that is said once.
|
||||
func TestNothingIsAskedWithoutARouter(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
var said []string
|
||||
r.a.logf = func(f string, a ...any) { said = append(said, f) }
|
||||
r.a.routerHere = func(context.Context) (bool, error) { return false, nil }
|
||||
r.open = []conditions.Condition{heldCondition()}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
_ = r.a.reconcile(context.Background())
|
||||
if len(r.asksSent(t)) != 0 {
|
||||
t.Error("asked with no router")
|
||||
}
|
||||
n := 0
|
||||
for _, s := range said {
|
||||
if strings.Contains(s, "no router takes asks") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("said %d times", n)
|
||||
}
|
||||
}
|
||||
|
||||
// After review: the condition's words keep where an answer is given without a channel; the ask's text does not.
|
||||
func TestTheAskDropsWhereItIsAnsweredAndTheConditionKeepsIt(t *testing.T) {
|
||||
c := heldCondition()
|
||||
if !strings.Contains(c.Explanation, FromMeshMCPServer) {
|
||||
t.Fatalf("the condition lost where it is answered: %q", c.Explanation)
|
||||
}
|
||||
q, _ := askOf("x", c, partsOf(c)[0], time.Now())
|
||||
if strings.Contains(q.Explanation, "mesh MCP server") || !strings.HasPrefix(q.Explanation, "Needs you: release it, or stop it.") {
|
||||
t.Errorf("the ask says %q", q.Explanation)
|
||||
}
|
||||
if askApproveFor >= 24*time.Hour {
|
||||
t.Errorf("an approving ask lasts %s, which the SDK may refuse at its bound", askApproveFor)
|
||||
}
|
||||
}
|
||||
|
||||
// After review (security finding 9): a warrant is acted on only for an ask open in the controller's own record,
|
||||
// once the claim stands, and never when given after the ask expired.
|
||||
func TestAWarrantIsActedOnlyForAnOpenAskItClaimsBeforeItExpired(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
r.open = []conditions.Condition{heldCondition()}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
w := r.warrantFor(t, heldCondition().Key, "Release")
|
||||
late := w
|
||||
late.At = r.store[w.Ask].Ask.Expires.Add(time.Minute)
|
||||
body, _ := json.Marshal(late)
|
||||
_ = r.a.Decided(context.Background(), body)
|
||||
if len(r.called) != 0 {
|
||||
t.Fatalf("acted on a warrant given after the ask expired: %v", r.called)
|
||||
}
|
||||
// Claimed already by another delivery: nothing done here.
|
||||
kept := r.store[w.Ask]
|
||||
kept.Acted = "acting"
|
||||
r.store[w.Ask] = kept
|
||||
body, _ = json.Marshal(w)
|
||||
_ = r.a.Decided(context.Background(), body)
|
||||
if len(r.called) != 0 {
|
||||
t.Fatalf("acted though the claim was another's: %v", r.called)
|
||||
}
|
||||
// Cancelled in its own record: refused.
|
||||
kept.Acted, kept.State = "", askCancelled
|
||||
r.store[w.Ask] = kept
|
||||
_ = r.a.Decided(context.Background(), body)
|
||||
if len(r.called) != 0 {
|
||||
t.Errorf("acted on a cancelled ask: %v", r.called)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0259 §6: a warrant authorises the act its option bound when the controller asked, and no
|
||||
// other. A record of the act changed after the ask — another delivery, another machine, another argument —
|
||||
// is refused and nothing is performed.
|
||||
func TestAWarrantPerformsOnlyTheActItsOptionBound(t *testing.T) {
|
||||
for name, change := range map[string]func(*conditions.Action){
|
||||
"another argument": func(a *conditions.Action) {
|
||||
a.Arguments = map[string]string{"id": "novox/mesh-controller@000000000000"}
|
||||
},
|
||||
"another verb": func(a *conditions.Action) { a.Verb = "mesh-delivery.stop" },
|
||||
"another machine": func(a *conditions.Action) { a.Machine = "anchor" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
r.open = []conditions.Condition{heldCondition()}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
w := r.warrantFor(t, heldCondition().Key, "Release")
|
||||
kept := r.store[w.Ask]
|
||||
acts := append([]conditions.Action(nil), kept.Actions...)
|
||||
i := kept.Options[w.Option]
|
||||
change(&acts[i])
|
||||
kept.Actions = acts
|
||||
r.store[w.Ask] = kept
|
||||
answerWith(t, r, w)
|
||||
if len(r.called)+len(r.acts) != 0 {
|
||||
t.Errorf("performed an act the option did not bind: %v %v", r.called, r.acts)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Every option of an ask binds its act.
|
||||
q, _ := askOf("x", heldCondition(), partsOf(heldCondition())[0], time.Now())
|
||||
for _, o := range q.Options {
|
||||
if o.Binds == "" {
|
||||
t.Errorf("the option %s binds nothing", o.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Failure is loud (novox/hq ADR 0259, the self-review of 2026-10-09): a condition that needs the operator and
|
||||
// could not be asked on any channel — no router, or the router refused the ask — is a condition of its own,
|
||||
// cleared once it can be asked again.
|
||||
func TestAnAskThatCannotBeDeliveredIsSaid(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
var raised [][]conditions.Observation
|
||||
r.a.raise = func(_ context.Context, obs []conditions.Observation) error {
|
||||
raised = append(raised, obs)
|
||||
return nil
|
||||
}
|
||||
last := func() []conditions.Observation { return raised[len(raised)-1] }
|
||||
routerHere := false
|
||||
r.a.routerHere = func(context.Context) (bool, error) { return routerHere, nil }
|
||||
channels := "channel/telegram=telegram@anchor[choice]own:true"
|
||||
r.a.channels = func(context.Context) string { return channels }
|
||||
r.open = []conditions.Condition{heldCondition()}
|
||||
|
||||
_ = r.a.reconcile(context.Background())
|
||||
if got := last(); len(got) != 1 || got[0].Kind != "asks-undelivered" ||
|
||||
!strings.Contains(got[0].Summary, heldCondition().Key) || !strings.Contains(got[0].Summary, "no router") {
|
||||
t.Fatalf("no router, said as %+v", got)
|
||||
}
|
||||
|
||||
routerHere = true
|
||||
_ = r.a.reconcile(context.Background())
|
||||
if got := last(); len(got) != 0 {
|
||||
t.Fatalf("asked, and still said undelivered: %+v", got)
|
||||
}
|
||||
first := r.asksSent(t)[0]
|
||||
refusal, _ := json.Marshal(asks.Warrant{Ask: first.ID, Asker: "mesh-controller", Outcome: asks.OutcomeRefused,
|
||||
Words: "no channel can carry any of its answers now", At: r.now})
|
||||
if err := r.a.Decided(context.Background(), refusal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
if got := last(); len(got) != 1 || !strings.Contains(got[0].Summary, "no channel can carry") {
|
||||
t.Fatalf("the router's refusal, said as %+v", got)
|
||||
}
|
||||
if why, ok := conditions.PlainWords(conditions.Words{Headline: last()[0].Headline, Explanation: last()[0].Explanation,
|
||||
Needs: last()[0].Needs, Resolved: last()[0].Resolved}, ""); !ok {
|
||||
t.Errorf("not plain: %s", why)
|
||||
}
|
||||
r.open = nil
|
||||
_ = r.a.reconcile(context.Background())
|
||||
if got := last(); len(got) != 0 {
|
||||
t.Errorf("nothing needs asking, and still said: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The review of 2026-10-09 (M1): an acknowledging answer never shares an ask with an authorising one. A
|
||||
// condition offering Restart and Silence is asked twice — Restart alone, about the condition, and Silence
|
||||
// alone, apart — so Silence chosen on a channel that only acknowledges leaves the Restart ask open.
|
||||
func TestAnAcknowledgementNeverSharesAnAskWithAnApproval(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
key := "module.shanks.plex.down"
|
||||
c := conditions.Condition{Key: key, Kind: "module-down", Severity: conditions.Urgent, Headline: "Plex down on shanks",
|
||||
Explanation: "Needs you: restart it, or silence this.", Needs: "restart it, or silence this.",
|
||||
Actions: []conditions.Action{
|
||||
{Label: "Restart", Verb: "node-service-manager.restart", Machine: "shanks", Level: conditions.LevelApprove,
|
||||
Arguments: map[string]string{"unit": "plex"}},
|
||||
conditions.SilenceAction(key)}}
|
||||
r.open = []conditions.Condition{c}
|
||||
if err := r.a.reconcile(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent := r.asksSent(t)
|
||||
if len(sent) != 2 {
|
||||
t.Fatalf("asked %d time(s): %+v", len(sent), sent)
|
||||
}
|
||||
for _, q := range sent {
|
||||
if err := q.Check(r.now); err != nil {
|
||||
t.Errorf("%s: %v", q.About, err)
|
||||
}
|
||||
levels := map[asks.Level]bool{}
|
||||
for _, o := range q.Options {
|
||||
levels[o.Level] = true
|
||||
}
|
||||
if len(levels) != 1 {
|
||||
t.Errorf("the ask about %s mixes levels: %+v", q.About, q.Options)
|
||||
}
|
||||
}
|
||||
byAbout := map[string]asks.Ask{}
|
||||
for _, q := range sent {
|
||||
byAbout[q.About] = q
|
||||
}
|
||||
if q := byAbout[key]; len(q.Options) != 1 || q.Options[0].Label != "Restart" {
|
||||
t.Errorf("the condition's own ask: %+v", q)
|
||||
}
|
||||
if q := byAbout[key+".acknowledge"]; len(q.Options) != 1 || q.Options[0].Level != asks.Acknowledge {
|
||||
t.Errorf("the acknowledging ask: %+v", q)
|
||||
}
|
||||
// Silence chosen: performed, and the Restart ask stays open, never asked twice.
|
||||
answerWith(t, r, r.warrantFor(t, key, "Silence for a week"))
|
||||
if len(r.silenced) != 1 || len(r.called) != 0 {
|
||||
t.Fatalf("silenced %v called %v", r.silenced, r.called)
|
||||
}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
open := 0
|
||||
for _, a := range r.store {
|
||||
if a.State == askOpen && a.Condition == key {
|
||||
open++
|
||||
if a.Part != "" || a.Ask.Options[0].Label != "Restart" {
|
||||
t.Errorf("the open ask is %+v", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
if open != 1 || len(r.asksSent(t)) != 2 {
|
||||
t.Errorf("after the silence: %d open, %d asked", open, len(r.asksSent(t)))
|
||||
}
|
||||
// And the approval still answers: Restart chosen on a channel that proves who answered is performed.
|
||||
answerWith(t, r, r.warrantFor(t, key, "Restart"))
|
||||
if len(r.called) != 1 || !strings.HasPrefix(r.called[0], "node-service-manager.restart@shanks") {
|
||||
t.Errorf("the approval kept through a silence was not performed: %v", r.called)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq issue 353: the controller's grant to ask is composed from the router's assignment and reaches the
|
||||
// bus only when its machine is pushed. Between the two, the bus refuses every ask (measured 2026-10-09, 17:54 to
|
||||
// 17:56 local: seven refusals of mesh.seat.operator-channel.accept.ask.mesh-controller). So nothing is asked
|
||||
// while the bus's user list is behind, it is said once, the conditions that need the operator are raised as
|
||||
// undelivered with what to do, and the asks go out once the bus holds the grant.
|
||||
func TestNothingIsAskedWhileTheBusLacksTheControllersGrant(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
var said []string
|
||||
r.a.logf = func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }
|
||||
var raised [][]conditions.Observation
|
||||
r.a.raise = func(_ context.Context, obs []conditions.Observation) error {
|
||||
raised = append(raised, obs)
|
||||
return nil
|
||||
}
|
||||
held := false
|
||||
r.a.grantHeld = func(context.Context) (bool, string, error) {
|
||||
return held, "the bus's user list on anchor is behind what the mesh composes; `push anchor` carries it", nil
|
||||
}
|
||||
r.open = []conditions.Condition{heldCondition()}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
_ = r.a.reconcile(context.Background())
|
||||
if len(r.asksSent(t)) != 0 {
|
||||
t.Error("asked while the bus lacks the grant")
|
||||
}
|
||||
n := 0
|
||||
for _, s := range said {
|
||||
if strings.Contains(s, "does not hold the controller's grant") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("said %d times: %q", n, said)
|
||||
}
|
||||
if len(raised) == 0 || len(raised[len(raised)-1]) != 1 ||
|
||||
!strings.Contains(raised[len(raised)-1][0].Summary, "`push anchor` carries it") ||
|
||||
raised[len(raised)-1][0].Kind != "asks-undelivered" {
|
||||
t.Fatalf("not said as a condition with what to do: %+v", raised)
|
||||
}
|
||||
held = true
|
||||
_ = r.a.reconcile(context.Background())
|
||||
if sent := r.asksSent(t); len(sent) != 1 || sent[0].About != heldCondition().Key {
|
||||
t.Errorf("not asked once the bus holds the grant: %+v", sent)
|
||||
}
|
||||
if last := raised[len(raised)-1]; len(last) != 0 {
|
||||
t.Errorf("the undelivered condition was not cleared: %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq issue 369: the router refused while its channels had not yet said they could send; they could twenty
|
||||
// minutes later, and "Questions for you not delivered" repeated that refusal for eleven hours, escalating on it.
|
||||
// The ask is made again; while the router's word on it is awaited the condition stands unchanged (neither
|
||||
// cleared nor raised again); once the router took it, the condition clears; a new refusal is said in its own
|
||||
// words.
|
||||
func TestARefusalIsAskedAgainAndTheUndeliveredConditionClearsOnceTaken(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
var raised [][]conditions.Observation
|
||||
r.a.raise = func(_ context.Context, obs []conditions.Observation) error {
|
||||
raised = append(raised, obs)
|
||||
return nil
|
||||
}
|
||||
last := func() []conditions.Observation { return raised[len(raised)-1] }
|
||||
r.a.channels = func(context.Context) string { return "channel/telegram=telegram@anchor[choice]own:true" }
|
||||
r.open = []conditions.Condition{unitsCondition()}
|
||||
refuse := func(words string) {
|
||||
t.Helper()
|
||||
sent := r.asksSent(t)
|
||||
refusal, _ := json.Marshal(asks.Warrant{Ask: sent[len(sent)-1].ID, Asker: "mesh-controller",
|
||||
Outcome: asks.OutcomeRefused, Words: words, At: r.now})
|
||||
if err := r.a.Decided(context.Background(), refusal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
refuse("no channel can carry any of its answers now: telegram: telegram has not said whether it can send")
|
||||
_ = r.a.reconcile(context.Background())
|
||||
if got := last(); len(got) != 1 || !strings.Contains(got[0].Summary, "telegram has not said") {
|
||||
t.Fatalf("the refusal, said as %+v", got)
|
||||
}
|
||||
// The wait ends: asked again; until the router's word on it is in, the condition stands as it was.
|
||||
r.now = r.now.Add(askEvery)
|
||||
_ = r.a.reconcile(context.Background())
|
||||
if n := len(r.asksSent(t)); n != 2 {
|
||||
t.Fatalf("not asked again: %d asks", n)
|
||||
}
|
||||
if got := last(); len(got) != 1 {
|
||||
t.Fatalf("cleared while the router's word on the new ask was awaited: the condition would flap")
|
||||
}
|
||||
r.now = r.now.Add(askVerdictWait / 2)
|
||||
_ = r.a.reconcile(context.Background())
|
||||
if got := last(); len(got) != 1 {
|
||||
t.Fatalf("cleared inside the verdict wait: the condition would flap")
|
||||
}
|
||||
// Refused again, now for a reason of today: said in those words.
|
||||
refuse("no channel can carry any of its answers now: telegram: no account is linked")
|
||||
_ = r.a.reconcile(context.Background())
|
||||
if got := last(); len(got) != 1 || !strings.Contains(got[0].Summary, "no account is linked") ||
|
||||
strings.Contains(got[0].Summary, "has not said") {
|
||||
t.Fatalf("an old refusal's words repeated: %+v", got)
|
||||
}
|
||||
// Asked again after the doubled wait, and taken: no refusal comes, and the condition clears.
|
||||
r.now = r.now.Add(2 * askEvery)
|
||||
_ = r.a.reconcile(context.Background())
|
||||
if n := len(r.asksSent(t)); n != 3 {
|
||||
t.Fatalf("not asked again after the doubled wait: %d asks", n)
|
||||
}
|
||||
r.now = r.now.Add(askVerdictWait)
|
||||
_ = r.a.reconcile(context.Background())
|
||||
if got := last(); len(got) != 0 {
|
||||
t.Fatalf("taken, and still said undelivered: %+v", got)
|
||||
}
|
||||
if n := len(r.asksSent(t)); n != 3 {
|
||||
t.Fatalf("an ask taken was asked again: %d asks", n)
|
||||
}
|
||||
// The operator answers it; the condition stays open (its fix takes a while): the old refusal is not said again.
|
||||
taken := r.asksSent(t)[2]
|
||||
r.store.Change(context.Background(), taken.ID, func(x *asked) bool {
|
||||
x.State, x.Ended = string(asks.OutcomeChosen), r.now
|
||||
return true
|
||||
})
|
||||
for i := 0; i < 5; i++ {
|
||||
r.now = r.now.Add(askEvery)
|
||||
_ = r.a.reconcile(context.Background())
|
||||
if got := last(); len(got) != 0 {
|
||||
t.Fatalf("an answered ask brought its old refusal back: %+v", got)
|
||||
}
|
||||
}
|
||||
// Its explanation opens with one verdict, not two.
|
||||
r.open = append(r.open, heldCondition())
|
||||
_ = r.a.reconcile(context.Background())
|
||||
refuse("no channel can carry any of its answers now")
|
||||
_ = r.a.reconcile(context.Background())
|
||||
if e := conditions.Verdict(last()[0].Needs, last()[0].Explanation); strings.Count(e, "Needs you") != 1 {
|
||||
t.Errorf("the explanation says its verdict twice: %q", e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package main
|
||||
|
||||
// The asker on the bus: its asks in the controller's bucket `asked`, its asks and cancels published on the
|
||||
// seat under the controller's name, the verbs a warrant chooses called with the controller's grant, and the
|
||||
// router's record of its asks read under its name (novox/hq ADR 0259).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// askerFrom is the serving controller's asker; nil in any other process.
|
||||
var askerFrom *asker
|
||||
|
||||
// askWithin is how long a verb a warrant chose is given to answer.
|
||||
const askWithin = time.Minute
|
||||
|
||||
type busAsked struct{ conn *nats.Conn }
|
||||
|
||||
func (b busAsked) kv(ctx context.Context) (jetstream.KeyValue, error) {
|
||||
js, err := jetstream.New(b.conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return js.KeyValue(ctx, broker.AskedBucket)
|
||||
}
|
||||
|
||||
func (b busAsked) Get(ctx context.Context, id string) (*asked, error) {
|
||||
kv, err := b.kv(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e, err := kv.Get(ctx, id)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r asked
|
||||
return &r, json.Unmarshal(e.Value(), &r)
|
||||
}
|
||||
|
||||
// Create keeps a new ask under its id, and only where none is kept: never over another.
|
||||
func (b busAsked) Create(ctx context.Context, r asked) error {
|
||||
kv, err := b.kv(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = kv.Create(ctx, r.ID, body)
|
||||
return err
|
||||
}
|
||||
|
||||
// Change applies change to the ask kept under id by compare-and-set on its key's revision (the review of
|
||||
// 2026-10-09, L2): read, changed, and written only over the revision read; when another write came between,
|
||||
// read again and asked again, at most askChangeTries times. change says whether to write at all.
|
||||
func (b busAsked) Change(ctx context.Context, id string, change func(*asked) bool) (bool, error) {
|
||||
kv, err := b.kv(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for try := 0; try < askChangeTries; try++ {
|
||||
e, err := kv.Get(ctx, id)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var r asked
|
||||
if err := json.Unmarshal(e.Value(), &r); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !change(&r) {
|
||||
return false, nil
|
||||
}
|
||||
body, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := kv.Update(ctx, id, body, e.Revision()); err != nil {
|
||||
var api *jetstream.APIError
|
||||
if errors.Is(err, jetstream.ErrKeyExists) || (errors.As(err, &api) && api.ErrorCode == jetstream.JSErrCodeStreamWrongLastSequence) {
|
||||
continue
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("the ask %s changed under every one of %d tries", id, askChangeTries)
|
||||
}
|
||||
|
||||
func (b busAsked) All(ctx context.Context) ([]asked, error) {
|
||||
kv, err := b.kv(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lister, err := kv.ListKeys(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = lister.Stop() }()
|
||||
var out []asked
|
||||
for k := range lister.Keys() {
|
||||
e, err := kv.Get(ctx, k)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var r asked
|
||||
if json.Unmarshal(e.Value(), &r) == nil {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// callAction performs an action's verb as the controller, through the grant that names it.
|
||||
func callAction(conn *nats.Conn) func(ctx context.Context, a conditions.Action, args map[string]string) error {
|
||||
return func(ctx context.Context, a conditions.Action, args map[string]string) error {
|
||||
seat, verb, ok := strings.Cut(a.Verb, ".")
|
||||
if !ok {
|
||||
return fmt.Errorf("%q names no seat and verb", a.Verb)
|
||||
}
|
||||
body := map[string]any{}
|
||||
for k, v := range args {
|
||||
body[k] = v
|
||||
}
|
||||
if seat == catalogue.DeliverySeat {
|
||||
_, err := askDeliveryOwner(ctx, conn, verb, body)
|
||||
return err
|
||||
}
|
||||
granted := false
|
||||
for _, v := range broker.VerbsTheControllerActsOnAWarrant {
|
||||
granted = granted || (v.Seat == seat && v.Verb == verb)
|
||||
}
|
||||
if !granted {
|
||||
return fmt.Errorf("%s: %w", a.Verb, errNotGranted)
|
||||
}
|
||||
var answer link.Answer
|
||||
var err error
|
||||
if a.Machine != "" {
|
||||
answer, err = link.AskSeatTool(ctx, conn, seat, verb, a.Machine, body, askWithin)
|
||||
} else {
|
||||
answer, err = link.AskMeshSeatTool(ctx, conn, seat, verb, body, askWithin)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if answer.Error != "" {
|
||||
return fmt.Errorf("%s refused: %s", a.Verb, answer.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// routerRecordOf reads the router's record of one of the controller's asks, under its name, and answers
|
||||
// how it ended when it did: the bucket is the one the asks seat's declarer names as its records.
|
||||
func routerRecordOf(conn *nats.Conn, inv *inventory.Inventory) func(ctx context.Context, id string) (*asks.Warrant, error) {
|
||||
return func(ctx context.Context, id string) (*asks.Warrant, error) {
|
||||
bucket, err := asksRecords(ctx, inv)
|
||||
if err != nil || bucket == "" {
|
||||
return nil, err
|
||||
}
|
||||
reply, err := conn.RequestWithContext(ctx, "$JS.API.DIRECT.GET.KV_"+bucket+".$KV."+bucket+"."+askerName+"."+id, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reply.Header.Get("Status") != "" {
|
||||
return nil, nil // none, or not readable: the event says it
|
||||
}
|
||||
var rec struct {
|
||||
State string `json:"state"`
|
||||
Warrant *asks.Warrant `json:"warrant"`
|
||||
}
|
||||
if json.Unmarshal(reply.Data, &rec) != nil || rec.State == "open" || rec.Warrant == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return rec.Warrant, nil
|
||||
}
|
||||
}
|
||||
|
||||
// asksRecords is the bucket the asks seat's declarer keeps its record of asks in.
|
||||
func asksRecords(ctx context.Context, inv *inventory.Inventory) (string, error) {
|
||||
declared, err := inv.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, m := range declared {
|
||||
for _, s := range m.DefinesSeats {
|
||||
if s.Name == broker.AsksSeat && len(s.Records) > 0 {
|
||||
return broker.BucketName(m.Module, s.Records[0]), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// routerHereIn says whether a module declaring the asks seat, with its ask named by its caller, is assigned:
|
||||
// without it nothing takes an ask, and asking would only fill a queue nobody reads.
|
||||
func routerHereIn(inv *inventory.Inventory) func(ctx context.Context) (bool, error) {
|
||||
return func(ctx context.Context) (bool, error) {
|
||||
entries, err := inv.Catalogued(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
for _, s := range e.Manifest.DefinesSeats {
|
||||
if s.Name == broker.AsksSeat && s.NamedByCaller("ask") && len(e.On) > 0 {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// grantHeldIn says whether the bus holds the controller's grant to ask (novox/hq issue 353): the user list the
|
||||
// machine holding the bus was last sent is the one the mesh composes now (brokerBehind, the same judgement a
|
||||
// push makes to send that machine first). While it is behind, the controller's ask is refused by the bus,
|
||||
// whatever the record says of the router, so nothing is asked and the operator is told to push that machine.
|
||||
// Judged at most every grantLookEvery: composing the list resolves the bus's machine whole.
|
||||
func grantHeldIn(open *stores) func(ctx context.Context) (bool, string, error) {
|
||||
var mu sync.Mutex
|
||||
var at time.Time
|
||||
var held bool
|
||||
var why string
|
||||
return func(ctx context.Context) (bool, string, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !at.IsZero() && time.Since(at) < grantLookEvery {
|
||||
return held, why, nil
|
||||
}
|
||||
machine, behind, err := brokerBehind(ctx, open, nil)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
at, held, why = time.Now(), !behind, ""
|
||||
if behind {
|
||||
why = fmt.Sprintf("the bus's user list on %s is behind what the mesh composes, so the bus has not been "+
|
||||
"given the controller's grant to ask; `push %s` carries it", machine, machine)
|
||||
}
|
||||
return held, why, nil
|
||||
}
|
||||
}
|
||||
|
||||
// grantLookEvery is how often the bus's user list is judged against the one its machine was last sent.
|
||||
const grantLookEvery = 30 * time.Second
|
||||
|
||||
// channelsIn is what the channels are now, as a fingerprint: each module claiming a kind of the channel
|
||||
// bench, where, promising what, and whether of its own account. An ask the router refused is asked again
|
||||
// once this changes.
|
||||
func channelsIn(inv *inventory.Inventory) func(ctx context.Context) string {
|
||||
return func(ctx context.Context) string {
|
||||
entries, err := inv.Catalogued(ctx)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
for _, e := range entries {
|
||||
for _, c := range e.Manifest.Claims {
|
||||
if c.Kind == "" || !catalogue.KindedBenches[c.Name] {
|
||||
continue
|
||||
}
|
||||
on := append([]string(nil), e.On...)
|
||||
sort.Strings(on)
|
||||
caps := append([]string(nil), c.Capabilities...)
|
||||
sort.Strings(caps)
|
||||
parts = append(parts, fmt.Sprintf("%s/%s=%s@%s[%s]own:%t", c.Name, c.Kind, e.Manifest.Module,
|
||||
strings.Join(on, ","), strings.Join(caps, ","), e.Manifest.RunsAs != ""))
|
||||
}
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
}
|
||||
|
||||
// startAsking makes the serving controller's asker and hands it the router's words.
|
||||
func startAsking(ctx context.Context, open *stores, server *link.Server, conn *nats.Conn, keeper *conditions.Keeper) {
|
||||
js, err := jetstream.New(conn)
|
||||
if err != nil {
|
||||
fmt.Printf("the operator cannot be asked: %v\n", err)
|
||||
return
|
||||
}
|
||||
a := &asker{
|
||||
open: keeper.Open,
|
||||
silence: func(ctx context.Context, key string, d time.Duration, by, why string) error {
|
||||
_, err := keeper.Silence(ctx, key, d, by, why)
|
||||
return err
|
||||
},
|
||||
store: busAsked{conn: conn},
|
||||
publish: func(ctx context.Context, subject string, body []byte, id string) error {
|
||||
_, err := js.Publish(ctx, subject, body, jetstream.WithMsgID(id))
|
||||
return err
|
||||
},
|
||||
call: callAction(conn),
|
||||
// A proposed settings layer is set by this controller itself on the warrant (novox/hq ADR 0277).
|
||||
setLayer: setLayerIn(open),
|
||||
record: func(ctx context.Context, act link.HandAct) error {
|
||||
_, err := link.RecordHandAct(ctx, conn, act)
|
||||
return err
|
||||
},
|
||||
routerRecord: routerRecordOf(conn, open.inventory),
|
||||
routerHere: routerHereIn(open.inventory),
|
||||
grantHeld: grantHeldIn(open),
|
||||
channels: channelsIn(open.inventory),
|
||||
raise: func(ctx context.Context, obs []conditions.Observation) error {
|
||||
return keeper.Reconcile(ctx, sourceAsker, obs)
|
||||
},
|
||||
now: time.Now,
|
||||
logf: func(format string, args ...any) { fmt.Printf(format+"\n", args...) },
|
||||
}
|
||||
if err := server.Decides(a); err != nil {
|
||||
fmt.Printf("the operator's answers cannot be heard, so nothing is asked: %v\n", err)
|
||||
return
|
||||
}
|
||||
askerFrom = a
|
||||
go a.keep(ctx)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,902 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// The suite's tests of a merge, but for the merge window's own (this file's), plan the merge as it is heard: a
|
||||
// window of nothing closes at once, so a merge with no walk open is cut into its walk at once, as every merge
|
||||
// was planned before novox/hq ADR 0276. The window's tests set it through the controller's settings.
|
||||
func init() { mergeWindowDefault = 0 }
|
||||
|
||||
// windowed is a mesh whose controller's settings give a merge window of 90 seconds and at most 10 minutes, as its
|
||||
// settings file says them, and
|
||||
// modules built from the catalogue (app, notes) and from three repositories of their own.
|
||||
func windowed(t *testing.T) *stores {
|
||||
t.Helper()
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
settings := t.TempDir() + "/merge-window.json"
|
||||
if err := os.WriteFile(settings, []byte(`{"merge-window": "90s", "merge-window-at-most": "10m"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(mergeWindowFileVar, settings)
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "mesh-controller", Version: "1"},
|
||||
inventory.Source{Repository: "novox/mesh-controller", Seat: "git", Ref: "main", BuiltFrom: "c0",
|
||||
Head: "c0"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range []struct{ module, repository, path string }{
|
||||
{"app", "novox/mesh-catalog", "modules/app"},
|
||||
{"notes", "novox/mesh-catalog", "modules/notes"},
|
||||
{"one", "novox/one", ""},
|
||||
{"two", "novox/two", ""},
|
||||
{"three", "novox/three", ""},
|
||||
} {
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: m.module, Version: "1"},
|
||||
inventory.Source{Repository: m.repository, Seat: "git", Path: m.path, Ref: "main", BuiltFrom: "c0",
|
||||
Head: "c0"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return open
|
||||
}
|
||||
|
||||
// t0 is the moment a test's clock starts: merges are dated by it, and heard after it.
|
||||
var t0 = time.Now().UTC().Truncate(time.Second)
|
||||
|
||||
// catalogueMerge is a merge of the catalogue changing one module's directory, made at a moment.
|
||||
func catalogueMerge(commit, module string, made time.Time) link.SourceMoved {
|
||||
return link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: commit,
|
||||
MergedAt: made.Format(time.RFC3339Nano), Paths: []string{"modules/" + module + "/module.json"},
|
||||
ModuleDirs: []string{"modules/" + module}, ModuleDirsSaid: true}
|
||||
}
|
||||
|
||||
// repoMerge is a merge of a repository of one module's own.
|
||||
func repoMerge(repo, commit string, made time.Time) link.SourceMoved {
|
||||
return link.SourceMoved{Owner: "novox", Repo: repo, Base: "main", Commit: commit,
|
||||
MergedAt: made.Format(time.RFC3339Nano), Paths: []string{"main.go"}}
|
||||
}
|
||||
|
||||
// hear is a merge heard at a moment.
|
||||
func hear(t *testing.T, open *stores, m link.SourceMoved, now time.Time) {
|
||||
t.Helper()
|
||||
if err := (following{open: open}).hearMerge(t.Context(), m, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// cutAt is the cutter looking at a moment.
|
||||
func cutAt(t *testing.T, open *stores, now time.Time) {
|
||||
t.Helper()
|
||||
if err := cutBatchesHeld(t.Context(), open, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// walks is every plan record that is a walk, newest first, and the batches not yet cut.
|
||||
func walks(t *testing.T, open *stores) (walks, batches []inventory.Plan) {
|
||||
t.Helper()
|
||||
recent, err := open.inventory.RecentPlans(t.Context(), 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range recent {
|
||||
if p.Batch() {
|
||||
batches = append(batches, p)
|
||||
} else {
|
||||
walks = append(walks, p)
|
||||
}
|
||||
}
|
||||
return walks, batches
|
||||
}
|
||||
|
||||
// The replay of issue 362: two catalogue merges 18 seconds apart are one batch, cut once into one walk at the
|
||||
// later commit, which names both merges — the earlier carried by the later.
|
||||
func TestTwoMergesSecondsApartAreOneWalkAtTheLaterCommit(t *testing.T) {
|
||||
open := windowed(t)
|
||||
asked := asksWithPaths(t)
|
||||
claude := catalogueMerge("553b7191claude", "app", t0)
|
||||
dunst := catalogueMerge("48bda475dunst", "notes", t0.Add(17*time.Second))
|
||||
hear(t, open, claude, t0.Add(time.Second))
|
||||
hear(t, open, dunst, t0.Add(18*time.Second))
|
||||
if ws, bs := walks(t, open); len(ws) != 0 || len(bs) != 1 {
|
||||
t.Fatalf("within the window: %d walk(s), %d batch(es)", len(ws), len(bs))
|
||||
}
|
||||
cutAt(t, open, t0.Add(18*time.Second+89*time.Second))
|
||||
if ws, _ := walks(t, open); len(ws) != 0 {
|
||||
t.Fatalf("cut before the window closed: %+v", ws)
|
||||
}
|
||||
cutAt(t, open, t0.Add(18*time.Second+90*time.Second))
|
||||
ws, bs := walks(t, open)
|
||||
if len(ws) != 1 || len(bs) != 0 {
|
||||
t.Fatalf("after the window: %d walk(s), %d batch(es)", len(ws), len(bs))
|
||||
}
|
||||
w := ws[0]
|
||||
if w.Commit != dunst.Commit || len(w.Commits) != 1 || w.Commits[0].Commit != dunst.Commit {
|
||||
t.Fatalf("the walk is at %s %+v, not the later commit", w.Commit, w.Commits)
|
||||
}
|
||||
for _, m := range []string{"app", "notes"} {
|
||||
if _, in := w.Modules[m]; !in {
|
||||
t.Fatalf("the walk does not build %s, which one of its merges moved: %v", m, w.Modules)
|
||||
}
|
||||
}
|
||||
want := []inventory.PlanMerge{{Repository: "novox/mesh-catalog", Commit: claude.Commit, Carried: dunst.Commit},
|
||||
{Repository: "novox/mesh-catalog", Commit: dunst.Commit}}
|
||||
if w.Delivery == nil || !slices.EqualFunc(w.Delivery.Merges, want, sameMerge) {
|
||||
t.Fatalf("the walk answers %+v, want %+v", w.Delivery, want)
|
||||
}
|
||||
if len(*asked) != 2 || (*asked)[0][2] != "main" || (*asked)[1][2] != "main" {
|
||||
t.Fatalf("the walk did not ask its modules at the branch, which holds the commit it carries: %v", *asked)
|
||||
}
|
||||
// Heard again, as the bus may hand it over twice: never a second merge, nor a second walk.
|
||||
hear(t, open, claude, t0.Add(5*time.Minute))
|
||||
if ws, bs := walks(t, open); len(ws) != 1 || len(bs) != 0 {
|
||||
t.Fatalf("a merge heard twice made %d walk(s) and %d batch(es)", len(ws), len(bs))
|
||||
}
|
||||
}
|
||||
|
||||
// Merges of three repositories in one window are one walk, one commit for each.
|
||||
func TestThreeRepositoriesInOneWindowAreOneWalk(t *testing.T) {
|
||||
open := windowed(t)
|
||||
asksRecorded(t)
|
||||
for i, r := range []string{"one", "two", "three"} {
|
||||
hear(t, open, repoMerge(r, "c-"+r, t0.Add(time.Duration(i)*20*time.Second)),
|
||||
t0.Add(time.Duration(i)*20*time.Second+time.Second))
|
||||
}
|
||||
cutAt(t, open, t0.Add(41*time.Second+90*time.Second))
|
||||
ws, bs := walks(t, open)
|
||||
if len(ws) != 1 || len(bs) != 0 {
|
||||
t.Fatalf("%d walk(s), %d batch(es)", len(ws), len(bs))
|
||||
}
|
||||
w := ws[0]
|
||||
if len(w.Commits) != 3 || len(w.Delivery.Merges) != 3 {
|
||||
t.Fatalf("the walk carries %+v and answers %+v", w.Commits, w.Delivery.Merges)
|
||||
}
|
||||
for _, r := range []string{"one", "two", "three"} {
|
||||
if w.CommitOf("novox/"+r) != "c-"+r {
|
||||
t.Fatalf("the walk carries %s at %q", r, w.CommitOf("novox/"+r))
|
||||
}
|
||||
if _, in := w.Modules[r]; !in {
|
||||
t.Fatalf("the walk does not build %s: %v", r, w.Modules)
|
||||
}
|
||||
}
|
||||
// Each module's ask is recorded at its own repository's commit.
|
||||
for _, r := range []string{"one", "two", "three"} {
|
||||
q, found, err := open.inventory.BuildRequestByID(t.Context(), w.Modules[r].Build)
|
||||
if err != nil || !found || q.Commit != "c-"+r {
|
||||
t.Fatalf("%s's build was not recorded at its own commit: %+v %v %v", r, q, found, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each merge restarts the window: a merge at second 80 keeps the batch open until second 170; the window
|
||||
// closes at most ten minutes after its first merge however busy.
|
||||
func TestAMergeRestartsTheWindowAndTheMaximumClosesIt(t *testing.T) {
|
||||
open := windowed(t)
|
||||
asksWithPaths(t)
|
||||
hear(t, open, repoMerge("one", "c1", t0), t0)
|
||||
hear(t, open, repoMerge("two", "c2", t0.Add(80*time.Second)), t0.Add(80*time.Second))
|
||||
cutAt(t, open, t0.Add(100*time.Second))
|
||||
if ws, _ := walks(t, open); len(ws) != 0 {
|
||||
t.Fatal("the window closed 90 seconds after its first merge, not after its last")
|
||||
}
|
||||
cutAt(t, open, t0.Add(170*time.Second))
|
||||
if ws, _ := walks(t, open); len(ws) != 1 || len(ws[0].Commits) != 2 {
|
||||
t.Fatalf("the window did not close 90 seconds after its last merge: %+v", ws)
|
||||
}
|
||||
|
||||
busy := windowed(t)
|
||||
asksWithPaths(t)
|
||||
var last time.Time
|
||||
for i := 0; i < 12; i++ {
|
||||
last = t0.Add(time.Duration(i) * time.Minute)
|
||||
repo := []string{"one", "two", "three"}[i%3]
|
||||
hear(t, busy, repoMerge(repo, "c"+string(rune('a'+i)), last), last)
|
||||
if ws, _ := walks(t, busy); len(ws) > 0 {
|
||||
if i != 10 {
|
||||
t.Fatalf("a busy window closed at merge %d (minute %d), not at its maximum", i, i)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
ws, _ := walks(t, busy)
|
||||
if len(ws) != 1 {
|
||||
t.Fatalf("ten minutes of merges a minute apart were never cut: %d walk(s)", len(ws))
|
||||
}
|
||||
}
|
||||
|
||||
// `plans` lists the batch being assembled first, in the operator's words: how long is left, when at the latest,
|
||||
// what is grouped, and that the plan is not yet calculated. It says so on the bus as plan-moved, too.
|
||||
func TestTheAssemblingBatchIsShown(t *testing.T) {
|
||||
open := windowed(t)
|
||||
asksWithPaths(t)
|
||||
var said []inventory.Plan
|
||||
was := inventory.PlanSaved
|
||||
inventory.PlanSaved = func(p inventory.Plan) { said = append(said, p) }
|
||||
t.Cleanup(func() { inventory.PlanSaved = was })
|
||||
now := time.Now().UTC()
|
||||
claude := catalogueMerge("553b7191claude", "app", now.Add(-20*time.Second))
|
||||
claude.Number, claude.Title = 174, "claude-code: an agent proposes a section"
|
||||
dunst := catalogueMerge("48bda475dunst", "notes", now.Add(-2*time.Second))
|
||||
dunst.Number, dunst.Title = 175, "dunst: the font the operator chose"
|
||||
hear(t, open, claude, now.Add(-19*time.Second))
|
||||
hear(t, open, dunst, now.Add(-time.Second))
|
||||
hear(t, open, repoMerge("one", "a6bc0931one", now), now)
|
||||
out := captured(t, func() error { return plansCommand(t.Context(), nil) })
|
||||
lines := strings.Split(out, "\n")
|
||||
first := lines[0]
|
||||
for _, want := range []string{"assembling: ", " s left (at the latest ", "grouped: novox/mesh-catalog@48bda475 " +
|
||||
"(answers 553b7191), novox/one@a6bc0931; plan not yet calculated"} {
|
||||
if !strings.Contains(first, want) {
|
||||
t.Fatalf("plans' first line %q does not say %q", first, want)
|
||||
}
|
||||
}
|
||||
// Under it, one line per repository: the pull request, what it answers, what it moves.
|
||||
if len(lines) < 3 || strings.TrimSpace(lines[1]) != "mesh-catalog #175 dunst: the font the operator chose (answers "+
|
||||
"#174 claude-code: an agent proposes a section) · app, notes" ||
|
||||
strings.TrimSpace(lines[2]) != "one a6bc0931 · one" {
|
||||
t.Fatalf("the grouped list reads %q", lines[1:4])
|
||||
}
|
||||
if len(said) == 0 || said[len(said)-1].State != inventory.PlanAssembling || said[len(said)-1].Delivery.Batch == nil ||
|
||||
len(said[len(said)-1].Delivery.Merges) != 3 {
|
||||
t.Fatalf("the batch was not said as assembling with its merges: %+v", said)
|
||||
}
|
||||
}
|
||||
|
||||
// captured is what a command printed.
|
||||
func captured(t *testing.T, run func() error) string {
|
||||
t.Helper()
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
was := os.Stdout
|
||||
os.Stdout = w
|
||||
runErr := run()
|
||||
os.Stdout = was
|
||||
_ = w.Close()
|
||||
var b bytes.Buffer
|
||||
_, _ = io.Copy(&b, r)
|
||||
if runErr != nil {
|
||||
t.Fatal(runErr)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// One walk at a time: a merge heard while a walk runs joins the next batch, which is cut when the walk ends;
|
||||
// the walk that started is never superseded.
|
||||
func TestAMergeDuringAWalkJoinsTheNextBatch(t *testing.T) {
|
||||
open := windowed(t)
|
||||
asksWithPaths(t)
|
||||
hear(t, open, repoMerge("one", "c1", t0), t0)
|
||||
cutAt(t, open, t0.Add(90*time.Second))
|
||||
ws, _ := walks(t, open)
|
||||
if len(ws) != 1 || !ws[0].Open() {
|
||||
t.Fatalf("the first batch was not cut: %+v", ws)
|
||||
}
|
||||
first := ws[0]
|
||||
hear(t, open, repoMerge("two", "c2", t0.Add(2*time.Minute)), t0.Add(2*time.Minute))
|
||||
cutAt(t, open, t0.Add(5*time.Minute))
|
||||
ws, bs := walks(t, open)
|
||||
if len(ws) != 1 || len(bs) != 1 || bs[0].State != inventory.PlanQueued || bs[0].Delivery.Batch.Behind != first.ID {
|
||||
t.Fatalf("the merge during the walk: %d walk(s), batches %+v", len(ws), bs)
|
||||
}
|
||||
if got, _ := open.inventory.PlanByID(t.Context(), first.ID); !got.Open() {
|
||||
t.Fatalf("the started walk was %s by the next merge", got.State)
|
||||
}
|
||||
if !strings.HasPrefix(batchWords(bs[0], t0.Add(5*time.Minute)), "queued behind "+first.ID) {
|
||||
t.Fatalf("a queued batch reads %q", batchWords(bs[0], t0.Add(5*time.Minute)))
|
||||
}
|
||||
first.State = inventory.PlanDone
|
||||
if err := open.inventory.SavePlan(t.Context(), &first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cutAt(t, open, t0.Add(6*time.Minute))
|
||||
ws, bs = walks(t, open)
|
||||
if len(ws) != 2 || len(bs) != 0 || ws[0].Commit != "c2" {
|
||||
t.Fatalf("the queued batch was not cut when the walk ended: %+v %+v", ws, bs)
|
||||
}
|
||||
}
|
||||
|
||||
// A walk waiting for its delivery's word is folded into the next batch's walk, which answers its merges; it
|
||||
// names the walk that took it over.
|
||||
func TestAWaitingWalkIsFoldedIntoTheNextBatch(t *testing.T) {
|
||||
open := windowed(t)
|
||||
asksWithPaths(t)
|
||||
ctx := t.Context()
|
||||
if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: "mesh-delivery", Version: "1",
|
||||
Claims: []catalogue.Claim{{Name: catalogue.DeliverySeat, Scope: catalogue.ScopeMesh}}},
|
||||
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/mesh-delivery", Ref: "main",
|
||||
BuiltFrom: "c0", Head: "c0"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := open.inventory.Assign(ctx, "anchor", "mesh-delivery"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hear(t, open, repoMerge("one", "c1", t0), t0)
|
||||
cutAt(t, open, t0.Add(90*time.Second))
|
||||
ws, _ := walks(t, open)
|
||||
if len(ws) != 1 || !ws[0].Waiting() {
|
||||
t.Fatalf("the walk does not wait for its word: %+v", ws)
|
||||
}
|
||||
waiting := ws[0]
|
||||
hear(t, open, repoMerge("two", "c2", t0.Add(2*time.Minute)), t0.Add(2*time.Minute))
|
||||
cutAt(t, open, t0.Add(2*time.Minute+90*time.Second))
|
||||
ws, bs := walks(t, open)
|
||||
if len(bs) != 0 || len(ws) != 2 {
|
||||
t.Fatalf("%d walk(s), %d batch(es)", len(ws), len(bs))
|
||||
}
|
||||
folded, err := open.inventory.PlanByID(ctx, waiting.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newer := ws[0]
|
||||
if folded.State != inventory.PlanSuperseded || folded.Delivery.TakenOverBy != newer.ID {
|
||||
t.Fatalf("the waiting walk is %s, taken over by %q", folded.State, folded.Delivery.TakenOverBy)
|
||||
}
|
||||
if len(newer.Delivery.Merges) != 2 || newer.CommitOf("novox/one") != "c1" || newer.CommitOf("novox/two") != "c2" {
|
||||
t.Fatalf("the newer walk answers %+v", newer.Delivery.Merges)
|
||||
}
|
||||
if _, in := newer.Modules["one"]; !in {
|
||||
t.Fatalf("what the folded walk was to build is not built: %v", newer.Modules)
|
||||
}
|
||||
}
|
||||
|
||||
// A merge heard after a later merge of its branch was walked is answered by that walk when it builds all it
|
||||
// moves — named at once on a walk done — and joins the next batch when it does not.
|
||||
func TestALateMergeIsAnsweredByTheWalkOfTheLaterOne(t *testing.T) {
|
||||
open := windowed(t)
|
||||
asksWithPaths(t)
|
||||
ctx := t.Context()
|
||||
later := catalogueMerge("48bda475dunst", "notes", t0.Add(17*time.Second))
|
||||
hear(t, open, later, t0.Add(18*time.Second))
|
||||
cutAt(t, open, t0.Add(2*time.Minute))
|
||||
ws, _ := walks(t, open)
|
||||
w := ws[0]
|
||||
w.State = inventory.PlanDone
|
||||
if err := open.inventory.SavePlan(ctx, &w); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Moves only notes, which the done walk built from the branch after it: answered there, at once.
|
||||
hear(t, open, catalogueMerge("553b7191early", "notes", t0), t0.Add(15*time.Minute))
|
||||
got, _ := open.inventory.PlanByID(ctx, w.ID)
|
||||
if got.State != inventory.PlanDone || len(got.Delivery.Merges) != 2 ||
|
||||
!sameMerge(got.Delivery.Merges[0], inventory.PlanMerge{Repository: "novox/mesh-catalog", Commit: "553b7191early",
|
||||
Carried: later.Commit}) {
|
||||
t.Fatalf("the late merge is not named by the walk that carried it: %+v", got.Delivery.Merges)
|
||||
}
|
||||
// Moves app, which that walk never built: the next batch walks it.
|
||||
hear(t, open, catalogueMerge("1111aaaaapp", "app", t0.Add(time.Second)), t0.Add(16*time.Minute))
|
||||
_, bs := walks(t, open)
|
||||
if len(bs) != 1 || bs[0].Delivery.Merges[0].Commit != "1111aaaaapp" {
|
||||
t.Fatalf("a late merge moving what the walk never built did not join the next batch: %+v", bs)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed walk marks nothing delivered: its earlier merges are walked alone, on their own commit, the newest
|
||||
// first; the first delivered answers the older ones, and the search stops.
|
||||
func TestAFailedWalkWalksItsEarlierMergesAlone(t *testing.T) {
|
||||
open := windowed(t)
|
||||
asked := asksWithPaths(t)
|
||||
ctx := t.Context()
|
||||
// Made before the cut marks their modules seen, as merges are: walked again, they are news all the same.
|
||||
oldest := catalogueMerge("aaaa0001", "app", t0.Add(-3*time.Minute))
|
||||
middle := catalogueMerge("bbbb0002", "app", t0.Add(-3*time.Minute+10*time.Second))
|
||||
newest := catalogueMerge("cccc0003", "notes", t0.Add(-3*time.Minute+20*time.Second))
|
||||
for i, m := range []link.SourceMoved{oldest, middle, newest} {
|
||||
hear(t, open, m, t0.Add(time.Duration(i)*10*time.Second+time.Second))
|
||||
}
|
||||
cutAt(t, open, t0.Add(2*time.Minute))
|
||||
ws, _ := walks(t, open)
|
||||
failed := ws[0]
|
||||
failed.State, failed.Note = inventory.PlanFailed, "notes failed to build in tier 0"
|
||||
if err := open.inventory.SavePlan(ctx, &failed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cutAt(t, open, t0.Add(3*time.Minute))
|
||||
got, _ := open.inventory.PlanByID(ctx, failed.ID)
|
||||
if got.State != inventory.PlanFailed || len(got.Delivery.Merges) != 3 {
|
||||
t.Fatalf("the failed walk's record changed: %s %+v", got.State, got.Delivery.Merges)
|
||||
}
|
||||
ws, _ = walks(t, open)
|
||||
alone := ws[0]
|
||||
if alone.ID == failed.ID || alone.Commit != middle.Commit || len(alone.Delivery.Merges) != 1 ||
|
||||
!sameMerge(alone.Delivery.Merges[0], inventory.PlanMerge{Repository: "novox/mesh-catalog", Commit: middle.Commit}) {
|
||||
t.Fatalf("the newest earlier merge was not walked alone on its own commit: %+v", alone)
|
||||
}
|
||||
if _, in := alone.Modules["app"]; !in || (*asked)[len(*asked)-1] != [3]string{"novox/mesh-catalog", "modules/app",
|
||||
middle.Commit} {
|
||||
t.Fatalf("the merge walked alone does not build app at its own commit: %v, asked %v", alone.Modules, *asked)
|
||||
}
|
||||
// Retried, the failed walk would name merges the search answers now.
|
||||
if _, err := retryPlan(ctx, open, failed.ID); err == nil || !strings.Contains(err.Error(), "walked alone") {
|
||||
t.Fatalf("a failed walk whose merges are searched was retried: %v", err)
|
||||
}
|
||||
alone.State = inventory.PlanDone
|
||||
if err := open.inventory.SavePlan(ctx, &alone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cutAt(t, open, t0.Add(4*time.Minute))
|
||||
ws, bs := walks(t, open)
|
||||
if ws[0].ID != alone.ID || len(bs) != 0 {
|
||||
t.Fatalf("the search went on after a merge was delivered: %+v", ws[0])
|
||||
}
|
||||
done, _ := open.inventory.PlanByID(ctx, alone.ID)
|
||||
if len(done.Delivery.Merges) != 2 || !sameMerge(done.Delivery.Merges[0], inventory.PlanMerge{Repository: "novox/mesh-catalog",
|
||||
Commit: oldest.Commit, Carried: middle.Commit}) {
|
||||
t.Fatalf("the oldest merge is not answered by the walk that delivered the one after it: %+v", done.Delivery.Merges)
|
||||
}
|
||||
// A stopped walk starts no search: a person ended it.
|
||||
hear(t, open, catalogueMerge("dddd0004", "app", t0.Add(5*time.Minute)), t0.Add(5*time.Minute))
|
||||
hear(t, open, catalogueMerge("eeee0005", "notes", t0.Add(5*time.Minute+time.Second)), t0.Add(5*time.Minute+time.Second))
|
||||
cutAt(t, open, t0.Add(8*time.Minute))
|
||||
ws, _ = walks(t, open)
|
||||
if _, err := stopWalk(ctx, open.inventory, ws[0].ID, "mesh-delivery for jochen", "not now"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cutAt(t, open, t0.Add(9*time.Minute))
|
||||
if again, _ := walks(t, open); again[0].ID != ws[0].ID {
|
||||
t.Fatalf("a stopped walk's earlier merge was walked alone: %+v", again[0])
|
||||
}
|
||||
}
|
||||
|
||||
// A delivery group's order holds inside the walk (ADR 0249 unchanged): merges of two repositories from one head
|
||||
// branch name, the node-engine's before the controller's, are tiered so; without the group, one tier.
|
||||
func TestAGroupsOrderBecomesTiersInsideTheWalk(t *testing.T) {
|
||||
for _, grouped := range []bool{true, false} {
|
||||
open := windowed(t)
|
||||
asksWithPaths(t)
|
||||
ctx := t.Context()
|
||||
for _, m := range []string{"mesh-host"} {
|
||||
if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: m, Version: "1"},
|
||||
inventory.Source{Repository: "novox/" + m, Seat: "git", Ref: "main", BuiltFrom: "c0", Head: "c0"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
host := repoMerge("mesh-host", "h1", t0)
|
||||
ctl := repoMerge("mesh-controller", "k1", t0.Add(time.Second))
|
||||
host.Head, ctl.Head = "feat/together", "feat/together"
|
||||
if !grouped {
|
||||
ctl.Head = "feat/alone"
|
||||
}
|
||||
hear(t, open, ctl, t0.Add(2*time.Second))
|
||||
hear(t, open, host, t0.Add(3*time.Second))
|
||||
cutAt(t, open, t0.Add(2*time.Minute))
|
||||
ws, _ := walks(t, open)
|
||||
if len(ws) != 1 {
|
||||
t.Fatalf("%d walks", len(ws))
|
||||
}
|
||||
tiers := ws[0].Tiers
|
||||
if grouped {
|
||||
if len(tiers) != 2 || !slices.Equal(tiers[0], []string{"mesh-host"}) ||
|
||||
!slices.Equal(tiers[1], []string{"mesh-controller"}) {
|
||||
t.Fatalf("the group's order is not the walk's tiers: %v", tiers)
|
||||
}
|
||||
} else if len(tiers) != 1 {
|
||||
t.Fatalf("two merges of no group were ordered: %v", tiers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A restarted controller resumes the window where it stood: the batch, its merges and their times are in the
|
||||
// store, a merge handed over again is not doubled, and the batch is cut when its window closes.
|
||||
func TestABatchSurvivesARestart(t *testing.T) {
|
||||
open := windowed(t)
|
||||
asksWithPaths(t)
|
||||
hear(t, open, repoMerge("one", "c1", t0), t0)
|
||||
hear(t, open, repoMerge("two", "c2", t0.Add(30*time.Second)), t0.Add(30*time.Second))
|
||||
|
||||
// A new controller: nothing of the first one's but the store.
|
||||
again, err := openStores(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(again.Close)
|
||||
hear(t, again, repoMerge("one", "c1", t0), t0.Add(60*time.Second)) // the bus hands it over again
|
||||
cutAt(t, again, t0.Add(119*time.Second))
|
||||
if ws, bs := walks(t, again); len(ws) != 0 || len(bs) != 1 || len(bs[0].Delivery.Merges) != 2 {
|
||||
t.Fatalf("the restarted controller's batch: %d walk(s), %+v", len(ws), bs)
|
||||
}
|
||||
cutAt(t, again, t0.Add(120*time.Second))
|
||||
ws, bs := walks(t, again)
|
||||
if len(ws) != 1 || len(bs) != 0 || len(ws[0].Delivery.Merges) != 2 {
|
||||
t.Fatalf("the restarted controller did not cut the batch at its window: %+v %+v", ws, bs)
|
||||
}
|
||||
}
|
||||
|
||||
// The window's settings reach the controller in its settings file, read at every look; one that says no
|
||||
// duration, or no file, is the default.
|
||||
func TestTheWindowIsTheControllersSetting(t *testing.T) {
|
||||
file := t.TempDir() + "/merge-window.json"
|
||||
t.Setenv(mergeWindowFileVar, file)
|
||||
for _, c := range []struct {
|
||||
content string
|
||||
window, atMost time.Duration
|
||||
}{
|
||||
{`{"merge-window": "60s", "merge-window-at-most": "5m"}`, time.Minute, 5 * time.Minute},
|
||||
{`{"merge-window": 30, "merge-window-at-most": ""}`, 30 * time.Second, mergeWindowAtMostDefault},
|
||||
{`{"merge-window": "soon"}`, mergeWindowDefault, mergeWindowAtMostDefault},
|
||||
} {
|
||||
if err := os.WriteFile(file, []byte(c.content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w, m := mergeWindowOf(); w != c.window || m != c.atMost {
|
||||
t.Errorf("%s reads as %s and %s, want %s and %s", c.content, w, m, c.window, c.atMost)
|
||||
}
|
||||
}
|
||||
t.Setenv(mergeWindowFileVar, file+".gone")
|
||||
if w, m := mergeWindowOf(); w != mergeWindowDefault || m != mergeWindowAtMostDefault {
|
||||
t.Errorf("no file reads as %s and %s", w, m)
|
||||
}
|
||||
}
|
||||
|
||||
// S16 names every merge a waiting walk answers, never one commit a supersession may have ended (issue 362).
|
||||
func TestAWaitingWalkNamesTheMergesItAnswers(t *testing.T) {
|
||||
f := calm(t0)
|
||||
f.waits = []waitFacts{{id: "plan-2", repository: "novox/mesh-catalog", commit: "48bda475dunst", awaits: "mesh-delivery",
|
||||
since: t0.Add(-31 * time.Minute), merges: []inventory.PlanMerge{
|
||||
{Repository: "novox/mesh-catalog", Commit: "553b7191claude", Carried: "48bda475dunst"},
|
||||
{Repository: "novox/mesh-catalog", Commit: "48bda475dunst"}}}}
|
||||
got := watchWaits(f)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Summary, "novox/mesh-catalog@553b7191") ||
|
||||
!strings.Contains(got[0].Summary, "novox/mesh-catalog@48bda475") {
|
||||
t.Fatalf("the waiting walk does not name both merges: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A merge heard after a later merge of its branch was cut is named by that walk however its modules read since
|
||||
// the cut, in a repository of one module (review of this change); a walk that never built what it moves names
|
||||
// nothing, and the merge joins the next batch.
|
||||
func TestALateMergeOfAOneModuleRepositoryIsNamed(t *testing.T) {
|
||||
open := windowed(t)
|
||||
asksWithPaths(t)
|
||||
ctx := t.Context()
|
||||
c2 := repoMerge("one", "c2", t0.Add(-50*time.Second))
|
||||
hear(t, open, c2, t0)
|
||||
cutAt(t, open, t0.Add(2*time.Minute))
|
||||
ws, _ := walks(t, open)
|
||||
w := ws[0]
|
||||
w.State = inventory.PlanDone
|
||||
if err := open.inventory.SavePlan(ctx, &w); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hear(t, open, repoMerge("one", "c1", t0.Add(-60*time.Second)), t0.Add(15*time.Minute))
|
||||
got, _ := open.inventory.PlanByID(ctx, w.ID)
|
||||
if len(got.Delivery.Merges) != 2 || !sameMerge(got.Delivery.Merges[0], inventory.PlanMerge{Repository: "novox/one",
|
||||
Commit: "c1", Carried: "c2"}) {
|
||||
t.Fatalf("the late merge was not named by the walk that carried it: %+v", got.Delivery.Merges)
|
||||
}
|
||||
|
||||
// A walk of two that built only two: a late merge of one is not its to name.
|
||||
hear(t, open, repoMerge("two", "d2", t0.Add(16*time.Minute)), t0.Add(16*time.Minute))
|
||||
cutAt(t, open, t0.Add(18*time.Minute))
|
||||
ws, _ = walks(t, open)
|
||||
w = ws[0]
|
||||
delete(w.Modules, "two")
|
||||
w.State = inventory.PlanDone
|
||||
if err := open.inventory.SavePlan(ctx, &w); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hear(t, open, repoMerge("two", "d1", t0.Add(15*time.Minute)), t0.Add(20*time.Minute))
|
||||
if got, _ := open.inventory.PlanByID(ctx, w.ID); len(got.Delivery.Merges) != 1 {
|
||||
t.Fatalf("a walk that never built what the late merge moves named it: %+v", got.Delivery.Merges)
|
||||
}
|
||||
if _, bs := walks(t, open); len(bs) != 1 || bs[0].Delivery.Merges[0].Commit != "d1" {
|
||||
t.Fatalf("the late merge did not join the next batch: %+v", bs)
|
||||
}
|
||||
}
|
||||
|
||||
// One walk at a time holds against the delivery's word too: a waiting walk let go beside a started one takes the
|
||||
// word and starts once that one ended, asking nothing before.
|
||||
func TestAWalkLetGoBesideAStartedOneStartsOnceItEnded(t *testing.T) {
|
||||
open := windowed(t)
|
||||
asked := asksWithPaths(t)
|
||||
ctx := t.Context()
|
||||
hear(t, open, repoMerge("one", "c1", t0), t0)
|
||||
cutAt(t, open, t0.Add(2*time.Minute))
|
||||
started, _ := walks(t, open)
|
||||
waiting := inventory.Plan{ID: "plan-waiting", Repository: "novox/two", Branch: "main", Commit: "d1", Created: t0,
|
||||
State: inventory.PlanBuilding, Tiers: [][]string{{"two"}}, Modules: map[string]*inventory.PlanModule{"two": {}},
|
||||
Delivery: &inventory.PlanDelivery{Awaits: catalogue.DeliverySeat}}
|
||||
if err := open.inventory.SavePlan(ctx, &waiting); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := letGo(ctx, open.inventory, waiting.ID, catalogue.DeliverySeat, "its turn"); err != nil {
|
||||
t.Fatalf("the word was refused beside %s: %v", started[0].ID, err)
|
||||
}
|
||||
before := len(*asked)
|
||||
advanceHeld(ctx, open)
|
||||
got, _ := open.inventory.PlanByID(ctx, waiting.ID)
|
||||
if len(*asked) != before || !strings.Contains(got.Note, "starts once "+started[0].ID) {
|
||||
t.Fatalf("a walk let go beside a started one asked (%d → %d) or does not say it waits: %q", before, len(*asked), got.Note)
|
||||
}
|
||||
// Read as a wait, an hour on: its note on the line, never LATE, and no tier of it late for S3.
|
||||
later := time.Now().Add(time.Hour)
|
||||
if line := planLine(got, later); !strings.Contains(line, "starts once "+started[0].ID) || strings.Contains(line, "LATE") {
|
||||
t.Fatalf("a deferred walk reads %q", line)
|
||||
}
|
||||
facts, _, err := gatherPlans(ctx, open.inventory, later, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range facts {
|
||||
if f.id == got.ID {
|
||||
t.Fatalf("a deferred walk is watched as a tier running late: %+v", f)
|
||||
}
|
||||
}
|
||||
done := started[0]
|
||||
done.State = inventory.PlanDone
|
||||
if err := open.inventory.SavePlan(ctx, &done); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
advanceHeld(ctx, open)
|
||||
if len(*asked) != before+1 {
|
||||
t.Fatalf("the walk did not start once the started one ended: asked %v", *asked)
|
||||
}
|
||||
}
|
||||
|
||||
// A file a merge of the batch removed and a later one brought back is not removed; one removed last is.
|
||||
func TestARemovedFileIsTheLastMergesWord(t *testing.T) {
|
||||
ev := func(commit string, paths, removed []string) inventory.BatchedMerge {
|
||||
m := link.SourceMoved{Owner: "novox", Repo: "one", Base: "main", Commit: commit, Paths: paths, Removed: removed}
|
||||
b, _ := json.Marshal(m)
|
||||
return inventory.BatchedMerge{Repository: "novox/one", Branch: "main", Commit: commit, Event: b,
|
||||
Merged: t0.Add(time.Duration(len(commit)) * time.Second)}
|
||||
}
|
||||
got := combinedMerges([]inventory.BatchedMerge{
|
||||
ev("a", []string{"x/module.json", "y/module.json"}, []string{"x/module.json", "y/module.json"}),
|
||||
ev("bb", []string{"x/module.json"}, nil),
|
||||
ev("ccc", []string{"z.go"}, nil)})
|
||||
if len(got) != 1 || got[0].Commit != "ccc" || !slices.Equal(got[0].Removed, []string{"y/module.json"}) ||
|
||||
len(got[0].Paths) != 3 {
|
||||
t.Fatalf("combined as %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The catch-up hands over what the bus lost after its branch's later merges were cut, and the record keeps it:
|
||||
// an earlier merge is named by the walk that carried it; a merge made before a cut and heard after it, which
|
||||
// no later merge carries, joins the next batch — neither reads as history and is dropped (review of this change).
|
||||
func TestTheCatchUpKeepsWhatACutMadeHistory(t *testing.T) {
|
||||
open := windowed(t)
|
||||
asksWithPaths(t)
|
||||
ctx := t.Context()
|
||||
base := time.Now().UTC().Add(-time.Hour).Truncate(time.Second)
|
||||
hear(t, open, repoMerge("one", "c2", base.Add(10*time.Second)), base.Add(11*time.Second))
|
||||
cutAt(t, open, base.Add(2*time.Minute))
|
||||
ws, _ := walks(t, open)
|
||||
w := ws[0]
|
||||
w.State = inventory.PlanDone
|
||||
if err := open.inventory.SavePlan(ctx, &w); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lost := []link.AnnouncedMerge{
|
||||
{SourceMoved: repoMerge("one", "c1", base), At: base.Add(time.Second)},
|
||||
{SourceMoved: repoMerge("one", "c3lost", base.Add(20*time.Second)), At: base.Add(21 * time.Second)},
|
||||
}
|
||||
catalogued := func(ctx context.Context) ([]inventory.Entry, map[string][]inventory.ReadRepository, error) {
|
||||
entries, err := open.inventory.Catalogued(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
read, err := readForPlanning(ctx, open.inventory)
|
||||
return entries, read, err
|
||||
}
|
||||
if err := catchUpOnMerges(ctx, time.Now(), unheardMerges{announcedList(lost), open.inventory}, catalogued,
|
||||
(following{open}).SourceMoved, t.Logf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := open.inventory.PlanByID(ctx, w.ID)
|
||||
if len(got.Delivery.Merges) != 2 || !sameMerge(got.Delivery.Merges[0], inventory.PlanMerge{Repository: "novox/one",
|
||||
Commit: "c1", Carried: "c2"}) {
|
||||
t.Fatalf("the earlier merge the catch-up handed over is not named by the walk that carried it: %+v",
|
||||
got.Delivery.Merges)
|
||||
}
|
||||
if _, kept, err := open.inventory.MergeOf(ctx, "novox/one", "c3lost"); err != nil || !kept {
|
||||
t.Fatalf("the merge made before the cut and heard after it was dropped: %v %v", kept, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A merge on the controller's own path never shares a batch with one that waits for mesh-delivery's word
|
||||
// (decided during the build, 2026-10-10): the two are batches of their own, cut one after the other; a walk on
|
||||
// the own path folds no waiting catalogue walk but batches its merges again behind it; the catalogue's walk
|
||||
// still waits for the word, so no catalogue delivery skips its turn.
|
||||
func TestAMergeOnTheControllersPathNeverSharesABatch(t *testing.T) {
|
||||
open := windowed(t)
|
||||
asked := asksWithPaths(t)
|
||||
ctx := t.Context()
|
||||
if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: "mesh-delivery", Version: "1",
|
||||
Claims: []catalogue.Claim{{Name: catalogue.DeliverySeat, Scope: catalogue.ScopeMesh}}},
|
||||
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/mesh-delivery", Ref: "main",
|
||||
BuiltFrom: "c0", Head: "c0"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := open.inventory.Assign(ctx, "anchor", "mesh-delivery"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hear(t, open, catalogueMerge("aaaa0001", "app", t0), t0)
|
||||
hear(t, open, repoMerge("mesh-controller", "k1", t0.Add(10*time.Second)), t0.Add(10*time.Second))
|
||||
hear(t, open, catalogueMerge("bbbb0002", "notes", t0.Add(20*time.Second)), t0.Add(20*time.Second))
|
||||
_, bs := walks(t, open)
|
||||
var ownBatch, catalogueBatch inventory.Plan
|
||||
for _, b := range bs {
|
||||
if b.OwnPath() {
|
||||
ownBatch = b
|
||||
} else {
|
||||
catalogueBatch = b
|
||||
}
|
||||
}
|
||||
if len(bs) != 2 || ownBatch.ID == "" || catalogueBatch.ID == "" || len(catalogueBatch.Delivery.Merges) != 2 ||
|
||||
len(ownBatch.Delivery.Merges) != 1 {
|
||||
t.Fatalf("a controller merge and two catalogue merges in one window: %+v", bs)
|
||||
}
|
||||
if !strings.Contains(batchWords(ownBatch, t0.Add(30*time.Second)), "own path") {
|
||||
t.Fatalf("the own-path batch does not say so: %q", batchWords(ownBatch, t0.Add(30*time.Second)))
|
||||
}
|
||||
// Both windows closed, the controller's batch is cut first and starts; the catalogue batch queues behind it,
|
||||
// is cut when the controller's walk ended, and waits for its word with both merges.
|
||||
cutAt(t, open, t0.Add(2*time.Minute))
|
||||
ws, bs := walks(t, open)
|
||||
if len(ws) != 1 || ws[0].Waiting() || ws[0].CommitOf("novox/mesh-controller") != "k1" {
|
||||
t.Fatalf("the controller's batch was not cut first into a started walk: %+v", ws)
|
||||
}
|
||||
ownWalk := ws[0]
|
||||
for _, m := range []string{"app", "notes"} {
|
||||
if _, in := ownWalk.Modules[m]; in {
|
||||
t.Fatalf("the controller's walk builds %s, a catalogue module: it skipped its turn", m)
|
||||
}
|
||||
}
|
||||
if len(bs) != 1 || bs[0].State != inventory.PlanQueued || bs[0].Delivery.Batch.Behind != ownWalk.ID || bs[0].OwnPath() {
|
||||
t.Fatalf("the catalogue batch does not queue behind the controller's walk: %+v", bs)
|
||||
}
|
||||
ownWalk.State = inventory.PlanDone
|
||||
if err := open.inventory.SavePlan(ctx, &ownWalk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cutAt(t, open, t0.Add(3*time.Minute))
|
||||
ws, bs = walks(t, open)
|
||||
if len(bs) != 0 || ws[0].ID != catalogueBatch.ID || !ws[0].Waiting() || len(ws[0].Delivery.Merges) != 2 {
|
||||
t.Fatalf("the catalogue batch was not cut into a waiting walk once the controller's ended: %+v %+v", ws, bs)
|
||||
}
|
||||
for _, m := range []string{"app", "notes"} {
|
||||
if _, in := ws[0].Modules[m]; !in {
|
||||
t.Fatalf("the catalogue walk does not build %s: %v", m, ws[0].Modules)
|
||||
}
|
||||
}
|
||||
for _, a := range *asked {
|
||||
if a[1] == "modules/app" || a[1] == "modules/notes" {
|
||||
t.Fatalf("a catalogue module was asked without the word: %v", *asked)
|
||||
}
|
||||
}
|
||||
|
||||
// A catalogue walk waiting for its word when an own-path batch is cut keeps waiting beside the own-path
|
||||
// walk, is not folded into it, and its word is taken meanwhile: it starts once the own-path walk ended.
|
||||
catalogueWalk := ws[0]
|
||||
hear(t, open, repoMerge("mesh-controller", "k2", t0.Add(4*time.Minute)), t0.Add(4*time.Minute))
|
||||
cutAt(t, open, t0.Add(6*time.Minute))
|
||||
ws, _ = walks(t, open)
|
||||
kept, _ := open.inventory.PlanByID(ctx, catalogueWalk.ID)
|
||||
if !kept.Waiting() || ws[0].CommitOf("novox/mesh-controller") != "k2" || ws[0].Waiting() {
|
||||
t.Fatalf("the waiting catalogue walk was not kept waiting beside the controller's walk: %s %+v", kept.State, ws)
|
||||
}
|
||||
if _, in := ws[0].Modules["app"]; in {
|
||||
t.Fatalf("the controller's walk folded the waiting catalogue walk in: %v", ws[0].Modules)
|
||||
}
|
||||
if _, err := letGo(ctx, open.inventory, catalogueWalk.ID, catalogue.DeliverySeat, "its turn"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := len(*asked)
|
||||
advanceHeld(ctx, open)
|
||||
if len(*asked) != before {
|
||||
t.Fatalf("the catalogue walk started beside the controller's: asked %v", (*asked)[before:])
|
||||
}
|
||||
own2 := ws[0]
|
||||
own2.State = inventory.PlanDone
|
||||
if err := open.inventory.SavePlan(ctx, &own2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
advanceHeld(ctx, open)
|
||||
if len(*asked) < before+1 || (*asked)[before][1] != "modules/app" {
|
||||
t.Fatalf("the catalogue walk did not start once the controller's ended: %v", (*asked)[before:])
|
||||
}
|
||||
}
|
||||
|
||||
// `plans` names a walk by what it moves (asked by the operator, 2026-10-10): the repository's pull request and
|
||||
// title, the modules it moves and the machines running them, then the state words; a record naming no pull
|
||||
// request is named by its commit, and a title is cut at fifty runes.
|
||||
func TestAPlanLineNamesWhatItMoves(t *testing.T) {
|
||||
now := time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)
|
||||
p := inventory.Plan{ID: "plan-1", Repository: "novox/mesh-catalog", Branch: "main", Commit: "c1c1c1c1c1",
|
||||
State: inventory.PlanBuilding, Tiers: [][]string{{"messenger", "telegram"}}, TierEntered: now.Add(-2 * time.Minute),
|
||||
Modules: map[string]*inventory.PlanModule{"messenger": {State: "asked"}, "telegram": {State: "asked"}},
|
||||
Commits: []inventory.PlanCommit{{Repository: "novox/mesh-catalog", Branch: "main", Commit: "c1c1c1c1c1"}},
|
||||
Delivery: &inventory.PlanDelivery{Merges: []inventory.PlanMerge{{Repository: "novox/mesh-catalog",
|
||||
Commit: "c1c1c1c1c1", Number: 175, Title: "a tap shows its outcome", Moves: []string{"telegram", "messenger"}}}}}
|
||||
running := func(module string) []string {
|
||||
if module == "messenger" || module == "telegram" {
|
||||
return []string{"novox"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if got, want := planLineOn(p, now, pauseView{}, tierAtLeast, running),
|
||||
"mesh-catalog #175 a tap shows its outcome · messenger, telegram → novox · tier 1 of 1, building for 2m0s"; got != want {
|
||||
t.Fatalf("the line reads %q, want %q", got, want)
|
||||
}
|
||||
if got := planLine(p, now); !strings.HasPrefix(got, "mesh-catalog #175 a tap shows its outcome · messenger, telegram · tier") {
|
||||
t.Fatalf("without the machines the line reads %q", got)
|
||||
}
|
||||
old := p
|
||||
old.Commits, old.Delivery = nil, nil
|
||||
if got := planLine(old, now); !strings.HasPrefix(got, "mesh-catalog c1c1c1c1 · tier 1 of 1") {
|
||||
t.Fatalf("a record naming no pull request reads %q", got)
|
||||
}
|
||||
long := p
|
||||
long.Delivery.Merges[0].Title = strings.Repeat("abcdefghij", 6)
|
||||
if got := planHeadline(long, nil); !strings.Contains(got, "#175 "+strings.Repeat("abcdefghij", 4)+"abcdefghi…") {
|
||||
t.Fatalf("a long title is not cut at fifty runes: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// sameMerge compares what a walk names of a merge: its repository, commit and the commit carrying it.
|
||||
func sameMerge(a, b inventory.PlanMerge) bool {
|
||||
return a.Repository == b.Repository && a.Commit == b.Commit && a.Carried == b.Carried
|
||||
}
|
||||
|
||||
// A catalogue merge heard after a later merge of its branch was walked without the word (a merge that touched
|
||||
// the bus too, on the controller's own path) is not answered by that walk while the delivery seat has a holder:
|
||||
// its delivery would skip its turn. It joins the next catalogue batch.
|
||||
func TestALateCatalogueMergeIsNotAnsweredByAnOwnPathWalk(t *testing.T) {
|
||||
open := windowed(t)
|
||||
asksWithPaths(t)
|
||||
ctx := t.Context()
|
||||
for _, m := range []struct {
|
||||
module string
|
||||
claims []catalogue.Claim
|
||||
}{
|
||||
{"nats", nil},
|
||||
{"mesh-delivery", []catalogue.Claim{{Name: catalogue.DeliverySeat, Scope: catalogue.ScopeMesh}}},
|
||||
} {
|
||||
if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: m.module, Version: "1", Claims: m.claims},
|
||||
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/" + m.module, Ref: "main",
|
||||
BuiltFrom: "c0", Head: "c0"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := open.inventory.Assign(ctx, "anchor", "mesh-delivery"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mixed := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "m1xed", MergedAt: t0.Format(time.RFC3339Nano),
|
||||
Paths: []string{"modules/nats/module.json", "modules/app/module.json"}, ModuleDirs: []string{"modules/nats", "modules/app"},
|
||||
ModuleDirsSaid: true}
|
||||
hear(t, open, mixed, t0.Add(time.Second))
|
||||
cutAt(t, open, t0.Add(2*time.Minute))
|
||||
ws, _ := walks(t, open)
|
||||
if len(ws) != 1 || ws[0].Waiting() {
|
||||
t.Fatalf("the mixed merge's walk waited, or was not cut: %+v", ws)
|
||||
}
|
||||
done := ws[0]
|
||||
done.State = inventory.PlanDone
|
||||
if err := open.inventory.SavePlan(ctx, &done); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hear(t, open, catalogueMerge("ear1ier", "app", t0.Add(-30*time.Second)), t0.Add(3*time.Minute))
|
||||
got, _ := open.inventory.PlanByID(ctx, done.ID)
|
||||
_, bs := walks(t, open)
|
||||
if len(got.Delivery.Merges) != 1 || len(bs) != 1 || bs[0].OwnPath() || bs[0].Delivery.Merges[0].Commit != "ear1ier" {
|
||||
t.Fatalf("the late catalogue merge was answered by the own-path walk (%+v) rather than the next catalogue batch (%+v)",
|
||||
got.Delivery.Merges, bs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// **Every wait of a batch has a bound and a condition** (novox/hq ADR 0276 decision 9): a batch still
|
||||
// assembling a minute past its maximum while no walk is open is a cut the controller failed to make (S18),
|
||||
// and a batch waiting behind an open walk longer than that walk's bound waits on a walk gone wrong (S19).
|
||||
|
||||
// The kinds S18 and S19 raise.
|
||||
const (
|
||||
kindBatchNotCut = "batch-not-cut"
|
||||
kindBatchBehindWalk = "batch-behind-walk"
|
||||
)
|
||||
|
||||
// batchFacts is one batch not yet cut, as the watchdogs read it.
|
||||
type batchFacts struct {
|
||||
id, state, grouped string
|
||||
// atMost is when its window closes at the latest; closed when it closed.
|
||||
atMost, closed time.Time
|
||||
// behind is the open walk it waits behind, and walkBound that walk's bound: a tier's bound for each of its
|
||||
// tiers.
|
||||
behind string
|
||||
walkBound time.Duration
|
||||
}
|
||||
|
||||
// gatherBatches is every batch not yet cut, with the bound of the walk it waits behind.
|
||||
func gatherBatches(ctx context.Context, inv *inventory.Inventory, now time.Time) ([]batchFacts, error) {
|
||||
batches, err := inv.Batches(ctx)
|
||||
if err != nil || len(batches) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
bounds, err := measuredTierBounds(ctx, inv, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []batchFacts
|
||||
for _, b := range batches {
|
||||
f := batchFacts{id: b.ID, state: b.State, grouped: groupedWords(b)}
|
||||
if w := b.Delivery; w != nil && w.Batch != nil {
|
||||
f.atMost, f.closed, f.behind = w.Batch.AtMost, w.Batch.ClosesAt, w.Batch.Behind
|
||||
if f.atMost.Before(f.closed) {
|
||||
f.closed = f.atMost
|
||||
}
|
||||
}
|
||||
if f.behind != "" {
|
||||
if walk, err := inv.PlanByID(ctx, f.behind); err == nil {
|
||||
f.walkBound = bounds.of(walk.Repository) * time.Duration(max(1, len(walk.Tiers)))
|
||||
}
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// watchBatchesNotCut is S18: a batch still assembling a minute past its maximum, while no walk is open.
|
||||
func watchBatchesNotCut(f *signalFacts) []conditions.Observation {
|
||||
var out []conditions.Observation
|
||||
for _, b := range f.batches {
|
||||
if b.state != inventory.PlanAssembling || b.atMost.IsZero() || f.now.Sub(b.atMost) <= batchLateAfter {
|
||||
continue
|
||||
}
|
||||
late := f.now.Sub(b.atMost)
|
||||
out = append(out, conditions.Observation{Scope: conditions.ScopePlan, ID: b.id, Kind: kindBatchNotCut,
|
||||
Severity: conditions.Warning,
|
||||
Summary: fmt.Sprintf("the batch %s is still assembling %s past its latest close (%s), with no walk open: "+
|
||||
"the controller did not cut it; grouped: %s", b.id, ago(late), b.atMost.UTC().Format(time.RFC3339), b.grouped),
|
||||
Said: fmt.Sprintf("assembling since %s past its maximum", ago(late)),
|
||||
Headline: "Merged changes are not being delivered",
|
||||
Explanation: fmt.Sprintf("Merges collected for one delivery should have been planned %s ago and were not. "+
|
||||
"Nothing is lost; the mesh keeps them until it plans them.", humanDuration(late)),
|
||||
Resolved: "Merged changes are being delivered again"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// watchBatchesBehind is S19: a batch waiting behind an open walk longer than that walk's bound, naming it.
|
||||
func watchBatchesBehind(f *signalFacts) []conditions.Observation {
|
||||
var out []conditions.Observation
|
||||
for _, b := range f.batches {
|
||||
if b.state != inventory.PlanQueued || b.behind == "" || b.walkBound <= 0 || f.now.Sub(b.closed) <= b.walkBound {
|
||||
continue
|
||||
}
|
||||
in := f.now.Sub(b.closed)
|
||||
out = append(out, conditions.Observation{Scope: conditions.ScopePlan, ID: b.id, Kind: kindBatchBehindWalk,
|
||||
Severity: conditions.Warning,
|
||||
Summary: fmt.Sprintf("the batch %s has waited %s behind the walk %s, longer than that walk's bound (%s); "+
|
||||
"grouped: %s; `plans %s` says where that walk stands", b.id, ago(in), b.behind, ago(b.walkBound),
|
||||
b.grouped, b.behind),
|
||||
Said: fmt.Sprintf("queued behind %s for %s", b.behind, ago(in)),
|
||||
Headline: "Merged changes wait behind a slow delivery",
|
||||
Explanation: fmt.Sprintf("Merges collected for the next delivery have waited %s for the delivery before "+
|
||||
"them, which is taking longer than it should. Nothing is lost.", humanDuration(in)),
|
||||
Resolved: "Merged changes no longer wait behind a slow delivery"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -166,6 +166,10 @@ func buildFrom(result link.BuildResult) inventory.Build {
|
||||
for _, r := range result.Read {
|
||||
kept.Read = append(kept.Read, inventory.ReadRepository{Repository: r.Repository, Ref: r.Ref})
|
||||
}
|
||||
// What it was made from, as files (novox/hq ADR 0267): the planner maps the next merge onto it.
|
||||
for _, s := range result.Sources {
|
||||
kept.Sources = append(kept.Sources, inventory.BuildSource{Repository: s.Repository, Ref: s.Ref, Paths: s.Paths})
|
||||
}
|
||||
var announced []inventory.Artifact
|
||||
for _, made := range result.Made {
|
||||
announced = append(announced, inventory.Artifact{
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
@@ -208,7 +210,9 @@ func TestARollbackNeverPutsBackABuildFromAnotherRepository(t *testing.T) {
|
||||
if _, _, err := takeIn(ctx, inv, fork); !errors.Is(err, errNotItsSource) {
|
||||
t.Fatalf("the fork's build was taken in: %v", err)
|
||||
}
|
||||
failed := onTrunk("build-1791600000000000000", "novox/mesh-catalog", "git", "modules/sudo",
|
||||
// Asked after the registered build, whenever the test runs: an id naming a fixed moment read as older
|
||||
// than the registered build once the clock passed it (2026-10-10 02:40 UTC), and the test failed on main.
|
||||
failed := onTrunk(fmt.Sprintf("build-%d", time.Now().Add(time.Hour).UnixNano()), "novox/mesh-catalog", "git", "modules/sudo",
|
||||
map[string]any{"module": "sudo", "version": "2"})
|
||||
failed.Commit = "badbadbad0123456"
|
||||
if _, _, err := takeIn(ctx, inv, failed); err != nil {
|
||||
|
||||
@@ -53,9 +53,26 @@ func assertBusObjects(ctx context.Context, inv *inventory.Inventory, r broker.Ra
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// And the work queues of seats that name their caller or their kind, with each holder's worker
|
||||
// (novox/hq ADR 0259 §3): an ask queues until the router takes it, a channel's work until that kind
|
||||
// takes it.
|
||||
trafficStreams, trafficWorkers, err := seatTrafficObjects(ctx, inv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Every one tried, and every failure named: one module's consumer the bus refuses is no reason
|
||||
// the modules after it in the list hear nothing (novox/hq issue 208, where this runs on each send).
|
||||
var failed []error
|
||||
for _, s := range trafficStreams {
|
||||
if err := r.EnsureStream(s); err != nil {
|
||||
failed = append(failed, fmt.Errorf("the work queue %s: %w", s.Name, err))
|
||||
}
|
||||
}
|
||||
for _, c := range trafficWorkers {
|
||||
if err := r.EnsureConsumer(c); err != nil {
|
||||
failed = append(failed, fmt.Errorf("the worker %s on %s: %w", c.Name, c.Stream, err))
|
||||
}
|
||||
}
|
||||
for _, c := range consumers {
|
||||
if err := r.EnsureConsumer(c.Consumer); err != nil {
|
||||
failed = append(failed, fmt.Errorf("how %s on %s hears what it consumes: %w", c.Module, c.Node, err))
|
||||
@@ -130,6 +147,37 @@ func moduleConsumers(ctx context.Context, inv *inventory.Inventory) ([]broker.Mo
|
||||
return broker.ConsumersOf(users), nil
|
||||
}
|
||||
|
||||
// seatTrafficObjects is the work queues and workers of seats that name their caller or their kind, from
|
||||
// the records the user list is composed from.
|
||||
func seatTrafficObjects(ctx context.Context, inv *inventory.Inventory) ([]broker.Stream, []broker.Consumer, error) {
|
||||
records, err := inv.BusRecords(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
users, err := broker.Users(records)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
streams, workers := broker.SeatTrafficObjects(users)
|
||||
// And the queue of every such seat the catalogue declares, held or not: work queues from registration,
|
||||
// so what is submitted before a holder is assigned waits for it (the correctness review of 2026-10-08).
|
||||
declared, err := inv.DeclaredTrafficSeats(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
have := map[string]bool{}
|
||||
for _, s := range streams {
|
||||
have[s.Name] = true
|
||||
}
|
||||
for _, s := range broker.TrafficQueues(declared) {
|
||||
if !have[s.Name] {
|
||||
streams = append(streams, s)
|
||||
have[s.Name] = true
|
||||
}
|
||||
}
|
||||
return streams, workers, nil
|
||||
}
|
||||
|
||||
// moduleConsumerCount is how many modules hear what they consume, for the raise's one line.
|
||||
func moduleConsumerCount(ctx context.Context, inv *inventory.Inventory) (int, error) {
|
||||
consumers, err := moduleConsumers(ctx, inv)
|
||||
|
||||
@@ -32,6 +32,9 @@ import (
|
||||
// provision it wants that a manifest given here offers. An overflow is refused in the pull request
|
||||
// that introduces it — a new requirement, a lowered bound, a longer slug — instead of on the
|
||||
// provider's machine when a real machine's name first meets the module's.
|
||||
// SomeManifestsVar, set by the merge gate, says the manifests given are only some of their repository's.
|
||||
const SomeManifestsVar = "MESH_MODULE_CHECK_SOME"
|
||||
|
||||
func moduleCheck(paths []string, out io.Writer) error {
|
||||
return moduleCheckFor(paths, catalogue.DefaultLongestMachine, out)
|
||||
}
|
||||
@@ -85,12 +88,24 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
|
||||
// Between the manifests: a seat declared twice, a use of a seat nothing declares, a claim on
|
||||
// a seat that does not exist. Run only over what parsed, because a problem inside one manifest
|
||||
// has already been said and would be said again here in a worse form.
|
||||
//
|
||||
// **Over some of a repository's manifests, a seat none of them declares is a note** (novox/hq issue 364), as
|
||||
// this command's own word says above: the merge gate passes only the manifests a change touches, and says so
|
||||
// with SomeManifestsVar, so a module that uses or claims a seat another module declares (the operator
|
||||
// channel's, a channel bench) was refused there for a manifest it was not given. Given every manifest — the
|
||||
// catalogue's own check, and registration — it stays a refusal.
|
||||
some := os.Getenv(SomeManifestsVar) != ""
|
||||
problems := catalogue.CatalogueProblems(shelf)
|
||||
sort.Strings(problems)
|
||||
for _, p := range problems {
|
||||
if some && catalogue.IsUndeclaredSeat(p) {
|
||||
fmt.Fprintf(out, "note: %s among the manifests given; registration judges it against the whole catalogue, "+
|
||||
"and passing the declaring module's manifest too judges it here\n", p)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintln(out, p)
|
||||
failed++
|
||||
}
|
||||
failed += len(problems)
|
||||
|
||||
// Between the manifests too: an identity against the bounds of the provisions it wants, which
|
||||
// only the provider's manifest states.
|
||||
|
||||
@@ -82,7 +82,9 @@ func checkHereCommand(ctx context.Context, args []string) error {
|
||||
if _, err := git("fetch", "--quiet", "origin", *base); err != nil {
|
||||
return fmt.Errorf("cannot fetch %s to say what the change touches: %w", *base, err)
|
||||
}
|
||||
changedText, err := git("diff", "--name-only", "origin/"+*base+"...HEAD")
|
||||
// Without rename detection, so a file moved out of a build source is said under its old name as well:
|
||||
// its going is a change to the build that held it (novox/hq ADR 0267).
|
||||
changedText, err := git("diff", "--name-only", "--no-renames", "origin/"+*base+"...HEAD")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -103,3 +103,23 @@ func TestTheControllersManifestServesEveryVerbOfItsSeat(t *testing.T) {
|
||||
t.Fatalf("the controller's own module.json fails module check: %v\n%s", err, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A module that uses a seat another module declares (novox/hq issue 364): refused over the whole catalogue when the
|
||||
// declarer is missing, a note when the gate says it gives only the manifests a change touches.
|
||||
func TestASeatAnotherModuleDeclaresIsANoteOverSomeManifests(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
user := filepath.Join(dir, "user.json")
|
||||
os.WriteFile(user, []byte(`{"module":"asker","version":"1","uses":["operator-channel"]}`), 0o600)
|
||||
var out bytes.Buffer
|
||||
if err := moduleCheck([]string{user}, &out); err == nil {
|
||||
t.Fatalf("a use of a seat nothing given declares passed the whole-catalogue check:\n%s", out.String())
|
||||
}
|
||||
t.Setenv(SomeManifestsVar, "1")
|
||||
out.Reset()
|
||||
if err := moduleCheck([]string{user}, &out); err != nil {
|
||||
t.Fatalf("over some manifests the use was refused:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "note: asker uses the seat \"operator-channel\"") {
|
||||
t.Fatalf("the note was not said:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
read, err := inv.ReadRepositories(ctx)
|
||||
read, err := readForPlanning(ctx, inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ func keeperOn(ctx context.Context, conn *nats.Conn) (*conditions.Keeper, error)
|
||||
Say: func(format string, args ...any) { fmt.Fprintf(os.Stderr, format+"\n", args...) },
|
||||
// What status leads with changed: composed again soon (a nudge outside the serving controller
|
||||
// does nothing).
|
||||
Changed: statusFrom.nudge,
|
||||
Changed: func() { statusFrom.nudge(); askerFrom.nudge() },
|
||||
// Written under the lease, carrying its epoch (novox/hq to-be 45 §6).
|
||||
Epoch: func() (uint64, error) { return theLease.epoch(context.WithoutCancel(ctx)) }}), nil
|
||||
}
|
||||
|
||||
@@ -169,6 +169,44 @@ func TestAShrinkOfMoreThanHalfIsUrgent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// THE FALSE ALARM (issue 368), replayed through the store D13 reads: an agent's home moved from the
|
||||
// operator's own home (94.7 MB) to the agent account's fresh one (490 B), and `data-shrank` was raised
|
||||
// for data that was never lost. A moved item is read against its new path only, so nothing is raised —
|
||||
// and a genuine shrink at the new path, a week of history later, still is.
|
||||
func TestAMovedPathIsNoShrinkAndAShrinkThereStillIs(t *testing.T) {
|
||||
inv := inventory.ForTest(t)
|
||||
ctx := t.Context()
|
||||
shelf := shelfFor(t, houseManifest)
|
||||
declared := []inventory.DeclaredData{{Module: "house", Item: "config", Class: "irreplaceable", Owned: true}}
|
||||
start := time.Now().Add(-6 * time.Hour)
|
||||
measure := func(at time.Time, path string, size int64) []conditions.Observation {
|
||||
t.Helper()
|
||||
if _, err := inv.RecordData(ctx, "home", declared, map[string]map[string]inventory.Measurement{"house": {
|
||||
"config": {Path: path, Size: bytesOf(size), MeasuredAt: when(at), LastWrite: when(at),
|
||||
LastBackup: when(at)}}}, "", at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
records, err := inv.Data(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
peaks, err := inv.DataPeaks(ctx, at.Add(-shrinkWindow))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dataFindings(records, peaks, shelf, nil, nil, at)
|
||||
}
|
||||
measure(start, "/home/operator/.claude", 94_700_000)
|
||||
if got := findingsByKind(measure(start.Add(10*time.Minute), "/home/agent/.claude", 490)); got[kindDataShrank].Kind != "" {
|
||||
t.Fatalf("a moved path raised a shrink: %+v", got[kindDataShrank])
|
||||
}
|
||||
measure(start.Add(2*time.Hour), "/home/agent/.claude", 300<<20)
|
||||
got := findingsByKind(measure(start.Add(4*time.Hour), "/home/agent/.claude", 1<<20))[kindDataShrank]
|
||||
if got.Severity != conditions.Urgent || !strings.Contains(got.Summary, "shrank") {
|
||||
t.Fatalf("a genuine shrink at the new path was not raised: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Data said to be written all the time and not written; data with no backup or an old one — urgent when
|
||||
// irreplaceable, a warning when valuable; and a new item given its bound before it is said.
|
||||
func TestQuietDataAndMissingBackupsAreSaidByClass(t *testing.T) {
|
||||
|
||||
@@ -103,6 +103,8 @@ func letGo(ctx context.Context, inv *inventory.Inventory, id, by, why string) (i
|
||||
return p, fmt.Errorf("%s was let go by %s at %s already", p.ID, p.Delivery.By,
|
||||
p.Delivery.Go.Local().Format("15:04:05"))
|
||||
}
|
||||
// **One walk at a time** (novox/hq ADR 0276): the word is taken, and the walk starts once no other walk is
|
||||
// started (advanceOnce), so the delivery's owner says it once and is not refused.
|
||||
now := time.Now().UTC()
|
||||
p.Delivery.Go, p.Delivery.By, p.Delivery.Why = &now, by, why
|
||||
p.Note = "let go by " + by + "; its first tier is asked next"
|
||||
@@ -574,6 +576,12 @@ func deliveryCommand(ctx context.Context, args []string) error {
|
||||
if walks, err = inv.OpenPlans(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
// And the batch being assembled (novox/hq ADR 0276): never a walk to link or let go, shown.
|
||||
batches, err := inv.Batches(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
walks = append(walks, batches...)
|
||||
recent, err := inv.RecentPlans(ctx, *limit)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -604,7 +612,7 @@ func theGraph(ctx context.Context, inv *inventory.Inventory) ([]inventory.Entry,
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
read, err := inv.ReadRepositories(ctx)
|
||||
read, err := readForPlanning(ctx, inv)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
@@ -136,12 +136,13 @@ func TestTheDeliveryOwnerIsAskedOverTheBus(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, verb := range []string{"stalled", "close"} {
|
||||
// And release and stop, which the operator's warrant chooses (novox/hq ADR 0259).
|
||||
for _, verb := range []string{"stalled", "close", "release", "stop"} {
|
||||
if !slices.Contains(granted.Publish, link.SeatToolSubject(catalogue.DeliverySeat, verb)) {
|
||||
t.Errorf("the controller may not ask %s.%s", catalogue.DeliverySeat, verb)
|
||||
}
|
||||
}
|
||||
if _, err := askDeliveryOwner(t.Context(), nil, "stop", nil); err == nil || !strings.Contains(err.Error(), "grant") {
|
||||
if _, err := askDeliveryOwner(t.Context(), nil, "retire-history", nil); err == nil || !strings.Contains(err.Error(), "grant") {
|
||||
t.Fatalf("a verb the grant does not name was asked: %v", err)
|
||||
}
|
||||
conn, err := nats.Connect(testbus.URL(t))
|
||||
|
||||
@@ -227,7 +227,9 @@ func TestAMergeWaitsForItsDeliverysWordAndThePersonsWordWorksWithoutIt(t *testin
|
||||
t.Fatalf("with no holder on record the walk waited (%v) or asked %v", p.Waiting(), *asked)
|
||||
}
|
||||
|
||||
// The holder on record: the next merge waits, asking nothing, and an advance asks nothing either.
|
||||
// The holder on record: the next merge waits, asking nothing, and an advance asks nothing either. One walk
|
||||
// is open at a time (novox/hq ADR 0276): the first ends before the next merge's batch is cut.
|
||||
finish(p.ID)
|
||||
if _, err := inv.Assign(ctx, "anchor", "mesh-delivery"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -259,10 +261,11 @@ func TestAMergeWaitsForItsDeliverysWordAndThePersonsWordWorksWithoutIt(t *testin
|
||||
t.Fatalf("the word was not kept: %+v %v", got.Delivery, err)
|
||||
}
|
||||
|
||||
// The delivery's owner's own merge never waits for it — and takes over what the older walk had not
|
||||
// built (app, folded in: ADR 0218), which goes with it on the controller's own path.
|
||||
// The delivery's owner's own merge never waits for it. A walk that started is never taken over (novox/hq
|
||||
// ADR 0276): the merge's batch is cut once it ended.
|
||||
finish(p.ID)
|
||||
p = merge("c3cccccccc", "modules/mesh-delivery/main.go")
|
||||
if p.Waiting() || len(*asked) != 4 {
|
||||
if p.Waiting() || len(*asked) != 3 {
|
||||
t.Fatalf("mesh-delivery's own walk waited for mesh-delivery: %v %v", p.Waiting(), *asked)
|
||||
}
|
||||
|
||||
@@ -280,7 +283,7 @@ func TestAMergeWaitsForItsDeliverysWordAndThePersonsWordWorksWithoutIt(t *testin
|
||||
}
|
||||
advanceHeld(ctx, open)
|
||||
got, _ = inv.PlanByID(ctx, p.ID)
|
||||
if got.Waiting() || !strings.HasPrefix(got.Delivery.By, "a person") || len(*asked) < 5 {
|
||||
if got.Waiting() || !strings.HasPrefix(got.Delivery.By, "a person") || len(*asked) < 4 {
|
||||
t.Fatalf("a person's word did not start the walk: %+v, asked %v", got.Delivery, *asked)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/secrets"
|
||||
)
|
||||
|
||||
// A module's own secret given at the operator's desk (novox/hq ADR 0259 §10, ADR 0277).
|
||||
//
|
||||
// mesh-controller secret ask <node> <module> <name> [--at <desk>]
|
||||
//
|
||||
// **The value never passes through whoever asked for it.** An agent, or the operator at the mesh MCP
|
||||
// server, calls `secret-ask` (or `give`) with the machine, the module, the secret's name and the desk — never
|
||||
// a value. The controller makes a sealing keypair for this one call, asks the desk's `node-launcher.secret` to
|
||||
// prompt the operator without showing what is typed, and is answered with what was typed **sealed to that
|
||||
// key**: no plaintext on the bus, in a runtime's log or in any call's record. It opens it here, seals it to the
|
||||
// module's machine exactly as `secret accept` does, and forgets it. What it answers says only that the
|
||||
// value was taken, or why not.
|
||||
//
|
||||
// **Bounded, and the prompt says who asked** (ADR 0277): one open prompt per secret and few an hour, read from
|
||||
// the store before the prompt opens (inventory.OpenSecretAsk), so an agent cannot keep a prompt in front of the
|
||||
// operator until they type. The prompt names the module, the secret, the machine and who asked — the caller as
|
||||
// the bus named it, never a word the caller chose — written by the desk's launcher from those names alone.
|
||||
//
|
||||
// **What remains** (ADR 0234's accepted residual risk): on an X11 desk any program of the operator's
|
||||
// account can read the keys as they are typed. And a program that calls the desk's prompt itself, with a
|
||||
// key of its own, is answered with what the operator typed into a prompt they did not ask for — as it could
|
||||
// draw a window of its own. The prompt says who asks and for what, so the operator types only into a
|
||||
// prompt they started.
|
||||
|
||||
// deskPromptWithin is how long the prompt waits for the operator: below the runtime's thirty seconds for
|
||||
// one call, as the launcher's menu is.
|
||||
const deskPromptWithin = 25
|
||||
|
||||
// deskGive is the desk path, its four reaches given so a test needs no store and no bus.
|
||||
type deskGive struct {
|
||||
// declares refuses a module or a secret the mesh would refuse, before anybody is asked to type.
|
||||
declares func(module, name string) error
|
||||
// known refuses a machine the mesh does not know, before anybody is asked to type; nil knows every one
|
||||
// (a test that does not look).
|
||||
known func(machine string) error
|
||||
// trusted says a module runs as an account of its own: its secret is never taken at a desk (below). Nil is
|
||||
// never (a test that does not look).
|
||||
trusted func(module string) (bool, error)
|
||||
// ask asks one machine's node-launcher.secret and answers its result, or the holder's refusal.
|
||||
ask func(machine string, args map[string]any) (json.RawMessage, error)
|
||||
// accept seals the value as `secret accept` does, and says whether it lives until the module's start.
|
||||
accept func(value string) (untilStart bool, err error)
|
||||
// record writes the act in the hand-act log.
|
||||
record func(link.HandAct) error
|
||||
// announce raises the condition that says a module's own secret was given (secretGivenObservation), on
|
||||
// every channel; nil announces nothing (a test that does not look).
|
||||
announce func(node, module, name, how string) error
|
||||
// askedBy is who asked, as the bus named the caller: said in the prompt and recorded.
|
||||
askedBy string
|
||||
// open records the ask and holds the bounds (one open per secret, few an hour), answering the record's id;
|
||||
// nil keeps no record (a test that does not look). end closes it with how it ended.
|
||||
open func(node, module, name, desk string) (int64, error)
|
||||
end func(id int64, outcome string) error
|
||||
}
|
||||
|
||||
// askedByName is the caller as the prompt names it: the first clause of what the bus said, in the characters
|
||||
// a name has, at most 80 of them. The desk's launcher refuses anything else, so no words of the caller's own
|
||||
// reach the prompt.
|
||||
func askedByName(caller string) string {
|
||||
first, _, _ := strings.Cut(caller, ",")
|
||||
var b strings.Builder
|
||||
for _, r := range strings.TrimSpace(first) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '/', r == '@',
|
||||
r == '-', r == ' ':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune('-')
|
||||
}
|
||||
if b.Len() >= 80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
name := strings.TrimSpace(b.String())
|
||||
if name == "" || strings.HasPrefix(name, "-") {
|
||||
return "an unnamed caller"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// errNothingGiven is a prompt dismissed, or not answered in time: nothing changes.
|
||||
var errNothingGiven = errors.New("nothing was given")
|
||||
|
||||
// give asks the desk for the value and seals it; it answers the words said to the caller.
|
||||
func (d deskGive) give(node, module, name, desk string) (string, error) {
|
||||
for what, v := range map[string]string{"the machine": node, "the module": module, "the secret": name, "the desk": desk} {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return "", fmt.Errorf("%s is not named", what)
|
||||
}
|
||||
}
|
||||
if d.known != nil {
|
||||
for what, machine := range map[string]string{"the machine the secret is for": node, "the desk": desk} {
|
||||
if err := d.known(machine); err != nil {
|
||||
return "", fmt.Errorf("nobody was asked to type anything: %s, %s, is not a machine this mesh knows: %w",
|
||||
what, machine, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := d.declares(module, name); err != nil {
|
||||
return "", fmt.Errorf("nobody was asked to type anything: %w", err)
|
||||
}
|
||||
// **A trusted party's secret is not taken at a desk** (the confirmation review of 2026-10-09, N1-give). The
|
||||
// prompt's answer comes back over the desk machine's bus, on a subject that machine's runtime answers — and
|
||||
// on a desk machine agents run as the operator, who holds that runtime's credential. An agent could answer
|
||||
// first, with a bot token of its own sealed to this call's key, and the channel the operator's answers are
|
||||
// proven on would be the agent's. So the value of a module running as its own account is typed at the
|
||||
// controller's terminal, where no bus carries it.
|
||||
if d.trusted != nil {
|
||||
trusted, err := d.trusted(module)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("nobody was asked to type anything: whether %s is a trusted party could not be read: %w", module, err)
|
||||
}
|
||||
if trusted {
|
||||
return "", fmt.Errorf("nobody was asked to type anything: %s runs as an account of its own and proves the "+
|
||||
"operator's answers, so its %s is given at the controller's terminal alone — there, run `mesh-controller "+
|
||||
"secret accept %s %s %s` and type it at its prompt. A desk's prompt is answered over the desk machine's "+
|
||||
"bus, where an agent may answer first (novox/hq ADR 0259 §10)", module, name, node, module, name)
|
||||
}
|
||||
}
|
||||
// The bounds, read and kept before anybody is asked to type (novox/hq ADR 0277): one open prompt per secret,
|
||||
// few an hour. How the ask ends is recorded whatever happens below.
|
||||
outcome := "failed"
|
||||
if d.open != nil {
|
||||
id, err := d.open(node, module, name, desk)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("nobody was asked to type anything: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if d.end != nil {
|
||||
_ = d.end(id, outcome)
|
||||
}
|
||||
}()
|
||||
}
|
||||
public, private, err := secrets.Keypair()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no key could be made to take the value: %w", err)
|
||||
}
|
||||
// By name, never by words: the holder writes the prompt from these, and says the controller asks, which
|
||||
// the bus alone makes true (broker.ControllerOnly). Who asked is the bus's word on the caller, cut to a
|
||||
// name's characters — never an argument of the call.
|
||||
raw, err := d.ask(desk, map[string]any{
|
||||
"module": module,
|
||||
"secret": name,
|
||||
"node": node,
|
||||
"asked_by": askedByName(d.askedBy),
|
||||
"seal_to": public,
|
||||
"timeout_seconds": deskPromptWithin,
|
||||
})
|
||||
if err != nil {
|
||||
outcome = "refused"
|
||||
return "", fmt.Errorf("the desk on %s could not be asked: %w", desk, err)
|
||||
}
|
||||
var answer struct {
|
||||
Sealed string `json:"sealed"`
|
||||
Cancelled bool `json:"cancelled"`
|
||||
TimedOut bool `json:"timed_out"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||
return "", fmt.Errorf("the desk on %s answered something that is not the prompt's answer", desk)
|
||||
}
|
||||
switch {
|
||||
case answer.TimedOut:
|
||||
outcome = "timed-out"
|
||||
return "", fmt.Errorf("%w: the prompt on %s was not answered within %d seconds", errNothingGiven, desk, deskPromptWithin)
|
||||
case answer.Cancelled:
|
||||
outcome = "dismissed"
|
||||
return "", fmt.Errorf("%w: the prompt on %s was dismissed", errNothingGiven, desk)
|
||||
case answer.Sealed == "":
|
||||
return "", fmt.Errorf("the desk on %s answered no sealed value", desk)
|
||||
}
|
||||
opened, err := secrets.Open(private, answer.Sealed)
|
||||
if err != nil {
|
||||
// Never the value, never what failed to open: only that it was not sealed to this call.
|
||||
return "", fmt.Errorf("the desk on %s answered a value not sealed to this call; nothing was taken", desk)
|
||||
}
|
||||
value := asSupplied(string(opened))
|
||||
for i := range opened {
|
||||
opened[i] = 0
|
||||
}
|
||||
if strings.TrimSpace(value) == "" {
|
||||
outcome = "empty"
|
||||
return "", fmt.Errorf("%w: the prompt on %s was answered empty", errNothingGiven, desk)
|
||||
}
|
||||
untilStart, err := d.accept(value)
|
||||
value = ""
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
outcome = "given"
|
||||
act := link.HandAct{Verb: "secret accept", Args: []string{node, module, name, "--at-desk", desk},
|
||||
Why: fmt.Sprintf("the operator gave %s for %s on %s at the desk on %s, asked by %s", name, module, node, desk,
|
||||
askedByName(d.askedBy)),
|
||||
Cause: "given-at-the-desk"}
|
||||
recorded := ""
|
||||
if err := d.record(act); err != nil {
|
||||
recorded = fmt.Sprintf("\n this act could NOT be recorded in the hand-act log, and is done anyway: %v", err)
|
||||
}
|
||||
if d.announce != nil {
|
||||
if err := d.announce(node, module, name, "at the desk on "+desk); err != nil {
|
||||
recorded += fmt.Sprintf("\n this change could NOT be announced on the operator's channels: %v", err)
|
||||
}
|
||||
}
|
||||
words := fmt.Sprintf("%s on %s now holds %q, given at the desk on %s and sealed to %s; the mesh cannot read it "+
|
||||
"back.\n run `push %s` to send it", module, node, name, desk, node, node)
|
||||
if untilStart {
|
||||
words += fmt.Sprintf("\n it lives until %s next starts well under the mesh, and is then replaced with a value "+
|
||||
"the mesh makes (ADR 0228)", module)
|
||||
}
|
||||
return words + recorded, nil
|
||||
}
|
||||
|
||||
// askAtDesk is `secret ask <node> <module> <name> [--at <machine>]`, and the terminal's `secret accept … --at-desk
|
||||
// <machine>`: the desk path, on this controller's stores and bus. The desk is the module's machine unless named.
|
||||
func askAtDesk(ctx context.Context, node, module, name, desk string) error {
|
||||
if desk == "" {
|
||||
desk = node
|
||||
}
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer open.Close()
|
||||
d := deskGive{
|
||||
askedBy: link.Caller(),
|
||||
open: func(node, module, name, desk string) (int64, error) {
|
||||
return open.inventory.OpenSecretAsk(ctx, node, module, name, askedByName(link.Caller()), desk)
|
||||
},
|
||||
end: func(id int64, outcome string) error { return open.inventory.EndSecretAsk(ctx, id, outcome) },
|
||||
declares: func(module, name string) error { return open.inventory.DeclaresOwnSecret(ctx, module, name) },
|
||||
known: func(machine string) error {
|
||||
_, err := open.inventory.NodeByName(ctx, machine)
|
||||
return err
|
||||
},
|
||||
trusted: func(module string) (bool, error) { return open.inventory.RunsAsItsOwnAccount(ctx, module) },
|
||||
ask: func(machine string, args map[string]any) (json.RawMessage, error) {
|
||||
var result json.RawMessage
|
||||
err := onTheBus(func(conn *nats.Conn) error {
|
||||
answer, err := link.AskSeatTool(ctx, conn, "node-launcher", "secret", machine, args,
|
||||
time.Duration(deskPromptWithin+5)*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if answer.Error != "" {
|
||||
return errors.New(answer.Error)
|
||||
}
|
||||
result = answer.Result
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
},
|
||||
accept: func(value string) (bool, error) {
|
||||
return open.inventory.AcceptGivenSecret(ctx, node, module, name, value)
|
||||
},
|
||||
record: func(act link.HandAct) error {
|
||||
return onTheBus(func(conn *nats.Conn) error {
|
||||
_, err := link.RecordHandAct(ctx, conn, act)
|
||||
return err
|
||||
})
|
||||
},
|
||||
announce: func(node, module, name, how string) error { return announceSecretGiven(ctx, node, module, name, how) },
|
||||
}
|
||||
words, err := d.give(node, module, name, desk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(words)
|
||||
return nil
|
||||
}
|
||||
|
||||
// kindSecretGiven is the condition every value given for a module's own secret raises (the review of 2026-10-09,
|
||||
// M4): on every channel, so a bot token changed by somebody else — a channel that now answers for them — is
|
||||
// heard of. It stays until the operator silences or clears it.
|
||||
const kindSecretGiven = "secret-given"
|
||||
|
||||
// secretGivenObservation is that condition: which secret, of which module on which machine, how and when.
|
||||
// The summary, for whoever looks closer, names the secret and the time. The words the operator reads are
|
||||
// held to the plain rule (conditions.PlainWords): no clock time — the channel says when, in the operator's
|
||||
// time — and the secret's name said as words. Words that broke the rule were replaced by the keeper with
|
||||
// "needs a look … a problem it calls secret given" (hq issue 359), which told the operator nothing.
|
||||
func secretGivenObservation(node, module, name, how string, at time.Time) conditions.Observation {
|
||||
key := node + "." + module + "." + name
|
||||
where := module + " on " + node
|
||||
// Within the bounds whatever the names' length: the module and machine, else the module, else the machine.
|
||||
headline := "New secret given for " + where
|
||||
for _, h := range []string{"New secret given for " + module, "New secret given on " + node} {
|
||||
if len(headline) > conditions.HeadlineMax {
|
||||
headline = h
|
||||
}
|
||||
}
|
||||
resolved := "You saw that " + module + " was given a new secret"
|
||||
if len(resolved) > conditions.HeadlineMax+20 {
|
||||
resolved = "You saw that a new secret was given on " + node
|
||||
}
|
||||
return conditions.Observation{Scope: conditions.ScopeMachine, ID: key, Token: kindSecretGiven, Kind: kindSecretGiven,
|
||||
Machine: node, Severity: conditions.Urgent, Source: kindSecretGiven,
|
||||
Summary: fmt.Sprintf("%s of %s on %s was given %s at %s", name, module, node, how,
|
||||
at.Local().Format("2006-01-02 15:04")),
|
||||
Headline: headline,
|
||||
Explanation: fmt.Sprintf("The secret %s of %s was given %s. If you gave it, nothing else is needed. If you "+
|
||||
"did not, somebody else now holds what %s acts with.", secretNameWords(name), where, how, module),
|
||||
Needs: "silence this if you just gave it; if you did not, give it again yourself so that only you hold it.",
|
||||
Resolved: resolved,
|
||||
Actions: []conditions.Action{conditions.SilenceAction(conditions.Key(conditions.ScopeMachine, key, kindSecretGiven))}}
|
||||
}
|
||||
|
||||
// secretNameWords is a secret's name as the operator reads it: "telegram-token" is "telegram token".
|
||||
func secretNameWords(name string) string {
|
||||
return strings.Join(strings.FieldsFunc(name, func(r rune) bool { return r == '-' || r == '_' || r == '.' }), " ")
|
||||
}
|
||||
|
||||
// announceSecretGiven raises it on this controller's keeper.
|
||||
func announceSecretGiven(ctx context.Context, node, module, name, how string) error {
|
||||
return withKeeper(ctx, func(k *conditions.Keeper) error {
|
||||
_, err := k.Observe(ctx, secretGivenObservation(node, module, name, how, time.Now()))
|
||||
return err
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/secrets"
|
||||
)
|
||||
|
||||
const typed = "123456789:AAEhBP0av28P4XFQnIuR-o-7Xnz1kkUzW3g"
|
||||
|
||||
// aDesk is the desk path with a prompt the test answers as the operator would, and what it was asked kept.
|
||||
func aDesk(t *testing.T, answer func(args map[string]any) (json.RawMessage, error)) (deskGive, *[]string, *[]link.HandAct, *[]map[string]any) {
|
||||
t.Helper()
|
||||
var accepted []string
|
||||
var acts []link.HandAct
|
||||
var asked []map[string]any
|
||||
return deskGive{
|
||||
declares: func(module, name string) error {
|
||||
if module != "telegram" || name != "telegram-token" {
|
||||
return errors.New(module + " does not declare " + name + " as an own secret")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
ask: func(machine string, args map[string]any) (json.RawMessage, error) {
|
||||
asked = append(asked, args)
|
||||
return answer(args)
|
||||
},
|
||||
accept: func(value string) (bool, error) { accepted = append(accepted, value); return false, nil },
|
||||
record: func(a link.HandAct) error { acts = append(acts, a); return nil },
|
||||
}, &accepted, &acts, &asked
|
||||
}
|
||||
|
||||
func sealedTo(t *testing.T, value string) func(args map[string]any) (json.RawMessage, error) {
|
||||
return func(args map[string]any) (json.RawMessage, error) {
|
||||
sealed, err := secrets.Seal(args["seal_to"].(string), []byte(value+"\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"sealed": sealed})
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0259 §10: the value typed at the desk is sealed as `secret accept` seals it, and is in no
|
||||
// answer, no prompt argument and no act recorded.
|
||||
func TestASecretGivenAtTheDeskIsSealedAndSaidNowhere(t *testing.T) {
|
||||
d, accepted, acts, asked := aDesk(t, sealedTo(t, typed))
|
||||
words, err := d.give("anchor", "telegram", "telegram-token", "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*accepted) != 1 || (*accepted)[0] != typed {
|
||||
t.Fatalf("the value sealed is not what was typed, its line ending taken off")
|
||||
}
|
||||
if len(*acts) != 1 || (*acts)[0].Verb != "secret accept" || (*acts)[0].Cause != "given-at-the-desk" ||
|
||||
!strings.Contains((*acts)[0].Why, "at the desk on laptop") {
|
||||
t.Errorf("the act: %+v", *acts)
|
||||
}
|
||||
raw, _ := json.Marshal(struct {
|
||||
Words string
|
||||
Acts []link.HandAct
|
||||
Asked []map[string]any
|
||||
}{words, *acts, *asked})
|
||||
if strings.Contains(string(raw), typed) || strings.Contains(string(raw), "AAEhBP0") {
|
||||
t.Fatal("the value appears in what was said, asked or recorded")
|
||||
}
|
||||
if !strings.Contains(words, "push anchor") || !strings.Contains(words, "given at the desk on laptop") {
|
||||
t.Errorf("%q", words)
|
||||
}
|
||||
if p := (*asked)[0]; p["seal_to"] == "" || p["timeout_seconds"] != deskPromptWithin {
|
||||
t.Errorf("the prompt was asked %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingIsAskedForASecretTheMeshWouldRefuse(t *testing.T) {
|
||||
for _, c := range [][2]string{{"telegram", "chat-id"}, {"nobody", "telegram-token"}} {
|
||||
d, accepted, _, asked := aDesk(t, sealedTo(t, typed))
|
||||
if _, err := d.give("anchor", c[0], c[1], "laptop"); err == nil || !strings.Contains(err.Error(), "nobody was asked") {
|
||||
t.Errorf("%v: %v", c, err)
|
||||
}
|
||||
if len(*asked) != 0 || len(*accepted) != 0 {
|
||||
t.Errorf("%v: the operator was asked anyway", c)
|
||||
}
|
||||
}
|
||||
d, _, _, _ := aDesk(t, sealedTo(t, typed))
|
||||
if _, err := d.give("anchor", "telegram", "telegram-token", ""); err == nil {
|
||||
t.Error("no desk was refused nowhere")
|
||||
}
|
||||
}
|
||||
|
||||
func TestADismissedEmptyLateOrForeignAnswerTakesNothing(t *testing.T) {
|
||||
for want, answer := range map[string]func(map[string]any) (json.RawMessage, error){
|
||||
"not answered within 25 seconds": func(map[string]any) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"cancelled":true,"timed_out":true}`), nil
|
||||
},
|
||||
"was dismissed": func(map[string]any) (json.RawMessage, error) { return json.RawMessage(`{"cancelled":true}`), nil },
|
||||
"answered empty": sealedTo(t, " "),
|
||||
"not sealed to this call": func(map[string]any) (json.RawMessage, error) {
|
||||
other, _, _ := secrets.Keypair()
|
||||
sealed, _ := secrets.Seal(other, []byte(typed))
|
||||
raw, _ := json.Marshal(map[string]any{"sealed": sealed})
|
||||
return raw, nil
|
||||
},
|
||||
"could not be asked": func(map[string]any) (json.RawMessage, error) { return nil, errors.New("no session answers") },
|
||||
} {
|
||||
d, accepted, acts, _ := aDesk(t, answer)
|
||||
_, err := d.give("anchor", "telegram", "telegram-token", "laptop")
|
||||
if err == nil || !strings.Contains(err.Error(), want) || strings.Contains(err.Error(), typed) {
|
||||
t.Errorf("want %q, got %v", want, err)
|
||||
}
|
||||
if len(*accepted) != 0 || len(*acts) != 0 {
|
||||
t.Errorf("%s: something was taken or recorded", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheGiveVerbRunsTheDeskPathAndTheControllerMayAskTheDesk(t *testing.T) {
|
||||
argv, err := argvFor("give", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token", "at": "laptop"})
|
||||
if err != nil || strings.Join(argv, " ") != "secret ask anchor telegram telegram-token --at laptop" {
|
||||
t.Fatalf("%v %v", argv, err)
|
||||
}
|
||||
if _, err := argvFor("give", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token"}); err == nil {
|
||||
t.Error("give without a desk was taken")
|
||||
}
|
||||
// secret-ask is the same line, with the desk the module's machine unless named (novox/hq ADR 0277).
|
||||
argv, err = argvFor("secret-ask", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token"})
|
||||
if err != nil || strings.Join(argv, " ") != "secret ask anchor telegram telegram-token" {
|
||||
t.Fatalf("%v %v", argv, err)
|
||||
}
|
||||
argv, err = argvFor("secret-ask", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token", "at": "laptop"})
|
||||
if err != nil || strings.Join(argv, " ") != "secret ask anchor telegram telegram-token --at laptop" {
|
||||
t.Fatalf("%v %v", argv, err)
|
||||
}
|
||||
perms, err := broker.PermissionsFor(broker.Principal{Kind: broker.KindController})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, p := range perms.Publish {
|
||||
found = found || p == "mesh.seat.node-launcher.tool.secret.*"
|
||||
}
|
||||
if !found {
|
||||
t.Error("the controller may not ask the desk's prompt")
|
||||
}
|
||||
}
|
||||
|
||||
// The review of 2026-10-09 (M4): the desk's prompt says who asks in words the caller does not choose — the
|
||||
// controller, which the bus alone lets ask it — and what for, from names the controller checked; the prompt
|
||||
// carries no free text of the caller's.
|
||||
func TestThePromptIsAskedByNameNeverByWordsTheCallerChose(t *testing.T) {
|
||||
d, _, _, asked := aDesk(t, sealedTo(t, typed))
|
||||
if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := (*asked)[0]
|
||||
if p["module"] != "telegram" || p["secret"] != "telegram-token" || p["node"] != "anchor" {
|
||||
t.Errorf("the prompt was not asked by name: %v", p)
|
||||
}
|
||||
for _, free := range []string{"prompt", "message"} {
|
||||
if _, there := p[free]; there {
|
||||
t.Errorf("the prompt carries the caller's %s: %v", free, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every value given for a module's own secret is announced as a condition, on every channel (the review of
|
||||
// 2026-10-09, M4): a bot token changed by somebody else is a channel that now answers for them.
|
||||
func TestAValueGivenAtTheDeskIsAnnounced(t *testing.T) {
|
||||
d, _, _, _ := aDesk(t, sealedTo(t, typed))
|
||||
var said []string
|
||||
d.announce = func(node, module, name, how string) error {
|
||||
said = append(said, node+" "+module+" "+name+" "+how)
|
||||
return nil
|
||||
}
|
||||
if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(said) != 1 || !strings.Contains(said[0], "anchor telegram telegram-token") || !strings.Contains(said[0], "laptop") {
|
||||
t.Fatalf("announced %v", said)
|
||||
}
|
||||
o := secretGivenObservation("anchor", "telegram", "telegram-token", "at the desk on laptop", time.Date(2026, 10, 9, 12, 3, 0, 0, time.UTC))
|
||||
if o.Severity != conditions.Urgent || !strings.Contains(o.Explanation, "telegram token") ||
|
||||
len(o.Actions) == 0 || o.Key() == "" {
|
||||
t.Errorf("the announcement %+v", o)
|
||||
}
|
||||
if strings.Contains(o.Summary+o.Explanation+o.Said, typed) {
|
||||
t.Error("the announcement carries the value")
|
||||
}
|
||||
}
|
||||
|
||||
// **The secret-given condition says itself in its own plain words** (hq issue 359): the words it carries pass
|
||||
// the plain rule, from the controller's terminal and from a desk, so the keeper keeps them — they once held a
|
||||
// clock time, and the operator read "Novox needs a look … a problem it calls secret given" instead. Its
|
||||
// severity and its answer stay: it is heard on every channel, and silenced by the operator.
|
||||
func TestTheSecretGivenConditionSaysItselfInPlainWords(t *testing.T) {
|
||||
at := time.Date(2026, 10, 9, 23, 32, 0, 0, time.Local)
|
||||
for _, how := range []string{"at the controller's terminal", "at the desk on laptop"} {
|
||||
o := secretGivenObservation("anchor", "telegram", "telegram-token", how, at)
|
||||
w := conditions.Words{Headline: o.Headline, Explanation: o.Explanation, Resolved: o.Resolved, Needs: o.Needs,
|
||||
Actions: o.Actions}
|
||||
if why, ok := conditions.PlainWords(w, o.Machine); !ok {
|
||||
t.Fatalf("given %s, the words are not plain: %s", how, why)
|
||||
}
|
||||
k, _ := withConditionsInMemory(t)
|
||||
c, err := k.Observe(t.Context(), o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Headline != "New secret given for telegram on anchor" || c.Severity != conditions.Urgent ||
|
||||
!strings.HasPrefix(c.Explanation, conditions.NeedsYou+" silence this") ||
|
||||
!strings.Contains(c.Explanation, "telegram token of telegram on anchor was given "+how) ||
|
||||
len(c.Actions) != 1 || c.Actions[0].Label != "Silence for a week" {
|
||||
t.Errorf("given %s, the keeper said %q / %q (%s, %v)", how, c.Headline, c.Explanation, c.Severity, c.Actions)
|
||||
}
|
||||
if strings.Contains(c.Headline+c.Explanation+c.Resolved+c.Needs, typed) {
|
||||
t.Error("the words carry the value")
|
||||
}
|
||||
}
|
||||
// A long module name keeps the headline and the resolved line within their bounds.
|
||||
for _, module := range []string{"a-module-with-a-rather-long-name-indeed",
|
||||
"a-module-with-a-name-so-long-that-no-headline-could-ever-hold-it"} {
|
||||
o := secretGivenObservation("anchor", module, "api-key", "at the controller's terminal", at)
|
||||
if why, ok := conditions.PlainWords(conditions.Words{Headline: o.Headline, Explanation: o.Explanation,
|
||||
Resolved: o.Resolved, Needs: o.Needs, Actions: o.Actions}, o.Machine); !ok {
|
||||
t.Errorf("the module %s: %s", module, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The bus lets the controller alone ask the desk's prompt (the review of 2026-10-09, M4): the runtime, which
|
||||
// carries every agent's calls, and a person granted every tool are denied it, however wide their grant.
|
||||
func TestOnlyTheControllerMayAskTheDesksPrompt(t *testing.T) {
|
||||
for _, p := range []broker.Principal{
|
||||
{Kind: broker.KindNodeTools, Node: "laptop"},
|
||||
{Kind: broker.KindPerson, Module: "operator", Invokes: []string{"*"}},
|
||||
{Kind: broker.KindModule, Node: "laptop", Module: "lab", Invokes: []string{"seat:node-launcher.secret"}},
|
||||
} {
|
||||
perms, err := broker.PermissionsFor(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, subject := range []string{"mesh.seat.node-launcher.tool.secret.laptop", "mesh.seat.node-launcher.tool.secret",
|
||||
"mesh.mod.rofi.tool.node-launcher.secret", "mesh.mod.rofi.tool.node-launcher.secret.laptop"} {
|
||||
if broker.MayPublish(perms, subject) {
|
||||
t.Errorf("%s may publish %s", p.Username(), subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
perms, _ := broker.PermissionsFor(broker.Principal{Kind: broker.KindController})
|
||||
if !broker.MayPublish(perms, "mesh.seat.node-launcher.tool.secret.laptop") {
|
||||
t.Error("the controller may not ask the desk's prompt")
|
||||
}
|
||||
}
|
||||
|
||||
// A value for a secret comes from the terminal or the desk, never through a verb (the review of 2026-10-09,
|
||||
// M4): `secret accept` with a value, run for a verb, is refused before anything is read.
|
||||
func TestASecretValueIsNeverAcceptedThroughAVerb(t *testing.T) {
|
||||
t.Setenv(verbVar, "mesh-controller.command")
|
||||
for _, args := range [][]string{
|
||||
{"accept", "anchor", "telegram", "telegram-token", "--from", "/dev/null"},
|
||||
{"accept", "anchor", "app", "db", "--from", "/dev/null", "--provider", "store"},
|
||||
} {
|
||||
err := secretCommand(context.Background(), args)
|
||||
if err == nil || !strings.Contains(err.Error(), "never through a verb") {
|
||||
t.Errorf("%v: %v", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The `give` and `secret-ask` verbs' own line passes the terminal-only rule of ADR 0266, and no `secret accept`
|
||||
// does: a value, a file, a provider or an extra word is still the terminal's alone (novox/hq ADR 0277).
|
||||
func TestOnlyTheGiveLinePassesTheTerminalRuleForSecrets(t *testing.T) {
|
||||
for _, argv := range [][]string{
|
||||
{"secret", "ask", "anchor", "telegram", "telegram-token", "--at", "laptop"},
|
||||
{"secret", "ask", "anchor", "telegram", "telegram-token"},
|
||||
} {
|
||||
if err := terminalOnly(argv); err != nil {
|
||||
t.Errorf("%v refused: %v", argv, err)
|
||||
}
|
||||
}
|
||||
for _, argv := range [][]string{
|
||||
{"secret", "accept", "anchor", "telegram", "telegram-token", "--at-desk", "laptop"},
|
||||
{"secret", "ask", "anchor", "telegram", "telegram-token", "--from", "/tmp/x"},
|
||||
{"secret", "ask", "anchor", "telegram", "telegram-token", "--at", "laptop", "--local"},
|
||||
{"secret", "ask", "anchor", "telegram", "--at", "laptop"},
|
||||
{"secret", "accept", "anchor", "telegram", "telegram-token"},
|
||||
{"secret", "accept", "anchor", "telegram", "telegram-token", "--from", "/tmp/x"},
|
||||
{"secret", "accept", "anchor", "telegram", "telegram-token", "--at-desk", "laptop", "--local"},
|
||||
{"secret", "accept", "anchor", "telegram", "--provider", "--at-desk", "laptop"},
|
||||
{"secret", "export", "anchor", "telegram", "telegram-token", "--at-desk", "laptop"},
|
||||
} {
|
||||
if err := terminalOnly(argv); err == nil {
|
||||
t.Errorf("%v passed the terminal rule", argv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The confirmation review of 2026-10-09, N1-give: a desk's prompt is answered over the desk machine's bus, and
|
||||
// on a desk machine agents run as the operator, who holds its runtime's credential — so a trusted party's
|
||||
// secret (a module running as an account of its own: the Telegram bot's token) is never taken at a desk.
|
||||
// Refused before anybody is asked to type, whoever called, naming the terminal's line.
|
||||
func TestATrustedPartysSecretIsNeverTakenAtADesk(t *testing.T) {
|
||||
d, accepted, acts, asked := aDesk(t, sealedTo(t, typed))
|
||||
d.trusted = func(module string) (bool, error) { return module == "telegram", nil }
|
||||
_, err := d.give("anchor", "telegram", "telegram-token", "laptop")
|
||||
if err == nil || !strings.Contains(err.Error(), "controller's terminal alone") ||
|
||||
!strings.Contains(err.Error(), "secret accept anchor telegram telegram-token") {
|
||||
t.Fatalf("a trusted party's secret was taken at the desk, or refused without the line: %v", err)
|
||||
}
|
||||
if len(*asked)+len(*accepted)+len(*acts) != 0 {
|
||||
t.Errorf("asked %v, accepted %d, recorded %v", *asked, len(*accepted), *acts)
|
||||
}
|
||||
d.trusted = func(string) (bool, error) { return false, errors.New("the store did not answer") }
|
||||
if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err == nil || len(*asked) != 0 {
|
||||
t.Errorf("a module not known to be untrusted was asked at the desk: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// And who may answer the desk's prompt at all: only the runtime of the machine it is asked on, carrying the
|
||||
// launcher that holds the seat there — never the controller, another machine's runtime, or a module's own
|
||||
// account (the confirmation review of 2026-10-09, N1-give).
|
||||
func TestOnlyTheDeskMachinesLauncherMayAnswerItsPrompt(t *testing.T) {
|
||||
launcher := broker.Declared{Module: "rofi", Holds: []broker.Seat{{Name: "node-launcher", Scope: "node",
|
||||
Serves: []string{"run", "secret"}}}}
|
||||
subject := "mesh.seat.node-launcher.tool.secret.laptop"
|
||||
for _, c := range []struct {
|
||||
p broker.Principal
|
||||
answers bool
|
||||
}{
|
||||
{broker.Principal{Kind: broker.KindNodeTools, Node: "laptop", Module: broker.RuntimeModule, Carries: []broker.Declared{launcher}}, true},
|
||||
{broker.Principal{Kind: broker.KindNodeTools, Node: "anchor", Module: broker.RuntimeModule, Carries: []broker.Declared{launcher}}, false},
|
||||
{broker.Principal{Kind: broker.KindController}, false},
|
||||
{broker.Principal{Kind: broker.KindModule, Node: "laptop", Module: "lab"}, false},
|
||||
{broker.Principal{Kind: broker.KindNode, Node: "laptop"}, false},
|
||||
} {
|
||||
perms, err := broker.PermissionsFor(c.p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := broker.MaySubscribe(perms, subject); got != c.answers {
|
||||
t.Errorf("%s may answer %s: %v, want %v", c.p.Username(), subject, got, c.answers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// N1-give at the controller's terminal (the confirmation review of 2026-10-09): a trusted party's secret is
|
||||
// announced before it is kept, and not kept when the announcement fails; another module's is kept first and
|
||||
// a failed announcement is said, not undone.
|
||||
func TestATrustedPartysSecretGivenAtTheTerminalIsAnnouncedBeforeItIsKept(t *testing.T) {
|
||||
var order []string
|
||||
announce := func(fail bool) func() error {
|
||||
return func() error {
|
||||
order = append(order, "announce")
|
||||
if fail {
|
||||
return errors.New("no channel")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
keep := func() (bool, error) { order = append(order, "keep"); return false, nil }
|
||||
|
||||
order = nil
|
||||
if _, unannounced, err := keepGiven(true, announce(false), keep); err != nil || unannounced != nil ||
|
||||
strings.Join(order, ",") != "announce,keep" {
|
||||
t.Errorf("trusted: %v %v, order %v; want announced, then kept", unannounced, err, order)
|
||||
}
|
||||
order = nil
|
||||
if _, _, err := keepGiven(true, announce(true), keep); err == nil || strings.Join(order, ",") != "announce" {
|
||||
t.Errorf("trusted, announcement failed: %v, order %v; want refused and nothing kept", err, order)
|
||||
}
|
||||
order = nil
|
||||
if _, unannounced, err := keepGiven(false, announce(true), keep); err != nil || unannounced == nil ||
|
||||
strings.Join(order, ",") != "keep,announce" {
|
||||
t.Errorf("not trusted: %v %v, order %v; want kept, then the failed announcement said", unannounced, err, order)
|
||||
}
|
||||
order = nil
|
||||
failing := func() (bool, error) { order = append(order, "keep"); return false, errors.New("store away") }
|
||||
if _, _, err := keepGiven(false, announce(false), failing); err == nil || strings.Join(order, ",") != "keep" {
|
||||
t.Errorf("not trusted, keep failed: %v, order %v; want refused and nothing announced", err, order)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine the mesh does not know, as the secret's or as the desk, is refused before anybody is asked to type.
|
||||
func TestAGiveNamingAMachineTheMeshDoesNotKnowAsksNobody(t *testing.T) {
|
||||
for _, unknown := range []string{"elsewhere", "nodesk"} {
|
||||
d, accepted, acts, asked := aDesk(t, func(map[string]any) (json.RawMessage, error) {
|
||||
t.Fatal("the desk was asked")
|
||||
return nil, nil
|
||||
})
|
||||
d.known = func(machine string) error {
|
||||
if machine == unknown {
|
||||
return errors.New("no node " + machine)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
node, desk := "anchor", "laptop"
|
||||
if unknown == "elsewhere" {
|
||||
node = unknown
|
||||
} else {
|
||||
desk = unknown
|
||||
}
|
||||
_, err := d.give(node, "telegram", "telegram-token", desk)
|
||||
if err == nil || !strings.Contains(err.Error(), "nobody was asked") || !strings.Contains(err.Error(), unknown) {
|
||||
t.Errorf("%s: %v", unknown, err)
|
||||
}
|
||||
if len(*accepted)+len(*acts)+len(*asked) != 0 {
|
||||
t.Errorf("%s: something happened: %v %v %v", unknown, *accepted, *acts, *asked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0277: the prompt names who asked — the controller's word on the bus's caller, cut to a name's
|
||||
// characters — and never a word the caller chose: there is no argument for it.
|
||||
func TestThePromptNamesWhoAskedFromTheBussWordAlone(t *testing.T) {
|
||||
d, _, acts, asked := aDesk(t, sealedTo(t, typed))
|
||||
d.askedBy = "g14/claude-code, through the mesh-controller seat"
|
||||
if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := (*asked)[0]["asked_by"]; got != "g14/claude-code" {
|
||||
t.Errorf("asked_by %q", got)
|
||||
}
|
||||
if len(*acts) != 1 || !strings.Contains((*acts)[0].Why, "asked by g14/claude-code") {
|
||||
t.Errorf("the record: %+v", *acts)
|
||||
}
|
||||
for in, want := range map[string]string{
|
||||
"jochen at a shell on novox": "jochen at a shell on novox",
|
||||
"laptop/agent": "laptop/agent",
|
||||
"Your bank asks\nType your PIN, now": "Your bank asks-Type your PIN",
|
||||
"": "an unnamed caller",
|
||||
"<b>x</b>": "an unnamed caller",
|
||||
strings.Repeat("a", 100): strings.Repeat("a", 80),
|
||||
"--prompt, something else": "an unnamed caller",
|
||||
} {
|
||||
if got := askedByName(in); got != want {
|
||||
t.Errorf("askedByName(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
// The verb's schema has no argument that reaches the prompt's words.
|
||||
for _, verb := range []string{"give", "secret-ask"} {
|
||||
for _, free := range []string{"asked_by", "prompt", "message", "value", "from"} {
|
||||
if _, err := argvFor(verb, map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token",
|
||||
"at": "laptop", free: "x"}); err == nil {
|
||||
t.Errorf("%s takes %s", verb, free)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An ask for a secret is bounded before anybody is asked to type (ADR 0277): the record refuses it, nothing is
|
||||
// asked; and how every ask ends is recorded.
|
||||
func TestASecretAskIsBoundedAndItsEndRecorded(t *testing.T) {
|
||||
d, accepted, _, asked := aDesk(t, sealedTo(t, typed))
|
||||
var ended []string
|
||||
d.open = func(node, module, name, desk string) (int64, error) { return 7, nil }
|
||||
d.end = func(id int64, outcome string) error {
|
||||
ended = append(ended, fmt.Sprintf("%d %s", id, outcome))
|
||||
return nil
|
||||
}
|
||||
if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d.open = func(node, module, name, desk string) (int64, error) {
|
||||
return 0, errors.New("an ask for telegram-token of telegram on anchor is still open")
|
||||
}
|
||||
_, err := d.give("anchor", "telegram", "telegram-token", "laptop")
|
||||
if err == nil || !strings.Contains(err.Error(), "nobody was asked to type anything") || !strings.Contains(err.Error(), "still open") {
|
||||
t.Errorf("a second ask: %v", err)
|
||||
}
|
||||
if len(*asked) != 1 || len(*accepted) != 1 {
|
||||
t.Errorf("asked %d, accepted %d", len(*asked), len(*accepted))
|
||||
}
|
||||
d.open = func(node, module, name, desk string) (int64, error) { return 8, nil }
|
||||
d.ask = func(string, map[string]any) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"cancelled":true}`), nil
|
||||
}
|
||||
if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); !errors.Is(err, errNothingGiven) {
|
||||
t.Errorf("a dismissed prompt: %v", err)
|
||||
}
|
||||
if strings.Join(ended, "; ") != "7 given; 8 dismissed" {
|
||||
t.Errorf("ended: %v", ended)
|
||||
}
|
||||
}
|
||||
|
||||
// A secret ask cannot be turned into a secret read: the line carries no value, the answer carries none, and every
|
||||
// other `secret` line is the terminal's.
|
||||
func TestASecretAskIsNeverASecretRead(t *testing.T) {
|
||||
t.Setenv(verbVar, "mesh-controller.secret-ask")
|
||||
for _, args := range [][]string{
|
||||
{"ask", "anchor", "telegram", "telegram-token", "the-value"},
|
||||
{"ask", "anchor", "telegram"},
|
||||
{"ask", "anchor", "telegram", "telegram-token", "--from", "/dev/null"},
|
||||
} {
|
||||
if err := secretCommand(context.Background(), args); err == nil {
|
||||
t.Errorf("secret %v was taken", args)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{{"recover", "anchor", "telegram", "telegram-token"}, {"export"}} {
|
||||
if err := terminalOnly(append([]string{"secret"}, args...)); err == nil {
|
||||
t.Errorf("secret %v passed the terminal rule", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,11 @@ var probeRegistry = []probe{
|
||||
{ID: agentAccountProbe, Asserts: "every machine that names an agent account has it judged, on its node-engine's " +
|
||||
"newest statement, unable to become root without a person", From: "ADR 0266, ADR 0259 §8",
|
||||
Kind: kindAgentCanBecomeRoot, Phase: 1, run: probeAgentAccounts},
|
||||
// Root where the trusted parties run (novox/hq ADR 0259 §8): while an agent can become root there without a
|
||||
// person, an answer proven there proves nothing.
|
||||
{ID: "D-root", Asserts: "no agent can become root without a person on a machine where the router or a channel " +
|
||||
"proving its sender runs: not by its own account, and not through a tool that runs its command as an account " +
|
||||
"that can", From: "ADR 0259 §8", Kind: kindRootNotFree, Phase: 2, run: probeAgentRoot},
|
||||
{ID: "DW", Asserts: "the watchdogs of the signals table ran within three of their intervals",
|
||||
From: "ADR 0227 rule 6: the watchers are watched", Kind: "watchdogs-silent", Phase: 1, run: probeWatchdogs},
|
||||
// The core's health definitions (novox/hq to-be 45 §8, ADR 0236): what a core component's new build is
|
||||
|
||||
@@ -205,7 +205,7 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
|
||||
if err != nil {
|
||||
return snapshot.Facts{}, err
|
||||
}
|
||||
read, err := inv.ReadRepositories(ctx)
|
||||
read, err := readForPlanning(ctx, inv)
|
||||
if err != nil {
|
||||
return snapshot.Facts{}, err
|
||||
}
|
||||
@@ -411,7 +411,18 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
|
||||
Commit: e.Source.BuiltFrom, Provided: e.Provided, RollOut: current[e.Manifest.Module].RollOut,
|
||||
Manifest: raw}
|
||||
for _, r := range read[e.Manifest.Module] {
|
||||
// The module's own build source is said apart: a gate that predates it would read an own
|
||||
// entry among Reads as a context of its own repository.
|
||||
if r.Own {
|
||||
if len(r.Paths) > 0 {
|
||||
mod.Sources = append(mod.Sources, snapshot.BuildSource{Own: true, Paths: r.Paths})
|
||||
}
|
||||
continue
|
||||
}
|
||||
mod.Reads = append(mod.Reads, snapshot.RepositoryName(r.Repository))
|
||||
if len(r.Paths) > 0 {
|
||||
mod.Sources = append(mod.Sources, snapshot.BuildSource{Repository: snapshot.RepositoryName(r.Repository), Paths: r.Paths})
|
||||
}
|
||||
}
|
||||
f.Modules = append(f.Modules, mod)
|
||||
if e.Provided || e.Source.Repository == "" {
|
||||
|
||||
@@ -18,7 +18,7 @@ func foundDirectory(module string, since time.Time) inventory.ResourceHealth {
|
||||
return inventory.ResourceHealth{Module: module, Resource: module + ".data", Kind: link.KindDirectory,
|
||||
Target: "/srv/" + module, State: link.StateUnhealthy, Since: since,
|
||||
Reason: link.ReasonUsedAsFound + " owned by 1000:1000, mode 700, as found; root, mode 755 was declared and " +
|
||||
"not given it — `mesh-host hand-over` at the machine hands it to the mesh"}
|
||||
"not given it — `nox node hand-over laptop <directory>` on the control-node, with its path, hands it to the mesh"}
|
||||
}
|
||||
|
||||
func TestADirectoryFoundBeforeTheSendIsAWaitForAPerson(t *testing.T) {
|
||||
|
||||
+158
-4
@@ -87,6 +87,9 @@ const (
|
||||
healthWaiting
|
||||
healthNotYet
|
||||
healthBroken
|
||||
// healthSuperseded is a judging that cannot go on: the machine was sent another build of the module
|
||||
// after the gate's send (novox/hq issue 352). No verdict on the build judged, and nothing put back.
|
||||
healthSuperseded
|
||||
)
|
||||
|
||||
// served is what one machine's node tools answered the bus's discovery with.
|
||||
@@ -122,6 +125,39 @@ type gateFacts struct {
|
||||
// groupsAdded is, per module, whether the move judged puts an account in a group its previous build did
|
||||
// not (issue 318 review): the only move whose wait for a new login is excused.
|
||||
groupsAdded map[string]bool
|
||||
// sent is, per machine, the declaration the gate's own send carried there (novox/hq issue 352): a
|
||||
// report is held against it, never against the send made last. sentBuilds is what each machine was
|
||||
// last sent of every module, and judged the commit of each module this gate judges: a machine last
|
||||
// sent another build of the module is not running the build judged.
|
||||
sent map[string]inventory.SentDeclaration
|
||||
sentBuilds map[string]map[string]string
|
||||
commits map[string]string
|
||||
}
|
||||
|
||||
// reportedOn says a machine's last report is on what the gate sent it (novox/hq issue 352): on that
|
||||
// declaration, or one it was sent after it — or, for a gate kept before sends were kept on it, on the
|
||||
// declaration last sent. On 2026-10-09 a release's gate read the control node's report against a newer
|
||||
// send another plan had just made there, and failed three builds the machine had reported healthy as
|
||||
// "has not reported on what it was sent".
|
||||
func (f gateFacts) reportedOn(machine string, r inventory.Reported) bool {
|
||||
if sent, kept := f.sent[machine]; kept {
|
||||
return sent.ReportsOn(r)
|
||||
}
|
||||
return r.Current
|
||||
}
|
||||
|
||||
// supersededOn says the machine was last sent another build of the module than the one this gate judges
|
||||
// (novox/hq issue 352): the judging cannot go on, whatever the machine reports. On 2026-10-09 a controller
|
||||
// put back by one gate judged another gate's newer controller build passed on the same machine, reading
|
||||
// the put-back build's health as the newer one's.
|
||||
func (f gateFacts) supersededOn(module, machine string) (string, bool) {
|
||||
judged, known := f.commits[module]
|
||||
sent, has := f.sentBuilds[machine][module]
|
||||
if !known || !has || judged == "" || sent == "" || sameCommit(sent, judged) {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf("%s was sent %s %s after this gate's %s: the build judged no longer runs there, and "+
|
||||
"this judging is superseded by that send's", machine, module, short(sent), short(judged)), true
|
||||
}
|
||||
|
||||
// gatherGateFacts reads what a judging needs, from the store, the bus and this controller's memory. A
|
||||
@@ -199,9 +235,12 @@ func judgeHealth(module, component string, m catalogue.Manifest, machine string,
|
||||
return healthBroken, fmt.Sprintf("the witness on %s judged the %s %s and %s: %s", machine, r.Component,
|
||||
short(r.From), r.Outcome, r.Why)
|
||||
}
|
||||
if why, superseded := f.supersededOn(module, machine); superseded {
|
||||
return healthSuperseded, why
|
||||
}
|
||||
r, said := f.reports[machine]
|
||||
switch {
|
||||
case !said || r.At == nil || !r.Current:
|
||||
case !said || r.At == nil || !f.reportedOn(machine, r):
|
||||
return healthNotYet, fmt.Sprintf("%s has not reported on what it was sent", machine)
|
||||
case r.Outcome == inventory.OutcomeFailed || r.Outcome == inventory.OutcomeRefused:
|
||||
return healthBroken, fmt.Sprintf("%s %s what it was sent", machine, r.Outcome)
|
||||
@@ -209,7 +248,9 @@ func judgeHealth(module, component string, m catalogue.Manifest, machine string,
|
||||
return healthNotYet, fmt.Sprintf("%s reported %q", machine, r.Outcome)
|
||||
}
|
||||
// **No new condition about it**: about the machine itself, or naming the module on that machine,
|
||||
// raised since the judging began. The gate's own are not evidence about the build.
|
||||
// raised since the judging began. The gate's own are not evidence about the build. A fault that was
|
||||
// there at the send and reopened since is not new; one that had cleared before the send and came back
|
||||
// after it is (OpenAt, novox/hq issue 348).
|
||||
if f.judged {
|
||||
if f.openErr != nil {
|
||||
return healthNotYet, "what is wrong cannot be read, so whether the build made anything wrong is not known: " +
|
||||
@@ -219,7 +260,7 @@ func judgeHealth(module, component string, m catalogue.Manifest, machine string,
|
||||
// A wait for a person's new login, or for a directory used as found to be handed over, is the module's
|
||||
// reading, not a fault raised since the send: the gate reads it from the statement below (ADR 0254,
|
||||
// novox/hq issue 339).
|
||||
if c.Source == gateProbe || c.Raised.Before(since) || c.Kind == kindReloginNeeded || c.Kind == kindUsedAsFound {
|
||||
if c.Source == gateProbe || c.OpenAt(since) || c.Kind == kindReloginNeeded || c.Kind == kindUsedAsFound {
|
||||
continue
|
||||
}
|
||||
onIt := c.Subject.Machine == machine || slices.Contains(c.Subject.Also, machine) ||
|
||||
@@ -331,7 +372,7 @@ func aboutTheMachine(machine string, moved []string, since time.Time, f gateFact
|
||||
aboutIt := c.Subject.Scope == conditions.ScopeMachine && (c.Subject.ID == machine || c.Subject.Machine == machine ||
|
||||
slices.Contains(c.Subject.Also, machine))
|
||||
// A directory used as found waits for a person, whatever the send did (novox/hq issue 339).
|
||||
if !aboutIt || c.Source == gateProbe || c.Raised.Before(since) || c.Kind == kindUsedAsFound {
|
||||
if !aboutIt || c.Source == gateProbe || c.OpenAt(since) || c.Kind == kindUsedAsFound {
|
||||
kept = append(kept, c)
|
||||
continue
|
||||
}
|
||||
@@ -436,6 +477,21 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
|
||||
return "", err
|
||||
}
|
||||
facts.groupsAdded = movesAddingGroups(ctx, open.inventory, g, pairs, shelf)
|
||||
facts.sent = g.Sent
|
||||
facts.commits, facts.sentBuilds = judgedCommits(g, pairs), map[string]map[string]string{}
|
||||
// A module this gate put back at once (putBackBroken) was sent its earlier build by the gate itself:
|
||||
// not another send, and not a judging superseded.
|
||||
for _, m := range g.Returned {
|
||||
delete(facts.commits, m)
|
||||
}
|
||||
for _, j := range pairs {
|
||||
if _, read := facts.sentBuilds[j.node]; read {
|
||||
continue
|
||||
}
|
||||
if builds, known, err := open.inventory.SentBuilds(ctx, j.node); err == nil && known {
|
||||
facts.sentBuilds[j.node] = builds
|
||||
}
|
||||
}
|
||||
// **What is wrong with a machine itself is the machine's** (novox/hq issue 281): read once for each
|
||||
// machine judged, apart from what is wrong with a module there, and never pinned on the module the
|
||||
// gate happens to be kept on.
|
||||
@@ -472,6 +528,13 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
|
||||
if _, seen := reading[j.module]; !seen {
|
||||
modules = append(modules, j.module)
|
||||
}
|
||||
if h == healthSuperseded {
|
||||
// Decided at once (novox/hq issue 352): nothing of this gate's can be judged on a machine that
|
||||
// was sent another build of it, and nothing is put back — the later send is what runs there.
|
||||
g.Failing, g.Last = nil, ""
|
||||
decide(g, inventory.GateSuperseded, said, now)
|
||||
return g.Verdict, nil
|
||||
}
|
||||
if h == healthBroken && !slices.Contains(g.Broken, j.module) {
|
||||
g.Broken = append(g.Broken, j.module)
|
||||
if g.BrokenWhy == "" {
|
||||
@@ -575,6 +638,40 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
|
||||
return g.Verdict, nil
|
||||
}
|
||||
|
||||
// judgedCommits is the commit of each module a gate judges: the gate's own To for its module, and each
|
||||
// carried move's. Pure.
|
||||
func judgedCommits(g *inventory.PlanGate, pairs []judged) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, c := range g.Carried {
|
||||
if c.To != "" {
|
||||
out[c.Module] = c.To
|
||||
}
|
||||
}
|
||||
if g.To != "" {
|
||||
for _, j := range pairs {
|
||||
if _, has := out[j.module]; !has && !slices.ContainsFunc(g.Carried, func(c inventory.CarriedMove) bool { return c.Module == j.module }) {
|
||||
out[j.module] = g.To
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sentNow is what each machine was just sent, read after a send for the gate to keep (novox/hq issue
|
||||
// 352): a machine whose send is not on record is left out, and its report is read as before.
|
||||
func sentNow(ctx context.Context, inv *inventory.Inventory, machines []string) map[string]inventory.SentDeclaration {
|
||||
out := map[string]inventory.SentDeclaration{}
|
||||
for _, n := range machines {
|
||||
if s, found, err := inv.SentTo(ctx, n); err == nil && found {
|
||||
out[n] = s
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// whyFor is a passing gate's why as one module's verdict says it: the send's, and that module's own wait
|
||||
// for a person, never another's (issue 318 review).
|
||||
func whyFor(g *inventory.PlanGate, module string) string {
|
||||
@@ -800,6 +897,16 @@ func gateFailed(ctx context.Context, open *stores, p *inventory.Plan, module str
|
||||
"back", module, short(state.Previous), inventory.KeptBuilds))
|
||||
return
|
||||
}
|
||||
if g.Component == lease.ComponentController {
|
||||
// **The controller is never put back to a build older than the store's schema** (novox/hq issue
|
||||
// 352): the build before it carries fewer migrations than the failed one applied, starts behind
|
||||
// its own records, and judges the next gate with what it can read. The current build is kept and
|
||||
// the condition says so; a person decides.
|
||||
if why, ok := controllerSchemaAllows(ctx, inv, previous); !ok {
|
||||
notBack(why)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := inv.RestoreModule(ctx, previous); err != nil {
|
||||
notBack(err.Error())
|
||||
return
|
||||
@@ -833,6 +940,53 @@ func gateFailed(ctx context.Context, open *stores, p *inventory.Plan, module str
|
||||
sayRollback(ctx, open, module, g, "")
|
||||
}
|
||||
|
||||
// controllerSchemaAllows says the store's schema lets this build of the controller be put back: the
|
||||
// build recorded, when it served, a reach at or past the highest migration the store has applied. One
|
||||
// that never recorded a reach is not proved safe, and is refused as such (novox/hq issue 352). Why
|
||||
// says what is kept and why when it is not.
|
||||
func controllerSchemaAllows(ctx context.Context, inv *inventory.Inventory, previous inventory.Build) (string, bool) {
|
||||
applied, err := inv.SchemaApplied(ctx)
|
||||
if err != nil {
|
||||
return "what the store's schema reaches cannot be read, so whether the build before it can read it is not " +
|
||||
"known; the current build is kept: " + err.Error(), false
|
||||
}
|
||||
build := versionOfBuild(previous)
|
||||
if build == "" {
|
||||
return fmt.Sprintf("the build before it (%s) names no bundle to know it by, so whether it can read the store's "+
|
||||
"schema (migration %04d) is not known; the current build is kept, and a person decides", short(previous.Commit), applied), false
|
||||
}
|
||||
reach, known, err := inv.SchemaReachOf(ctx, build)
|
||||
if err != nil {
|
||||
return "what the build before it knows of the store's schema cannot be read; the current build is kept: " + err.Error(), false
|
||||
}
|
||||
if !known {
|
||||
return fmt.Sprintf("the build before it (%s, %s) never recorded how far it reads the store's schema — a "+
|
||||
"controller records that when it serves — so it is not proved to read migration %04d, which the store "+
|
||||
"has applied; a controller older than its store starts behind its own records and judges with what it "+
|
||||
"can read, so the current build is kept, and a person decides", short(previous.Commit), build, applied), false
|
||||
}
|
||||
if reach < applied {
|
||||
return fmt.Sprintf("the build before it (%s, %s) reads the store's schema up to migration %04d, and the store "+
|
||||
"is at %04d: a controller older than its store starts behind its own records and judges with what it "+
|
||||
"can read, so the current build is kept, and a person decides", short(previous.Commit), build, reach, applied), false
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
// versionOfBuild is the version a build's bundle is delivered as — its archive's digest, short, as the
|
||||
// catalogue names it (`${version}`) — read from the build's artifacts; empty when none is a bundle.
|
||||
func versionOfBuild(b inventory.Build) string {
|
||||
for _, a := range b.Made {
|
||||
if a.Kind != catalogue.ArtifactBundle && a.Kind != catalogue.ArtifactArchive {
|
||||
continue
|
||||
}
|
||||
if _, hex, found := strings.Cut(a.Reference, "sha256:"); found && len(hex) >= 12 {
|
||||
return hex[:12]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// rollbacks is what a failed send puts back, sent together (novox/hq issue 281): a gate that judged one
|
||||
// send judges what it moved as one, and what it found wanting goes back in one send per machine — not
|
||||
// in a send for each module, which is the churn that failed the gate in the first place.
|
||||
|
||||
@@ -113,9 +113,10 @@ func aGateMesh(t *testing.T) *gateMesh {
|
||||
}
|
||||
for node, h := range g.health {
|
||||
if h == healthNotYet {
|
||||
// Not reported on the send: neither the send made last, nor the gate's own (issue 352).
|
||||
f.rolledBack[node] = nil
|
||||
r := f.reports[node]
|
||||
r.Current = false
|
||||
r.Current, r.Declared, r.ReportedSequence = false, "", 0
|
||||
f.reports[node] = r
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,11 +60,18 @@ var handActVerbs = []handActVerb{
|
||||
// a person's word (ADR 0242), which the push itself reads from what it carried (recorded_push.go).
|
||||
{Verb: "push", Decision: "a recorded build moves only by a person's push: that push is the word its " +
|
||||
"upgrade policy asks for (ADR 0242)", DecidedWhen: pushedRecorded},
|
||||
{Verb: "plans stop"},
|
||||
// Stopping or starting a walk the operator chose on a warrant (novox/hq ADR 0259) is their decision.
|
||||
{Verb: "plans stop", Decision: "the operator's answer to an ask is their decision, not a repair (ADR 0259)",
|
||||
DecidedFor: []string{conditions.CauseOperatorAnswer}},
|
||||
{Verb: "plans close"},
|
||||
// A walk started by a person instead of its delivery's owner (novox/hq ADR 0239): the owner down, or
|
||||
// not trusted with it — either is a repair the owner should have made.
|
||||
{Verb: "plans go"},
|
||||
// not trusted with it — either is a repair the owner should have made. Unless the operator chose it on
|
||||
// a warrant (ADR 0259).
|
||||
{Verb: "plans go", Decision: "the operator's answer to an ask is their decision, not a repair (ADR 0259)",
|
||||
DecidedFor: []string{conditions.CauseOperatorAnswer}},
|
||||
// An act the operator chose on a warrant (novox/hq ADR 0259): asked by the controller, answered on a
|
||||
// channel that proved who answered, performed by the controller as itself.
|
||||
{Verb: handActWarrant, Decision: "the operator chose it, answering what the controller asked (ADR 0259)"},
|
||||
{Verb: "broker consumer-reset"},
|
||||
// Silencing the same condition twice says the condition, or what it watches, wants mending — unless
|
||||
// it is the operator's answer on a notification: a decision to live with it (novox/hq ADR 0258).
|
||||
@@ -103,6 +110,9 @@ var handActVerbs = []handActVerb{
|
||||
// to that judgement. Several values rotate for one leak, and a leak that recurs is a defect of the
|
||||
// module that prints them, an issue against it, not a healer that rotates. A rotation for any other
|
||||
// cause — a credential that stopped working — counts: a schedule or a healer could take it over.
|
||||
// A value given at the desk (novox/hq ADR 0259 §10): an outside party's key, such as a bot token, which
|
||||
// only a person can give. Their word, never a repair.
|
||||
{Verb: "secret accept", Decision: "a value an outside party issued is given by a person, at their desk"},
|
||||
{Verb: "secret rotate", Decision: "a value a person judged disclosed is replaced on their word",
|
||||
DecidedFor: []string{causeLeakedInLogs}},
|
||||
}
|
||||
@@ -241,6 +251,9 @@ func handActCommand(ctx context.Context, args []string) error {
|
||||
if len(args) > 0 && args[0] == "drill" {
|
||||
return handActDrill(ctx, args[1:])
|
||||
}
|
||||
if len(args) > 0 && args[0] == "warrant" {
|
||||
return handActWarrantCommand(ctx, args[1:])
|
||||
}
|
||||
if len(args) > 0 && args[0] != "list" && !strings.HasPrefix(args[0], "-") {
|
||||
return errors.New("hand-act record <what> --why <text> --cause <word> | hand-act drill <what> --why <text> " +
|
||||
"| hand-acts [--days N] [--json]")
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// A hand-over's line is judged before anything is asked (novox/hq issue 356): a node's name and the directory's
|
||||
// absolute path exactly as the engine states it — no `..`, no doubled or trailing separator, nothing relative.
|
||||
func TestAHandOverLineIsJudgedBeforeItIsAsked(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{},
|
||||
{"laptop"},
|
||||
{"laptop", "/srv/notes", "extra"},
|
||||
{"", "/srv/notes"},
|
||||
{"--node", "/srv/notes"},
|
||||
{"laptop", "srv/notes"},
|
||||
{"laptop", "/srv/../etc"},
|
||||
{"laptop", "/srv//notes"},
|
||||
{"laptop", "/srv/notes/"},
|
||||
{"laptop", "/srv/notes/."},
|
||||
} {
|
||||
if _, _, err := handOverLine(args); err == nil {
|
||||
t.Errorf("%q was taken", args)
|
||||
}
|
||||
}
|
||||
node, path, err := handOverLine([]string{"laptop", "/srv/notes"})
|
||||
if err != nil || node != "laptop" || path != "/srv/notes" {
|
||||
t.Fatalf("read as %q %q %v", node, path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Who hands over is the line's caller in the words every verb's caller is recorded in — the operator through
|
||||
// mesh-cli — or the controller's terminal when nobody is named.
|
||||
func TestAHandOverNamesWhoAsked(t *testing.T) {
|
||||
t.Setenv(link.CallerVar, " jo through mesh-cli on anchor ")
|
||||
if by := handOverBy(); by != "jo through mesh-cli on anchor" {
|
||||
t.Fatalf("by %q", by)
|
||||
}
|
||||
t.Setenv(link.CallerVar, "")
|
||||
if by := handOverBy(); by != "the controller's terminal" {
|
||||
t.Fatalf("by %q", by)
|
||||
}
|
||||
}
|
||||
|
||||
// **A hand-over is the controller's terminal's alone** (novox/hq issue 356, ADR 0266): through any verb, and
|
||||
// through mesh-cli outside the terminal, `node hand-over` is refused and nothing runs — at the next apply root
|
||||
// gives the directory to the account the module declares, and whoever may call a verb includes agents.
|
||||
func TestAHandOverIsRefusedThroughEveryVerb(t *testing.T) {
|
||||
line := []string{"node", "hand-over", "laptop", "/srv/notes"}
|
||||
if err := terminalOnly(line); err == nil {
|
||||
t.Fatal("node hand-over passed as a verb's line")
|
||||
}
|
||||
if _, err := argvFor("command", map[string]any{"command": "node hand-over laptop /srv/notes"}); err == nil {
|
||||
t.Fatal("the command verb composed node hand-over")
|
||||
}
|
||||
if _, err := ordinaryLine(line); err == nil {
|
||||
t.Fatal("node hand-over composed as an ordinary mesh-cli line")
|
||||
}
|
||||
if _, err := argvFor("node", map[string]any{"node": "laptop", "hand-over": "/srv/notes"}); err == nil {
|
||||
t.Fatal("the node verb composed a hand-over")
|
||||
}
|
||||
}
|
||||
|
||||
// The condition's words are plain and name no machine's binary; the operator's line, with the node and the path
|
||||
// (novox/hq ADR 0272), is in the summary the module's health gives (moduleHealthWord) and in the evidence.
|
||||
func TestTheUsedAsFoundConditionNamesTheOperatorsLine(t *testing.T) {
|
||||
rs := []inventory.ResourceHealth{foundDirectory("notes", time.Now().Add(-24*time.Hour))}
|
||||
o := usedAsFoundObservation("notes", "laptop", "notes on laptop uses notes.data as found", rs)
|
||||
if strings.Contains(o.Needs, "mesh-host") || strings.Contains(o.Explanation, "mesh-host") ||
|
||||
!strings.Contains(o.Needs, "control-node") {
|
||||
t.Fatalf("the condition's words: %q %q", o.Needs, o.Explanation)
|
||||
}
|
||||
if why, ok := conditions.PlainWords(conditions.Words{Headline: o.Headline, Explanation: o.Explanation, Needs: o.Needs,
|
||||
Resolved: o.Resolved}, "laptop"); !ok {
|
||||
t.Fatalf("not plain: %s", why)
|
||||
}
|
||||
f := gateFacts{now: time.Now(), health: map[string]inventory.NodeHealth{"laptop": {Node: "laptop", HeardAt: time.Now(),
|
||||
Resources: rs}}}
|
||||
h, why := moduleHealthWord("notes", "laptop", time.Now().Add(-time.Hour), f)
|
||||
if h != healthPerson || !strings.Contains(why, "`nox node hand-over laptop <directory>` on the control-node") ||
|
||||
strings.Contains(why, "mesh-host") || strings.Contains(why, "/srv/") {
|
||||
t.Fatalf("the module's health reads %v %q; want the operator's line", h, why)
|
||||
}
|
||||
}
|
||||
|
||||
// **Nothing is asked of a node the mesh does not know, and the engine's refusal is the command's failure**
|
||||
// (review of issue 356): a refused hand-over never exits as a success.
|
||||
func TestAHandOverAsksOnlyAKnownNodeAndFailsOnARefusal(t *testing.T) {
|
||||
t.Setenv(link.CallerVar, "jo through mesh-cli on anchor")
|
||||
asked := 0
|
||||
ask := func(answer link.HandOverAnswer) func(node, path, by string) (link.HandOverAnswer, error) {
|
||||
return func(node, path, by string) (link.HandOverAnswer, error) {
|
||||
asked++
|
||||
if node != "laptop" || path != "/srv/notes" || by != "jo through mesh-cli on anchor" {
|
||||
t.Fatalf("asked %q %q %q", node, path, by)
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
}
|
||||
unknown := func(string) error { return errors.New("no node called laptop") }
|
||||
known := func(string) error { return nil }
|
||||
var out bytes.Buffer
|
||||
err := handOverAsked([]string{"laptop", "/srv/notes"}, unknown, ask(link.HandOverAnswer{Said: "x"}), &out)
|
||||
if err == nil || asked != 0 || !strings.Contains(err.Error(), "nothing was asked") {
|
||||
t.Fatalf("an unknown node: %v, asked %d", err, asked)
|
||||
}
|
||||
err = handOverAsked([]string{"laptop", "/srv/../etc"}, known, ask(link.HandOverAnswer{Said: "x"}), &out)
|
||||
if err == nil || asked != 0 {
|
||||
t.Fatalf("a refused line was asked: %v, asked %d", err, asked)
|
||||
}
|
||||
err = handOverAsked([]string{"laptop", "/srv/notes"}, known,
|
||||
ask(link.HandOverAnswer{Refused: "/srv/notes is not used as found; nothing was handed over"}), &out)
|
||||
if err == nil || !strings.Contains(err.Error(), "laptop refused: /srv/notes is not used as found") || out.Len() != 0 {
|
||||
t.Fatalf("a refusal: %v, printed %q", err, out.String())
|
||||
}
|
||||
err = handOverAsked([]string{"laptop", "/srv/notes"}, known, ask(link.HandOverAnswer{Said: "handed over"}), &out)
|
||||
if err != nil || !strings.HasPrefix(out.String(), "handed over\n") || !strings.Contains(out.String(), "`nox push laptop`") {
|
||||
t.Fatalf("a record: %v, printed %q", err, out.String())
|
||||
}
|
||||
failing := func(string, string, string) (link.HandOverAnswer, error) {
|
||||
return link.HandOverAnswer{}, errors.New("no engine")
|
||||
}
|
||||
if err := handOverAsked([]string{"laptop", "/srv/notes"}, known, failing, &out); err == nil {
|
||||
t.Fatal("an ask that failed was a success")
|
||||
}
|
||||
}
|
||||
|
||||
// The setuid search's line asks only a known node, one name and nothing else, and fails on a refusal
|
||||
// (novox/hq issue 361).
|
||||
func TestASetuidSearchAsksOnlyAKnownNodeAndFailsOnARefusal(t *testing.T) {
|
||||
t.Setenv(link.CallerVar, "jo through mesh-cli on anchor")
|
||||
asked := 0
|
||||
ask := func(answer link.HandOverAnswer) func(node, by string) (link.HandOverAnswer, error) {
|
||||
return func(node, by string) (link.HandOverAnswer, error) {
|
||||
asked++
|
||||
if node != "novox" || by != "jo through mesh-cli on anchor" {
|
||||
t.Fatalf("asked %q %q", node, by)
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
}
|
||||
known := func(string) error { return nil }
|
||||
var out bytes.Buffer
|
||||
for _, args := range [][]string{nil, {"novox", "extra"}, {"-x"}} {
|
||||
if err := setuidSearchAsked(args, known, ask(link.HandOverAnswer{Said: "x"}), &out); err == nil || asked != 0 {
|
||||
t.Fatalf("%v was asked: %v", args, err)
|
||||
}
|
||||
}
|
||||
if err := setuidSearchAsked([]string{"novox"}, func(string) error { return errors.New("no node called novox") },
|
||||
ask(link.HandOverAnswer{Said: "x"}), &out); err == nil || asked != 0 {
|
||||
t.Fatalf("an unknown node: %v", err)
|
||||
}
|
||||
if err := setuidSearchAsked([]string{"novox"}, known, ask(link.HandOverAnswer{Refused: "no; no search was started"}),
|
||||
&out); err == nil || !strings.Contains(err.Error(), "novox refused") || out.Len() != 0 {
|
||||
t.Fatalf("a refusal: %v, printed %q", err, out.String())
|
||||
}
|
||||
if err := setuidSearchAsked([]string{"novox"}, known, ask(link.HandOverAnswer{Said: "a new search starts"}),
|
||||
&out); err != nil || !strings.HasPrefix(out.String(), "a new search starts\n") {
|
||||
t.Fatalf("a start: %v, printed %q", err, out.String())
|
||||
}
|
||||
}
|
||||
@@ -625,7 +625,7 @@ func planStale(ctx context.Context, inv *inventory.Inventory, p inventory.Plan)
|
||||
}
|
||||
for _, newer := range recent {
|
||||
if newer.ID == p.ID || !newer.Created.After(p.Created) || !repositoryMatches(newer.Repository, p.Repository) ||
|
||||
newer.Branch != p.Branch || newer.State == inventory.PlanSuperseded {
|
||||
newer.Branch != p.Branch || newer.State == inventory.PlanSuperseded || newer.Batch() {
|
||||
continue
|
||||
}
|
||||
return inventory.PlanSuperseded, fmt.Sprintf("superseded by %s (%s %s), a newer merge of the same repository "+
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// hideTyping turns a terminal's echo off while a secret is typed at it, and gives back what restores it. On
|
||||
// anything that is not a terminal (a pipe, a file) it does nothing.
|
||||
func hideTyping(f *os.File) func() {
|
||||
fd := int(f.Fd())
|
||||
before, err := unix.IoctlGetTermios(fd, unix.TCGETS)
|
||||
if err != nil {
|
||||
return func() {}
|
||||
}
|
||||
hidden := *before
|
||||
hidden.Lflag &^= unix.ECHO
|
||||
if err := unix.IoctlSetTermios(fd, unix.TCSETS, &hidden); err != nil {
|
||||
return func() {}
|
||||
}
|
||||
return func() {
|
||||
_ = unix.IoctlSetTermios(fd, unix.TCSETS, before)
|
||||
_, _ = os.Stderr.WriteString("\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/lease"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// novox/hq issue 348: on 2026-10-09 the control node's resolver stopped answering on its private address
|
||||
// at 10:57:57 UTC; the machine's network condition was raised at 10:58:45. The node-engine and the
|
||||
// controller were sent at 10:59:34. The new node-engine's first statement judged the names once — unknown,
|
||||
// "one look failed; a second decides" — and that statement cleared the condition, though its last evidence
|
||||
// still said "connection refused". The next look raised it again at 11:00:23, after the send, and both
|
||||
// builds failed their gate at 11:10 with "raised since it was sent" and were put back, for a fault that
|
||||
// began before they were sent.
|
||||
|
||||
// namesRefused is the control node's names part as its node-engine said it in the outage: its own
|
||||
// resolver, at its own address, refusing.
|
||||
func namesRefused(state string, streak int) link.NetworkPart {
|
||||
p := link.NetworkPart{Part: link.PartNames, State: state, Since: h0, Streak: streak}
|
||||
if state == link.StateUnhealthy {
|
||||
p.Reason = "1 of its 2 resolvers do not answer as the mesh's do"
|
||||
p.Said = "10.77.0.1 — anchor.internal (IPv4): read udp 10.77.0.1:35244->10.77.0.1:53: read: connection refused"
|
||||
p.Toward = []string{"10.77.0.1"}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func networkSaying(parts ...link.NetworkPart) *link.NetworkHealth {
|
||||
state := link.StateHealthy
|
||||
for _, p := range parts {
|
||||
switch {
|
||||
case p.State == link.StateUnhealthy:
|
||||
state = link.StateUnhealthy
|
||||
case p.State == link.StateUnknown && state == link.StateHealthy:
|
||||
state = link.StateUnknown
|
||||
}
|
||||
}
|
||||
return &link.NetworkHealth{State: state, Since: h0, Parts: parts}
|
||||
}
|
||||
|
||||
// TestReplay348 replays the statements of the outage: the condition raised before the send is not
|
||||
// cleared by the restarted engine's first, undecided statement, and the gate does not count it against
|
||||
// the builds sent after it began.
|
||||
func TestReplay348(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv, k := open.inventory, conditionsFrom
|
||||
say := func(at time.Time, n *link.NetworkHealth) {
|
||||
t.Helper()
|
||||
if err := stateHealth(ctx, inv, k, "anchor", link.Health{Contract: link.ReadinessContract, At: at, Network: n}, at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
network := func() (conditions.Condition, bool) {
|
||||
t.Helper()
|
||||
list, err := k.Open(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range list {
|
||||
if c.Key == "machine.anchor.network" {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return conditions.Condition{}, false
|
||||
}
|
||||
|
||||
// 10:58:45 — the second failing look: raised.
|
||||
say(h0, networkSaying(namesRefused(link.StateUnhealthy, 2)))
|
||||
raised, ok := network()
|
||||
if !ok {
|
||||
t.Fatal("the resolver refusing on the control node raised nothing")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
sent := time.Now().UTC()
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
// 10:59:42 — the restarted engine's first statement: one look failed, a second decides.
|
||||
say(h0.Add(time.Minute), networkSaying(namesRefused(link.StateUnknown, 1)))
|
||||
if _, ok := network(); !ok {
|
||||
t.Fatal("a statement that judged nothing yet cleared the condition: the restarted engine's first look " +
|
||||
"said the fault was gone while it still refused")
|
||||
}
|
||||
// 11:00:23 — its second look: unhealthy again, the same raising.
|
||||
say(h0.Add(2*time.Minute), networkSaying(namesRefused(link.StateUnhealthy, 2)))
|
||||
again, ok := network()
|
||||
if !ok || !again.Raised.Equal(raised.Raised) || again.Count != 1 {
|
||||
t.Fatalf("the same raising was not kept: raised %s (first %s), count %d", again.Raised, raised.Raised, again.Count)
|
||||
}
|
||||
|
||||
// The gate on the control node, for a build sent after the fault began.
|
||||
open2, err := k.Open(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := gateFacts{judged: true, open: open2}
|
||||
if w := aboutTheMachine("anchor", []string{"mesh-host"}, sent, f); w.whole != "" || len(w.on) != 0 {
|
||||
t.Fatalf("a fault from before the send held the build: %+v", w)
|
||||
}
|
||||
|
||||
// Decided healthy: cleared.
|
||||
say(h0.Add(3*time.Minute), networkSaying(link.NetworkPart{Part: link.PartNames, State: link.StateHealthy, Since: h0}))
|
||||
if c, ok := network(); ok {
|
||||
t.Fatalf("a statement that decides the names healthy left %s open", c.Key)
|
||||
}
|
||||
}
|
||||
|
||||
// A fault there at the send, cleared and reopened after it, is not raised since the send; one that cleared
|
||||
// before the send and came back after it is — a send that breaks a recovered machine fails its gate (review
|
||||
// of mesh-controller PR 179, A2). Read through OpenAt, by both of the gate's readings.
|
||||
func TestAFaultThatFlappedAfterTheSendIsNotTheSendsAndOneThatRecoveredBeforeItIs(t *testing.T) {
|
||||
since := h0
|
||||
network := func(first time.Time, gaps ...conditions.Gap) conditions.Condition {
|
||||
raised := since.Add(time.Minute)
|
||||
if len(gaps) > 0 {
|
||||
raised = gaps[len(gaps)-1].Reopened
|
||||
}
|
||||
return conditions.Condition{Key: "machine.anchor.network", Kind: kindMachineNetwork,
|
||||
Subject: conditions.Subject{Scope: conditions.ScopeMachine, ID: "anchor", Machine: "anchor"},
|
||||
Summary: "anchor's network is not healthy", Source: sourceNetwork, First: first, Gaps: gaps, Raised: raised}
|
||||
}
|
||||
held := func(c conditions.Condition) bool {
|
||||
return aboutTheMachine("anchor", []string{"mesh-controller"}, since, gateFacts{judged: true,
|
||||
open: []conditions.Condition{c}}).whole != ""
|
||||
}
|
||||
// The day's case: raised before the send, cleared 8 s after it, reopened 49 s after it.
|
||||
flapped := network(since.Add(-49*time.Second),
|
||||
conditions.Gap{Cleared: since.Add(8 * time.Second), Reopened: since.Add(49 * time.Second)})
|
||||
if held(flapped) {
|
||||
t.Fatal("a fault there at the send, flapping after it, held the machine")
|
||||
}
|
||||
// Recovered before the send, broken again after it: the send's.
|
||||
recovered := network(since.Add(-time.Hour),
|
||||
conditions.Gap{Cleared: since.Add(-30 * time.Second), Reopened: since.Add(20 * time.Second)})
|
||||
if !held(recovered) {
|
||||
t.Fatal("a machine recovered at the send and broken after it passed the gate")
|
||||
}
|
||||
// An older gap, before the send, and the fault there at the send: not the send's.
|
||||
twice := network(since.Add(-time.Hour),
|
||||
conditions.Gap{Cleared: since.Add(-50 * time.Minute), Reopened: since.Add(-45 * time.Minute)},
|
||||
conditions.Gap{Cleared: since.Add(10 * time.Second), Reopened: since.Add(30 * time.Second)})
|
||||
if held(twice) {
|
||||
t.Fatal("a fault there at the send, with an older gap, held the machine")
|
||||
}
|
||||
// Raised after the send, never cleared: the send's.
|
||||
if !held(network(time.Time{})) {
|
||||
t.Fatal("a fault raised after the send held nothing")
|
||||
}
|
||||
// And a module's own, through judgeHealth.
|
||||
at := since.Add(2 * time.Minute)
|
||||
g := gateFacts{judged: true, now: at, reports: map[string]inventory.Reported{"anchor": {Node: "anchor",
|
||||
Outcome: inventory.OutcomeApplied, At: &at, Current: true}}, engines: map[string]string{},
|
||||
served: map[string]served{}, rolledBack: map[string][]lease.Rollback{},
|
||||
open: []conditions.Condition{{Key: "provider.app.anchor.x.failing", Subject: conditions.Subject{
|
||||
Scope: conditions.ScopeProvider, ID: "app.anchor.x", Machine: "anchor"}, Summary: "failing",
|
||||
First: since.Add(-time.Hour), Raised: since.Add(time.Minute),
|
||||
Gaps: []conditions.Gap{{Cleared: since.Add(5 * time.Second), Reopened: since.Add(time.Minute)}}}}}
|
||||
if _, why := judgeHealth("app", "", catalogue.Manifest{Module: "app"}, "anchor", since, g); strings.HasPrefix(why, "raised since it was sent") {
|
||||
t.Fatalf("a module's own fault there at the send: %s", why)
|
||||
}
|
||||
g.open[0].Gaps[0].Cleared = since.Add(-5 * time.Second)
|
||||
if _, why := judgeHealth("app", "", catalogue.Manifest{Module: "app"}, "anchor", since, g); !strings.HasPrefix(why, "raised since it was sent") {
|
||||
t.Fatalf("a module's own fault, recovered at the send and back after it, was not counted: %s", why)
|
||||
}
|
||||
}
|
||||
|
||||
// Only an undecided part holds a condition that names it; a condition about another part clears, and a
|
||||
// statement unknown as a whole holds every part (review of PR 179, A4). Pure.
|
||||
func TestAnUndecidedPartHoldsOnlyWhatNamesIt(t *testing.T) {
|
||||
f := netFacts(map[string]*inventory.NetworkHealth{
|
||||
"anchor": aNetwork(link.StateUnknown, inventory.NetworkPart{Part: link.PartNames, State: link.StateUnknown, Streak: 1},
|
||||
inventory.NetworkPart{Part: link.PartRoute, State: link.StateHealthy}),
|
||||
"laptop": aNetwork(link.StateHealthy, inventory.NetworkPart{Part: link.PartNames, State: link.StateHealthy}),
|
||||
"spare": aNetwork(link.StateStarting, inventory.NetworkPart{Part: link.PartTunnel, State: link.StateHealthy}),
|
||||
"other": aNetwork(link.StateStarting, inventory.NetworkPart{Part: link.PartTunnel, State: link.StateStarting}),
|
||||
})
|
||||
u := undecidedParts(f)
|
||||
if !u["anchor"][link.PartNames] || u["anchor"][link.PartRoute] || u["laptop"] != nil || !u["spare"]["*"] ||
|
||||
!u["other"][link.PartTunnel] || u["other"]["*"] {
|
||||
t.Fatalf("undecided: %v", u)
|
||||
}
|
||||
about := func(machine, said string, also ...string) conditions.Condition {
|
||||
return conditions.Condition{Subject: conditions.Subject{Scope: conditions.ScopeMachine, ID: machine,
|
||||
Machine: machine, Also: also}, Evidence: []conditions.Evidence{{Said: said}}}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
c conditions.Condition
|
||||
held bool
|
||||
}{
|
||||
{about("anchor", "names since 2026-10-09 10:58:45 UTC: 10.77.0.1 — refused"), true},
|
||||
{about("anchor", "route since 2026-10-09 10:58:45 UTC: no default route"), false},
|
||||
{about("laptop", "names since 2026-10-09 10:58:45 UTC: refused"), false},
|
||||
{about("spare", "route since …: no default route"), true},
|
||||
{about("hub", "anchor: names: refused", "anchor"), true},
|
||||
{about("hub", "anchor: tunnel: no handshake", "anchor"), false},
|
||||
} {
|
||||
if got := heldUndecided(c.c, u); got != c.held {
|
||||
t.Errorf("%s %q held %v, want %v", c.c.Subject.Machine, c.c.Evidence[0].Said, got, c.held)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A release walks its modules without a record per module: D10 counts what its tier names as rolling,
|
||||
// so the node-engine a release walks is not "behind, and no plan is rolling it out" on its first machine.
|
||||
func TestAReleaseRollsOutWhatItsTierNames(t *testing.T) {
|
||||
plans := []inventory.Plan{
|
||||
{ID: "release-1", State: inventory.PlanRolling, Tiers: [][]string{{"mesh-host"}}, Modules: map[string]*inventory.PlanModule{}},
|
||||
{ID: "plan-2", State: inventory.PlanRolling, Modules: map[string]*inventory.PlanModule{"letta": {}}},
|
||||
}
|
||||
got := rollingModules(plans)
|
||||
if !got["mesh-host"] || !got["letta"] || len(got) != 2 {
|
||||
t.Fatalf("rolling: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/lease"
|
||||
)
|
||||
|
||||
// novox/hq issue 352: on 2026-10-09 a release's gate on the control node read the machine's report against a
|
||||
// newer send another plan had just made there — not against its own send — and failed three builds the
|
||||
// machine had reported healthy ("has not reported on what it was sent"), put them back on every machine,
|
||||
// to a controller older than the store's schema, and that controller then judged the newer plan's
|
||||
// controller passed from the put-back build's health.
|
||||
|
||||
// TestReplay352 replays the walk on the backlog fixture: the release sends anchor and anchor reports;
|
||||
// another send reaches anchor, unreported; the gate still passes. And a send that moves a judged module
|
||||
// to another build supersedes the judging: no verdict, nothing put back.
|
||||
func TestReplay352(t *testing.T) {
|
||||
t.Run("a newer send to the judged machine does not unreport the gate's", testANewerSendDoesNotUnreportTheGatesOwn)
|
||||
t.Run("a send that moves the module supersedes the judging", testASendThatMovesTheModuleSupersedesTheJudging)
|
||||
}
|
||||
|
||||
func testANewerSendDoesNotUnreportTheGatesOwn(t *testing.T) {
|
||||
b := aBacklog(t)
|
||||
ctx := t.Context()
|
||||
inv := b.open.inventory
|
||||
advancePlans(ctx, b.open) // anchor is sent, and the fixture reports it applied
|
||||
// 16:31:37 — another plan sends anchor a newer declaration, which it has not reported on.
|
||||
carried, _, _ := inv.SentBuilds(ctx, "anchor")
|
||||
if err := inv.RecordSent(ctx, nodeID(t, b.open, "anchor"), "d-anchor-newer", carried); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reports, _ := inv.LastReports(ctx)
|
||||
for _, r := range reports {
|
||||
if r.Node == "anchor" && r.Current {
|
||||
t.Fatal("the fixture's newer send reads as reported")
|
||||
}
|
||||
}
|
||||
gateEvery, gateBound = 0, 0 // past the bound at once: before the fix, "has not reported" fails it here
|
||||
for i := 0; i < 4; i++ {
|
||||
advancePlans(ctx, b.open)
|
||||
}
|
||||
p := b.release(t)
|
||||
if p.State == inventory.PlanFailed || strings.Contains(p.Note, "has not reported") {
|
||||
t.Fatalf("the release failed on the newer send: %s %s", p.State, p.Note)
|
||||
}
|
||||
if g := p.Release.Gate; g != nil && (g.Sent == nil || g.Sent["anchor"].Digest == "") {
|
||||
t.Fatalf("the gate does not keep what it sent: %+v", g)
|
||||
}
|
||||
if v, found, err := inv.GateOf(ctx, "build-app-c2"); err != nil || !found || v.Verdict != inventory.GatePassed {
|
||||
t.Fatalf("app's pass on anchor was not kept: %+v %v %v", v, found, err)
|
||||
}
|
||||
if current, _ := inv.CurrentBuilds(ctx); current["app"].Commit != "c2" {
|
||||
t.Fatalf("app was put back to %s", current["app"].Commit)
|
||||
}
|
||||
}
|
||||
|
||||
// A plan's own first send waits while a release judges the same module on that machine with another build.
|
||||
func TestAPlansFirstSendWaitsForAReleaseJudgingTheModuleThere(t *testing.T) {
|
||||
b := aBacklog(t)
|
||||
ctx := t.Context()
|
||||
advancePlans(ctx, b.open) // the release judges app c2 on anchor
|
||||
_, _, err := gatedSend(ctx, b.open, "anchor", []inventory.CarriedMove{{Module: "app", Node: "anchor", From: "c2", To: "c3", Build: "build-app-c3"}})
|
||||
if !errors.Is(err, errWalkedElsewhere) || !strings.Contains(err.Error(), "release-") {
|
||||
t.Fatalf("a newer build of a judged module was sent under the release's gate: %v", err)
|
||||
}
|
||||
if len(b.sent) != 1 {
|
||||
t.Fatalf("sent %v", b.sent)
|
||||
}
|
||||
}
|
||||
|
||||
// A merge plan's judging is superseded the same way: another send moved its module on the first machine.
|
||||
func TestAPlansJudgingIsSupersededByASendThatMovesItsModule(t *testing.T) {
|
||||
g := aGateMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := g.open.inventory
|
||||
advancePlans(ctx, g.open) // anchor is sent app c2 first
|
||||
if err := inv.RecordSent(ctx, nodeID(t, g.open, "anchor"), "d-anchor-c3", map[string]string{"app": "c3"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gateEvery = 0
|
||||
advancePlans(ctx, g.open)
|
||||
p := g.plan(t)
|
||||
if p.State != inventory.PlanSuperseded || !strings.Contains(p.Note, "superseded") || !strings.Contains(p.Note, "c3") {
|
||||
t.Fatalf("the plan is %s: %s", p.State, p.Note)
|
||||
}
|
||||
// Nothing put back: the registered build stands, the build is not marked, and the plan's gate made no
|
||||
// rollback (a release may walk what the other send left waiting on anchor; that is not a put-back).
|
||||
if r := p.Modules["app"].Gate.Rollback; r != "" {
|
||||
t.Fatalf("a superseded judging made a rollback: %q", r)
|
||||
}
|
||||
if current, _ := inv.CurrentBuilds(ctx); current["app"].Commit != "c2" {
|
||||
t.Fatalf("app was put back to %s", current["app"].Commit)
|
||||
}
|
||||
if failed, _ := inv.GateFailed(ctx, "build-2"); failed {
|
||||
t.Fatal("a superseded build was marked failed")
|
||||
}
|
||||
}
|
||||
|
||||
func testASendThatMovesTheModuleSupersedesTheJudging(t *testing.T) {
|
||||
b := aBacklog(t)
|
||||
ctx := t.Context()
|
||||
inv := b.open.inventory
|
||||
advancePlans(ctx, b.open)
|
||||
// Another send moves app on anchor to a build this gate does not judge.
|
||||
carried, _, _ := inv.SentBuilds(ctx, "anchor")
|
||||
carried["app"] = "c3"
|
||||
if err := inv.RecordSent(ctx, nodeID(t, b.open, "anchor"), "d-anchor-c3", carried); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gateEvery = 0
|
||||
advancePlans(ctx, b.open)
|
||||
p := b.release(t)
|
||||
if p.State != inventory.PlanSuperseded || !strings.Contains(p.Note, "superseded") || !strings.Contains(p.Note, "c3") {
|
||||
t.Fatalf("the release is %s: %s", p.State, p.Note)
|
||||
}
|
||||
if !reflect.DeepEqual(b.sent, [][]string{{"anchor"}}) {
|
||||
t.Fatalf("sent %v: a superseded judging puts nothing back", b.sent)
|
||||
}
|
||||
if _, found, _ := inv.GateOf(ctx, "build-app-c2"); found {
|
||||
t.Fatal("a superseded judging kept a verdict")
|
||||
}
|
||||
if current, _ := inv.CurrentBuilds(ctx); current["app"].Commit != "c2" {
|
||||
t.Fatalf("app was put back to %s", current["app"].Commit)
|
||||
}
|
||||
}
|
||||
|
||||
// A report is on the gate's own send: the declaration itself, or one sequenced after it; a gate kept
|
||||
// without its send reads the report against the send made last, as before. Pure.
|
||||
func TestAReportIsHeldAgainstTheGatesOwnSend(t *testing.T) {
|
||||
sent := inventory.SentDeclaration{Digest: "d-490", Sequence: 490}
|
||||
for _, c := range []struct {
|
||||
r inventory.Reported
|
||||
want bool
|
||||
}{
|
||||
{inventory.Reported{Declared: "d-490", Current: false}, true},
|
||||
{inventory.Reported{Declared: "d-491", ReportedSequence: 491, Current: true}, true},
|
||||
{inventory.Reported{Declared: "d-489", ReportedSequence: 489, Current: false}, false},
|
||||
{inventory.Reported{Declared: "other", ReportedSequence: 490}, true}, // the same sequence, said by another digest
|
||||
{inventory.Reported{Declared: "d-495", ReportedSequence: 495, Current: true}, true}, // the last send: this one or a later one
|
||||
{inventory.Reported{Declared: "", Current: false}, false},
|
||||
} {
|
||||
if got := sent.ReportsOn(c.r); got != c.want {
|
||||
t.Errorf("%+v on %+v: %v", c.r, sent, got)
|
||||
}
|
||||
}
|
||||
byDigest := inventory.SentDeclaration{Digest: "d-1"}
|
||||
if !byDigest.ReportsOn(inventory.Reported{Declared: "d-1"}) || byDigest.ReportsOn(inventory.Reported{ReportedSequence: 5}) ||
|
||||
!byDigest.ReportsOn(inventory.Reported{Current: true}) {
|
||||
t.Fatal("a send kept without a sequence is matched by its digest and by the last send alone")
|
||||
}
|
||||
f := gateFacts{sent: map[string]inventory.SentDeclaration{"anchor": sent}}
|
||||
if !f.reportedOn("anchor", inventory.Reported{Declared: "d-490"}) || f.reportedOn("anchor", inventory.Reported{Declared: "d-1"}) {
|
||||
t.Fatal("a gate that kept its send read the report against something other than it")
|
||||
}
|
||||
if !f.reportedOn("laptop", inventory.Reported{Current: true}) || f.reportedOn("laptop", inventory.Reported{Current: false}) {
|
||||
t.Fatal("a gate that did not keep its send does not read the report against the send made last")
|
||||
}
|
||||
// Through the merge plan's first-machine wait too.
|
||||
at := time.Now()
|
||||
state := inventory.PlanModule{First: []string{"anchor"}, FirstAt: &at,
|
||||
Gate: &inventory.PlanGate{Machines: []string{"anchor"}, Sent: map[string]inventory.SentDeclaration{"anchor": sent}}}
|
||||
reports := []inventory.Reported{{Node: "anchor", At: &at, Outcome: inventory.OutcomeApplied, Current: false, Declared: "d-490"}}
|
||||
if step := nextRollout(state, []string{"anchor", "laptop"}, false, reports, at.Add(time.Minute), time.Hour); step.waiting != "" || step.failed != "" {
|
||||
t.Fatalf("the first machine's report on the plan's own send read as none: %+v", step)
|
||||
}
|
||||
reports[0].Declared = "d-480"
|
||||
if step := nextRollout(state, []string{"anchor", "laptop"}, false, reports, at.Add(time.Minute), time.Hour); step.waiting == "" {
|
||||
t.Fatalf("a report on an older send read as the plan's: %+v", step)
|
||||
}
|
||||
}
|
||||
|
||||
// A gate judges only the build the machine was last sent: last sent another build of the module, the
|
||||
// judging is superseded, whatever the machine reports. Pure.
|
||||
func TestAGateJudgesOnlyTheBuildTheMachineWasLastSent(t *testing.T) {
|
||||
at := time.Now()
|
||||
f := gateFacts{now: at, reports: map[string]inventory.Reported{"anchor": {Node: "anchor", Outcome: inventory.OutcomeApplied,
|
||||
At: &at, Current: true}}, engines: map[string]string{}, served: map[string]served{}, rolledBack: map[string][]lease.Rollback{},
|
||||
commits: map[string]string{"mesh-controller": "e6b00e2e"}, sentBuilds: map[string]map[string]string{"anchor": {"mesh-controller": "ef26d4cb"}}}
|
||||
taken := at.Add(-30 * time.Second)
|
||||
f.holder = &lease.Holder{Taken: taken, Health: &lease.Health{Ready: true}}
|
||||
h, why := judgeHealth("mesh-controller", lease.ComponentController, catalogue.Manifest{}, "anchor", at.Add(-time.Minute), f)
|
||||
if h != healthSuperseded || !strings.Contains(why, "ef26d4cb") || !strings.Contains(why, "e6b00e2e") {
|
||||
t.Fatalf("a controller build the machine no longer runs: %v %q", h, why)
|
||||
}
|
||||
f.sentBuilds["anchor"]["mesh-controller"] = "e6b00e2e"
|
||||
if h, why := judgeHealth("mesh-controller", lease.ComponentController, catalogue.Manifest{}, "anchor", at.Add(-time.Minute), f); h == healthSuperseded {
|
||||
t.Fatalf("the build sent read as another: %q", why)
|
||||
}
|
||||
delete(f.sentBuilds, "anchor")
|
||||
if h, why := judgeHealth("mesh-controller", lease.ComponentController, catalogue.Manifest{}, "anchor", at.Add(-time.Minute), f); h == healthSuperseded {
|
||||
t.Fatalf("a machine whose send is not known read as superseded: %q", why)
|
||||
}
|
||||
g := &inventory.PlanGate{To: "c2", Carried: []inventory.CarriedMove{{Module: "late", Node: "anchor", To: "c5"}}}
|
||||
if got := judgedCommits(g, []judged{{"app", "anchor"}, {"late", "anchor"}}); got["app"] != "c2" || got["late"] != "c5" {
|
||||
t.Fatalf("judged commits %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A move of another build of a module to a machine where a release or a plan is judging that module
|
||||
// waits for that judging; the same build to that machine is already there. Pure.
|
||||
func TestAWalkWaitsForAJudgingOfTheSameModuleOnThatMachine(t *testing.T) {
|
||||
at := time.Now()
|
||||
release := inventory.Plan{ID: "release-1", State: inventory.PlanRolling, Release: &inventory.PlanRelease{
|
||||
Gate: &inventory.PlanGate{Machines: []string{"novox"}, Carried: []inventory.CarriedMove{
|
||||
{Module: "mesh-controller", Node: "novox", From: "ef26d4cb", To: "2913c54c"}}}}}
|
||||
merge := inventory.Plan{ID: "plan-1", State: inventory.PlanRolling, Modules: map[string]*inventory.PlanModule{
|
||||
"app": {First: []string{"anchor"}, FirstAt: &at, Commit: "c2", Gate: &inventory.PlanGate{Machines: []string{"anchor"}}}}}
|
||||
f := moveFacts{plans: []inventory.Plan{release, merge}}
|
||||
for _, c := range []struct {
|
||||
module, node, to, want string
|
||||
}{
|
||||
{"mesh-controller", "novox", "e6b00e2e", "release-1"}, // the day's case: a newer controller to the judged machine
|
||||
{"mesh-controller", "novox", "2913c54c", ""}, // the same build: already there
|
||||
{"mesh-controller", "ace", "2913c54c", "release-1"}, // another machine while the first is judged
|
||||
{"mesh-host", "novox", "x", ""}, // a module the release does not carry
|
||||
{"app", "anchor", "c2", ""},
|
||||
{"app", "anchor", "c3", "plan-1"},
|
||||
{"app", "laptop", "c2", "plan-1"},
|
||||
} {
|
||||
if got := f.walkedBy(c.module, c.node, c.to); got != c.want {
|
||||
t.Errorf("%s %s to %s: walked by %q, want %q", c.module, c.to, c.node, got, c.want)
|
||||
}
|
||||
}
|
||||
release.Release.Gate.Verdict = inventory.GatePassed
|
||||
merge.Modules["app"].Gate.Verdict = inventory.GatePassed
|
||||
if f.walkedBy("mesh-controller", "novox", "e6b00e2e") != "" || f.walkedBy("app", "laptop", "c3") != "" {
|
||||
t.Fatal("a passed judging still holds a move")
|
||||
}
|
||||
release.Release.Gate.Verdict = ""
|
||||
f.plans[0].State = inventory.PlanSuperseded
|
||||
if f.walkedBy("mesh-controller", "novox", "e6b00e2e") != "" {
|
||||
t.Fatal("a closed release still holds a move")
|
||||
}
|
||||
}
|
||||
|
||||
// The controller is never put back to a build that reaches less of the store's schema than the store
|
||||
// has, or to one that never said what it reaches: the current build is kept, and the condition says so.
|
||||
func TestTheControllerIsNotPutBackToABuildOlderThanTheStoresSchema(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
keeper, _ := withConditionsInMemory(t)
|
||||
told := &conditions.Told{}
|
||||
was := doctorFrom
|
||||
doctorFrom = &doctor{open: open, keeper: keeper, teller: told}
|
||||
t.Cleanup(func() { doctorFrom = was })
|
||||
wasSend := sendRollout
|
||||
var sent [][]string
|
||||
sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) {
|
||||
sent = append(sent, names)
|
||||
return names, nil
|
||||
}
|
||||
t.Cleanup(func() { sendRollout = wasSend })
|
||||
|
||||
build := func(id, commit, digest string, asked time.Time) inventory.Build {
|
||||
manifest, _ := json.Marshal(catalogue.Manifest{Module: "mesh-controller", Version: commit})
|
||||
b := inventory.Build{ID: id, Module: "mesh-controller", Commit: commit, Repository: "novox/mesh-controller", Path: ".",
|
||||
Manifest: manifest, Asked: asked, At: asked, Made: []inventory.Artifact{{Name: "controller", Kind: catalogue.ArtifactBundle,
|
||||
Reference: "mesh-artifact://mesh-controller/controller/blobs/sha256:" + digest}}}
|
||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "mesh-controller", Version: commit},
|
||||
inventory.Source{Repository: "novox/mesh-controller", Seat: "git", Path: ".", BuiltFrom: commit, Head: commit, Asked: asked}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
previous := build("build-old", "ef26d4cb", strings.Repeat("1", 64), time.Now().Add(-2*time.Hour))
|
||||
failed := build("build-new", "e6b00e2e", strings.Repeat("2", 64), time.Now().Add(-time.Minute))
|
||||
if versionOfBuild(previous) != strings.Repeat("1", 12) {
|
||||
t.Fatalf("the build's version is %q", versionOfBuild(previous))
|
||||
}
|
||||
applied, err := inv.SchemaApplied(ctx)
|
||||
if err != nil || applied < 87 {
|
||||
t.Fatalf("the store's schema reaches %d (%v)", applied, err)
|
||||
}
|
||||
// The build before never recorded what it reads: not proved, refused.
|
||||
if why, ok := controllerSchemaAllows(ctx, inv, previous); ok || !strings.Contains(why, "never recorded") {
|
||||
t.Fatalf("an unknown reach: %v %q", ok, why)
|
||||
}
|
||||
// It reads less than the store has: refused, naming both.
|
||||
if err := inv.RecordSchemaReach(ctx, versionOfBuild(previous), applied-1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if why, ok := controllerSchemaAllows(ctx, inv, previous); ok || !strings.Contains(why, "is at") {
|
||||
t.Fatalf("a reach behind the store: %v %q", ok, why)
|
||||
}
|
||||
// Through the gate: the failed build is marked, nothing is put back, nothing is sent, the condition is urgent.
|
||||
at := time.Now().Add(-5 * time.Minute)
|
||||
state := &inventory.PlanModule{Build: failed.ID, Commit: failed.Commit, Previous: previous.Commit, First: []string{"anchor"}, FirstAt: &at}
|
||||
p := inventory.Plan{ID: "plan-352", Repository: "novox/mesh-controller", Branch: "main", Commit: failed.Commit, Created: at,
|
||||
State: inventory.PlanRolling, Tiers: [][]string{{"mesh-controller"}}, Modules: map[string]*inventory.PlanModule{"mesh-controller": state}}
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gateFailed(ctx, open, &p, "mesh-controller", state, []string{"anchor"}, "not healthy within 10m0s of its apply")
|
||||
if state.Gate.Rollback != inventory.NotRolledBack || !strings.Contains(p.Note, "NOT put back") || !strings.Contains(p.Note, "the current build is kept") {
|
||||
t.Fatalf("rollback %q: %s", state.Gate.Rollback, p.Note)
|
||||
}
|
||||
if len(sent) != 0 {
|
||||
t.Fatalf("sent %v: nothing is put back", sent)
|
||||
}
|
||||
if current, _ := inv.CurrentBuilds(ctx); current["mesh-controller"].Commit != failed.Commit {
|
||||
t.Fatalf("the module was put back to %s", current["mesh-controller"].Commit)
|
||||
}
|
||||
if marked, _ := inv.GateFailed(ctx, failed.ID); !marked {
|
||||
t.Fatal("the failed build is not marked failed at its gate")
|
||||
}
|
||||
open2, _ := keeper.Open(ctx)
|
||||
var found bool
|
||||
for _, c := range open2 {
|
||||
if c.Kind == kindRollbackFailed && c.Severity == conditions.Urgent && strings.Contains(c.Summary, "current build is kept") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("no urgent rollback-failed condition saying the current build is kept: %+v", open2)
|
||||
}
|
||||
// Reaching the store: allowed.
|
||||
if err := inv.RecordSchemaReach(ctx, versionOfBuild(previous), applied); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if why, ok := controllerSchemaAllows(ctx, inv, previous); !ok {
|
||||
t.Fatalf("a build that reads the whole schema was refused: %q", why)
|
||||
}
|
||||
// A build with no bundle to know it by: refused.
|
||||
if why, ok := controllerSchemaAllows(ctx, inv, inventory.Build{Commit: "x"}); ok || !strings.Contains(why, "names no bundle") {
|
||||
t.Fatalf("a build without a bundle: %v %q", ok, why)
|
||||
}
|
||||
}
|
||||
|
||||
// The lease's holder names the build the declaration told it it is, and the version stamp only without one.
|
||||
func TestTheHolderNamesTheBuildTheDeclarationToldIt(t *testing.T) {
|
||||
t.Setenv(RunningBuildVar, " ad62528c47c7 ")
|
||||
if h := holderOf("x"); h.Build != "ad62528c47c7" {
|
||||
t.Fatalf("the holder's build is %q", h.Build)
|
||||
}
|
||||
t.Setenv(RunningBuildVar, "")
|
||||
if h := holderOf("x"); h.Build != version {
|
||||
t.Fatalf("without a declared version the holder's build is %q", h.Build)
|
||||
}
|
||||
if reach, err := schemaReach(); err != nil || reach < 87 {
|
||||
t.Fatalf("this build's reach is %d (%v)", reach, err)
|
||||
}
|
||||
}
|
||||
@@ -98,8 +98,9 @@ func judgeNetworks(ctx context.Context, inv *inventory.Inventory, k *conditions.
|
||||
problems = append(problems, err.Error())
|
||||
}
|
||||
}
|
||||
undecided := undecidedParts(f)
|
||||
for _, c := range open {
|
||||
if !slices.Contains(networkKinds, c.Kind) || said[c.Key] {
|
||||
if !slices.Contains(networkKinds, c.Kind) || said[c.Key] || heldUndecided(c, undecided) {
|
||||
continue
|
||||
}
|
||||
why := "no machine says it any more"
|
||||
@@ -116,6 +117,59 @@ func judgeNetworks(ctx context.Context, inv *inventory.Inventory, k *conditions.
|
||||
return nil
|
||||
}
|
||||
|
||||
// undecidedParts is, per machine, every part of its newest statement not yet judged: starting, or unknown
|
||||
// — one look failed and a second decides (novox/hq issue 348). Such a part does not say its fault is gone.
|
||||
// A statement whose parts are all decided but whose whole is unknown or starting holds every part. Pure.
|
||||
//
|
||||
// On 2026-10-09 the control node's resolver refused every question from 10:58 to 11:18 UTC. A build of
|
||||
// the node-engine sent at 10:59:34 restarted it; its first statement judged the names once (unknown, "one
|
||||
// look failed; a second decides"), and that statement cleared the control node's network condition while
|
||||
// its last evidence still said "connection refused". The second look raised it again forty seconds later —
|
||||
// after the send — and the gate failed the build for a fault from before it.
|
||||
func undecidedParts(f networkFacts) map[string]map[string]bool {
|
||||
out := map[string]map[string]bool{}
|
||||
for m, h := range f.healths {
|
||||
if h.Network == nil {
|
||||
continue
|
||||
}
|
||||
parts := map[string]bool{}
|
||||
for _, p := range h.Network.Parts {
|
||||
if p.State == link.StateUnknown || p.State == link.StateStarting {
|
||||
parts[p.Part] = true
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 && (h.Network.State == link.StateUnknown || h.Network.State == link.StateStarting) {
|
||||
parts["*"] = true
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
out[m] = parts
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// heldUndecided says an open network condition is kept rather than cleared: a part its newest evidence
|
||||
// names is undecided in the newest statement of a machine it is about. A condition about other parts
|
||||
// clears as before. Pure.
|
||||
func heldUndecided(c conditions.Condition, undecided map[string]map[string]bool) bool {
|
||||
said := ""
|
||||
if len(c.Evidence) > 0 {
|
||||
said = c.Evidence[0].Said
|
||||
}
|
||||
for _, m := range append([]string{c.Subject.Machine}, c.Subject.Also...) {
|
||||
parts := undecided[m]
|
||||
if parts["*"] {
|
||||
return true
|
||||
}
|
||||
for part := range parts {
|
||||
if strings.Contains(said, part+" since ") || strings.Contains(said, part+": ") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pointed is one machine's failing part that points at another machine.
|
||||
type pointed struct {
|
||||
from string
|
||||
|
||||
@@ -78,6 +78,8 @@ func run() error {
|
||||
return rotateCommand(ctx, args[1:])
|
||||
case "ask":
|
||||
return askCommand(ctx, args[1:])
|
||||
case "rehearse":
|
||||
return rehearseCommand(ctx, args[1:])
|
||||
case "builds":
|
||||
return buildsCommand(ctx, args[1:])
|
||||
// The build queue, controlled by hand (novox/hq ADR 0219).
|
||||
|
||||
@@ -1204,7 +1204,18 @@ func graphOfFacts(f snapshot.Facts) ([]inventory.Entry, map[string][]inventory.R
|
||||
entries = append(entries, inventory.Entry{Manifest: manifest, Provided: mod.Provided,
|
||||
Source: inventory.Source{Repository: mod.Repository, Path: mod.Path, BuiltFrom: mod.Commit}})
|
||||
for _, r := range mod.Reads {
|
||||
read[mod.Name] = append(read[mod.Name], inventory.ReadRepository{Repository: r})
|
||||
entry := inventory.ReadRepository{Repository: r}
|
||||
for _, s := range mod.Sources {
|
||||
if !s.Own && s.Repository == r && len(s.Paths) > 0 {
|
||||
entry.Paths = s.Paths
|
||||
}
|
||||
}
|
||||
read[mod.Name] = append(read[mod.Name], entry)
|
||||
}
|
||||
for _, s := range mod.Sources {
|
||||
if s.Own && len(s.Paths) > 0 {
|
||||
read[mod.Name] = append(read[mod.Name], inventory.ReadRepository{Own: true, Paths: s.Paths})
|
||||
}
|
||||
}
|
||||
}
|
||||
var edges []inventory.Edge
|
||||
|
||||
@@ -141,6 +141,12 @@ func runForMeshCLI(ctx context.Context, node string, asked link.CLIAsked, v cliV
|
||||
if v.refused != "" {
|
||||
return link.CLIRefusal(v.refused)
|
||||
}
|
||||
// Standard input is the terminal's alone: a secret given at the terminal reaches `secret accept`, and no ordinary
|
||||
// call is handed what the asker's standard input held (novox/hq ADR 0259 §10, ADR 0272).
|
||||
if len(asked.Stdin) > 0 && !v.terminal {
|
||||
return link.CLIAnswer{Exit: 1, Why: v.why, Refused: "standard input is given to a line that runs as the " +
|
||||
"controller's terminal alone, and this one does not. Nothing ran"}
|
||||
}
|
||||
if cliServers[asked.Line[0]] {
|
||||
return link.CLIAnswer{Exit: 1, Why: v.why, Refused: fmt.Sprintf("%s serves until stopped, and is not a "+
|
||||
"command line mesh-cli runs. Nothing ran", asked.Line[0])}
|
||||
@@ -155,8 +161,12 @@ func runForMeshCLI(ctx context.Context, node string, asked link.CLIAsked, v cliV
|
||||
}
|
||||
cmd := selfCommand(ctx, line)
|
||||
cmd.Env = commandEnvironment(fmt.Sprintf("%s through mesh-cli on %s", asked.Account, node), verb, v.terminal)
|
||||
// No standard input: a command that reads one gets nothing, and fails saying so (ADR 0272 §5).
|
||||
// No standard input unless mesh-cli carried one for a terminal line: a command that reads one gets nothing, and
|
||||
// fails saying so (ADR 0272 §5).
|
||||
cmd.Stdin = nil
|
||||
if len(asked.Stdin) > 0 {
|
||||
cmd.Stdin = bytes.NewReader(asked.Stdin)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
err := cmd.Run()
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// secretAcceptWants makes the test binary, run as a command line, read a secret as `secret accept` reads it and
|
||||
// say whether it is the value whose SHA-256 the variable names (TestMain).
|
||||
const secretAcceptWants = "MESH_TEST_SECRET_ACCEPT_WANTS"
|
||||
|
||||
// readAsSecretAccept is that process: `secret accept <node> <module> <name> [--from -]`, the value read by
|
||||
// valueFor, compared by digest, and only the verdict printed.
|
||||
func readAsSecretAccept(want string, argv []string) int {
|
||||
if len(argv) < 5 || argv[0] != "secret" || argv[1] != "accept" {
|
||||
fmt.Printf("not a secret accept line: %q\n", argv)
|
||||
return 2
|
||||
}
|
||||
from := ""
|
||||
if len(argv) == 7 && argv[5] == "--from" {
|
||||
from = argv[6]
|
||||
}
|
||||
value, err := valueFor(argv[2], argv[3], argv[4], from)
|
||||
if err != nil {
|
||||
fmt.Printf("secret accept read nothing: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
sum := sha256.Sum256([]byte(asSupplied(value)))
|
||||
if hex.EncodeToString(sum[:]) != want {
|
||||
fmt.Printf("secret accept read something else (%d bytes)\n", len(value))
|
||||
return 1
|
||||
}
|
||||
fmt.Println("secret accept read the value it was given")
|
||||
return 0
|
||||
}
|
||||
|
||||
// novox/hq ADR 0259 §10, ADR 0272: what mesh-cli's standard input held reaches `secret accept` on a line that runs
|
||||
// as the controller's terminal, and appears nowhere else — not in the answer, not in the journal, not in the calls
|
||||
// record; an ordinary line carrying it is refused and nothing runs.
|
||||
func TestStandardInputReachesSecretAcceptAtTheTerminalAndNowhereElse(t *testing.T) {
|
||||
token := "123456789:AAEhBOweik6ad9r_QxGivenAtTheTerminal"
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
t.Setenv(secretAcceptWants, hex.EncodeToString(sum[:]))
|
||||
var journal []string
|
||||
var mu sync.Mutex
|
||||
was := cliJournal
|
||||
cliJournal = func(line string) { mu.Lock(); journal = append(journal, line); mu.Unlock() }
|
||||
t.Cleanup(func() { cliJournal = was })
|
||||
ctx := context.Background()
|
||||
|
||||
for _, line := range [][]string{
|
||||
{"secret", "accept", "anchor", "telegram", "telegram-token", "--from", "-"},
|
||||
{"secret", "accept", "anchor", "telegram", "telegram-token"}, // the prompt's path, a line on standard input
|
||||
} {
|
||||
asked := cliAsked("operator", 1000, line...)
|
||||
asked.Stdin = []byte(token + "\n")
|
||||
a := runForMeshCLI(ctx, "control", asked, cliVerdict{terminal: true, why: "the terminal"})
|
||||
if a.Exit != 0 || !strings.Contains(string(a.Stdout), "read the value it was given") {
|
||||
t.Fatalf("%q: secret accept did not read what mesh-cli carried: %+v (%s)", line, a, a.Stdout)
|
||||
}
|
||||
if body, _ := json.Marshal(a); strings.Contains(string(body), "AAEh") || strings.Contains(string(body), base64.StdEncoding.EncodeToString([]byte(token))) {
|
||||
t.Fatalf("the answer carries the secret: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// Without standard input, the line reads nothing, as before.
|
||||
a := runForMeshCLI(ctx, "control", cliAsked("operator", 1000, "secret", "accept", "anchor", "telegram",
|
||||
"telegram-token", "--from", "-"), cliVerdict{terminal: true, why: "the terminal"})
|
||||
if a.Exit == 0 {
|
||||
t.Fatalf("a line with no standard input read a value: %+v", a)
|
||||
}
|
||||
|
||||
// An ordinary call is never handed it: refused, and nothing ran.
|
||||
asked := cliAsked("operator", 1000, "status")
|
||||
asked.Stdin = []byte(token)
|
||||
a = runForMeshCLI(ctx, "laptop", asked, cliVerdict{why: "not the terminal"})
|
||||
if a.Exit == 0 || !strings.Contains(a.Refused, "terminal alone") || len(a.Stdout) != 0 {
|
||||
t.Fatalf("an ordinary line was given standard input: %+v", a)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, l := range journal {
|
||||
if strings.Contains(l, "AAEh") {
|
||||
t.Fatalf("the journal says the secret: %s", l)
|
||||
}
|
||||
}
|
||||
if len(journal) == 0 {
|
||||
t.Fatal("the lines were not said in the journal at all")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -24,7 +24,7 @@ var cliNodes = []inventory.Node{
|
||||
{Name: "unnamed"},
|
||||
}
|
||||
|
||||
func asked(account string, uid uint32, line ...string) link.CLIAsked {
|
||||
func cliAsked(account string, uid uint32, line ...string) link.CLIAsked {
|
||||
return link.CLIAsked{Line: line, Account: account, UID: uid, Session: "session-1.scope"}
|
||||
}
|
||||
|
||||
@@ -39,13 +39,13 @@ func TestMeshCLIIsTheTerminalOnlyForTheControlNodesOperator(t *testing.T) {
|
||||
refused string
|
||||
why string
|
||||
}{
|
||||
{"the control-node's operator", "control", asked("operator", 1000, "status"), control, true, "", "the controller's terminal"},
|
||||
{"another node's operator", "laptop", asked("operator", 1000, "status"), control, false, "", "agents on laptop may run as operator"},
|
||||
{"another account", "control", asked("agent", 1001, "status"), control, false, "operator account (operator) only", ""},
|
||||
{"root", "control", asked("root", 0, "status"), control, false, "never root", ""},
|
||||
{"a node with no operator account", "unnamed", asked("operator", 1000, "status"), control, false, "does not know unnamed's operator account", ""},
|
||||
{"a node the mesh does not know", "elsewhere", asked("operator", 1000, "status"), control, false, "not a node this mesh knows", ""},
|
||||
{"two control-nodes", "control", asked("operator", 1000, "status"), []string{"control", "laptop"}, false, "", "2 control-nodes"},
|
||||
{"the control-node's operator", "control", cliAsked("operator", 1000, "status"), control, true, "", "the controller's terminal"},
|
||||
{"another node's operator", "laptop", cliAsked("operator", 1000, "status"), control, false, "", "agents on laptop may run as operator"},
|
||||
{"another account", "control", cliAsked("agent", 1001, "status"), control, false, "operator account (operator) only", ""},
|
||||
{"root", "control", cliAsked("root", 0, "status"), control, false, "never root", ""},
|
||||
{"a node with no operator account", "unnamed", cliAsked("operator", 1000, "status"), control, false, "does not know unnamed's operator account", ""},
|
||||
{"a node the mesh does not know", "elsewhere", cliAsked("operator", 1000, "status"), control, false, "not a node this mesh knows", ""},
|
||||
{"two control-nodes", "control", cliAsked("operator", 1000, "status"), []string{"control", "laptop"}, false, "", "2 control-nodes"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
v := judgeCLI(c.node, c.asked, cliNodes, c.control)
|
||||
@@ -70,7 +70,7 @@ func TestTheTerminalRunsWithoutAVerbAndAnOrdinaryCallNamesMeshCLI(t *testing.T)
|
||||
t.Setenv(servedVar, "1")
|
||||
ctx := context.Background()
|
||||
|
||||
a := runForMeshCLI(ctx, "control", asked("operator", 1000, "status"), cliVerdict{terminal: true, why: "the terminal"})
|
||||
a := runForMeshCLI(ctx, "control", cliAsked("operator", 1000, "status"), cliVerdict{terminal: true, why: "the terminal"})
|
||||
if a.Exit != 0 || a.Refused != "" || !a.Terminal {
|
||||
t.Fatalf("the terminal's line did not run: %+v", a)
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func TestTheTerminalRunsWithoutAVerbAndAnOrdinaryCallNamesMeshCLI(t *testing.T)
|
||||
t.Fatalf("the terminal's line ran with %s", got)
|
||||
}
|
||||
|
||||
a = runForMeshCLI(ctx, "laptop", asked("operator", 1000, "status"), cliVerdict{why: "not the terminal"})
|
||||
a = runForMeshCLI(ctx, "laptop", cliAsked("operator", 1000, "status"), cliVerdict{why: "not the terminal"})
|
||||
if a.Exit != 0 || a.Terminal || a.Why != "not the terminal" {
|
||||
t.Fatalf("an ordinary line did not run as one: %+v", a)
|
||||
}
|
||||
@@ -93,21 +93,21 @@ func TestAnOrdinaryCallMeetsTheCommandVerbsRefusals(t *testing.T) {
|
||||
t.Setenv(echoEnvironment, "1")
|
||||
ctx := context.Background()
|
||||
ordinary := cliVerdict{why: "not the terminal"}
|
||||
a := runForMeshCLI(ctx, "laptop", asked("operator", 1000, "cleanup", "delete", "x"), ordinary)
|
||||
a := runForMeshCLI(ctx, "laptop", cliAsked("operator", 1000, "cleanup", "delete", "x"), ordinary)
|
||||
if a.Refused == "" || len(a.Stdout) != 0 || a.Exit != 1 || a.Why != "not the terminal" {
|
||||
t.Fatalf("a repair without --why ran as an ordinary call: %+v", a)
|
||||
}
|
||||
a = runForMeshCLI(ctx, "laptop", asked("operator", 1000, "settings", "set", "claude-code", "{}"), ordinary)
|
||||
a = runForMeshCLI(ctx, "laptop", cliAsked("operator", 1000, "settings", "set", "claude-code", "{}"), ordinary)
|
||||
if a.Refused != "" || !strings.Contains(string(a.Stdout), `verb="mesh-cli"`) {
|
||||
t.Fatalf("an ordinary settings set did not run through the settings verb's path with MESH_VERB set: %+v", a)
|
||||
}
|
||||
for _, server := range []string{"serve", "api", "board"} {
|
||||
a := runForMeshCLI(ctx, "control", asked("operator", 1000, server), cliVerdict{terminal: true})
|
||||
a := runForMeshCLI(ctx, "control", cliAsked("operator", 1000, server), cliVerdict{terminal: true})
|
||||
if a.Refused == "" || len(a.Stdout) != 0 {
|
||||
t.Fatalf("%s was run for mesh-cli: %+v", server, a)
|
||||
}
|
||||
}
|
||||
a = runForMeshCLI(ctx, "control", asked("agent", 1001, "status"), cliVerdict{refused: "agent is not answered"})
|
||||
a = runForMeshCLI(ctx, "control", cliAsked("agent", 1001, "status"), cliVerdict{refused: "agent is not answered"})
|
||||
if a.Refused != "agent is not answered" || len(a.Stdout) != 0 {
|
||||
t.Fatalf("a refused line ran: %+v", a)
|
||||
}
|
||||
@@ -184,9 +184,9 @@ func TestEveryMeshCLILineIsSaidInTheJournal(t *testing.T) {
|
||||
cliJournal = func(line string) { said = append(said, line) }
|
||||
t.Cleanup(func() { cliJournal = was })
|
||||
ctx := link.WithCallID(context.Background(), "call-1")
|
||||
runForMeshCLI(ctx, "control", asked("operator", 1000, "settings", "set", "x", `{"password":"s3cret"}`),
|
||||
runForMeshCLI(ctx, "control", cliAsked("operator", 1000, "settings", "set", "x", `{"password":"s3cret"}`),
|
||||
cliVerdict{terminal: true, why: "the terminal"})
|
||||
runForMeshCLI(ctx, "control", asked("agent", 1001, "status"), cliVerdict{refused: "agent is not answered"})
|
||||
runForMeshCLI(ctx, "control", cliAsked("agent", 1001, "status"), cliVerdict{refused: "agent is not answered"})
|
||||
all := strings.Join(said, "\n")
|
||||
if len(said) != 2 || !strings.Contains(all, "call-1") || !strings.Contains(all, "operator on control") ||
|
||||
!strings.Contains(all, "as the controller's terminal") || !strings.Contains(all, "refused") {
|
||||
@@ -213,7 +213,7 @@ func TestAnOrdinaryLineRunsNothingTheCommandVerbWouldRefuse(t *testing.T) {
|
||||
if _, err := ordinaryLine(line); err == nil {
|
||||
t.Errorf("%q composed as an ordinary line", line)
|
||||
}
|
||||
a := runForMeshCLI(context.Background(), "laptop", asked("operator", 1000, line...), cliVerdict{why: "not the terminal"})
|
||||
a := runForMeshCLI(context.Background(), "laptop", cliAsked("operator", 1000, line...), cliVerdict{why: "not the terminal"})
|
||||
if a.Refused == "" || len(a.Stdout) != 0 {
|
||||
t.Errorf("%q ran as an ordinary line: %+v", line, a)
|
||||
}
|
||||
@@ -279,11 +279,11 @@ func TestTheTerminalsMarkIsStrippedFromEveryOtherLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
a := runForMeshCLI(context.Background(), "laptop", asked("operator", 1000, "status"), cliVerdict{why: "not the terminal"})
|
||||
a := runForMeshCLI(context.Background(), "laptop", cliAsked("operator", 1000, "status"), cliVerdict{why: "not the terminal"})
|
||||
if got := string(a.Stdout); !strings.Contains(got, "terminal=false") || !strings.Contains(got, `verb="mesh-cli"`) {
|
||||
t.Fatalf("an ordinary line with the mark in the serving environment ran as %s", got)
|
||||
}
|
||||
a = runForMeshCLI(context.Background(), "control", asked("operator", 1000, "status"), cliVerdict{terminal: true})
|
||||
a = runForMeshCLI(context.Background(), "control", cliAsked("operator", 1000, "status"), cliVerdict{terminal: true})
|
||||
if got := string(a.Stdout); !strings.Contains(got, "terminal=true") {
|
||||
t.Fatalf("the terminal's line ran as %s", got)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,50 @@ type merges interface {
|
||||
AnnouncedMerges(ctx context.Context, since time.Time) ([]link.AnnouncedMerge, error)
|
||||
}
|
||||
|
||||
// unheardMerges is the announcements of merges the controller has not heard (novox/hq ADR 0276): a merge kept
|
||||
// in a batch is not acted on until its batch is cut, which can be past mergeGrace behind a long walk, and is
|
||||
// not missed for that.
|
||||
type unheardMerges struct {
|
||||
merges
|
||||
inv *inventory.Inventory
|
||||
}
|
||||
|
||||
func (u unheardMerges) AnnouncedMerges(ctx context.Context, since time.Time) ([]link.AnnouncedMerge, error) {
|
||||
all, err := u.merges.AnnouncedMerges(ctx, since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []link.AnnouncedMerge
|
||||
for _, a := range all {
|
||||
_, kept, err := u.inv.MergeOf(ctx, a.Owner+"/"+a.Repo, a.Commit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !kept {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// owedToTheRecord says a merge read as history is still owed to the record (novox/hq ADR 0276): the merges
|
||||
// reader knows, when it keeps them (unheardMerges).
|
||||
func owedToTheRecord(ctx context.Context, announced merges, m link.SourceMoved, entries []inventory.Entry,
|
||||
read map[string][]inventory.ReadRepository) bool {
|
||||
u, ok := announced.(unheardMerges)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
merged, err := time.Parse(time.RFC3339Nano, m.MergedAt)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
raw := m
|
||||
raw.MergedAt = ""
|
||||
owed, err := owedLate(ctx, u.inv, m, merged, wouldMove(raw, entries, read))
|
||||
return err == nil && owed
|
||||
}
|
||||
|
||||
// catchingUpOnMerges reads back the forge's announcements on a timer, until the context ends.
|
||||
func catchingUpOnMerges(ctx context.Context, open *stores, announced merges) {
|
||||
f := following{open}
|
||||
@@ -48,7 +92,7 @@ func catchingUpOnMerges(ctx context.Context, open *stores, announced merges) {
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
read, err := open.inventory.ReadRepositories(ctx)
|
||||
read, err := readForPlanning(ctx, open.inventory)
|
||||
return entries, read, err
|
||||
}
|
||||
failing := ""
|
||||
@@ -61,7 +105,7 @@ func catchingUpOnMerges(ctx context.Context, open *stores, announced merges) {
|
||||
case <-tick.C:
|
||||
}
|
||||
watchedMerges.begin()
|
||||
err := catchUpOnMerges(ctx, time.Now(), announced, catalogued, f.SourceMoved, func(format string, args ...any) {
|
||||
err := catchUpOnMerges(ctx, time.Now(), unheardMerges{announced, open.inventory}, catalogued, f.SourceMoved, func(format string, args ...any) {
|
||||
fmt.Printf(format+"\n", args...)
|
||||
})
|
||||
// What the pass found is what S5 says (novox/hq to-be 45 §3); a pass that could not read
|
||||
@@ -102,10 +146,15 @@ func catchUpOnMerges(ctx context.Context, now time.Time, announced merges,
|
||||
return err
|
||||
}
|
||||
for _, a := range all {
|
||||
// A merge the forge said no time of is dated by its announcement, so a packaging module's look can
|
||||
// make it history once acted on, and the catch-up does not act on it again every pass (ADR 0267).
|
||||
if a.SourceMoved.MergedAt == "" && !a.At.IsZero() {
|
||||
a.SourceMoved.MergedAt = a.At.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if now.Sub(a.At) < mergeGrace {
|
||||
continue
|
||||
}
|
||||
if len(wouldMove(a.SourceMoved, entries, read)) == 0 {
|
||||
if len(wouldMove(a.SourceMoved, entries, read)) == 0 && !owedToTheRecord(ctx, announced, a.SourceMoved, entries, read) {
|
||||
continue
|
||||
}
|
||||
if entries, read, err = catalogued(ctx); err != nil {
|
||||
@@ -113,8 +162,13 @@ func catchUpOnMerges(ctx context.Context, now time.Time, announced merges,
|
||||
}
|
||||
moves := wouldMove(a.SourceMoved, entries, read)
|
||||
if len(moves) == 0 {
|
||||
if !owedToTheRecord(ctx, announced, a.SourceMoved, entries, read) {
|
||||
continue
|
||||
}
|
||||
raw := a.SourceMoved
|
||||
raw.MergedAt = ""
|
||||
moves = wouldMove(raw, entries, read)
|
||||
}
|
||||
var names []string
|
||||
for _, e := range moves {
|
||||
names = append(names, e.Manifest.Module)
|
||||
|
||||
@@ -402,6 +402,7 @@ func moduleUnhealthyObservation(module, node string, rs []inventory.ResourceHeal
|
||||
Explanation: fmt.Sprintf("%s on %s is not healthy: %s. It clears as soon as it runs again.", module, node,
|
||||
namesWords(plain, 3)),
|
||||
Needs: needs,
|
||||
Actions: moduleActions(node, rs),
|
||||
Resolved: fmt.Sprintf("%s works again on %s", module, node)}
|
||||
}
|
||||
|
||||
@@ -555,8 +556,8 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea
|
||||
said = append(said, wait)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
said = append(said, fmt.Sprintf("on %s, %s uses %s as found and waits for a person to hand it over "+
|
||||
"(`mesh-host hand-over <directory>` at the machine)", machine, module, strings.Join(found, ", ")))
|
||||
said = append(said, fmt.Sprintf("on %s, %s uses %s as found and waits for the operator to hand it over "+
|
||||
"(`nox node hand-over %s <directory>` on the control-node)", machine, module, strings.Join(found, ", "), machine))
|
||||
}
|
||||
return healthPerson, strings.Join(said, "; ")
|
||||
}
|
||||
@@ -677,7 +678,9 @@ func usedAsFoundObservation(module, node, said string, rs []inventory.ResourceHe
|
||||
o.Explanation = fmt.Sprintf("A directory of %s was already on %s, with another owner or mode than %s declares. "+
|
||||
"The mesh left it as it was rather than hand it to an account, so %s may not be able to use it.",
|
||||
module, node, module, module)
|
||||
o.Needs = fmt.Sprintf("on %s, run mesh-host hand-over with the directory's path as root.", node)
|
||||
// Plain words (ADR 0253): the line itself — `nox node hand-over <node> <path>` on the control-node (ADR 0272,
|
||||
// issue 356) — is in the summary and the evidence, which name the directory; a path is never in these.
|
||||
o.Needs = "hand the directory over from the control-node, as the operator; the details name it and the line to type."
|
||||
o.Resolved = fmt.Sprintf("%s's directory on %s is the mesh's", module, node)
|
||||
o.Actions = nil
|
||||
return o
|
||||
|
||||
@@ -10,10 +10,12 @@ import (
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/overlay"
|
||||
)
|
||||
|
||||
@@ -397,8 +399,9 @@ func assignCommand(ctx context.Context, verb string, args []string) error {
|
||||
func settingsCommand(ctx context.Context, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("settings show <module> [--node <node>] [--history], settings set <module> <file> " +
|
||||
"[--node <node>] [--replace], settings clear <module> [--node <node>], or settings preferences " +
|
||||
"[<module>] [--node <node>]")
|
||||
"[--node <node>] [--replace], settings clear <module> [--node <node>], settings preferences " +
|
||||
"[<module>] [--node <node>], settings propose <module> <file | --clear> [--node <node>] [--replace], " +
|
||||
"or settings proposals [<id>]")
|
||||
}
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
@@ -412,12 +415,38 @@ func settingsCommand(ctx context.Context, args []string) error {
|
||||
// **What a set removes is refused unless meant** (novox/hq ADR 0217). A layer is replaced whole,
|
||||
// and on 2026-10-05 setting one placement dropped a machine's whole layer for a module without a
|
||||
// word (novox/hq issue 304). Adding and changing keys needs nothing; removing one needs this.
|
||||
replace := set.Bool("replace", false, "for set: remove the keys the new layer does not name")
|
||||
replace := set.Bool("replace", false, "for set and propose: remove the keys the new layer does not name")
|
||||
history := set.Bool("history", false, "for show: the layers this one replaced, the latest first")
|
||||
clear := set.Bool("clear", false, "for propose: propose that the layer be removed")
|
||||
positionals, err := parseAround(set, args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch args[0] {
|
||||
case "propose":
|
||||
// A trusted setting, proposed by anyone and set only on the operator's warrant (novox/hq ADR 0277):
|
||||
// the stores are opened there, so the proposal and the verb's refusals below never meet.
|
||||
if len(positionals) < 1 || len(positionals) > 2 {
|
||||
return errors.New("settings propose <module> <settings.json | {…}> [--node <node>] [--replace], or settings propose <module> --clear [--node <node>]")
|
||||
}
|
||||
values := ""
|
||||
if len(positionals) == 2 {
|
||||
values = positionals[1]
|
||||
}
|
||||
return proposeCommand(ctx, positionals[0], *node, values, *clear, *replace)
|
||||
case "proposals":
|
||||
if len(positionals) > 1 || *node != "" || *clear || *replace || *history {
|
||||
return errors.New("settings proposals [<id>]")
|
||||
}
|
||||
id := ""
|
||||
if len(positionals) == 1 {
|
||||
id = positionals[0]
|
||||
}
|
||||
return proposalsCommand(ctx, id)
|
||||
}
|
||||
if *clear {
|
||||
return errors.New("--clear is for settings propose; a layer is cleared with settings clear")
|
||||
}
|
||||
|
||||
where := "the whole mesh"
|
||||
if *node != "" {
|
||||
@@ -455,7 +484,7 @@ func settingsCommand(ctx context.Context, args []string) error {
|
||||
"removal is meant (novox/hq ADR 0217). Nothing was changed",
|
||||
positionals[0], where, strings.Join(removed, ", "), positionals[0], nodeFlag(*node))
|
||||
}
|
||||
if err := inv.SetSettings(ctx, *node, positionals[0], values); err != nil {
|
||||
if err := inv.SetSettingsBy(ctx, *node, positionals[0], values, setByWords()); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s on %s:\n", positionals[0], where)
|
||||
@@ -496,8 +525,12 @@ func settingsCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
for _, p := range past {
|
||||
shown, _ := json.MarshalIndent(p.Values, " ", " ")
|
||||
fmt.Printf("%s on %s, until %s (%s):\n %s\n", positionals[0], where,
|
||||
p.ReplacedAt.Local().Format("2006-01-02 15:04:05"), p.ReplacedBy, shown)
|
||||
by := ""
|
||||
if p.SetBy != "" {
|
||||
by = "; " + p.SetBy
|
||||
}
|
||||
fmt.Printf("%s on %s, until %s (%s%s):\n %s\n", positionals[0], where,
|
||||
p.ReplacedAt.Local().Format("2006-01-02 15:04:05"), p.ReplacedBy, by, shown)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -513,6 +546,14 @@ func settingsCommand(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(shown))
|
||||
// And who set it (novox/hq ADR 0277): a layer the operator approved on their phone says so.
|
||||
if setBy, setAt, has, err := inv.LayerOrigin(ctx, *node, positionals[0]); err != nil {
|
||||
return err
|
||||
} else if has && setBy != "" {
|
||||
fmt.Printf(" %s\n", setBy)
|
||||
} else if has {
|
||||
fmt.Printf(" set at %s; who set it was not kept\n", setAt.Local().Format("2006-01-02 15:04"))
|
||||
}
|
||||
}
|
||||
// Every value the module gives a default or a layer sets, and where it came from (novox/hq
|
||||
// ADR 0262): the default, the mesh's layer, or this node's. Said after the layer, which stays
|
||||
@@ -606,17 +647,26 @@ func settingsCommand(ctx context.Context, args []string) error {
|
||||
if err := refuseTerminalSettingsThroughAVerb(ctx, inv, before, nil, positionals[0], where); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := inv.ClearSettings(ctx, *node, positionals[0]); err != nil {
|
||||
if err := inv.ClearSettingsBy(ctx, *node, positionals[0], setByWords()); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s on %s is back to what the module says\n", positionals[0], where)
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("settings has no %q; it has show, set, clear and preferences", args[0])
|
||||
return fmt.Errorf("settings has no %q; it has show, set, clear, preferences, propose and proposals", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
// setByWords is who sets a layer from this process, as the layer keeps it (novox/hq ADR 0277): the caller at the
|
||||
// controller's terminal, or through which verb.
|
||||
func setByWords() string {
|
||||
if verb, through := throughAVerb(); through {
|
||||
return "set by " + link.Caller() + " through " + verb + " at " + time.Now().Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
return "set at the controller's terminal by " + link.Caller() + " at " + time.Now().Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
// describeEffective says each setting's value on a machine or the whole mesh, where it came from, and
|
||||
// the module's default when a layer overrides it.
|
||||
func describeEffective(module, where string, values []catalogue.SettingSource) string {
|
||||
@@ -1107,10 +1157,13 @@ func refuseTerminalSettingsThroughAVerb(ctx context.Context, inv *inventory.Inve
|
||||
}
|
||||
// A trusted mergeable file takes any key, so its module's whole layer is the terminal's (novox/hq issue 340).
|
||||
if files := catalogue.TrustedMergeable(shelf[module]); len(files) > 0 && !sameLayer(before, after) {
|
||||
return fmt.Errorf("the settings of %s on %s are set at the controller's terminal only (`mesh-cli` on the control-node), never through a verb (this "+
|
||||
"line came through %q): %s merges whatever key a layer sets into a file root or a consumer trusts, so "+
|
||||
"any key could point the module at a listener of the caller's, and whoever may call a verb includes "+
|
||||
"agents (novox/hq issue 340; a file nothing trusts says \"trusted\": false). Nothing was changed",
|
||||
return fmt.Errorf("the settings of %s on %s are set at the controller's terminal (`mesh-cli` on the control-node) or on "+
|
||||
"the operator's warrant, never through a verb alone (this line came through %q): %s merges whatever key a "+
|
||||
"layer sets into a file root or a consumer trusts, so any key could point the module at a listener of the "+
|
||||
"caller's, and whoever may call a verb includes agents (novox/hq issue 340; a file nothing trusts says "+
|
||||
"\"trusted\": false). Propose it instead: the settings verb with propose puts the exact values to the "+
|
||||
"operator on a channel that proves who answers, and the layer is set on their Approve (novox/hq ADR 0277). "+
|
||||
"Nothing was changed",
|
||||
module, where, verb, strings.Join(files, ", "))
|
||||
}
|
||||
for _, key := range catalogue.TerminalKeys(shelf[module]) {
|
||||
@@ -1119,11 +1172,13 @@ func refuseTerminalSettingsThroughAVerb(ctx context.Context, inv *inventory.Inve
|
||||
if string(was) == string(now) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("%s of %s on %s is set at the controller's terminal only (`mesh-cli` on the control-node), never through a verb (this "+
|
||||
"line came through %q): it says where root creates and owns a module's directories, which of "+
|
||||
"the machine's paths are mounted into its container, what the mesh's consumers trust, or what a file "+
|
||||
"root or a person's session obeys takes, and whoever may call a verb includes agents (novox/hq issue 339; "+
|
||||
"issue 340 for a mergeable file's own keys). Nothing was changed", key, module, where, verb)
|
||||
return fmt.Errorf("%s of %s on %s is set at the controller's terminal (`mesh-cli` on the control-node) or on the "+
|
||||
"operator's warrant, never through a verb alone (this line came through %q): it says where root creates and "+
|
||||
"owns a module's directories, which of the machine's paths are mounted into its container, what the mesh's "+
|
||||
"consumers trust, or what a file root or a person's session obeys takes, and whoever may call a verb "+
|
||||
"includes agents (novox/hq issue 339; issue 340 for a mergeable file's own keys). Propose it instead: the "+
|
||||
"settings verb with propose puts the exact values to the operator on a channel that proves who answers, and "+
|
||||
"the layer is set on their Approve (novox/hq ADR 0277). Nothing was changed", key, module, where, verb)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -426,10 +426,10 @@ func overlayShow(ctx context.Context, open *stores) error {
|
||||
tunnel.Interface, tunnel.Range, tunnel.Port)
|
||||
case n.Hub && hubName == n.Name && tunnel.Interface != "":
|
||||
fmt.Printf(" hub — found a tunnel on %s and did NOT take it over: its key is not the tunnel's; "+
|
||||
"`mesh-host overlay take --tunnel %s` on the machine takes it", tunnel.Interface, tunnel.Interface)
|
||||
"`nox-mesh-host overlay take --tunnel %s` on the machine takes it", tunnel.Interface, tunnel.Interface)
|
||||
case n.Hub:
|
||||
fmt.Print(" hub — found no tunnel; if the machine runs the predecessor's, " +
|
||||
"`mesh-host overlay take --tunnel <iface>` there adopts it (novox/hq ADR 0105)")
|
||||
"`nox-mesh-host overlay take --tunnel <iface>` there adopts it (novox/hq ADR 0105)")
|
||||
case !n.Reachable():
|
||||
fmt.Print(" not dialable")
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"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/token"
|
||||
)
|
||||
|
||||
@@ -28,7 +30,7 @@ import (
|
||||
|
||||
func nodeCommand(ctx context.Context, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("node add <name>, node list, node show <name>, or " + publicDomainUsage)
|
||||
return errors.New("node add <name>, node list, node show <name>, " + publicDomainUsage + ", or " + handOverUsage)
|
||||
}
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
@@ -112,11 +114,163 @@ func nodeCommand(ctx context.Context, args []string) error {
|
||||
// an optional second argument is the home when it is not /home/<account>.
|
||||
return nodeAccount(ctx, inv, args[1:])
|
||||
|
||||
case "hand-over":
|
||||
// A directory the node-engine uses as found, handed to the mesh (novox/hq issue 356, issue 339). Here, at
|
||||
// the controller's terminal, and nowhere else: at the next apply root gives the directory to the account
|
||||
// the module declares, and whoever may call a verb includes agents.
|
||||
return nodeHandOver(ctx, open, args[1:])
|
||||
|
||||
case "setuid-search":
|
||||
// A fresh search for setuid programs on a node (novox/hq issue 361), after the operator changed by hand
|
||||
// what the last one found. Here, at the controller's terminal, and nowhere else: a search never makes a
|
||||
// machine free wrongly, but asked again and again it would keep the machine unjudged and its disks busy,
|
||||
// and whoever may call a verb includes agents.
|
||||
return nodeSetuidSearch(ctx, open, args[1:])
|
||||
|
||||
default:
|
||||
return fmt.Errorf("node has no %q; it has add, list, show, public-domain, account and agent-account", args[0])
|
||||
return fmt.Errorf("node has no %q; it has add, list, show, public-domain, account, agent-account, hand-over "+
|
||||
"and setuid-search", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
const handOverUsage = "node hand-over <node> <directory> — hand a directory the node-engine on <node> uses as found " +
|
||||
"to the mesh: its next apply gives it the declared owner and mode. The directory's absolute path, as the module's " +
|
||||
"condition names it"
|
||||
|
||||
// handOverLine reads a hand-over's line: the node and the directory's absolute path, exactly as the engine states
|
||||
// it. Judged before anything is asked, and judged again by the engine, which is the one that acts.
|
||||
func handOverLine(args []string) (node, path string, err error) {
|
||||
if len(args) != 2 {
|
||||
return "", "", errors.New(handOverUsage)
|
||||
}
|
||||
node, path = args[0], args[1]
|
||||
if node == "" || strings.HasPrefix(node, "-") {
|
||||
return "", "", fmt.Errorf("%q is not a node's name; %s", node, handOverUsage)
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
return "", "", fmt.Errorf("%q is not an absolute path; %s", path, handOverUsage)
|
||||
}
|
||||
if filepath.Clean(path) != path {
|
||||
return "", "", fmt.Errorf("%q is not the directory's path as the engine states it (no `..`, no doubled or "+
|
||||
"trailing separator); %s", path, handOverUsage)
|
||||
}
|
||||
return node, path, nil
|
||||
}
|
||||
|
||||
// handOverBy is who hands the directory over, in the words a verb's caller is recorded in: the operator through
|
||||
// mesh-cli on the control-node, or whoever runs this controller's binary at its terminal.
|
||||
func handOverBy() string {
|
||||
if by := strings.TrimSpace(os.Getenv(link.CallerVar)); by != "" {
|
||||
return by
|
||||
}
|
||||
return "the controller's terminal"
|
||||
}
|
||||
|
||||
// nodeHandOver asks the node's engine to take a directory it uses as found as the mesh's, and says what came of
|
||||
// it. The engine records the hand-over or refuses; nothing is recorded here, because the directory is the
|
||||
// machine's and the engine is the one that reads it. The ask is signed with the mesh's key (issue 356's review).
|
||||
func nodeHandOver(ctx context.Context, open *stores, args []string) error {
|
||||
known := func(node string) error {
|
||||
_, err := open.inventory.NodeByName(ctx, node)
|
||||
return err
|
||||
}
|
||||
ask := func(node, path, by string) (link.HandOverAnswer, error) {
|
||||
ident, err := open.Identity(ctx)
|
||||
if err != nil {
|
||||
return link.HandOverAnswer{}, fmt.Errorf("the mesh's signing key cannot be read, so nothing was asked of %s: %w",
|
||||
node, err)
|
||||
}
|
||||
address, err := broker.BusAddress()
|
||||
if err != nil {
|
||||
return link.HandOverAnswer{}, err
|
||||
}
|
||||
js, err := broker.Dial(address)
|
||||
if err != nil {
|
||||
return link.HandOverAnswer{}, fmt.Errorf("cannot reach the bus, so nothing was asked of %s: %w", node, err)
|
||||
}
|
||||
defer js.Close()
|
||||
return link.AskHandOver(ctx, js.Conn(), ident, node, path, by, link.HandOverWithin)
|
||||
}
|
||||
return handOverAsked(args, known, ask, os.Stdout)
|
||||
}
|
||||
|
||||
// handOverAsked is the hand-over's line with its two acts given: whether the mesh knows the node, and the ask.
|
||||
// Nothing is asked of a line or a node that is refused, and the engine's refusal is this command's failure —
|
||||
// never a success with the refusal printed.
|
||||
func handOverAsked(args []string, known func(node string) error,
|
||||
ask func(node, path, by string) (link.HandOverAnswer, error), out io.Writer) error {
|
||||
node, path, err := handOverLine(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := known(node); err != nil {
|
||||
return fmt.Errorf("nothing was asked: %w", err)
|
||||
}
|
||||
answer, err := ask(node, path, handOverBy())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if answer.Refused != "" {
|
||||
return fmt.Errorf("%s refused: %s", node, answer.Refused)
|
||||
}
|
||||
fmt.Fprintln(out, answer.Said)
|
||||
fmt.Fprintf(out, " the module's condition clears once %s applies; `nox push %s` applies it now\n", node, node)
|
||||
return nil
|
||||
}
|
||||
|
||||
const setuidSearchUsage = "node setuid-search <node> — throw away the node-engine's last search for setuid " +
|
||||
"programs on <node> and start a full one: after a setuid-root program it found was removed by hand. Until it " +
|
||||
"completes, root-free says the node is not judged yet"
|
||||
|
||||
// nodeSetuidSearch asks the node's engine for a fresh search, signed with the mesh's key as a hand-over is.
|
||||
func nodeSetuidSearch(ctx context.Context, open *stores, args []string) error {
|
||||
known := func(node string) error {
|
||||
_, err := open.inventory.NodeByName(ctx, node)
|
||||
return err
|
||||
}
|
||||
ask := func(node, by string) (link.HandOverAnswer, error) {
|
||||
ident, err := open.Identity(ctx)
|
||||
if err != nil {
|
||||
return link.HandOverAnswer{}, fmt.Errorf("the mesh's signing key cannot be read, so nothing was asked of %s: %w",
|
||||
node, err)
|
||||
}
|
||||
address, err := broker.BusAddress()
|
||||
if err != nil {
|
||||
return link.HandOverAnswer{}, err
|
||||
}
|
||||
js, err := broker.Dial(address)
|
||||
if err != nil {
|
||||
return link.HandOverAnswer{}, fmt.Errorf("cannot reach the bus, so nothing was asked of %s: %w", node, err)
|
||||
}
|
||||
defer js.Close()
|
||||
return link.AskSetuidSearch(ctx, js.Conn(), ident, node, by, link.HandOverWithin)
|
||||
}
|
||||
return setuidSearchAsked(args, known, ask, os.Stdout)
|
||||
}
|
||||
|
||||
// setuidSearchAsked is the line with its two acts given: whether the mesh knows the node, and the ask. The
|
||||
// engine's refusal is this command's failure.
|
||||
func setuidSearchAsked(args []string, known func(node string) error,
|
||||
ask func(node, by string) (link.HandOverAnswer, error), out io.Writer) error {
|
||||
if len(args) != 1 || args[0] == "" || strings.HasPrefix(args[0], "-") {
|
||||
return errors.New(setuidSearchUsage)
|
||||
}
|
||||
node := args[0]
|
||||
if err := known(node); err != nil {
|
||||
return fmt.Errorf("nothing was asked: %w", err)
|
||||
}
|
||||
answer, err := ask(node, handOverBy())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if answer.Refused != "" {
|
||||
return fmt.Errorf("%s refused: %s", node, answer.Refused)
|
||||
}
|
||||
fmt.Fprintln(out, answer.Said)
|
||||
fmt.Fprintf(out, " the controller's root-free verb shows the search's progress until it completes\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// addNode creates a node record, adopted when the operator says so (novox/hq ADR 0100).
|
||||
func addNode(ctx context.Context, inv *inventory.Inventory, args []string) error {
|
||||
set := flag.NewFlagSet("node add", flag.ContinueOnError)
|
||||
|
||||
@@ -157,7 +157,7 @@ func TestAMergeRebuildsTheModulesItChanged(t *testing.T) {
|
||||
{"a file directly among the modules", merge([]string{"modules/README.md"}, false), ""},
|
||||
{"a root file beside a module's", merge([]string{"merge-check.sh", "modules/keycloak/x.ts"}, false), "keycloak"},
|
||||
} {
|
||||
if got := named(whatTheMergeTouched(candidates, known, c.m)); got != c.want {
|
||||
if got := named(whatTheMergeTouched(candidates, known, c.m, nil)); got != c.want {
|
||||
t.Errorf("%s: rebuilt %q, wanted %q", c.what, got, c.want)
|
||||
}
|
||||
}
|
||||
@@ -285,7 +285,7 @@ func TestAChangeInsideAModuleIsThatModulesHeldOrNot(t *testing.T) {
|
||||
{"an old announcer saying nothing", merge(showcase, nil, false), ""},
|
||||
{"a manifest the merge removed, said or not", merge([]string{"modules/gone/module.json", "modules/gone/x.ts"}, nil, true), ""},
|
||||
} {
|
||||
if got := named(whatTheMergeTouched(candidates, known, c.m)); got != c.want {
|
||||
if got := named(whatTheMergeTouched(candidates, known, c.m, nil)); got != c.want {
|
||||
t.Errorf("%s: rebuilt %q, wanted %q", c.what, got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,6 +362,17 @@ var plainWordings = map[string]func(conditions.Observation) words{
|
||||
Explanation: "Its walk across the machines has not moved for longer than usual. Nothing is lost.",
|
||||
Resolved: "Resolved: the delivery moves again"}
|
||||
}),
|
||||
kindBatchNotCut: worded(func(o conditions.Observation) words {
|
||||
return words{Headline: "Merged changes are not being delivered",
|
||||
Explanation: "Merges collected for one delivery should have been planned and were not. Nothing is lost.",
|
||||
Resolved: "Resolved: the merged changes are being delivered"}
|
||||
}),
|
||||
kindBatchBehindWalk: worded(func(o conditions.Observation) words {
|
||||
return words{Headline: "Merged changes wait behind a slow delivery",
|
||||
Explanation: "Merges collected for the next delivery wait for the delivery before them, which is taking " +
|
||||
"longer than it should. Nothing is lost.",
|
||||
Resolved: "Resolved: the merged changes no longer wait"}
|
||||
}),
|
||||
kindWalkWaiting: worded(func(o conditions.Observation) words {
|
||||
return words{Headline: "A delivery is waiting to start",
|
||||
Explanation: "A merged change is built, and mesh-delivery (the module that decides when a delivery goes " +
|
||||
@@ -680,6 +691,8 @@ func walkWaitingWords(w waitFacts, in time.Duration, severity conditions.Severit
|
||||
}
|
||||
|
||||
// waitingNeeds is what the operator does about a walk waiting past its urgent bound: nothing before it.
|
||||
// Start and Stop are also asked of the operator (novox/hq ADR 0259); the condition's own words keep saying
|
||||
// where they are given without a channel, and the ask's text drops that (askText).
|
||||
func waitingNeeds(severity conditions.Severity) string {
|
||||
if severity == conditions.Urgent {
|
||||
return "start it, or stop it, " + FromMeshMCPServer
|
||||
@@ -687,6 +700,20 @@ func waitingNeeds(severity conditions.Severity) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// waitingActions are the answers to a walk waiting past its urgent bound: start it, or stop it — the plan's
|
||||
// own verbs, approved by the operator (novox/hq ADR 0259). None before the bound.
|
||||
func waitingActions(plan string, severity conditions.Severity) []conditions.Action {
|
||||
if severity != conditions.Urgent || plan == "" {
|
||||
return nil
|
||||
}
|
||||
return []conditions.Action{
|
||||
{Label: "Start", Verb: "mesh-controller.plans", Level: conditions.LevelApprove,
|
||||
Arguments: map[string]string{"go": plan, "why": "", "cause": conditions.CauseOperatorAnswer}},
|
||||
{Label: "Stop", Verb: "mesh-controller.plans", Level: conditions.LevelApprove,
|
||||
Arguments: map[string]string{"stop": plan, "why": "", "cause": conditions.CauseOperatorAnswer}},
|
||||
}
|
||||
}
|
||||
|
||||
// moduleNeeds is what the operator can do about a module unhealthy on a machine: log in again where its
|
||||
// account's groups wait for it (ADR 0252), restart a failed service, or nothing where the mesh restarts it.
|
||||
// No answer is offered for a restart: a desk click performs only an acknowledgement (ADR 0258).
|
||||
@@ -701,11 +728,35 @@ func moduleNeeds(node string, rs []inventory.ResourceHealth) string {
|
||||
}
|
||||
}
|
||||
if unit != "" {
|
||||
// Also asked of the operator (moduleActions); the ask's text drops where (askText).
|
||||
return fmt.Sprintf("restart its service %s on %s %s", unit, node, FromMeshMCPServer)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// moduleActions are the answers to a module unhealthy on a machine: restart its failed service there,
|
||||
// approved by the operator (novox/hq ADR 0259) — none when the mesh restarts it, or a new login is what it
|
||||
// waits for.
|
||||
func moduleActions(node string, rs []inventory.ResourceHealth) []conditions.Action {
|
||||
for _, r := range rs {
|
||||
if strings.Contains(r.Reason, "relogin needed") {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
for _, r := range rs {
|
||||
if r.Kind != link.KindUnit || r.Target == "" {
|
||||
continue
|
||||
}
|
||||
scope := "system"
|
||||
if r.Account != "" {
|
||||
scope = "user"
|
||||
}
|
||||
return []conditions.Action{{Label: "Restart", Verb: "node-service-manager.restart", Machine: node,
|
||||
Level: conditions.LevelApprove, Arguments: map[string]string{"unit": r.Target, "scope": scope}}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FromMeshMCPServer ends what the operator needs when no notification can do it (ADR 0258), naming the mesh MCP
|
||||
// server (the glossary's word; "console" is retired): the answer is not an
|
||||
// acknowledgement, so it is given where the operator is known to be the one asking, until answers are
|
||||
@@ -776,15 +827,19 @@ func stalledWords(l stalledLine, o conditions.Observation) (headline, explanatio
|
||||
long = "for " + humanDuration(d)
|
||||
}
|
||||
if o.Resolver == conditions.ResolverOperator {
|
||||
// Words only: releasing or stopping a delivery is not an acknowledgement, so no desk click
|
||||
// performs it (ADR 0258).
|
||||
// Asked of the operator, approved on a channel that proves who answered (novox/hq ADR 0259); the
|
||||
// router says where each can be answered, so the words do not.
|
||||
release := conditions.Action{Label: "Release", Verb: "mesh-delivery.release", Level: conditions.LevelApprove,
|
||||
Arguments: map[string]string{"id": l.ID, "why": ""}}
|
||||
stop := conditions.Action{Label: "Stop", Verb: "mesh-delivery.stop", Level: conditions.LevelApprove,
|
||||
Arguments: map[string]string{"id": l.ID, "why": ""}}
|
||||
switch held {
|
||||
case "held":
|
||||
needs = "release it, or stop it, " + FromMeshMCPServer
|
||||
needs, actions = "release it, or stop it, "+FromMeshMCPServer, []conditions.Action{release, stop}
|
||||
case "ready", "checked":
|
||||
needs = "merge its pull request, or close it."
|
||||
default:
|
||||
needs = "stop it " + FromMeshMCPServer
|
||||
needs, actions = "stop it "+FromMeshMCPServer, []conditions.Action{stop}
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("Delivery of %s %s %s", name, held, long),
|
||||
|
||||
@@ -65,13 +65,21 @@ func TestADeliveryWaitingNeedsNothingUntilItsBoundThenOffersStartAndStop(t *test
|
||||
t.Errorf("the summary lost the way on for whoever looks closer: %q", got[0].Summary)
|
||||
}
|
||||
|
||||
// Past four hours it is urgent, and offers the controller's own answers.
|
||||
// Past four hours it is urgent, and asks the operator to start or stop it (novox/hq ADR 0259): the plan's
|
||||
// own verbs, approved, which the controller performs on the warrant. The router says where to answer.
|
||||
f.waits[0].since = now.Add(-5 * time.Hour)
|
||||
got = watchWaits(f)
|
||||
plainExample(t, got[0], "openrazer delivery waiting to start",
|
||||
"Needs you: start it, or stop it, from the mesh MCP server; this notification cannot do it. The change to openrazer is merged and built, and mesh-delivery (the "+
|
||||
"module that decides when a delivery goes out) has not let it start for 5 hours, so mesh-delivery may "+
|
||||
"be stuck.")
|
||||
"be stuck.", "Start", "Stop")
|
||||
for i, want := range []string{"go", "stop"} {
|
||||
a := got[0].Actions[i]
|
||||
if a.Verb != "mesh-controller.plans" || a.Arguments[want] != "plan-1791454185265004861" ||
|
||||
a.Level != conditions.LevelApprove || a.Arguments["cause"] != conditions.CauseOperatorAnswer {
|
||||
t.Errorf("%s: %+v", a.Label, a)
|
||||
}
|
||||
}
|
||||
|
||||
// Many modules are counted, not listed in the headline.
|
||||
f.waits[0].modules = []string{"a", "b", "c", "d"}
|
||||
@@ -82,16 +90,20 @@ func TestADeliveryWaitingNeedsNothingUntilItsBoundThenOffersStartAndStop(t *test
|
||||
}
|
||||
|
||||
// **A module unhealthy**: "openrazer on g14 is not healthy: its unit openrazer-daemon.service failed in the
|
||||
// account's own service manager (exit-code)". Restarting is not an acknowledgement, so it is said in words
|
||||
// and offered as no answer (ADR 0258).
|
||||
// account's own service manager (exit-code)". Restarting is not an acknowledgement: it is asked of the
|
||||
// operator at the approve level (novox/hq ADR 0259), so a desk click never performs it (ADR 0258).
|
||||
func TestAModuleUnhealthyAsksForARestartInWords(t *testing.T) {
|
||||
o := moduleUnhealthyObservation("openrazer", "g14", []inventory.ResourceHealth{{Kind: link.KindUnit,
|
||||
Resource: "openrazer-daemon", Target: "openrazer-daemon.service",
|
||||
Resource: "openrazer-daemon", Target: "openrazer-daemon.service", Account: "jochen",
|
||||
Reason: "failed in the account's own service manager (exit-code)", Since: time.Now()}})
|
||||
plainExample(t, o, "openrazer not working on g14",
|
||||
"Needs you: restart its service openrazer-daemon on g14 from the mesh MCP server; this notification cannot do it. "+
|
||||
"openrazer on g14 is not healthy: its service openrazer-daemon stopped with an error. It clears as soon "+
|
||||
"as it runs again.")
|
||||
"as it runs again.", "Restart")
|
||||
if a := o.Actions[0]; a.Verb != "node-service-manager.restart" || a.Machine != "g14" || a.Level != conditions.LevelApprove ||
|
||||
a.Arguments["unit"] != "openrazer-daemon.service" || a.Arguments["scope"] != "user" {
|
||||
t.Errorf("restart: %+v", a)
|
||||
}
|
||||
// An account waiting for a new login (ADR 0252) asks for the login, held to the plain rule.
|
||||
o = moduleUnhealthyObservation("openrazer", "g14", []inventory.ResourceHealth{{Kind: "account",
|
||||
Resource: "operator-in-group", Target: "jochen", Reason: "relogin needed: the account is in the group"}})
|
||||
@@ -154,7 +166,12 @@ func TestADeliveryHeldAsksForReleaseOrStopInWords(t *testing.T) {
|
||||
Bound: "24h0m0s", H2: "none: the state is the operator's", Says: "it waits for the operator"}})
|
||||
plainExample(t, got[0], "Delivery of hq held for 36 hours",
|
||||
"Needs you: release it, or stop it, from the mesh MCP server; this notification cannot do it. A delivery of hq has been held for 36 hours, past its limit.",
|
||||
)
|
||||
"Release", "Stop")
|
||||
for i, verb := range []string{"mesh-delivery.release", "mesh-delivery.stop"} {
|
||||
if a := got[0].Actions[i]; a.Verb != verb || a.Arguments["id"] != "novox/hq@055550802096" || a.Level != conditions.LevelApprove {
|
||||
t.Errorf("%+v", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **Every kind the controller raises has plain words**, and its words are plain for a subject of every
|
||||
|
||||
@@ -164,12 +164,13 @@ func retryRefusal(p inventory.Plan, plans []inventory.Plan) error {
|
||||
case p.Open():
|
||||
return fmt.Errorf("%s is still %s; nothing in it failed to retry — `rebuild <module>` asks one module again", p.ID, p.State)
|
||||
}
|
||||
|
||||
if len(failedIn(p)) > 0 {
|
||||
if q, found := newerOpenPlan(p, plans); found {
|
||||
return fmt.Errorf("%s supersedes it: a newer merge of %s (%s at %s) is open, and retrying %s would build "+
|
||||
"what that one replaced", q.ID, q.Repository, q.ID, short(q.Commit), p.ID)
|
||||
}
|
||||
return nil
|
||||
return oneWalkAtATime(p, plans)
|
||||
}
|
||||
stopped := stoppedRollouts(p)
|
||||
if len(stopped) == 0 {
|
||||
@@ -193,6 +194,17 @@ func retryRefusal(p inventory.Plan, plans []inventory.Plan) error {
|
||||
"the older build back", m, q.ID, q.State, q.Repository, short(q.Commit), p.ID)
|
||||
}
|
||||
}
|
||||
return oneWalkAtATime(p, plans)
|
||||
}
|
||||
|
||||
// oneWalkAtATime refuses a retry while another walk is open, started or waiting for its word (novox/hq ADR
|
||||
// 0276): a walk retried beside it would be two walks at once.
|
||||
func oneWalkAtATime(p inventory.Plan, plans []inventory.Plan) error {
|
||||
for _, q := range plans {
|
||||
if q.ID != p.ID && q.Open() && q.Release == nil {
|
||||
return fmt.Errorf("%s is open (%s): one walk at a time — retry %s once it ended", q.ID, q.Named(), p.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -264,6 +276,18 @@ func retryPlan(ctx context.Context, open *stores, id string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
plans = append(plans, recent...)
|
||||
// **A failed walk whose earlier merges are walked alone is not retried** (novox/hq ADR 0276): the search for
|
||||
// the merge that brought the failure answers them now, and a retried walk would name them twice.
|
||||
if p.Delivery != nil && len(p.Delivery.Merges) > 0 {
|
||||
kept, err := inv.MergesOf(ctx, p.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(kept) < len(p.Delivery.Merges) {
|
||||
return "", fmt.Errorf("%s's earlier merges are walked alone, to find which one brought its failure: "+
|
||||
"those walks answer them; a newer merge, or `rebuild <module>`, builds again", p.ID)
|
||||
}
|
||||
}
|
||||
if err := retryRefusal(p, plans); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -15,16 +15,16 @@ import (
|
||||
// inventory.Dependencies (dependenciesOf over the records), and the merge planned by reachOfMerge — the
|
||||
// path a real merge takes, short of the bus.
|
||||
//
|
||||
// **The repository rows are CURRENT BEHAVIOUR, documented — not the rule the operator states**
|
||||
// (novox/hq issue 338, and the decision pending on it): a build that read a repository gives its module a
|
||||
// packages edge to every module built from that repository, and mergeCandidates moves it on any merge to
|
||||
// that repository, whatever the files. So a change to C alone, or to a README, moves the module that
|
||||
// packages C's repository. ADR 0238 §3 records exactly that today ("a repository a recipe names"); the
|
||||
// expectations marked 338 change with that decision.
|
||||
func TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday(t *testing.T) {
|
||||
// **A shared repository moves only what a change's files are in the build source of** (novox/hq ADR 0267,
|
||||
// issues 338 and 363): each build records the build source it said, and a merge is mapped onto those of the
|
||||
// newest builds. A build that said none (P below, as every build before ADR 0267) is read as before: P moves
|
||||
// on any merge to the repository it packages, though through no edge. The rows tagged 338 held the opposite
|
||||
// until ADR 0267 was built.
|
||||
func TestASharedRepositoryIsPlannedFromTheRecordedBuildSources(t *testing.T) {
|
||||
inv := inventory.ForTest(t)
|
||||
ctx := t.Context()
|
||||
asked := time.Now().Add(-time.Hour)
|
||||
sourcesOf := map[string][]inventory.BuildSource{}
|
||||
register := func(m catalogue.Manifest, repository, path string, against []string, read []inventory.ReadRepository) {
|
||||
t.Helper()
|
||||
if err := inv.RegisterModule(ctx, m, inventory.Source{Repository: repository, Seat: "git", Path: path,
|
||||
@@ -32,7 +32,8 @@ func TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-" + m.Module, Repository: repository, Ref: "main",
|
||||
Module: m.Module, Commit: "old", On: "builder", Path: path, Against: against, Read: read, Asked: asked}); err != nil {
|
||||
Module: m.Module, Commit: "old", On: "builder", Path: path, Against: against, Read: read, Asked: asked,
|
||||
Sources: sourcesOf[m.Module]}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -40,7 +41,16 @@ func TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday(t *testing.T) {
|
||||
agent := catalogue.Manifest{Module: "build-agent", Version: "1",
|
||||
Claims: []catalogue.Claim{{Name: "node-build-agent", Scope: catalogue.ScopeNode}}}
|
||||
|
||||
// The shape of issue 338.
|
||||
// The shape of issue 338, each build saying its build source (ADR 0267).
|
||||
gomod := []string{"go.mod", "go.sum"}
|
||||
sourcesOf["mesh-controller"] = []inventory.BuildSource{{Paths: append([]string{"module.json", "cmd/mesh-controller/",
|
||||
"internal/conditions/", "internal/broker/"}, gomod...)}}
|
||||
sourcesOf["build-agent"] = []inventory.BuildSource{
|
||||
{Paths: []string{"modules/build-agent/Dockerfile", "modules/build-agent/module.json"}},
|
||||
{Repository: "novox/mesh-controller", Ref: "main", Paths: append([]string{"cmd/mesh-builder/", "internal/broker/"}, gomod...)}}
|
||||
sourcesOf["route-proxy"] = []inventory.BuildSource{
|
||||
{Paths: []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}},
|
||||
{Repository: "novox/mesh-controller", Ref: "main", Paths: append([]string{"examples/route-proxy/", "internal/broker/"}, gomod...)}}
|
||||
register(catalogue.Manifest{Module: "mesh-controller", Version: "1"}, "novox/mesh-controller", "", nil, nil)
|
||||
register(agent, "novox/mesh-catalog", "modules/build-agent", nil, controllerRead)
|
||||
register(catalogue.Manifest{Module: "route-proxy", Version: "1"}, "novox/mesh-catalog", "modules/route-proxy", nil, controllerRead)
|
||||
@@ -81,13 +91,15 @@ func TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday(t *testing.T) {
|
||||
if !reflect.DeepEqual(shared, sharedRepositoryEdges) {
|
||||
t.Errorf("derived %v\nthe hand-written rows use %v", shared, sharedRepositoryEdges)
|
||||
}
|
||||
// Each kind derived from its record: built against (stands-on), build.on (declared), read (packages).
|
||||
// Each kind derived from its record: built against (stands-on), build.on (declared); a read draws none.
|
||||
for _, e := range edges {
|
||||
if e.Kind == inventory.EdgePackages {
|
||||
t.Errorf("a packages edge was drawn (ADR 0267 rule 4): %v", e)
|
||||
}
|
||||
}
|
||||
for _, want := range []inventory.Edge{
|
||||
dep("d", inventory.EdgeStandsOn, "a"),
|
||||
dep("e", inventory.EdgeDeclared, "b"),
|
||||
dep("p", inventory.EdgePackages, "a"),
|
||||
dep("p", inventory.EdgePackages, "b"),
|
||||
dep("p", inventory.EdgePackages, "c"),
|
||||
dep("d", inventory.EdgeBuiltBy, "build-agent"),
|
||||
} {
|
||||
found := false
|
||||
@@ -105,15 +117,23 @@ func TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday(t *testing.T) {
|
||||
want string
|
||||
issue338 bool
|
||||
}{
|
||||
{"A and B changed, C untouched: D after A, E after B; P packages their repository", "one",
|
||||
{"A and B changed, C untouched: D after A, E after B; P, which said no build source, reads all", "one",
|
||||
[]string{"modules/a/x.go", "modules/b/x.go"}, "a,b,p | d,e", false},
|
||||
{"C alone: C, and P, which packages C's repository", "one",
|
||||
[]string{"modules/c/x.go"}, "c,p", true},
|
||||
{"a README of the repository P packages: P moves, nothing built from it does", "one",
|
||||
[]string{"README.md"}, "p", true},
|
||||
{"C alone: C, and P, read whole as before; P after nothing", "one",
|
||||
[]string{"modules/c/x.go"}, "c,p", false},
|
||||
{"a README of the repository P packages: P, read whole as before", "one",
|
||||
[]string{"README.md"}, "p", false},
|
||||
{"the dependent's repository: D alone", "two", []string{"d/main.go"}, "d", false},
|
||||
{"a README of the controller's repository: all three, three tiers", "mesh-controller",
|
||||
[]string{"README.md"}, "mesh-controller | build-agent | route-proxy", true},
|
||||
{"a README of the controller's repository: no module", "mesh-controller",
|
||||
[]string{"README.md"}, "", true},
|
||||
{"the controller's own command: the controller alone", "mesh-controller",
|
||||
[]string{"cmd/mesh-controller/main.go"}, "mesh-controller", true},
|
||||
{"a package only the controller builds from: the controller alone", "mesh-controller",
|
||||
[]string{"internal/conditions/condition.go"}, "mesh-controller", true},
|
||||
{"the route proxy's program: the route proxy alone", "mesh-controller",
|
||||
[]string{"examples/route-proxy/main.go"}, "route-proxy", true},
|
||||
{"a package all three build from: all three", "mesh-controller",
|
||||
[]string{"internal/broker/broker.go"}, "mesh-controller | build-agent | route-proxy", false},
|
||||
{"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
|
||||
[]string{"modules/route-proxy/module.json"}, "route-proxy", false},
|
||||
{"the build agent's directory: it alone, nothing it builds", "mesh-catalog",
|
||||
@@ -123,7 +143,7 @@ func TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday(t *testing.T) {
|
||||
if got != c.want {
|
||||
tag := ""
|
||||
if c.issue338 {
|
||||
tag = " (current behaviour, issue 338)"
|
||||
tag = " (issue 338, flipped by ADR 0267)"
|
||||
}
|
||||
t.Errorf("%s: planned %q, wanted %q%s", c.what, got, c.want, tag)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
snapshot "github.com/novox/mesh-controller/internal/facts"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
@@ -21,7 +24,7 @@ import (
|
||||
// kind widens the plan orders the tiers
|
||||
// stands-on yes yes, after its base is built
|
||||
// declared yes yes, after its base is built
|
||||
// packages yes no, the same tier (a code dependency)
|
||||
// packages no no — retired by novox/hq ADR 0267; one recorded before is read and ignored
|
||||
// built-by no yes, after the build machine — except for what the build machine stands
|
||||
// on, and for the controller whose worker it binds
|
||||
// worker-of no yes, the build seat's holder after the controller (hq issue 206)
|
||||
@@ -123,9 +126,9 @@ func TestAPlanIsWhatTheChangeTouchedAndWhatIsBuiltOnIt(t *testing.T) {
|
||||
{what: "transitive: F on D on A, A changed",
|
||||
edges: []inventory.Edge{dep("f", standsOn, "d"), dep("d", standsOn, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a | d | f"},
|
||||
{what: "transitive across kinds: F declared on D, D packages A",
|
||||
{what: "transitive across kinds stops at a packages edge: F declared on D, D packages A (ADR 0267)",
|
||||
edges: []inventory.Edge{dep("f", declared, "d"), dep("d", packages, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a,d | f"},
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
|
||||
|
||||
// Each kind alone: X depends on A, A changed (widening), then both changed (ordering).
|
||||
{what: "stands-on (built against A's artifact) widens", edges: []inventory.Edge{dep("x", standsOn, "a")},
|
||||
@@ -136,8 +139,8 @@ func TestAPlanIsWhatTheChangeTouchedAndWhatIsBuiltOnIt(t *testing.T) {
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a | x"},
|
||||
{what: "declared orders", edges: []inventory.Edge{dep("x", declared, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
|
||||
{what: "packages widens, into the same tier", edges: []inventory.Edge{dep("x", packages, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a,x"},
|
||||
{what: "packages, recorded before ADR 0267, widens nothing", edges: []inventory.Edge{dep("x", packages, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
|
||||
{what: "packages does not order", edges: []inventory.Edge{dep("x", packages, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a,x"},
|
||||
{what: "built-by never widens", edges: []inventory.Edge{dep("x", builtBy, "a")},
|
||||
@@ -188,7 +191,7 @@ func TestAPlanIsWhatTheChangeTouchedAndWhatIsBuiltOnIt(t *testing.T) {
|
||||
repo: "one", paths: []string{"modules/z/x"}, want: "z | a,b | d"},
|
||||
{what: "a diamond of mixed kinds orders on the ordering side only",
|
||||
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", packages, "b")},
|
||||
repo: "one", paths: []string{"modules/b/x"}, want: "b,d"},
|
||||
repo: "one", paths: []string{"modules/b/x"}, want: "b"},
|
||||
|
||||
// A cycle the catalogue should never produce: what remains is one last tier, and said.
|
||||
{what: "a cycle is one last tier, not lost", edges: []inventory.Edge{dep("a", standsOn, "b"), dep("b", standsOn, "a"),
|
||||
@@ -225,22 +228,16 @@ func TestAPlanIsWhatTheChangeTouchedAndWhatIsBuiltOnIt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// **CURRENT BEHAVIOUR, documented — not the rule the operator states.** novox/hq issue 338 (a module
|
||||
// built from a shared repository moves on every merge to it) and the decision pending on it would change
|
||||
// every row here. Today:
|
||||
// **A shared repository moves only what a change's files are in the build source of** (novox/hq ADR 0267,
|
||||
// issue 338, issue 363). The controller is built from its repository's root as a Go bundle; the route proxy
|
||||
// and the build seat's holder build images whose context is that repository and which name the package they
|
||||
// compile. Each newest trunk build said its build source — the import closure of its program — and a merge
|
||||
// is mapped onto those. The rows tagged 338 held the opposite until ADR 0267 was built: every merge to the
|
||||
// controller's repository planned all three, in three tiers.
|
||||
//
|
||||
// - mesh-controller is built from its repository's root, so every file of that repository touches it;
|
||||
// - route-proxy and build-agent package the whole of that repository (a build context), so the build
|
||||
// record's `read` makes them move on any merge to it, whatever the files, and dependenciesOf gives
|
||||
// each a packages edge to every module built from it;
|
||||
// - built-by (route-proxy on build-agent) and worker-of (build-agent on the controller) make it three
|
||||
// tiers.
|
||||
//
|
||||
// These follow ADR 0238 §3 as written ("the whole repository for a module built from its root, and a
|
||||
// repository a recipe names"), so they are not failures; when the decision on issue 338 lands, these
|
||||
// expectations change with it. The edges are the ones dependenciesOf derives from this catalogue — held
|
||||
// to that by TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday, which derives them from the store.
|
||||
func TestASharedRepositoryMovesWhatPackagesItAsItDoesToday(t *testing.T) {
|
||||
// The build sources are this repository's own programs as GoBuildSource reads them (held to that by
|
||||
// TestThisRepositorysProgramsHaveBuildSourcesOfTheirOwn in internal/builder), cut to what the rows need.
|
||||
func TestASharedRepositoryMovesOnlyWhatItsBuildSourceHolds(t *testing.T) {
|
||||
const catalogueRepo = "http://forge.internal:20000/novox/mesh-catalog.git"
|
||||
const controllerRepo = "http://forge.internal:20000/novox/mesh-controller.git"
|
||||
entries := []inventory.Entry{
|
||||
@@ -249,7 +246,26 @@ func TestASharedRepositoryMovesWhatPackagesItAsItDoesToday(t *testing.T) {
|
||||
fromRepo("route-proxy", catalogueRepo, "modules/route-proxy"),
|
||||
fromRepo("gitea", catalogueRepo, "modules/gitea"),
|
||||
}
|
||||
gomod := []string{"go.mod", "go.sum", "vendor/modules.txt"}
|
||||
with := func(paths ...string) []string { return append(append([]string{}, gomod...), paths...) }
|
||||
read := map[string][]inventory.ReadRepository{
|
||||
"mesh-controller": {{Own: true, Paths: with("module.json", "cmd/mesh-controller/", "internal/conditions/",
|
||||
"internal/broker/", "internal/builder/", "internal/inventory/", "internal/inventory/migrations/**",
|
||||
"vendor/github.com/nats-io/nats.go/")}},
|
||||
"build-agent": {
|
||||
{Repository: "novox/mesh-controller", Ref: "main", Paths: with("cmd/mesh-builder/", "internal/broker/",
|
||||
"internal/builder/", "internal/inventory/", "internal/inventory/migrations/**", "vendor/github.com/nats-io/nats.go/")},
|
||||
{Own: true, Paths: []string{"modules/build-agent/Dockerfile", "modules/build-agent/module.json"}},
|
||||
},
|
||||
"route-proxy": {
|
||||
{Repository: "novox/mesh-controller", Ref: "main", Paths: with("examples/route-proxy/", "internal/broker/",
|
||||
"vendor/github.com/nats-io/nats.go/")},
|
||||
{Own: true, Paths: []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}},
|
||||
},
|
||||
}
|
||||
// With no build source said — before each module's first trunk build under ADR 0267, or while an
|
||||
// earlier merge's build of it is pending — a module is read as before.
|
||||
unsaid := map[string][]inventory.ReadRepository{
|
||||
"build-agent": {{Repository: "novox/mesh-controller", Ref: "main"}},
|
||||
"route-proxy": {{Repository: "novox/mesh-controller", Ref: "main"}},
|
||||
}
|
||||
@@ -257,51 +273,176 @@ func TestASharedRepositoryMovesWhatPackagesItAsItDoesToday(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
what, repo string
|
||||
paths []string
|
||||
read map[string][]inventory.ReadRepository
|
||||
want string
|
||||
unread string
|
||||
}{
|
||||
// The live three-tier plan of 2026-10-08 (issue 338), in the worker-of order (issue 206) that
|
||||
// TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency's controller case holds too.
|
||||
{"a README of the controller's repository moves all three, in three tiers", "mesh-controller",
|
||||
[]string{"README.md"}, "mesh-controller | build-agent | route-proxy"},
|
||||
{"the controller's own code: the same", "mesh-controller",
|
||||
[]string{"cmd/mesh-controller/main.go"}, "mesh-controller | build-agent | route-proxy"},
|
||||
{"the route proxy's program alone: the same, the controller with it", "mesh-controller",
|
||||
[]string{"examples/route-proxy/main.go"}, "mesh-controller | build-agent | route-proxy"},
|
||||
// In the catalogue, where they live, the rule is path-precise.
|
||||
{"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
|
||||
[]string{"modules/route-proxy/module.json"}, "route-proxy"},
|
||||
{"the build agent's directory: it alone, nothing it builds", "mesh-catalog",
|
||||
[]string{"modules/build-agent/module.json"}, "build-agent"},
|
||||
// The operator's acceptance: a merge of the controller's own code plans the controller alone.
|
||||
{"the controller's own command: the controller alone (338)", "mesh-controller",
|
||||
[]string{"cmd/mesh-controller/main.go"}, read, "mesh-controller", ""},
|
||||
{"a package only the controller builds from: the controller alone (338)", "mesh-controller",
|
||||
[]string{"internal/conditions/condition.go", "internal/conditions/bus.go"}, read, "mesh-controller", ""},
|
||||
{"a test beside the controller's command: nothing is built from it", "mesh-controller",
|
||||
[]string{"cmd/mesh-controller/main_test.go"}, read, "", "cmd/mesh-controller/main_test.go"},
|
||||
{"a README of the controller's repository: no module (338)", "mesh-controller",
|
||||
[]string{"README.md"}, read, "", "README.md"},
|
||||
{"the route proxy's program alone: the route proxy alone (338)", "mesh-controller",
|
||||
[]string{"examples/route-proxy/main.go"}, read, "route-proxy", ""},
|
||||
{"the build seat's program alone: its holder alone", "mesh-controller",
|
||||
[]string{"cmd/mesh-builder/main.go"}, read, "build-agent", ""},
|
||||
{"a package the build seat's program and the controller build from: both", "mesh-controller",
|
||||
[]string{"internal/builder/builder.go"}, read, "mesh-controller | build-agent", ""},
|
||||
{"a migration the controller and the build seat's program embed: both", "mesh-controller",
|
||||
[]string{"internal/inventory/migrations/0088-a-build-says-its-build-source.sql"}, read,
|
||||
"mesh-controller | build-agent", ""},
|
||||
// A package all three build from: all three; the build seat's holder after the controller whose worker
|
||||
// it binds (worker-of, issue 206), the proxy after the holder that builds it (built-by).
|
||||
{"a package all three build from: all three", "mesh-controller",
|
||||
[]string{"internal/broker/broker.go"}, read, "mesh-controller | build-agent | route-proxy", ""},
|
||||
{"a vendored package all three build from: all three", "mesh-controller",
|
||||
[]string{"vendor/github.com/nats-io/nats.go/nats.go"}, read, "mesh-controller | build-agent | route-proxy", ""},
|
||||
{"go.sum: all three", "mesh-controller",
|
||||
[]string{"go.sum"}, read, "mesh-controller | build-agent | route-proxy", ""},
|
||||
{"a file added to the proxy's package: the proxy", "mesh-controller",
|
||||
[]string{"examples/route-proxy/new.go"}, read, "route-proxy", ""},
|
||||
// Before any build source is said: as before.
|
||||
{"no build source said: a README moves all three, as before", "mesh-controller",
|
||||
[]string{"README.md"}, unsaid, "mesh-controller | build-agent | route-proxy", ""},
|
||||
// In the catalogue, where they live, each by its own build source.
|
||||
{"the route proxy's recipe: it alone", "mesh-catalog",
|
||||
[]string{"modules/route-proxy/Dockerfile"}, read, "route-proxy", ""},
|
||||
{"the route proxy's manifest: it alone", "mesh-catalog",
|
||||
[]string{"modules/route-proxy/module.json"}, read, "route-proxy", ""},
|
||||
{"the route proxy's README: nothing", "mesh-catalog",
|
||||
[]string{"modules/route-proxy/README.md"}, read, "", "modules/route-proxy/README.md"},
|
||||
{"the build agent's manifest: it alone, nothing it builds", "mesh-catalog",
|
||||
[]string{"modules/build-agent/module.json"}, read, "build-agent", ""},
|
||||
{"another module of the catalogue: neither", "mesh-catalog",
|
||||
[]string{"modules/gitea/index.ts"}, "gitea"},
|
||||
[]string{"modules/gitea/index.ts"}, read, "gitea", ""},
|
||||
} {
|
||||
r, got := planMerge(t, c.repo, c.paths, entries, read, edges)
|
||||
r, got := planMerge(t, c.repo, c.paths, entries, c.read, edges)
|
||||
if got != c.want {
|
||||
t.Errorf("%s: planned %q, wanted %q (as today; issue 338)", c.what, got, c.want)
|
||||
t.Errorf("%s: planned %q, wanted %q", c.what, got, c.want)
|
||||
}
|
||||
if c.repo == "mesh-controller" && strings.Join(r.Unread, ",") != "" {
|
||||
t.Errorf("%s: a root-built module reads every file, and %v were said unread", c.what, r.Unread)
|
||||
if u := strings.Join(r.Unread, ","); u != c.unread {
|
||||
t.Errorf("%s: unread %q, wanted %q", c.what, u, c.unread)
|
||||
}
|
||||
}
|
||||
// Files not all said: everything the repository builds, as before.
|
||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main", Commit: "head",
|
||||
Paths: []string{"README.md"}, PathsTruncated: true}
|
||||
if got := tiered(reachOfMerge(m, entries, read, edges).Plan.Tiers); got != "mesh-controller | build-agent | route-proxy" {
|
||||
t.Errorf("a merge whose files were not all said: planned %q, wanted all three", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **A module whose recorded build source a plan has overtaken is read whole** (novox/hq ADR 0267): a merge
|
||||
// that added an import to the route proxy is planned; before a build of it works — still building, failed,
|
||||
// or its plan closed before reaching it — a merge changing only the newly imported package must still move
|
||||
// the proxy, since the build source its last build said does not hold that package.
|
||||
func TestAModuleAPlanOvertookIsReadWhole(t *testing.T) {
|
||||
built := time.Date(2026, 10, 10, 1, 0, 0, 0, time.UTC)
|
||||
read := map[string][]inventory.ReadRepository{
|
||||
"route-proxy": {
|
||||
{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"examples/route-proxy/", "go.mod"}, Built: built},
|
||||
{Own: true, Paths: []string{"modules/route-proxy/module.json"}, Built: built},
|
||||
},
|
||||
"mesh-controller": {{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/"}, Built: built}},
|
||||
}
|
||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main", Paths: []string{"internal/newly/imported.go"}}
|
||||
if readsFrom(read["route-proxy"], m) {
|
||||
t.Fatal("the said build source holds the new package: the fixture is wrong")
|
||||
}
|
||||
proxy := func(state string) map[string]*inventory.PlanModule {
|
||||
return map[string]*inventory.PlanModule{"route-proxy": {State: state}, "gitea": {State: "built"}}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
plans []inventory.Plan
|
||||
whole bool
|
||||
}{
|
||||
{"no plan", nil, false},
|
||||
{"a plan still building it", []inventory.Plan{{State: inventory.PlanBuilding, Created: built.Add(-time.Hour),
|
||||
Modules: proxy("building")}}, true},
|
||||
{"a plan made after its build that failed before building it", []inventory.Plan{{State: "failed",
|
||||
Created: built.Add(time.Minute), Modules: proxy("waiting")}}, true},
|
||||
{"a plan made after its build that built it", []inventory.Plan{{State: inventory.PlanDone,
|
||||
Created: built.Add(time.Minute), Modules: proxy("built")}}, false},
|
||||
{"a plan closed before its build", []inventory.Plan{{State: "failed", Created: built.Add(-time.Hour),
|
||||
Modules: proxy("waiting")}}, false},
|
||||
} {
|
||||
view := planningView(read, c.plans)
|
||||
if readsFrom(view["route-proxy"], m) != c.whole {
|
||||
t.Errorf("%s: read whole %v, wanted %v", c.what, !c.whole, c.whole)
|
||||
}
|
||||
if (ownSource(view["route-proxy"]) == nil) != c.whole {
|
||||
t.Errorf("%s: its own build source kept %v", c.what, ownSource(view["route-proxy"]) != nil)
|
||||
}
|
||||
if ownSource(view["mesh-controller"]) == nil {
|
||||
t.Errorf("%s: a module no plan holds lost its build source", c.what)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **A missed merge that moves only a module packaging the repository is acted on** (novox/hq ADR 0267,
|
||||
// issue 266): the catch-up asks wouldMove, which counts it; once a plan or build of it is made after the
|
||||
// merge, the merge is history for it, and the catch-up leaves it.
|
||||
func TestAMissedMergeMovingOnlyAPackagingModuleIsActedOnOnce(t *testing.T) {
|
||||
merged := time.Date(2026, 10, 10, 1, 0, 0, 0, time.UTC)
|
||||
entries := []inventory.Entry{
|
||||
fromRepo("mesh-controller", "http://forge.internal:20000/novox/mesh-controller.git", ""),
|
||||
fromRepo("route-proxy", "http://forge.internal:20000/novox/mesh-catalog.git", "modules/route-proxy"),
|
||||
}
|
||||
read := map[string][]inventory.ReadRepository{
|
||||
"mesh-controller": {{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/"}, Built: merged.Add(-time.Hour)}},
|
||||
"route-proxy": {{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"examples/route-proxy/"},
|
||||
Built: merged.Add(-time.Hour), Looked: merged.Add(-time.Hour)}},
|
||||
}
|
||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main", Commit: "c1",
|
||||
MergedAt: merged.Format(time.RFC3339), Paths: []string{"examples/route-proxy/main.go"}}
|
||||
if got := wouldMove(m, entries, planningView(read, nil)); len(got) != 1 || got[0].Manifest.Module != "route-proxy" {
|
||||
t.Fatalf("a missed merge of the proxy's program would move %v", got)
|
||||
}
|
||||
// Acted on at once: the plan answering this very merge is a look, however close the clocks.
|
||||
atOnce := []inventory.Plan{{State: inventory.PlanBuilding, Commit: "c1", Created: merged.Add(2 * time.Second),
|
||||
Modules: map[string]*inventory.PlanModule{"route-proxy": {State: "building"}}}}
|
||||
if got := wouldMove(m, entries, planningView(read, atOnce)); len(got) != 0 {
|
||||
t.Fatalf("a merge whose own plan holds the proxy would move %v again", got)
|
||||
}
|
||||
acted := []inventory.Plan{{State: inventory.PlanBuilding, Created: merged.Add(2 * time.Minute),
|
||||
Modules: map[string]*inventory.PlanModule{"route-proxy": {State: "building"}}}}
|
||||
if got := wouldMove(m, entries, planningView(read, acted)); len(got) != 0 {
|
||||
t.Fatalf("a merge acted on for the proxy would move %v again", got)
|
||||
}
|
||||
// A plan that closed without building it looked at nothing: the merge is still news for it.
|
||||
closed := []inventory.Plan{{State: "failed", Created: merged.Add(2 * time.Minute),
|
||||
Modules: map[string]*inventory.PlanModule{"route-proxy": {State: "waiting"}}}}
|
||||
if got := wouldMove(m, entries, planningView(read, closed)); len(got) != 1 {
|
||||
t.Fatalf("a plan that never built the proxy hid the merge from it: %v", got)
|
||||
}
|
||||
// A look just before the merge, on clocks a little apart, is no look after it.
|
||||
skewed := []inventory.Plan{{State: inventory.PlanBuilding, Created: merged.Add(30 * time.Second),
|
||||
Modules: map[string]*inventory.PlanModule{"route-proxy": {State: "building"}}}}
|
||||
if got := wouldMove(m, entries, planningView(read, skewed)); len(got) != 1 {
|
||||
t.Fatalf("a look within the clocks' margin made the merge history: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// sharedRepositoryEdges is what dependenciesOf derives for the catalogue of the test above, sorted as it
|
||||
// sorts them.
|
||||
// sorts them: no packages edge (novox/hq ADR 0267 rule 4).
|
||||
var sharedRepositoryEdges = []inventory.Edge{
|
||||
dep("build-agent", inventory.EdgePackages, "mesh-controller"),
|
||||
dep("build-agent", inventory.EdgeWorkerOf, "mesh-controller"),
|
||||
dep("gitea", inventory.EdgeBuiltBy, "build-agent"),
|
||||
dep("mesh-controller", inventory.EdgeBuiltBy, "build-agent"),
|
||||
dep("route-proxy", inventory.EdgeBuiltBy, "build-agent"),
|
||||
dep("route-proxy", inventory.EdgePackages, "mesh-controller"),
|
||||
}
|
||||
|
||||
// **The planner's invariant, over random catalogues.** For any catalogue whose dependencies form no cycle
|
||||
// and any set of changed files in one repository:
|
||||
//
|
||||
// - the plan is exactly the modules of that repository whose directory holds a changed file (every file,
|
||||
// for a module built from the root), and everything reachable from them along stands-on, declared and
|
||||
// packages — never along built-by or worker-of;
|
||||
// for a module built from the root), and everything reachable from them along stands-on and declared —
|
||||
// never along packages (novox/hq ADR 0267), built-by or worker-of;
|
||||
// - every stands-on, declared, built-by and worker-of edge with both ends in the plan has the module
|
||||
// depended on in an earlier tier;
|
||||
// - no cycle is said.
|
||||
@@ -310,7 +451,7 @@ var sharedRepositoryEdges = []inventory.Edge{
|
||||
func TestAPlanIsTheTouchedModulesAndWhatIsReachableAlongTheWideningEdges(t *testing.T) {
|
||||
kinds := []string{inventory.EdgeStandsOn, inventory.EdgeDeclared, inventory.EdgePackages,
|
||||
inventory.EdgeBuiltBy, inventory.EdgeWorkerOf}
|
||||
widens := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true, inventory.EdgePackages: true}
|
||||
widens := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true}
|
||||
orders := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true,
|
||||
inventory.EdgeBuiltBy: true, inventory.EdgeWorkerOf: true}
|
||||
// Directory names drawn from one pool, so two repositories hold directories of the same name, and one
|
||||
@@ -445,3 +586,95 @@ func describe(entries []inventory.Entry) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// **The merge gate reads the build sources the snapshot carries** (novox/hq ADR 0267): the gate's plan of a
|
||||
// change is the merge handler's, so a snapshot taken by a controller that records build sources narrows the
|
||||
// gate's plan as it narrows the merge's; one without them reads every module as before.
|
||||
func TestTheGatePlansFromTheBuildSourcesTheSnapshotCarries(t *testing.T) {
|
||||
manifest := func(name string) json.RawMessage { return json.RawMessage(`{"module":"` + name + `","version":"1"}`) }
|
||||
facts := snapshot.Facts{Modules: []snapshot.Module{
|
||||
{Name: "mesh-controller", Repository: "novox/mesh-controller", Manifest: manifest("mesh-controller"),
|
||||
Sources: []snapshot.BuildSource{{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/", "internal/broker/"}}}},
|
||||
{Name: "route-proxy", Repository: "novox/mesh-catalog", Path: "modules/route-proxy", Manifest: manifest("route-proxy"),
|
||||
Reads: []string{"novox/mesh-controller"},
|
||||
Sources: []snapshot.BuildSource{{Repository: "novox/mesh-controller", Paths: []string{"examples/route-proxy/", "internal/broker/"}},
|
||||
{Own: true, Paths: []string{"modules/route-proxy/module.json"}}}},
|
||||
}}
|
||||
for paths, want := range map[string]string{
|
||||
"cmd/mesh-controller/main.go": "mesh-controller",
|
||||
"examples/route-proxy/main.go": "route-proxy",
|
||||
"internal/broker/broker.go": "mesh-controller,route-proxy",
|
||||
"README.md": "",
|
||||
} {
|
||||
r, err := reachOfChange(facts, "novox/mesh-controller", []string{paths}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := tiered(r.Plan.Tiers); got != want {
|
||||
t.Errorf("%s: the gate planned %q, wanted %q", paths, got, want)
|
||||
}
|
||||
}
|
||||
// A snapshot without build sources: as before.
|
||||
for i := range facts.Modules {
|
||||
facts.Modules[i].Sources = nil
|
||||
}
|
||||
r, err := reachOfChange(facts, "novox/mesh-controller", []string{"README.md"}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := tiered(r.Plan.Tiers); got != "mesh-controller,route-proxy" {
|
||||
t.Errorf("a snapshot without build sources: the gate planned %q for a README", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **A plan says why each module is in it** (novox/hq ADR 0267, issue 363): the files of its build source the
|
||||
// merge changed, or why it is read whole — never that it packages a repository as though that moved it.
|
||||
func TestAPlanSaysWhyEachModuleIsInIt(t *testing.T) {
|
||||
const controllerRepo = "http://forge.internal:20000/novox/mesh-controller.git"
|
||||
controller := fromRepo("mesh-controller", controllerRepo, "")
|
||||
agent := fromRepo("build-agent", "http://forge.internal:20000/novox/mesh-catalog.git", "modules/build-agent")
|
||||
gitea := fromRepo("gitea", "http://forge.internal:20000/novox/mesh-catalog.git", "modules/gitea")
|
||||
built := time.Date(2026, 10, 10, 12, 26, 0, 0, time.UTC)
|
||||
read := map[string][]inventory.ReadRepository{
|
||||
"mesh-controller": {{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/", "internal/link/"}, Built: built}},
|
||||
"build-agent": {
|
||||
{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"cmd/mesh-builder/", "internal/link/"}, Built: built},
|
||||
{Own: true, Paths: []string{"modules/build-agent/module.json"}, Built: built},
|
||||
},
|
||||
}
|
||||
merge := func(repo string, paths ...string) link.SourceMoved {
|
||||
return link.SourceMoved{Owner: "novox", Repo: repo, Base: "main", Paths: paths}
|
||||
}
|
||||
open := []inventory.Plan{{ID: "plan-1", State: inventory.PlanBuilding, Created: built.Add(time.Minute),
|
||||
Modules: map[string]*inventory.PlanModule{"build-agent": {State: "asked"}}}}
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
e inventory.Entry
|
||||
read map[string][]inventory.ReadRepository
|
||||
m link.SourceMoved
|
||||
says string
|
||||
}{
|
||||
{"the controller's own closure", controller, read, merge("mesh-controller", "README.md", "internal/link/handacts.go"),
|
||||
"its build source changed: 1 changed file(s) in it, e.g. internal/link/handacts.go"},
|
||||
{"the build seat's closure, through its context", agent, read, merge("mesh-controller", "internal/link/handacts.go"),
|
||||
"its build source in novox/mesh-controller changed: 1 changed file(s) in it, e.g. internal/link/handacts.go"},
|
||||
{"an open plan has yet to build it", agent, planningView(read, open), merge("mesh-controller", "cmd/mesh-controller/main.go"),
|
||||
"read whole: plan plan-1 has not built it yet, so every file of novox/mesh-controller, which its build context is, is its build source"},
|
||||
{"nothing recorded, built from its root", controller, nil, merge("mesh-controller", "README.md"),
|
||||
"read whole: no build source recorded, so every file of its repository is its build source"},
|
||||
{"nothing recorded, in its directory", gitea, nil, merge("mesh-catalog", "modules/gitea/index.ts"),
|
||||
"read whole: no build source recorded, so its directory is its build source; e.g. modules/gitea/index.ts"},
|
||||
{"a root manifest is not in a module's directory", gitea, nil, merge("mesh-catalog", "module.json", "modules/gitea/x.ts"),
|
||||
"read whole: no build source recorded, so its directory is its build source; e.g. modules/gitea/x.ts"},
|
||||
{"a context on another branch says nothing of this one", agent, map[string][]inventory.ReadRepository{"build-agent": {
|
||||
{Repository: "novox/mesh-controller", Ref: "release", Paths: []string{"internal/link/"}},
|
||||
{Repository: "novox/mesh-controller", Ref: "main"}}}, merge("mesh-controller", "internal/link/handacts.go"),
|
||||
"read whole: no build source recorded, so every file of novox/mesh-controller, which its build context is, is its build source"},
|
||||
{"files not all said", controller, read, link.SourceMoved{Owner: "novox", Repo: "mesh-controller", PathsTruncated: true,
|
||||
Paths: []string{"x"}}, "read whole: the merge's changed files were not all said"},
|
||||
} {
|
||||
if got := whyMoved(c.e, c.read[c.e.Manifest.Module], c.m); got != c.says {
|
||||
t.Errorf("%s:\n said %q\n wanted %q", c.what, got, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// Who can become root where the trusted parties run (novox/hq ADR 0259 §8, as reviewed on 2026-10-09).
|
||||
//
|
||||
// The router and every channel proving its sender run as accounts of their own, so that no agent reads what
|
||||
// they hold or speaks as them. **Root on their machine undoes all of it**, and so does an agent running as the
|
||||
// operator's account there. A machine is **root-free** — an answer proven there may authorise — only when all
|
||||
// of these are measured, now, and hold:
|
||||
//
|
||||
// 1. the machine names an account agents run as (novox/hq ADR 0266), so no agent runs as the operator's
|
||||
// account, which may become root;
|
||||
// 2. its node-engine — running as root, which no agent controls — judged that account unable to become root
|
||||
// without a person, in a statement heard within the last 15 minutes (agentConfined, judgedConfined). The
|
||||
// engine gives that verdict only from a complete search for setuid programs younger than mesh-host's
|
||||
// rootsearch.FreshFor; before one, it says "not judged yet", which is no pass (novox/hq issue 361);
|
||||
// 3. the login shell's `execute` is not served there (novox/hq ADR 0268): its holder's setting withholds it
|
||||
// **and** the bus was asked and heard no `execute` answered there. `execute` runs commands as the machine's
|
||||
// runtime account, which the mesh's acting tools give passwordless sudo; that account is taken to become
|
||||
// root, always, so no measure of it is asked.
|
||||
//
|
||||
// **Nothing else is a pass.** A machine that names no agent account, an unknown machine, a store or bus that
|
||||
// could not be read, a verdict stale or absent — each is not root-free, and says why. The sudo module's own
|
||||
// measure is no longer part of this judgement: it ran in the machine's runtime, as the very account an agent
|
||||
// could become, so it could not be believed.
|
||||
//
|
||||
// The one judgement (judgeRoot) is read two ways: the self-check raises `root-not-free` on every machine where
|
||||
// the router or a module of its own account runs and the judgement fails; and the `root-free` verb answers it
|
||||
// live, to the router, which honours a verified sender only on its pass. A machine holding the operator's
|
||||
// graphical session, where a messaging client's desktop app may run, is not judged here: the operator accepted
|
||||
// that gap for now (hq issue 344).
|
||||
|
||||
// kindRootNotFree is the condition a trusted party's machine that is not root-free raises. Its own key, apart
|
||||
// from ADR 0266's agent-can-become-root (Token agent-root, from DA): the two judge different things — DA the
|
||||
// agent account alone, this the whole of root-free — and one key from two probes flapped between them (the
|
||||
// confirmation review of 2026-10-09).
|
||||
const kindRootNotFree = "root-not-free"
|
||||
|
||||
// routerSeat is the seat the router holds: where it runs counts as a trusted party's machine.
|
||||
const routerSeat = "operator-channel"
|
||||
|
||||
const (
|
||||
loginShellSeat = "node-login-shell"
|
||||
// loginShellVerb is the seat's verb that runs a command, and the name of the setting its holder withholds
|
||||
// it by (novox/hq ADR 0268).
|
||||
loginShellVerb = "execute"
|
||||
// executeServes is the one value of that setting that serves the verb; anything else withholds it.
|
||||
executeServes = "serve"
|
||||
)
|
||||
|
||||
// loginShellServed judges whether the login shell's execute is served on a machine, failing closed (novox/hq
|
||||
// ADR 0268): served while the holder's setting there is `serve` (or the holder has no such setting and claims
|
||||
// the verb), or while the bus heard the verb answered there, or while the bus could not be asked. why says
|
||||
// which, in words.
|
||||
func loginShellServed(holder string, claims bool, setting *any, heard, asked bool) (bool, string) {
|
||||
var why []string
|
||||
switch {
|
||||
case setting != nil:
|
||||
if v, _ := (*setting).(string); v == executeServes {
|
||||
why = append(why, holder+"'s execute setting there is "+executeServes)
|
||||
}
|
||||
case claims:
|
||||
why = append(why, holder+" claims execute and has no setting that withholds it")
|
||||
}
|
||||
if heard {
|
||||
why = append(why, "the bus hears execute answered there")
|
||||
} else if !asked {
|
||||
why = append(why, "the bus could not be asked whether execute is answered there")
|
||||
}
|
||||
return len(why) > 0, strings.Join(why, "; ")
|
||||
}
|
||||
|
||||
// rootFacts is what the judgement reads of one machine.
|
||||
type rootFacts struct {
|
||||
Machine string
|
||||
// Unread is every read that failed, in words: any one is a fail.
|
||||
Unread []string
|
||||
// AgentNamed is whether the machine names an agent account; Confined whether its node-engine judged it
|
||||
// unable to become root, freshly; ConfinedWhy the judgement's words either way.
|
||||
AgentNamed bool
|
||||
Confined bool
|
||||
ConfinedWhy string
|
||||
// Execute is whether the login shell's execute is served there; ExecuteWhy why, in words.
|
||||
Execute bool
|
||||
ExecuteWhy string
|
||||
// SearchPending is the agent account unjudged only because the node-engine's setuid search runs, within
|
||||
// the quiet the controller gives it (searchQuietFor; ADR 0266's quiet window, issue 361).
|
||||
SearchPending bool
|
||||
}
|
||||
|
||||
// rootVerdict is the judgement on one machine, as the root-free verb answers it.
|
||||
type rootVerdict struct {
|
||||
Machine string `json:"machine"`
|
||||
Free bool `json:"free"`
|
||||
Why string `json:"why"`
|
||||
Judged time.Time `json:"judged"`
|
||||
// Quiet is a machine not free only because its setuid search still runs, within searchQuietFor: the
|
||||
// self-check raises nothing for it then (ADR 0266's quiet window). It is never free for it.
|
||||
Quiet bool `json:"quiet,omitempty"`
|
||||
}
|
||||
|
||||
// judgeRoot is the one judgement: free only when nothing failed to read, an agent account is named and judged
|
||||
// confined, and execute is not served.
|
||||
func judgeRoot(f rootFacts, now time.Time) rootVerdict {
|
||||
v := rootVerdict{Machine: f.Machine, Judged: now.UTC()}
|
||||
var not []string
|
||||
if len(f.Unread) > 0 {
|
||||
not = append(not, "not measured: "+strings.Join(f.Unread, "; "))
|
||||
}
|
||||
switch {
|
||||
case f.ConfinedWhy == "":
|
||||
// Not read (said above), or nothing said of it: never a pass.
|
||||
if len(f.Unread) == 0 {
|
||||
not = append(not, "whether agents there can become root was not judged")
|
||||
}
|
||||
case !f.AgentNamed:
|
||||
not = append(not, "agents run as the operator's account there, which may become root ("+f.ConfinedWhy+")")
|
||||
case !f.Confined:
|
||||
not = append(not, f.ConfinedWhy)
|
||||
}
|
||||
if f.Execute {
|
||||
not = append(not, "the login shell runs any command an agent gives it as the machine's runtime account, "+
|
||||
"which can become root ("+orNoneKnown(f.ExecuteWhy)+")")
|
||||
}
|
||||
if len(not) > 0 {
|
||||
v.Why = strings.Join(not, "; ")
|
||||
// The one failure is the agent account not judged yet, because its search runs.
|
||||
v.Quiet = len(not) == 1 && f.SearchPending && f.AgentNamed && !f.Confined && len(f.Unread) == 0 && !f.Execute
|
||||
return v
|
||||
}
|
||||
v.Free = true
|
||||
v.Why = f.ConfinedWhy + "; the login shell's execute is not served there"
|
||||
return v
|
||||
}
|
||||
|
||||
// rootReader reads the facts of machines live: the catalogue's placements, the node-engine's verdicts and the
|
||||
// bus's discovery, each once per reader.
|
||||
type rootReader struct {
|
||||
entries []inventory.Entry
|
||||
read error
|
||||
heard map[string]map[string]map[string]bool
|
||||
asked error
|
||||
// confined is agentConfined; settings the login shell holder's settings on a machine. Replaceable in a test.
|
||||
confined func(ctx context.Context, node string, now time.Time) (named, confined bool, why string, err error)
|
||||
// quiet says the one thing keeping a machine's agent account unjudged is the node-engine's setuid
|
||||
// search, within searchQuietFor (ADR 0266, searchStillRunning). Read by the self-check alone, to raise nothing
|
||||
// then; nil reads no quiet. It never makes a machine root-free.
|
||||
quiet func(ctx context.Context, node string, now time.Time) bool
|
||||
settings func(ctx context.Context, node, module string) ([]catalogue.Layer, error)
|
||||
}
|
||||
|
||||
func newRootReader(ctx context.Context, inv *inventory.Inventory, conn *nats.Conn) *rootReader {
|
||||
r := &rootReader{}
|
||||
r.entries, r.read = inv.Catalogued(ctx)
|
||||
if conn == nil {
|
||||
r.asked = fmt.Errorf("this process holds no connection to the bus")
|
||||
} else {
|
||||
r.heard, r.asked = discoverSeatVerbs(ctx, conn)
|
||||
}
|
||||
r.confined = func(ctx context.Context, node string, now time.Time) (bool, bool, string, error) {
|
||||
return agentConfined(ctx, inv, node, now)
|
||||
}
|
||||
r.settings = inv.SettingsFor
|
||||
return r
|
||||
}
|
||||
|
||||
// facts reads one machine, at now.
|
||||
func (r *rootReader) facts(ctx context.Context, machine string, now time.Time) rootFacts {
|
||||
f := rootFacts{Machine: machine}
|
||||
if r.read != nil {
|
||||
f.Unread = append(f.Unread, "the catalogue's placements could not be read: "+r.read.Error())
|
||||
}
|
||||
named, confined, why, err := r.confined(ctx, machine, now)
|
||||
if err != nil {
|
||||
f.Unread = append(f.Unread, "the account agents run as could not be read: "+err.Error())
|
||||
} else {
|
||||
f.AgentNamed, f.Confined, f.ConfinedWhy = named, confined, why
|
||||
}
|
||||
f.Execute, f.ExecuteWhy = r.executeServed(ctx, machine)
|
||||
if r.quiet != nil && f.AgentNamed && !f.Confined {
|
||||
f.SearchPending = r.quiet(ctx, machine, now)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// executeServed is whether the login shell's execute is served on a machine, failing closed: the bus not
|
||||
// asked, the placements not read, or a holder's setting not read, is served.
|
||||
func (r *rootReader) executeServed(ctx context.Context, machine string) (bool, string) {
|
||||
heard := r.heard[loginShellSeat][loginShellVerb][machine]
|
||||
asked := r.asked == nil
|
||||
var whys []string
|
||||
served := false
|
||||
holders := 0
|
||||
for _, e := range r.entries {
|
||||
if !e.Manifest.ClaimsSeat(loginShellSeat) || !slices.Contains(e.On, machine) {
|
||||
continue
|
||||
}
|
||||
holders++
|
||||
var setting *any
|
||||
if _, declared := e.Manifest.Settings[loginShellVerb]; declared {
|
||||
layers, err := r.settings(ctx, machine, e.Manifest.Module)
|
||||
if err != nil {
|
||||
served = true
|
||||
whys = append(whys, e.Manifest.Module+"'s setting there could not be read: "+err.Error())
|
||||
continue
|
||||
}
|
||||
for _, s := range catalogue.Effective(e.Manifest, layers) {
|
||||
if s.Key == loginShellVerb {
|
||||
v := s.Value
|
||||
setting = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
if s, why := loginShellServed(e.Manifest.Module, claimServes(e.Manifest, loginShellSeat, loginShellVerb),
|
||||
setting, heard, asked); s {
|
||||
served = true
|
||||
whys = append(whys, why)
|
||||
}
|
||||
}
|
||||
if holders == 0 {
|
||||
// Nobody is assigned to serve it; the bus must still hear nobody answering it.
|
||||
if s, why := loginShellServed("no holder", false, nil, heard, asked); s {
|
||||
served = true
|
||||
whys = append(whys, why)
|
||||
}
|
||||
}
|
||||
if r.read != nil {
|
||||
served = true
|
||||
whys = append(whys, "who holds the login shell there could not be read")
|
||||
}
|
||||
return served, strings.Join(whys, "; ")
|
||||
}
|
||||
|
||||
// claimServes says whether a manifest's claim of a seat names a verb among those it serves.
|
||||
func claimServes(m catalogue.Manifest, seat, verb string) bool {
|
||||
for _, c := range m.Claims {
|
||||
if c.Name == seat && slices.Contains(c.Serves, verb) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// judgeRootFree is the root-free verb's answer: each named machine judged now. It never fails: what could not
|
||||
// be read is a machine not free, saying so.
|
||||
func judgeRootFree(ctx context.Context, r *rootReader, machines []string, now time.Time) []rootVerdict {
|
||||
out := make([]rootVerdict, 0, len(machines))
|
||||
for _, m := range machines {
|
||||
out = append(out, judgeRoot(r.facts(ctx, m, now), now))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// trustedMachines are the machines where the router or a module of its own account runs, each with those
|
||||
// modules.
|
||||
func trustedMachines(entries []inventory.Entry) map[string][]string {
|
||||
trusted := map[string][]string{}
|
||||
for _, e := range entries {
|
||||
for _, node := range e.On {
|
||||
if e.Manifest.RunsAs != "" || e.Manifest.ClaimsSeat(routerSeat) {
|
||||
trusted[node] = append(trusted[node], e.Manifest.Module)
|
||||
}
|
||||
}
|
||||
}
|
||||
return trusted
|
||||
}
|
||||
|
||||
// agentRootObservation is the condition of a trusted party's machine that is not root-free.
|
||||
func agentRootObservation(v rootVerdict, trusted []string) conditions.Observation {
|
||||
trusted = append([]string(nil), trusted...)
|
||||
sort.Strings(trusted)
|
||||
return conditions.Observation{Scope: conditions.ScopeMachine, ID: v.Machine, Token: kindRootNotFree,
|
||||
Machine: v.Machine, Kind: kindRootNotFree, Severity: conditions.Urgent,
|
||||
Summary: fmt.Sprintf("%s is not root-free, where %s run: until it is, the router approves nothing proven "+
|
||||
"there (novox/hq ADR 0259 §8): %s", v.Machine, strings.Join(trusted, ", "), v.Why),
|
||||
Headline: "Phone answers held on " + v.Machine,
|
||||
Needs: "give the programs working for you on " + v.Machine + " an account that cannot become root.",
|
||||
Explanation: "The modules that prove your answers from your phone run on " + v.Machine + ", and the mesh " +
|
||||
"cannot show that a program working for you there is unable to become root or to act as you. Until " +
|
||||
"it can, answers from your phone can only acknowledge.",
|
||||
Resolved: "Answers from your phone can approve again on " + v.Machine}
|
||||
}
|
||||
|
||||
// probeAgentRoot is the probe: every trusted party's machine, judged by the one judgement.
|
||||
func probeAgentRoot(ctx context.Context, d *doctor) ([]conditions.Observation, error) {
|
||||
var conn *nats.Conn
|
||||
if d.js != nil {
|
||||
conn = d.js.Conn()
|
||||
}
|
||||
inv := d.open.inventory
|
||||
r := newRootReader(ctx, inv, conn)
|
||||
if r.read != nil {
|
||||
return nil, r.read
|
||||
}
|
||||
// The self-check alone reads ADR 0266's quiet window: nothing raised while a machine's setuid search
|
||||
// runs, within searchQuietFor. The root-free verb never reads it, so the machine still answers not free.
|
||||
r.quiet = func(ctx context.Context, node string, now time.Time) bool {
|
||||
n, err := inv.NodeByName(ctx, node)
|
||||
if err != nil || n.AgentAccount == "" {
|
||||
return false
|
||||
}
|
||||
h, had, err := inv.HealthOf(ctx, node)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
quiet, err := searchStillRunning(ctx, inv, node, n.AgentAccount, h, had, now)
|
||||
return err == nil && quiet
|
||||
}
|
||||
return rootObservations(ctx, r, trustedMachines(r.entries), time.Now()), nil
|
||||
}
|
||||
|
||||
// rootObservations judges the machines and says each that fails.
|
||||
func rootObservations(ctx context.Context, r *rootReader, trusted map[string][]string, now time.Time) []conditions.Observation {
|
||||
var machines []string
|
||||
for m := range trusted {
|
||||
machines = append(machines, m)
|
||||
}
|
||||
sort.Strings(machines)
|
||||
var out []conditions.Observation
|
||||
for _, v := range judgeRootFree(ctx, r, machines, now) {
|
||||
if !v.Free && !v.Quiet {
|
||||
out = append(out, agentRootObservation(v, trusted[v.Machine]))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rootClock is the clock the root-free verb judges by.
|
||||
var rootClock = time.Now
|
||||
|
||||
// rootFreeAnswer is the root-free verb: the named machines, each judged now by the serving controller. Only it
|
||||
// answers: a process that is not serving says so, and a caller reads that as no machine free.
|
||||
func rootFreeAnswer(ctx context.Context, machines string, now time.Time) (any, error) {
|
||||
d := doctorFrom
|
||||
if d == nil || d.open == nil || d.open.inventory == nil {
|
||||
return nil, fmt.Errorf("this controller is not serving, so it judges no machine root-free: ask again, and " +
|
||||
"the serving controller answers")
|
||||
}
|
||||
var names []string
|
||||
for _, m := range strings.Split(machines, ",") {
|
||||
if m = strings.TrimSpace(m); m != "" && !slices.Contains(names, m) {
|
||||
names = append(names, m)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("root-free judges the machines named, and none was")
|
||||
}
|
||||
var conn *nats.Conn
|
||||
if d.js != nil {
|
||||
conn = d.js.Conn()
|
||||
}
|
||||
return map[string]any{"machines": judgeRootFree(ctx, newRootReader(ctx, d.open.inventory, conn), names, now)}, nil
|
||||
}
|
||||
|
||||
// rootFreeNow is the machines judged root-free, for composing a push's memberships: only those that pass.
|
||||
func rootFreeNow(ctx context.Context, r *rootReader, machines []string, now time.Time) map[string]bool {
|
||||
free := map[string]bool{}
|
||||
for _, v := range judgeRootFree(ctx, r, machines, now) {
|
||||
if v.Free {
|
||||
free[v.Machine] = true
|
||||
}
|
||||
}
|
||||
return free
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
var rootNow = time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// A machine is root-free only on a positive measure of each thing (the review of 2026-10-09, H2/H3): every
|
||||
// read succeeded, an agent account is named and judged confined by the node-engine, execute is not served.
|
||||
func TestRootFreeIsAPositiveMeasureAndNothingElse(t *testing.T) {
|
||||
pass := rootFacts{Machine: "anchor", AgentNamed: true, Confined: true,
|
||||
ConfinedWhy: "the agent account agents cannot become root without a person (judged 2026-10-09 12:00)"}
|
||||
if v := judgeRoot(pass, rootNow); !v.Free || v.Machine != "anchor" || !v.Judged.Equal(rootNow) {
|
||||
t.Fatalf("the one pass: %+v", v)
|
||||
}
|
||||
fails := map[string]func(*rootFacts){
|
||||
"a read failed": func(f *rootFacts) { f.Unread = []string{"the store did not answer"} },
|
||||
"no agent account named": func(f *rootFacts) { f.AgentNamed, f.Confined = false, false },
|
||||
"not confined": func(f *rootFacts) { f.Confined = false },
|
||||
"nothing said of it": func(f *rootFacts) { f.ConfinedWhy = "" },
|
||||
"execute served": func(f *rootFacts) { f.Execute, f.ExecuteWhy = true, "the bus hears execute answered there" },
|
||||
"confined, but agent read": func(f *rootFacts) { f.Unread, f.ConfinedWhy = []string{"x"}, "" },
|
||||
}
|
||||
for name, mutate := range fails {
|
||||
f := pass
|
||||
mutate(&f)
|
||||
if v := judgeRoot(f, rootNow); v.Free || v.Why == "" {
|
||||
t.Errorf("%s: judged %+v", name, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The reader fails closed on every case the review named: the agent account read only where the coding-agent
|
||||
// module runs (old :225), no account to measure taken for a pass (old :246), and a measure that did not answer
|
||||
// taken for "not root" (old :114, :123).
|
||||
func TestTheRootReaderFailsClosed(t *testing.T) {
|
||||
shell := catalogue.Manifest{Module: "zsh", Claims: []catalogue.Claim{{Name: loginShellSeat, Serves: []string{"execute"}}},
|
||||
Settings: map[string]catalogue.SettingDeclaration{"execute": {Default: "withhold"}}}
|
||||
reader := func() *rootReader {
|
||||
return &rootReader{
|
||||
entries: []inventory.Entry{{Manifest: shell, On: []string{"anchor"}}},
|
||||
heard: map[string]map[string]map[string]bool{},
|
||||
confined: func(context.Context, string, time.Time) (bool, bool, string, error) {
|
||||
return true, true, "the agent account agents cannot become root without a person", nil
|
||||
},
|
||||
settings: func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, nil },
|
||||
}
|
||||
}
|
||||
ctx := context.Background()
|
||||
if v := judgeRootFree(ctx, reader(), []string{"anchor"}, rootNow)[0]; !v.Free {
|
||||
t.Fatalf("the control: agents confined, execute withheld and unheard, everything read: %+v", v)
|
||||
}
|
||||
cases := map[string]func(*rootReader){
|
||||
"no agent account named, no coding-agent module there": func(r *rootReader) {
|
||||
r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) {
|
||||
return false, false, "anchor names no agent account: agents run as the operator account (ops)", nil
|
||||
}
|
||||
},
|
||||
"the node-engine's verdict not read": func(r *rootReader) {
|
||||
r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) {
|
||||
return false, false, "", errors.New("the store did not answer")
|
||||
}
|
||||
},
|
||||
"a stale verdict": func(r *rootReader) {
|
||||
r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) {
|
||||
return true, false, "the agent account agents is not judged: the machine's newest statement was heard at …", nil
|
||||
}
|
||||
},
|
||||
"the bus not asked": func(r *rootReader) { r.asked = errors.New("no bus") },
|
||||
"the placements not read": func(r *rootReader) { r.read = errors.New("no store") },
|
||||
"the holder's setting not read": func(r *rootReader) {
|
||||
r.settings = func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, errors.New("no store") }
|
||||
},
|
||||
"execute heard on the bus": func(r *rootReader) {
|
||||
r.heard = map[string]map[string]map[string]bool{loginShellSeat: {"execute": {"anchor": true}}}
|
||||
},
|
||||
"a holder that serves execute": func(r *rootReader) {
|
||||
r.entries[0].Manifest.Settings = nil
|
||||
},
|
||||
}
|
||||
for name, mutate := range cases {
|
||||
r := reader()
|
||||
mutate(r)
|
||||
if v := judgeRootFree(ctx, r, []string{"anchor"}, rootNow)[0]; v.Free {
|
||||
t.Errorf("%s: judged free: %+v", name, v)
|
||||
}
|
||||
}
|
||||
// A machine with no login shell holder at all: still the bus must hear none.
|
||||
r := reader()
|
||||
r.entries = nil
|
||||
r.heard = map[string]map[string]map[string]bool{loginShellSeat: {"execute": {"anchor": true}}}
|
||||
if v := judgeRootFree(ctx, r, []string{"anchor"}, rootNow)[0]; v.Free {
|
||||
t.Errorf("execute answered by a module nobody assigned: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// The probe says agent-root, by the same judgement, on each machine where the router or a module of its own
|
||||
// account runs and that is not root-free; urgent and in plain words; nothing where every one is free.
|
||||
func TestTheProbeSaysEachMachineThatIsNotRootFree(t *testing.T) {
|
||||
entries := []inventory.Entry{
|
||||
{Manifest: catalogue.Manifest{Module: "telegram", RunsAs: "telegram"}, On: []string{"anchor"}},
|
||||
{Manifest: catalogue.Manifest{Module: "messenger", RunsAs: "messenger",
|
||||
Claims: []catalogue.Claim{{Name: routerSeat}}}, On: []string{"anchor"}},
|
||||
{Manifest: catalogue.Manifest{Module: "xorg", Claims: []catalogue.Claim{{Name: catalogue.DisplayServerSeat}}},
|
||||
On: []string{"laptop"}},
|
||||
}
|
||||
trusted := trustedMachines(entries)
|
||||
if len(trusted["anchor"]) != 2 || len(trusted["laptop"]) != 0 {
|
||||
t.Fatalf("trusted %v", trusted)
|
||||
}
|
||||
r := &rootReader{entries: entries, heard: map[string]map[string]map[string]bool{},
|
||||
confined: func(_ context.Context, node string, _ time.Time) (bool, bool, string, error) {
|
||||
return false, false, node + " names no agent account: agents run as the operator account (ops)", nil
|
||||
},
|
||||
settings: func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, nil }}
|
||||
got := rootObservations(context.Background(), r, trusted, rootNow)
|
||||
if len(got) != 1 || got[0].Machine != "anchor" || got[0].Kind != kindRootNotFree || got[0].Severity != conditions.Urgent ||
|
||||
!strings.Contains(got[0].Summary, "messenger, telegram") || !strings.Contains(got[0].Summary, "names no agent account") {
|
||||
t.Fatalf("said %+v", got)
|
||||
}
|
||||
o := got[0]
|
||||
if why, ok := conditions.PlainWords(conditions.Words{Headline: o.Headline, Explanation: o.Explanation,
|
||||
Needs: o.Needs, Resolved: o.Resolved}, o.Machine); !ok {
|
||||
t.Errorf("not plain: %s", why)
|
||||
}
|
||||
r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) {
|
||||
return true, true, "confined", nil
|
||||
}
|
||||
if got := rootObservations(context.Background(), r, trusted, rootNow); len(got) != 0 {
|
||||
t.Errorf("said of a free machine: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0268: the login shell counts only where its execute is served, and fails closed.
|
||||
func TestTheLoginShellCountsOnlyWhereExecuteIsServed(t *testing.T) {
|
||||
val := func(v any) *any { return &v }
|
||||
cases := []struct {
|
||||
name string
|
||||
claims bool
|
||||
setting *any
|
||||
heard, asked bool
|
||||
served bool
|
||||
saysInTheWhys string
|
||||
}{
|
||||
{"withheld by the setting and silent on the bus", true, val("withhold"), false, true, false, ""},
|
||||
{"withheld by the setting, the machine not yet pushed", true, val("withhold"), true, true, true, "the bus hears"},
|
||||
{"the setting serves", true, val("serve"), false, true, true, "setting there is serve"},
|
||||
{"a wrong value withholds, as the holder does", true, val("Serve"), false, true, false, ""},
|
||||
{"the setting withholds, the bus could not be asked", true, val("withhold"), false, false, true, "could not be asked"},
|
||||
{"a holder with no such setting that claims execute", true, nil, false, true, true, "claims execute"},
|
||||
{"a holder with no such setting that does not claim it, heard all the same", false, nil, true, true, true, "the bus hears"},
|
||||
{"a holder with no such setting that does not claim it, silent", false, nil, false, true, false, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
served, why := loginShellServed("zsh", c.claims, c.setting, c.heard, c.asked)
|
||||
if served != c.served || (c.saysInTheWhys != "" && !strings.Contains(why, c.saysInTheWhys)) {
|
||||
t.Errorf("%s: served %v (%q), want %v saying %q", c.name, served, why, c.served, c.saysInTheWhys)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The root-free verb is the serving controller's alone, answered in its process and never as a command; a
|
||||
// controller not serving answers an error, which the router reads as no machine free.
|
||||
func TestRootFreeIsAnsweredOnlyByTheServingController(t *testing.T) {
|
||||
was := doctorFrom
|
||||
doctorFrom = nil
|
||||
t.Cleanup(func() { doctorFrom = was })
|
||||
if _, err := rootFreeAnswer(context.Background(), "anchor", rootNow); err == nil {
|
||||
t.Error("a controller not serving judged a machine")
|
||||
}
|
||||
if !inProcess["root-free"] {
|
||||
t.Error("root-free is not answered in the serving process")
|
||||
}
|
||||
if _, err := argvFor("root-free", map[string]any{"machines": "anchor"}); err == nil {
|
||||
t.Error("root-free ran as a command")
|
||||
}
|
||||
// The router names its machines as a list (its contract with this verb); one text separated by commas is
|
||||
// the same; anything else in the list is refused.
|
||||
for _, given := range []any{[]any{"anchor", "relay"}, "anchor, relay"} {
|
||||
a, err := readArguments("root-free", map[string]any{"machines": given})
|
||||
if err != nil || a.given["machines"] != "anchor,relay" && a.given["machines"] != "anchor, relay" {
|
||||
t.Errorf("root-free given %v read %v (%v)", given, a, err)
|
||||
}
|
||||
}
|
||||
if _, err := readArguments("root-free", map[string]any{"machines": []any{"anchor", 7}}); err == nil {
|
||||
t.Error("root-free took a number for a machine")
|
||||
}
|
||||
if _, err := readArguments("status", map[string]any{"machines": []any{"anchor"}}); err == nil {
|
||||
t.Error("a verb that takes no list took one")
|
||||
}
|
||||
}
|
||||
|
||||
// The confirmation review of 2026-10-09: ADR 0266's quiet window (#175) keeps the self-check from raising
|
||||
// agent-can-become-root while the node-engine's setuid search runs and no complete one judges. It must not make root-free answer free:
|
||||
// root-free needs a complete, fresh verdict. A verdict still waiting for the search is "not judged" to
|
||||
// agentConfined, so the machine is not root-free, whatever the quiet says — and the same statement, complete
|
||||
// and healthy, is the control.
|
||||
func TestAMachineWaitingForItsFirstSetuidSearchIsNotRootFree(t *testing.T) {
|
||||
now := rootNow
|
||||
statement := func(state, reason string) inventory.NodeHealth {
|
||||
return inventory.NodeHealth{Node: "anchor", Contract: link.RootContract, SaidAt: now, HeardAt: now,
|
||||
Resources: []inventory.ResourceHealth{{Module: "claude-code", Resource: "agent", Kind: link.KindAccount,
|
||||
Target: "agents", State: state, Reason: reason, Root: link.RootNever}}}
|
||||
}
|
||||
judged := func(h inventory.NodeHealth) rootVerdict {
|
||||
confined, why := judgedConfined("agents", h, true, now)
|
||||
return judgeRoot(rootFacts{Machine: "anchor", AgentNamed: true, Confined: confined, ConfinedWhy: why}, now)
|
||||
}
|
||||
if v := judged(statement(link.StateHealthy, "")); !v.Free {
|
||||
t.Fatalf("the control: a complete healthy verdict, fresh: %+v", v)
|
||||
}
|
||||
pending := statement(link.StateUnknown, link.ReasonRootPending+": the search runs")
|
||||
if rootVerdictKind("agents", pending, true, now) != verdictPending {
|
||||
t.Fatal("the statement is not one the quiet window counts as waiting for the search")
|
||||
}
|
||||
if v := judged(pending); v.Free {
|
||||
t.Errorf("a machine whose setuid search is pending was judged root-free: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// The confirmation review of 2026-10-09, on #154 beside ADR 0266: D-root keeps ADR 0266's quiet window — nothing
|
||||
// raised while the one thing unjudged is the setuid search, within searchQuietFor — while the root-free verb
|
||||
// still answers the machine not free; and D-root's condition has a key of its own, apart from DA's.
|
||||
func TestRootNotFreeIsQuietWhileTheFirstSearchRunsAndKeyedApartFromDA(t *testing.T) {
|
||||
entries := []inventory.Entry{{Manifest: catalogue.Manifest{Module: "telegram", RunsAs: "telegram"}, On: []string{"anchor"}}}
|
||||
r := &rootReader{entries: entries, heard: map[string]map[string]map[string]bool{},
|
||||
confined: func(context.Context, string, time.Time) (bool, bool, string, error) {
|
||||
return true, false, "the agent account agents is not judged: the search for setuid programs runs", nil
|
||||
},
|
||||
settings: func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, nil },
|
||||
quiet: func(context.Context, string, time.Time) bool { return true }}
|
||||
trusted := trustedMachines(entries)
|
||||
if got := rootObservations(context.Background(), r, trusted, rootNow); len(got) != 0 {
|
||||
t.Errorf("raised while the search runs: %+v", got)
|
||||
}
|
||||
if v := judgeRootFree(context.Background(), r, []string{"anchor"}, rootNow)[0]; v.Free || !v.Quiet {
|
||||
t.Errorf("root-free while the search runs: %+v", v)
|
||||
}
|
||||
// Quiet hides nothing else: the login shell served as well is said.
|
||||
r.heard = map[string]map[string]map[string]bool{loginShellSeat: {"execute": {"anchor": true}}}
|
||||
if got := rootObservations(context.Background(), r, trusted, rootNow); len(got) != 1 {
|
||||
t.Errorf("a second failure was kept quiet: %+v", got)
|
||||
}
|
||||
// Past searchQuietFor, said.
|
||||
r.heard, r.quiet = map[string]map[string]map[string]bool{}, func(context.Context, string, time.Time) bool { return false }
|
||||
got := rootObservations(context.Background(), r, trusted, rootNow)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("a search past searchQuietFor was not said: %+v", got)
|
||||
}
|
||||
// One key per judgement: DA's is machine.<m>.agent-root, this one its own.
|
||||
da := conditions.Observation{Scope: conditions.ScopeMachine, ID: "anchor", Token: "agent-root", Machine: "anchor"}
|
||||
if got[0].Key() == da.Key() {
|
||||
t.Errorf("D-root and DA share the key %s", da.Key())
|
||||
}
|
||||
}
|
||||
@@ -252,6 +252,10 @@ func askEveryResolver(ctx context.Context, resolvers map[string]string, places [
|
||||
v.wrong[0], andMore(len(v.wrong)-1))
|
||||
} else {
|
||||
// Nothing but silence: held for the next run, which raises it if the resolver is still silent.
|
||||
// Refused on every try too: a few hundred milliseconds of refusals is a resolver restarting as
|
||||
// well as one that stopped, and the two looks a finding needs are this run and the next
|
||||
// (novox/hq issue 348). The machine's own node-engine is the fast detector: its names check
|
||||
// raised the control node's resolver within a minute on 2026-10-09.
|
||||
o.Confirm = true
|
||||
o.Summary = fmt.Sprintf("the mesh's resolver on %s does not answer: %d of the %d question(s) about the "+
|
||||
"machines' names went unanswered, each asked %d times — the first, %s", node, len(v.unanswered), v.asked,
|
||||
@@ -637,31 +641,8 @@ const (
|
||||
// discoverHolders asks the bus's discovery who serves what, and answers seat → machine for every
|
||||
// endpoint a seat's verb is served on.
|
||||
func discoverHolders(ctx context.Context, conn *nats.Conn) (map[string]map[string]bool, error) {
|
||||
inbox := conn.NewRespInbox()
|
||||
sub, err := conn.SubscribeSync(inbox)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
if err := conn.PublishRequest("$SRV.INFO", inbox, nil); err != nil {
|
||||
return nil, fmt.Errorf("asking the bus who serves what: %w", err)
|
||||
}
|
||||
out := map[string]map[string]bool{}
|
||||
deadline := time.Now().Add(discoveryPatience)
|
||||
for time.Now().Before(deadline) {
|
||||
wait, cancel := context.WithTimeout(ctx, discoveryQuiet)
|
||||
msg, err := sub.NextMsgWithContext(wait)
|
||||
cancel()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
break
|
||||
}
|
||||
var info micro.Info
|
||||
if json.Unmarshal(msg.Data, &info) != nil {
|
||||
continue
|
||||
}
|
||||
err := discoverServices(ctx, conn, func(info micro.Info) {
|
||||
for _, e := range info.Endpoints {
|
||||
seat, node := e.Metadata["seat"], e.Metadata["node"]
|
||||
if seat == "" {
|
||||
@@ -680,8 +661,69 @@ func discoverHolders(ctx context.Context, conn *nats.Conn) (map[string]map[strin
|
||||
out[info.Name] = map[string]bool{}
|
||||
}
|
||||
out[info.Name][info.ID] = true
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// discoverSeatVerbs asks the bus's discovery the same, one level finer: seat → verb → machine, for every
|
||||
// seat verb answered (an endpoint's `tool` is its verb). A seat held where a verb is withheld (novox/hq ADR
|
||||
// 0268) shows the seat and not that verb.
|
||||
func discoverSeatVerbs(ctx context.Context, conn *nats.Conn) (map[string]map[string]map[string]bool, error) {
|
||||
out := map[string]map[string]map[string]bool{}
|
||||
err := discoverServices(ctx, conn, func(info micro.Info) {
|
||||
for _, e := range info.Endpoints {
|
||||
seat, verb, node := e.Metadata["seat"], e.Metadata["tool"], e.Metadata["node"]
|
||||
if seat == "" || verb == "" {
|
||||
continue
|
||||
}
|
||||
return out, nil
|
||||
if node == "" {
|
||||
node = info.ID
|
||||
}
|
||||
if out[seat] == nil {
|
||||
out[seat] = map[string]map[string]bool{}
|
||||
}
|
||||
if out[seat][verb] == nil {
|
||||
out[seat][verb] = map[string]bool{}
|
||||
}
|
||||
out[seat][verb][node] = true
|
||||
}
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// discoverServices asks the bus's discovery once and hands every service's answer to visit, waiting
|
||||
// discoveryQuiet after the last and discoveryPatience at the most.
|
||||
func discoverServices(ctx context.Context, conn *nats.Conn, visit func(micro.Info)) error {
|
||||
if conn == nil {
|
||||
return errors.New("the controller holds no connection to the bus")
|
||||
}
|
||||
inbox := conn.NewRespInbox()
|
||||
sub, err := conn.SubscribeSync(inbox)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
if err := conn.PublishRequest("$SRV.INFO", inbox, nil); err != nil {
|
||||
return fmt.Errorf("asking the bus who serves what: %w", err)
|
||||
}
|
||||
deadline := time.Now().Add(discoveryPatience)
|
||||
for time.Now().Before(deadline) {
|
||||
wait, cancel := context.WithTimeout(ctx, discoveryQuiet)
|
||||
msg, err := sub.NextMsgWithContext(wait)
|
||||
cancel()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
break
|
||||
}
|
||||
var info micro.Info
|
||||
if json.Unmarshal(msg.Data, &info) != nil {
|
||||
continue
|
||||
}
|
||||
visit(info)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// probeArchives is D4: every archive the mesh keeps is held by its manifest in the artifact store.
|
||||
@@ -1040,12 +1082,7 @@ func probeCoreBuilds(ctx context.Context, d *doctor) ([]conditions.Observation,
|
||||
return nil, err
|
||||
}
|
||||
hostVersions := deliveredVersions(shelf[hostModule])
|
||||
rolling := map[string]bool{}
|
||||
for _, p := range plans {
|
||||
for m := range p.Modules {
|
||||
rolling[m] = true
|
||||
}
|
||||
}
|
||||
rolling := rollingModules(plans)
|
||||
nodes, err := inv.Nodes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1103,6 +1140,26 @@ func probeCoreBuilds(ctx context.Context, d *doctor) ([]conditions.Observation,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// rollingModules is every module an open plan is rolling out: those it keeps a record of, and those its
|
||||
// tiers name. **A release keeps no record per module** — its walk is per machine, its modules only in its
|
||||
// tier — so reading the records alone, D10 said "no plan is rolling them out" about the node-engine while
|
||||
// a release walked it, and the gate on that release's first machine waited on what its own send causes
|
||||
// (novox/hq issue 348). Pure.
|
||||
func rollingModules(plans []inventory.Plan) map[string]bool {
|
||||
rolling := map[string]bool{}
|
||||
for _, p := range plans {
|
||||
for m := range p.Modules {
|
||||
rolling[m] = true
|
||||
}
|
||||
for _, tier := range p.Tiers {
|
||||
for _, m := range tier {
|
||||
rolling[m] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return rolling
|
||||
}
|
||||
|
||||
// deliveredVersions are the versions a module's registered build is delivered as: the last element
|
||||
// of every resource path under a `versions/` directory, which registration filled from the artifact's
|
||||
// digest (catalogue `${version}`). The node-engine names itself by that directory.
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
package main
|
||||
|
||||
// A trusted setting proposed through a verb and set only on the operator's warrant (novox/hq ADR 0277).
|
||||
//
|
||||
// mesh-controller settings propose <module> <values.json | {…}> [--node <node>] [--replace]
|
||||
// mesh-controller settings propose <module> --clear [--node <node>]
|
||||
// mesh-controller settings proposals [<id>]
|
||||
//
|
||||
// Whoever the bus admits may PROPOSE a settings layer — the keys issue 339 made the terminal's (`places`,
|
||||
// `accesses`, what a provider serves, what a trusted file asks for) among them. A proposal changes nothing: it is
|
||||
// kept in the controller's own asks (broker.AskedBucket, written by the controller alone) and asked of the
|
||||
// operator through the operator channel as an ask whose two answers, Approve and Decline, are both at the level
|
||||
// approve, so only a channel that proves who answered (Telegram, today) carries either. The serving controller
|
||||
// acts on the warrant as on any other of its asks (asker.Decided): once, for the ask it holds, the option it
|
||||
// offered, and only when the act about to be performed — the module, the machine, the digest of the exact values,
|
||||
// the layer they replace, whether a removal is meant — is the one the option bound when the operator was shown
|
||||
// it. Then it sets the layer as `settings set` at the terminal does, with the same judgement, keeps who approved
|
||||
// it beside the layer (`settings` says it back), and records the warrant in the hand-act log on the bus, where a
|
||||
// person's decisions are read; the router edits the ask on every channel to its outcome. No seat event of its
|
||||
// own: nothing consumes one, and the installer's first user list in the node-engine's repository names every
|
||||
// controller event, so one would cost a node-engine change for a fact without a reader. The push afterwards is a
|
||||
// separate act, as it is for a layer set at the terminal.
|
||||
//
|
||||
// **What the operator reads** is the module, the machine, each key with its exact new value and, for a changed
|
||||
// key, the value it replaces. A value that may not leave the mesh (an address, a path, a secret's shape: the
|
||||
// router's content rule, outward.Check) is shown with that part replaced by ‹address›, ‹path› or ‹withheld›, the
|
||||
// ask says so, and the whole is read with `settings proposals <id>` — whose fingerprint must be the one on the
|
||||
// phone. Fail closed: a proposal nothing can carry to the operator is refused at once, in words, and never left
|
||||
// waiting for an answer that cannot come.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/outward"
|
||||
)
|
||||
|
||||
// settingsProposal is one proposed change to a module's settings layer, as the controller's ask keeps it
|
||||
// (asked.Proposal). Every field the ask is composed from is here, so the serving controller composes the same ask
|
||||
// the proposer did, and the warrant's digest holds to it.
|
||||
type settingsProposal struct {
|
||||
Module string `json:"module"`
|
||||
// Node is the machine, or empty for the whole mesh.
|
||||
Node string `json:"node,omitempty"`
|
||||
// Values is the layer proposed, whole; Clear says the layer is removed instead.
|
||||
Values map[string]any `json:"values,omitempty"`
|
||||
Clear bool `json:"clear,omitempty"`
|
||||
// Replace says the keys the layer had and Values do not name are meant to go (novox/hq ADR 0217).
|
||||
Replace bool `json:"replace,omitempty"`
|
||||
// Before is the layer as it stood when the proposal was made, and HadLayer whether there was one: what the
|
||||
// operator was shown the change against, and what must still stand when the warrant is acted on.
|
||||
Before map[string]any `json:"before,omitempty"`
|
||||
HadLayer bool `json:"had-layer"`
|
||||
// From is who proposed it, as the bus named the caller; At is when.
|
||||
From string `json:"from"`
|
||||
At time.Time `json:"at"`
|
||||
// Digest is the digest of Values (layerDigest), BeforeDigest of Before.
|
||||
Digest string `json:"digest"`
|
||||
BeforeDigest string `json:"before-digest"`
|
||||
}
|
||||
|
||||
// proposalVerb is the verb a proposal's answers bind: the controller's own settings, performed by itself.
|
||||
const proposalVerb = askerName + ".settings"
|
||||
|
||||
// The two answers, both at the level approve.
|
||||
const (
|
||||
answerApprove = "approve"
|
||||
answerDecline = "decline"
|
||||
)
|
||||
|
||||
// layerDigest is the digest a proposal binds: SHA-256 over the layer's canonical JSON (Go sorts a map's keys), an
|
||||
// absent layer and an empty one alike.
|
||||
func layerDigest(values map[string]any) string {
|
||||
if values == nil {
|
||||
values = map[string]any{}
|
||||
}
|
||||
raw, _ := json.Marshal(values)
|
||||
sum := sha256.Sum256(raw)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// fingerprint is a digest as the operator is shown it: its first 24 hexadecimal digits in groups of four, so no
|
||||
// word of it is long enough for the router to take for a secret.
|
||||
func fingerprint(digest string) string {
|
||||
hexed := strings.TrimPrefix(digest, "sha256:")
|
||||
if len(hexed) < 24 {
|
||||
return hexed
|
||||
}
|
||||
var groups []string
|
||||
for i := 0; i < 24; i += 4 {
|
||||
groups = append(groups, hexed[i:i+4])
|
||||
}
|
||||
return strings.Join(groups, " ")
|
||||
}
|
||||
|
||||
// where is the layer in words: "shanks" or "the whole mesh".
|
||||
func (p settingsProposal) where() string {
|
||||
if p.Node == "" {
|
||||
return "the whole mesh"
|
||||
}
|
||||
return p.Node
|
||||
}
|
||||
|
||||
// about is what the ask is about, a key without spaces: the module's layer on the machine, or on the mesh.
|
||||
func (p settingsProposal) about() string {
|
||||
if p.Node == "" {
|
||||
return "settings." + p.Module + ".mesh"
|
||||
}
|
||||
return "settings." + p.Module + "." + p.Node
|
||||
}
|
||||
|
||||
// actions are the proposal's two answers as the controller keeps them (asked.Actions): each binds the exact act
|
||||
// through boundAct — the verb, the machine, the level and every argument, the values' digest among them.
|
||||
func (p settingsProposal) actions(id string) []conditions.Action {
|
||||
args := func(answer string) map[string]string {
|
||||
return map[string]string{"proposal": id, "answer": answer, "module": p.Module, "node": p.Node,
|
||||
"values": p.Digest, "before": p.BeforeDigest, "had-layer": strconv.FormatBool(p.HadLayer),
|
||||
"replace": strconv.FormatBool(p.Replace), "clear": strconv.FormatBool(p.Clear), "from": p.From,
|
||||
"at": p.At.UTC().Format(time.RFC3339Nano)}
|
||||
}
|
||||
return []conditions.Action{
|
||||
{Label: "Approve", Verb: proposalVerb, Machine: p.Node, Level: conditions.LevelApprove, Arguments: args(answerApprove)},
|
||||
{Label: "Decline", Verb: proposalVerb, Machine: p.Node, Level: conditions.LevelApprove, Arguments: args(answerDecline)},
|
||||
}
|
||||
}
|
||||
|
||||
// ask is the proposal's ask, composed from it alone, as the router is sent it: the headline, the explanation
|
||||
// with the change shown under the content rule, and the two options with their bound acts.
|
||||
func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[string]int) {
|
||||
verb := "Set"
|
||||
if p.Clear {
|
||||
verb = "Clear"
|
||||
}
|
||||
headline := fmt.Sprintf("%s %s on %s?", verb, p.Module, p.where())
|
||||
if len([]rune(headline)) > asks.HeadlineLength {
|
||||
headline = fmt.Sprintf("%s settings on %s?", verb, p.where())
|
||||
}
|
||||
if len([]rune(headline)) > asks.HeadlineLength {
|
||||
headline = verb + " settings?"
|
||||
}
|
||||
shown, whole := p.change(machines)
|
||||
var b strings.Builder
|
||||
if p.Clear {
|
||||
fmt.Fprintf(&b, "Clear the settings of %s on %s, back to what the module says?\n\n", p.Module, p.where())
|
||||
} else {
|
||||
fmt.Fprintf(&b, "Set the settings of %s on %s to these values?\n\n", p.Module, p.where())
|
||||
}
|
||||
fmt.Fprintf(&b, "Proposed by %s, at %s. ", sayable(p.From, machines), p.At.Local().Format("15:04 on 2 Jan"))
|
||||
switch {
|
||||
case p.Clear && p.HadLayer:
|
||||
b.WriteString("The layer it removes:\n\n")
|
||||
case p.Clear:
|
||||
b.WriteString("There is no layer to remove; approving changes nothing.")
|
||||
case !p.HadLayer:
|
||||
b.WriteString("There is no layer yet; this is the whole of it:\n\n")
|
||||
default:
|
||||
b.WriteString("The layer is replaced whole; what changes against it:\n\n")
|
||||
}
|
||||
b.WriteString(shown)
|
||||
fmt.Fprintf(&b, "\n\nFingerprint %s.", fingerprint(p.Digest))
|
||||
if !whole {
|
||||
b.WriteString(" Parts shown as ‹address›, ‹path› or ‹withheld› may not leave the mesh: read it whole, with " +
|
||||
"this fingerprint, through the mesh MCP server (mesh-controller.settings, proposal " + id + ") or with " +
|
||||
"mesh-cli settings proposals " + id + ".")
|
||||
}
|
||||
if p.Clear {
|
||||
b.WriteString(" Approved, the layer is removed at once and the machine takes it at its next push.")
|
||||
} else {
|
||||
b.WriteString(" Approved, the layer is set at once and the machine takes it at its next push.")
|
||||
}
|
||||
q := asks.Ask{ID: id, Headline: headline, Explanation: b.String(), Who: asks.Operator,
|
||||
Expires: p.At.Add(askApproveFor), OnExpiry: "the proposal is discarded; nothing changes",
|
||||
About: p.about()}
|
||||
options := map[string]int{}
|
||||
for i, act := range p.actions(id) {
|
||||
binds, _ := asks.ActDigest(boundAct(act))
|
||||
oid := optionID(act.Label)
|
||||
options[oid] = i
|
||||
does := "the settings are set; nothing is pushed yet"
|
||||
if p.Clear {
|
||||
does = "the layer is removed; nothing is pushed yet"
|
||||
}
|
||||
if act.Arguments["answer"] == answerDecline {
|
||||
does = "nothing changes; the proposal is discarded"
|
||||
}
|
||||
q.Options = append(q.Options, asks.Option{ID: oid, Label: act.Label, Does: does, Level: asks.Level(act.Level),
|
||||
Binds: binds})
|
||||
}
|
||||
return q, options
|
||||
}
|
||||
|
||||
// shownMost is the most of a change the phone is shown, in bytes; the rest is read whole with `settings proposals`.
|
||||
const shownMost = 1400
|
||||
|
||||
// change is what changes, line by line, each value shown under the content rule, and whether every value was
|
||||
// shown whole: "+ key: value" added, "~ key: value (was: old)" changed, "- key (was: old)" removed, and the
|
||||
// count of keys unchanged.
|
||||
func (p settingsProposal) change(machines []string) (string, bool) {
|
||||
whole := true
|
||||
say := func(v any) string {
|
||||
s, w := sayableValue(v, machines)
|
||||
whole = whole && w
|
||||
return s
|
||||
}
|
||||
var lines []string
|
||||
if p.Clear {
|
||||
was := leaves(p.Before, "")
|
||||
for _, k := range layerKeys(was) {
|
||||
lines = append(lines, fmt.Sprintf("- %s: %s", k, say(was[k])))
|
||||
}
|
||||
} else {
|
||||
added, changed, removed := settingsChange(p.Before, p.Values)
|
||||
was, now := leaves(p.Before, ""), leaves(p.Values, "")
|
||||
for _, k := range added {
|
||||
lines = append(lines, fmt.Sprintf("+ %s: %s", k, say(now[k])))
|
||||
}
|
||||
for _, k := range changed {
|
||||
lines = append(lines, fmt.Sprintf("~ %s: %s (was: %s)", k, say(now[k]), say(was[k])))
|
||||
}
|
||||
for _, k := range removed {
|
||||
lines = append(lines, fmt.Sprintf("- %s (was: %s)", k, say(was[k])))
|
||||
}
|
||||
unchanged := len(now) - len(added) - len(changed)
|
||||
switch {
|
||||
case len(lines) == 0 && unchanged > 0:
|
||||
lines = append(lines, fmt.Sprintf("= nothing changes: the %d key(s) are as they stand", unchanged))
|
||||
case unchanged > 0:
|
||||
lines = append(lines, fmt.Sprintf("= %d key(s) unchanged", unchanged))
|
||||
}
|
||||
}
|
||||
out := strings.Join(lines, "\n")
|
||||
if len(out) > shownMost {
|
||||
cut := shownMost
|
||||
for cut > 0 && out[cut] != '\n' {
|
||||
cut--
|
||||
}
|
||||
out = out[:cut] + fmt.Sprintf("\n… NOT SHOWN IN FULL here: %d more bytes", len(strings.Join(lines, "\n"))-cut)
|
||||
whole = false
|
||||
}
|
||||
return out, whole
|
||||
}
|
||||
|
||||
func layerKeys(m map[string]any) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// The shapes a value is shown without, in place: an address with what follows it up to a separator, and a
|
||||
// path. Replaced in place rather than word by word, so "recalbox=smb://host/share@/mnt/recalbox" is shown as
|
||||
// "recalbox=‹address›@‹path›" and keeps its shape.
|
||||
var (
|
||||
shownURL = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^\s@"',;)\]}]*`)
|
||||
shownIPv4 = regexp.MustCompile(`\b\d{1,3}(\.\d{1,3}){3}(:\d+)?\b`)
|
||||
shownEmail = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}`)
|
||||
shownPath = regexp.MustCompile(`(^|[\s=@,;:"'(\[{])((?:~|\.{1,2})?/[^\s"',;:)\]}]*)`)
|
||||
)
|
||||
|
||||
// Markers for what is not shown.
|
||||
const (
|
||||
markAddress = "‹address›"
|
||||
markPath = "‹path›"
|
||||
markWithheld = "‹withheld›"
|
||||
)
|
||||
|
||||
// sayableValue is a value as the phone is shown it, and whether it was shown whole. A string is shown bare; any
|
||||
// other value as JSON.
|
||||
func sayableValue(v any, machines []string) (string, bool) {
|
||||
text, ok := v.(string)
|
||||
if !ok {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return markWithheld, false
|
||||
}
|
||||
text = string(raw)
|
||||
}
|
||||
if text == "" {
|
||||
return `""`, true
|
||||
}
|
||||
out := sayable(text, machines)
|
||||
return out, out == text
|
||||
}
|
||||
|
||||
// sayable is a text with what may not leave the mesh replaced in place by a marker; what the markers cannot make
|
||||
// pass is withheld whole.
|
||||
func sayable(text string, machines []string) string {
|
||||
if _, ok := outward.Check(text, machines...); ok {
|
||||
return text
|
||||
}
|
||||
out := shownURL.ReplaceAllString(text, markAddress)
|
||||
out = shownEmail.ReplaceAllString(out, markAddress)
|
||||
out = shownIPv4.ReplaceAllString(out, markAddress)
|
||||
out = shownPath.ReplaceAllString(out, "${1}"+markPath)
|
||||
// Then word by word, as the router reads them: a host name, a path the shapes above missed, a secret's shape.
|
||||
words := strings.FieldsFunc(out, func(r rune) bool {
|
||||
return r == ' ' || r == '\t' || r == '\n' || strings.ContainsRune("\"'`()[]{}<>,;|", r)
|
||||
})
|
||||
for _, w := range words {
|
||||
if refusal, ok := outward.Check(w, machines...); !ok {
|
||||
mark := markWithheld
|
||||
switch refusal.Class {
|
||||
case "address":
|
||||
mark = markAddress
|
||||
case "path":
|
||||
mark = markPath
|
||||
}
|
||||
out = strings.ReplaceAll(out, w, mark)
|
||||
}
|
||||
}
|
||||
if _, ok := outward.Check(out, machines...); ok {
|
||||
return out
|
||||
}
|
||||
out = strings.ReplaceAll(outward.Scrub(out, markWithheld, machines...), "(withheld)", markWithheld)
|
||||
if _, ok := outward.Check(out, machines...); ok {
|
||||
return out
|
||||
}
|
||||
return markWithheld
|
||||
}
|
||||
|
||||
// ---- proposing ---------------------------------------------------------------------------------------
|
||||
|
||||
// proposer is the propose command's reaches, given so a test needs no store and no bus.
|
||||
type proposer struct {
|
||||
// layer reads a module's layer as it stands.
|
||||
layer func(ctx context.Context, node, module string) (map[string]any, bool, error)
|
||||
// judge judges the layer as SetSettings would, keeping nothing.
|
||||
judge func(ctx context.Context, node, module string, values map[string]any) error
|
||||
// machines are the mesh's machine names: allowed in the ask's words.
|
||||
machines func(ctx context.Context) ([]string, error)
|
||||
store askedStore
|
||||
publish func(ctx context.Context, subject string, body []byte, id string) error
|
||||
// routerHere and grantHeld are the asker's own judgements of whether an ask can be carried; nil is yes.
|
||||
routerHere func(ctx context.Context) (bool, error)
|
||||
grantHeld func(ctx context.Context) (bool, string, error)
|
||||
// routerRecord reads the router's record of an ask: its state, or "" for none.
|
||||
routerRecord func(ctx context.Context, id string) (string, error)
|
||||
now func() time.Time
|
||||
caller string
|
||||
// waitFor and waitEvery bound how long propose waits for the router's word on the new ask.
|
||||
waitFor time.Duration
|
||||
waitEvery time.Duration
|
||||
}
|
||||
|
||||
// proposeInput is what is proposed.
|
||||
type proposeInput struct {
|
||||
module string
|
||||
node string
|
||||
values map[string]any
|
||||
clear bool
|
||||
replace bool
|
||||
}
|
||||
|
||||
// atTheTerminalInstead names the other way, said whenever a proposal is refused for want of a channel.
|
||||
func atTheTerminalInstead(in proposeInput) string {
|
||||
if in.clear {
|
||||
return "the operator may clear it at the controller's terminal instead: mesh-cli settings clear " + in.module + nodeFlag(in.node)
|
||||
}
|
||||
return "the operator may set it at the controller's terminal instead: mesh-cli settings set " + in.module + " '{…}'" + nodeFlag(in.node)
|
||||
}
|
||||
|
||||
// propose keeps a proposal in the controller's asks and asks the operator; it answers the words said to the
|
||||
// caller. Nothing is set here.
|
||||
func (pr proposer) propose(ctx context.Context, in proposeInput) (string, error) {
|
||||
refuse := func(format string, args ...any) (string, error) {
|
||||
return "", fmt.Errorf(format+". Nothing was proposed", args...)
|
||||
}
|
||||
if in.module == "" {
|
||||
return refuse("a proposal names a module")
|
||||
}
|
||||
if !in.clear && in.values == nil {
|
||||
return refuse("a proposal gives the values, or says --clear")
|
||||
}
|
||||
now := pr.now()
|
||||
before, had, err := pr.layer(ctx, in.node, in.module)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p := settingsProposal{Module: in.module, Node: in.node, Values: in.values, Clear: in.clear, Replace: in.replace,
|
||||
Before: before, HadLayer: had, From: pr.caller, At: now, BeforeDigest: layerDigest(before)}
|
||||
if in.clear {
|
||||
// A clear binds the layer it removes: its digest is the fingerprint the operator reads.
|
||||
p.Values, p.Digest = nil, layerDigest(before)
|
||||
} else {
|
||||
// Judged now, as SetSettings judges, so the operator is never asked about a layer the mesh would refuse —
|
||||
// and judged again when the warrant is acted on.
|
||||
if err := pr.judge(ctx, in.node, in.module, in.values); err != nil {
|
||||
return refuse("%v", err)
|
||||
}
|
||||
_, _, removed := settingsChange(before, in.values)
|
||||
if len(removed) > 0 && !in.replace {
|
||||
return refuse("%s on %s: this layer would no longer set %s. A layer is replaced whole; read it with "+
|
||||
"`settings show %s%s` and include what should stay, or add --replace if the removal is meant "+
|
||||
"(novox/hq ADR 0217)", in.module, p.where(), strings.Join(removed, ", "), in.module, nodeFlag(in.node))
|
||||
}
|
||||
p.Digest = layerDigest(in.values)
|
||||
}
|
||||
var machines []string
|
||||
if pr.machines != nil {
|
||||
if machines, err = pr.machines(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
id := newProposalID()
|
||||
q, options := p.ask(id, machines)
|
||||
if err := q.Check(now); err != nil {
|
||||
return refuse("%v", err)
|
||||
}
|
||||
words := []string{q.Headline, q.Explanation, q.OnExpiry}
|
||||
for _, o := range q.Options {
|
||||
words = append(words, o.Label, o.Does)
|
||||
}
|
||||
if refusal, ok := outward.Check(strings.Join(words, "\n"), machines...); !ok {
|
||||
return refuse("the ask's words would carry %s, which may not leave the mesh, and the change could not be "+
|
||||
"shown without it; %s", refusal, atTheTerminalInstead(in))
|
||||
}
|
||||
// Fail closed, before anything is kept: no router, no grant, no channel means no ask.
|
||||
if pr.routerHere != nil {
|
||||
here, err := pr.routerHere(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !here {
|
||||
return refuse("no router takes the controller's asks (no module holding the operator channel is assigned), "+
|
||||
"so the operator cannot be asked; %s", atTheTerminalInstead(in))
|
||||
}
|
||||
}
|
||||
if pr.grantHeld != nil {
|
||||
held, why, err := pr.grantHeld(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !held {
|
||||
return refuse("the operator cannot be asked yet: %s; %s", why, atTheTerminalInstead(in))
|
||||
}
|
||||
}
|
||||
all, err := pr.store.All(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
open := 0
|
||||
for _, r := range all {
|
||||
if r.State != askOpen || !now.Before(r.Ask.Expires) {
|
||||
continue
|
||||
}
|
||||
if r.Proposal != nil && r.Proposal.Module == p.Module && r.Proposal.Node == p.Node && r.Proposal.Digest == p.Digest &&
|
||||
r.Proposal.Clear == p.Clear {
|
||||
return refuse("the same change is already proposed as %s and waits for the operator's answer until %s; "+
|
||||
"`settings proposals` lists it", r.ID, r.Ask.Expires.Local().Format("15:04"))
|
||||
}
|
||||
open++
|
||||
}
|
||||
if open >= askMostOpen {
|
||||
return refuse("%d questions already wait for the operator's answer, and the operator is asked at most %d at "+
|
||||
"once; `settings proposals` lists the proposals among them", open, askMostOpen)
|
||||
}
|
||||
// Kept before it is published, as the asker keeps every ask, so a warrant always finds it.
|
||||
if err := pr.store.Create(ctx, asked{ID: id, Condition: q.About, Ask: q, Actions: p.actions(id), Options: options,
|
||||
State: askOpen, Opened: now, Proposal: &p}); err != nil {
|
||||
return "", fmt.Errorf("the proposal could not be kept in the controller's asks, so the operator was not asked: %w", err)
|
||||
}
|
||||
body, err := json.Marshal(q)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := pr.publish(ctx, asks.AskSubject(askerName), body, "ask."+id); err != nil {
|
||||
_, _ = pr.store.Change(ctx, id, func(x *asked) bool {
|
||||
if x.State != askOpen || x.Acted != "" {
|
||||
return false
|
||||
}
|
||||
x.State, x.Ended, x.Acted = askUnsent, pr.now(), "nothing: it could not be published: "+err.Error()
|
||||
return true
|
||||
})
|
||||
return "", fmt.Errorf("the operator could not be asked (%v); the proposal %s is kept and will never become "+
|
||||
"active. Propose it again, or %s", err, id, atTheTerminalInstead(in))
|
||||
}
|
||||
// The router's word, before answering: it refuses at once an ask no channel can carry (the serving controller
|
||||
// hears that and ends the ask here), and records one it took.
|
||||
taken := "the router has not said yet whether it took the ask; `settings proposals` shows where it stands"
|
||||
for deadline := time.Now().Add(pr.waitFor); time.Now().Before(deadline); {
|
||||
if r, err := pr.store.Get(ctx, id); err == nil && r != nil && r.State != askOpen {
|
||||
why := r.Acted
|
||||
if r.Warrant != nil && r.Warrant.Words != "" {
|
||||
why = r.Warrant.Words
|
||||
}
|
||||
return "", fmt.Errorf("the router did not ask the operator: the ask %s %s (%s). Nothing changes; %s",
|
||||
id, r.State, why, atTheTerminalInstead(in))
|
||||
}
|
||||
if pr.routerRecord != nil {
|
||||
if state, err := pr.routerRecord(ctx, id); err == nil && state != "" {
|
||||
taken = "the router took the ask and shows it on the channels that can carry it"
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(pr.waitEvery)
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "proposal %s: %s\n", id, q.Headline)
|
||||
fmt.Fprintf(&b, " %s\n", taken)
|
||||
fmt.Fprintf(&b, " the operator is asked on a channel that proves who answers, and reads the change with fingerprint %s\n",
|
||||
fingerprint(p.Digest))
|
||||
fmt.Fprintf(&b, " until %s nothing changes: the layer is set only on Approve, and discarded on Decline or at expiry\n",
|
||||
q.Expires.Local().Format("2006-01-02 15:04"))
|
||||
fmt.Fprintf(&b, " `settings proposals %s` shows it whole; once approved, `push %s` sends it", id, pushWord(p.Node))
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func pushWord(node string) string {
|
||||
if node == "" {
|
||||
return "--behind"
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func newProposalID() string {
|
||||
return "s" + strings.TrimPrefix(newAskID(), "c")
|
||||
}
|
||||
|
||||
// How long propose waits for the router's word on a new ask, and how often it looks.
|
||||
const (
|
||||
routerAnswersWithin = 8 * time.Second
|
||||
routerAnswersEvery = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
// proposeCommand is `settings propose`, on this controller's stores and bus.
|
||||
func proposeCommand(ctx context.Context, module, node, valuesArg string, clear, replace bool) error {
|
||||
var values map[string]any
|
||||
if !clear {
|
||||
if valuesArg == "" {
|
||||
return errors.New("settings propose <module> <settings.json | {…}> [--node <node>] [--replace], or settings propose <module> --clear [--node <node>]")
|
||||
}
|
||||
var raw []byte
|
||||
var err error
|
||||
if strings.HasPrefix(strings.TrimSpace(valuesArg), "{") {
|
||||
raw = []byte(valuesArg)
|
||||
} else if raw, err = os.ReadFile(valuesArg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(raw, &values); err != nil {
|
||||
return fmt.Errorf("%s is not a settings file: %w", valuesArg, err)
|
||||
}
|
||||
} else if valuesArg != "" {
|
||||
return errors.New("settings propose: --clear takes no values")
|
||||
}
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer open.Close()
|
||||
js, err := aBus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer js.Close()
|
||||
conn := js.Conn()
|
||||
pr := proposer{
|
||||
layer: open.inventory.Layer,
|
||||
judge: open.inventory.JudgeSettings,
|
||||
machines: func(ctx context.Context) ([]string, error) {
|
||||
nodes, err := open.inventory.Nodes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var names []string
|
||||
for _, n := range nodes {
|
||||
names = append(names, n.Name)
|
||||
}
|
||||
return names, nil
|
||||
},
|
||||
store: busAsked{conn: conn},
|
||||
publish: func(ctx context.Context, subject string, body []byte, id string) error {
|
||||
_, err := js.Context().Publish(subject, body, nats.MsgId(id), nats.Context(ctx))
|
||||
return err
|
||||
},
|
||||
routerHere: routerHereIn(open.inventory),
|
||||
grantHeld: grantHeldIn(open),
|
||||
routerRecord: func(ctx context.Context, id string) (string, error) {
|
||||
bucket, err := asksRecords(ctx, open.inventory)
|
||||
if err != nil || bucket == "" {
|
||||
return "", err
|
||||
}
|
||||
state, _, err := readRouterRecord(ctx, conn, bucket, askerName, id)
|
||||
return state, err
|
||||
},
|
||||
now: time.Now,
|
||||
caller: link.Caller(),
|
||||
waitFor: routerAnswersWithin, waitEvery: routerAnswersEvery,
|
||||
}
|
||||
words, err := pr.propose(ctx, proposeInput{module: module, node: node, values: values, clear: clear, replace: replace})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(words)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- listing ---------------------------------------------------------------------------------------
|
||||
|
||||
// proposalsCommand is `settings proposals [<id>]`: every proposal, newest first, and where each stands; with an
|
||||
// id, the proposal whole — its values, the layer it was shown against, and its digest.
|
||||
func proposalsCommand(ctx context.Context, id string) error {
|
||||
return onTheBus(func(conn *nats.Conn) error {
|
||||
all, err := busAsked{conn: conn}.All(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var proposals []asked
|
||||
for _, r := range all {
|
||||
if r.Proposal != nil {
|
||||
proposals = append(proposals, r)
|
||||
}
|
||||
}
|
||||
sort.Slice(proposals, func(i, j int) bool { return proposals[i].Opened.After(proposals[j].Opened) })
|
||||
if id != "" {
|
||||
for _, r := range proposals {
|
||||
if r.ID == id {
|
||||
fmt.Print(describeProposal(r, time.Now()))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("no proposal %s is kept", id)
|
||||
}
|
||||
if len(proposals) == 0 {
|
||||
fmt.Println("no settings have been proposed")
|
||||
return nil
|
||||
}
|
||||
for _, r := range proposals {
|
||||
fmt.Print(proposalLine(r, time.Now()))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// proposalState is where a proposal stands, in a word or two.
|
||||
func proposalState(r asked, now time.Time) string {
|
||||
switch {
|
||||
case r.State == askOpen && now.Before(r.Ask.Expires):
|
||||
return "waiting for the operator until " + r.Ask.Expires.Local().Format("2006-01-02 15:04")
|
||||
case r.State == askOpen:
|
||||
return "expired unanswered; discarded"
|
||||
case r.Acted != "":
|
||||
return r.State + ": " + r.Acted
|
||||
}
|
||||
return r.State
|
||||
}
|
||||
|
||||
func proposalLine(r asked, now time.Time) string {
|
||||
p := *r.Proposal
|
||||
what := "set"
|
||||
if p.Clear {
|
||||
what = "clear"
|
||||
}
|
||||
keys := strings.Join(layerKeys(p.Values), ", ")
|
||||
if p.Clear {
|
||||
keys = "the whole layer"
|
||||
}
|
||||
return fmt.Sprintf("%s %s %s on %s (%s)\n by %s at %s; fingerprint %s\n %s\n", r.ID, what, p.Module, p.where(),
|
||||
keys, p.From, p.At.Local().Format("2006-01-02 15:04"), fingerprint(p.Digest), proposalState(r, now))
|
||||
}
|
||||
|
||||
func describeProposal(r asked, now time.Time) string {
|
||||
p := *r.Proposal
|
||||
var b strings.Builder
|
||||
b.WriteString(proposalLine(r, now))
|
||||
fmt.Fprintf(&b, " digest %s; the layer it was shown against %s\n", p.Digest, p.BeforeDigest)
|
||||
shownValues, _ := json.MarshalIndent(p.Values, " ", " ")
|
||||
if p.Clear {
|
||||
fmt.Fprintf(&b, " clears the layer\n")
|
||||
} else {
|
||||
fmt.Fprintf(&b, " values:\n %s\n", shownValues)
|
||||
}
|
||||
if p.HadLayer {
|
||||
shownBefore, _ := json.MarshalIndent(p.Before, " ", " ")
|
||||
fmt.Fprintf(&b, " the layer as it stood:\n %s\n", shownBefore)
|
||||
} else {
|
||||
b.WriteString(" there was no layer\n")
|
||||
}
|
||||
if r.Warrant != nil && r.Warrant.By != nil {
|
||||
fmt.Fprintf(&b, " answered: %s, through %s, at %s\n", r.Warrant.Says(), viaWords(*r.Warrant),
|
||||
r.Warrant.At.Local().Format("2006-01-02 15:04"))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ---- acting on the warrant --------------------------------------------------------------------------
|
||||
|
||||
// setOnWarrant performs an approved proposal: the layer set or cleared as `settings set` and `settings clear` at the
|
||||
// terminal do, with who approved it kept beside the layer. It answers the words of what changed.
|
||||
type setOnWarrant func(ctx context.Context, p settingsProposal, askID, setBy string) (string, error)
|
||||
|
||||
// decideProposal is what the asker does with a warrant for a proposal (asker.Decided): nothing on Decline, and on
|
||||
// Approve the act only when it is the one the option bound — the digest of the exact values kept here is the one
|
||||
// in the act, and so the one the ask's option bound and the warrant's ask digest covers.
|
||||
func (a *asker) decideProposal(ctx context.Context, r asked, act conditions.Action, w asks.Warrant) (string, error) {
|
||||
p := *r.Proposal
|
||||
if act.Arguments["answer"] != answerApprove {
|
||||
return "nothing: the operator declined; the layer is unchanged", nil
|
||||
}
|
||||
if a.setLayer == nil {
|
||||
return "", errors.New("this controller cannot set a layer on a warrant")
|
||||
}
|
||||
// The record's own proposal against the act the option bound: the act is composed again from the record —
|
||||
// its module, machine, values (digested again), the layer they replace, whether a removal is meant, a clear,
|
||||
// who proposed it and when — and must digest to what the option bound when the operator was shown it. A
|
||||
// record changed after the ask, in any field, is refused and nothing is set.
|
||||
again := p
|
||||
again.Digest, again.BeforeDigest = layerDigest(p.Values), layerDigest(p.Before)
|
||||
if p.Clear {
|
||||
again.Digest = layerDigest(p.Before)
|
||||
}
|
||||
recomposed := again.actions(r.ID)
|
||||
chosen := -1
|
||||
for i, candidate := range recomposed {
|
||||
if candidate.Arguments["answer"] == act.Arguments["answer"] {
|
||||
chosen = i
|
||||
}
|
||||
}
|
||||
option, offered := r.Ask.Option(w.Option)
|
||||
if chosen < 0 || !offered {
|
||||
return "", fmt.Errorf("the proposal %s offers no answer %q: nothing is set", r.ID, act.Arguments["answer"])
|
||||
}
|
||||
if err := option.Performs(boundAct(recomposed[chosen])); err != nil {
|
||||
return "", fmt.Errorf("the proposal kept for %s is not the one the operator was shown: %v", r.ID, err)
|
||||
}
|
||||
setBy := fmt.Sprintf("approved by %s via %s at %s (ask %s, proposed by %s)", byWords(w), viaWords(w),
|
||||
w.At.Local().Format("2006-01-02 15:04"), r.ID, p.From)
|
||||
return a.setLayer(ctx, p, r.ID, setBy)
|
||||
}
|
||||
|
||||
// setLayerIn is setOnWarrant on this controller's stores: the layer must still be the one the operator was shown
|
||||
// the change against (to-be 46 §10, step 7), then it is set with the same judgement as at the terminal.
|
||||
func setLayerIn(open *stores) setOnWarrant {
|
||||
return func(ctx context.Context, p settingsProposal, askID, setBy string) (string, error) {
|
||||
inv := open.inventory
|
||||
before, had, err := inv.Layer(ctx, p.Node, p.Module)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if layerDigest(before) != p.BeforeDigest || had != p.HadLayer {
|
||||
return "", fmt.Errorf("the layer of %s on %s changed since the operator was shown the change: it is not set; "+
|
||||
"propose it again", p.Module, p.where())
|
||||
}
|
||||
var changed string
|
||||
if p.Clear {
|
||||
if err := inv.ClearSettingsBy(ctx, p.Node, p.Module, setBy); err != nil {
|
||||
return "", err
|
||||
}
|
||||
changed = "the layer is removed"
|
||||
} else {
|
||||
added, altered, removed := settingsChange(before, p.Values)
|
||||
if len(removed) > 0 && !p.Replace {
|
||||
return "", fmt.Errorf("the layer would no longer set %s, and the removal was not meant: nothing is set",
|
||||
strings.Join(removed, ", "))
|
||||
}
|
||||
if err := inv.SetSettingsBy(ctx, p.Node, p.Module, p.Values, setBy); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var parts []string
|
||||
for _, k := range added {
|
||||
parts = append(parts, "+ "+k)
|
||||
}
|
||||
for _, k := range altered {
|
||||
parts = append(parts, "~ "+k)
|
||||
}
|
||||
for _, k := range removed {
|
||||
parts = append(parts, "- "+k)
|
||||
}
|
||||
changed = strings.Join(parts, ", ")
|
||||
if changed == "" {
|
||||
changed = "nothing changed"
|
||||
}
|
||||
}
|
||||
return changed + " (ask " + askID + ")", nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/outward"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0277: a trusted setting is proposed through a verb by anyone the bus admits, asked of the
|
||||
// operator at the level approve with the exact change, and set only on the operator's warrant — once, for the
|
||||
// exact values the option bound; declined, expired or refused, it is discarded; nothing is asked when nothing
|
||||
// can carry the ask.
|
||||
|
||||
// aProposer is the propose path with its reaches faked: a layer as it stands, a judge that records what it
|
||||
// judged, a memory store, and what was published.
|
||||
type proposerRig struct {
|
||||
pr proposer
|
||||
store memAskedStore
|
||||
sent []published
|
||||
judged []map[string]any
|
||||
before map[string]any
|
||||
had bool
|
||||
refusedBy string
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func newProposerRig(t *testing.T) *proposerRig {
|
||||
r := &proposerRig{store: memAskedStore{}, now: time.Date(2026, 10, 10, 14, 5, 0, 0, time.UTC)}
|
||||
r.pr = proposer{
|
||||
layer: func(_ context.Context, node, module string) (map[string]any, bool, error) {
|
||||
return r.before, r.had, nil
|
||||
},
|
||||
judge: func(_ context.Context, node, module string, values map[string]any) error {
|
||||
r.judged = append(r.judged, values)
|
||||
if r.refusedBy != "" {
|
||||
return errors.New(r.refusedBy)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
machines: func(context.Context) ([]string, error) { return []string{"anchor", "laptop", "shanks"}, nil },
|
||||
store: r.store,
|
||||
publish: func(_ context.Context, subject string, body []byte, id string) error {
|
||||
r.sent = append(r.sent, published{subject, id, body})
|
||||
return nil
|
||||
},
|
||||
now: func() time.Time { return r.now },
|
||||
caller: "g14/claude-code, through the mesh-controller seat",
|
||||
waitFor: time.Millisecond,
|
||||
waitEvery: time.Millisecond,
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *proposerRig) askSent(t *testing.T) asks.Ask {
|
||||
t.Helper()
|
||||
if len(r.sent) != 1 || r.sent[0].subject != asks.AskSubject("mesh-controller") {
|
||||
t.Fatalf("published %+v", r.sent)
|
||||
}
|
||||
var q asks.Ask
|
||||
if err := json.Unmarshal(r.sent[0].body, &q); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func (r *proposerRig) theProposal(t *testing.T) asked {
|
||||
t.Helper()
|
||||
for _, a := range r.store {
|
||||
if a.Proposal != nil {
|
||||
return a
|
||||
}
|
||||
}
|
||||
t.Fatal("no proposal is kept")
|
||||
return asked{}
|
||||
}
|
||||
|
||||
var mountsSources = map[string]any{"sources": "recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro", "shares": "library=/mnt/library"}
|
||||
|
||||
// The ask: at the level approve on both answers, each binding the proposal's act, with every key and its exact new
|
||||
// value as far as the content rule lets it leave the mesh, the layer it was shown against, and nothing set.
|
||||
func TestAProposalAsksAtTheLevelApproveWithTheExactChange(t *testing.T) {
|
||||
r := newProposerRig(t)
|
||||
r.before, r.had = map[string]any{"shares": "none", "old": "x"}, true
|
||||
words, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: mountsSources, replace: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := r.askSent(t)
|
||||
if err := q.Check(r.now); err != nil {
|
||||
t.Fatalf("the ask is refused: %v", err)
|
||||
}
|
||||
if q.Headline != "Set mounts on shanks?" || q.About != "settings.mounts.shanks" || q.Who != asks.Operator ||
|
||||
!q.Expires.Equal(r.now.Add(askApproveFor)) {
|
||||
t.Errorf("the ask: %+v", q)
|
||||
}
|
||||
if len(q.Options) != 2 {
|
||||
t.Fatalf("options %+v", q.Options)
|
||||
}
|
||||
for _, o := range q.Options {
|
||||
if o.Level != asks.Approve || !strings.HasPrefix(o.Binds, "sha256:") {
|
||||
t.Errorf("the option %s is %s and binds %q", o.ID, o.Level, o.Binds)
|
||||
}
|
||||
}
|
||||
if q.Options[0].Binds == q.Options[1].Binds {
|
||||
t.Error("Approve and Decline bind the same act")
|
||||
}
|
||||
for _, line := range []string{
|
||||
"Set the settings of mounts on shanks to these values?",
|
||||
"Proposed by g14/claude-code, through the mesh-controller seat, at " + r.now.Local().Format("15:04 on 2 Jan") + ".",
|
||||
"+ sources: recalbox=‹address›@‹path›:ro",
|
||||
"~ shares: library=‹path› (was: none)",
|
||||
"- old (was: x)",
|
||||
"Fingerprint " + fingerprint(layerDigest(mountsSources)) + ".",
|
||||
"read it whole, with this fingerprint",
|
||||
} {
|
||||
if !strings.Contains(q.Explanation, line) {
|
||||
t.Errorf("the explanation lacks %q:\n%s", line, q.Explanation)
|
||||
}
|
||||
}
|
||||
for _, leak := range []string{"nas.lan", "/mnt/recalbox", "/mnt/library", "smb://"} {
|
||||
if strings.Contains(q.Explanation, leak) {
|
||||
t.Errorf("the explanation carries %q, which may not leave the mesh", leak)
|
||||
}
|
||||
}
|
||||
all := []string{q.Headline, q.Explanation, q.OnExpiry}
|
||||
for _, o := range q.Options {
|
||||
all = append(all, o.Label, o.Does)
|
||||
}
|
||||
if refusal, ok := outward.Check(strings.Join(all, "\n"), "anchor", "laptop", "shanks"); !ok {
|
||||
t.Errorf("the router would refuse the ask: %s", refusal)
|
||||
}
|
||||
// Kept as the controller's own ask, about no condition, with the proposal whole and its digests.
|
||||
kept := r.theProposal(t)
|
||||
p := kept.Proposal
|
||||
if kept.State != askOpen || kept.Ask.Digest() != q.Digest() || p.Module != "mounts" || p.Node != "shanks" ||
|
||||
p.Digest != layerDigest(mountsSources) || p.BeforeDigest != layerDigest(r.before) || !p.Replace || !p.HadLayer ||
|
||||
p.From != r.pr.caller || kept.ofACondition() {
|
||||
t.Errorf("kept %+v / %+v", kept, p)
|
||||
}
|
||||
// Each option binds exactly the act the controller will perform (boundAct over the kept action).
|
||||
for i, act := range kept.Actions {
|
||||
binds, _ := asks.ActDigest(boundAct(act))
|
||||
if q.Options[i].Binds != binds || act.Arguments["values"] != p.Digest || act.Arguments["before"] != p.BeforeDigest ||
|
||||
act.Verb != proposalVerb || act.Level != conditions.LevelApprove {
|
||||
t.Errorf("the option %s does not bind the kept act: %+v", q.Options[i].ID, act)
|
||||
}
|
||||
}
|
||||
if len(r.judged) != 1 || r.judged[0]["sources"] != mountsSources["sources"] {
|
||||
t.Errorf("judged %v", r.judged)
|
||||
}
|
||||
if !strings.Contains(words, "nothing changes") || !strings.Contains(words, kept.ID) {
|
||||
t.Errorf("the caller is told: %s", words)
|
||||
}
|
||||
}
|
||||
|
||||
// A layer for the whole mesh is proposed too.
|
||||
func TestAMeshWideLayerIsProposed(t *testing.T) {
|
||||
r := newProposerRig(t)
|
||||
if _, err := r.pr.propose(context.Background(), proposeInput{module: "notes", values: map[string]any{"x": "1"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := r.askSent(t)
|
||||
if q.Headline != "Set notes on the whole mesh?" || q.About != "settings.notes.mesh" ||
|
||||
!strings.Contains(q.Explanation, "Set the settings of notes on the whole mesh to these values?") {
|
||||
t.Errorf("%+v", q)
|
||||
}
|
||||
}
|
||||
|
||||
// A proposal expired unanswered is kept so by the reconciling, and a warrant for it afterwards sets nothing.
|
||||
func TestAnExpiredProposalIsKeptExpired(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
calls := withSetLayer(r)
|
||||
a := aProposalAsked(t, r, "s7", aProposal(r.now))
|
||||
r.now = r.now.Add(askApproveFor + time.Minute)
|
||||
if err := r.a.reconcile(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := r.store["s7"]; got.State != string(asks.OutcomeExpired) || !strings.HasPrefix(got.Acted, "nothing") {
|
||||
t.Errorf("kept as %+v", got)
|
||||
}
|
||||
answerWith(t, r, warrantOn(a, "approve", r.now.Add(-2*time.Minute)))
|
||||
if len(*calls) != 0 {
|
||||
t.Errorf("set after expiry: %+v", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
// A clear is proposed too, and shows the layer it removes.
|
||||
func TestAClearIsProposedAndShowsWhatItRemoves(t *testing.T) {
|
||||
r := newProposerRig(t)
|
||||
r.before, r.had = map[string]any{"places": map[string]any{"data": map[string]any{"path": "/srv/notes", "owner": "1001:1001"}}}, true
|
||||
if _, err := r.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", clear: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := r.askSent(t)
|
||||
if q.Headline != "Clear notes on laptop?" || !strings.Contains(q.Explanation, "- places.data.owner: 1001:1001") ||
|
||||
!strings.Contains(q.Explanation, "- places.data.path: ‹path›") {
|
||||
t.Errorf("%+v", q)
|
||||
}
|
||||
if p := r.theProposal(t).Proposal; !p.Clear || p.Digest != layerDigest(r.before) || len(r.judged) != 0 {
|
||||
t.Errorf("a clear: %+v, judged %v", p, r.judged)
|
||||
}
|
||||
}
|
||||
|
||||
// What the phone is shown keeps a value's shape and passes the content rule: an address, a path, a secret's
|
||||
// shape each replaced in place; a value every word of which may leave the mesh shown whole.
|
||||
func TestAValueIsShownInItsShapeAndPassesTheContentRule(t *testing.T) {
|
||||
for in, want := range map[any]string{
|
||||
"recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro": "recalbox=‹address›@‹path›:ro",
|
||||
"library=/mnt/library": "library=‹path›",
|
||||
"none": "none",
|
||||
"Inter 13": "Inter 13",
|
||||
"10.77.0.9:53": "‹address›",
|
||||
"jochen@example.com": "‹address›",
|
||||
"nas.lan": "‹address›",
|
||||
"anchor": "anchor",
|
||||
"-----BEGIN CERTIFICATE-----": "‹withheld›",
|
||||
42: "42",
|
||||
true: "true",
|
||||
} {
|
||||
got, whole := sayableValue(in, []string{"anchor"})
|
||||
if got != want {
|
||||
t.Errorf("%v shown as %q, want %q", in, got, want)
|
||||
}
|
||||
if whole != (got == want && !strings.Contains(want, "‹")) {
|
||||
t.Errorf("%v: whole %v", in, whole)
|
||||
}
|
||||
if _, ok := outward.Check(got, "anchor"); !ok {
|
||||
t.Errorf("%v shown as %q, which the router refuses", in, got)
|
||||
}
|
||||
}
|
||||
for _, v := range []any{[]any{"a", "/etc/x"}, map[string]any{"path": "/srv/x", "owner": "1001:1001"}} {
|
||||
got, _ := sayableValue(v, nil)
|
||||
if _, ok := outward.Check(got); !ok || strings.Contains(got, "/srv") || strings.Contains(got, "/etc") {
|
||||
t.Errorf("%v shown as %q", v, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A proposal that the mesh would refuse to set, or that would silently remove a key, is refused before anybody is
|
||||
// asked (ADR 0217 holds for a proposal as for a set).
|
||||
func TestAProposalTheMeshWouldRefuseIsNotAsked(t *testing.T) {
|
||||
r := newProposerRig(t)
|
||||
r.before, r.had = map[string]any{"a": 1, "b": 2}, true
|
||||
_, err := r.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: map[string]any{"a": 1}})
|
||||
if err == nil || !strings.Contains(err.Error(), "would no longer set b") || !strings.Contains(err.Error(), "ADR 0217") {
|
||||
t.Errorf("a silent removal: %v", err)
|
||||
}
|
||||
r.refusedBy = "refused: notes on laptop cannot compose"
|
||||
_, err = r.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: map[string]any{"a": 1, "b": 3}})
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot compose") {
|
||||
t.Errorf("a layer the mesh refuses: %v", err)
|
||||
}
|
||||
if len(r.sent) != 0 || len(r.store) != 0 {
|
||||
t.Errorf("asked anyway: %+v %+v", r.sent, r.store)
|
||||
}
|
||||
}
|
||||
|
||||
// Fail closed: no router, no grant, a publish that fails, or the router's refusal each leave nothing waiting for
|
||||
// an answer that cannot come, and name the terminal's line.
|
||||
func TestAProposalFailsClosedWhenNothingCanCarryIt(t *testing.T) {
|
||||
r := newProposerRig(t)
|
||||
in := proposeInput{module: "mounts", node: "shanks", values: mountsSources}
|
||||
r.pr.routerHere = func(context.Context) (bool, error) { return false, nil }
|
||||
if _, err := r.pr.propose(context.Background(), in); err == nil || !strings.Contains(err.Error(), "no router") ||
|
||||
!strings.Contains(err.Error(), "mesh-cli settings set mounts") {
|
||||
t.Errorf("without a router: %v", err)
|
||||
}
|
||||
r.pr.routerHere = nil
|
||||
r.pr.grantHeld = func(context.Context) (bool, string, error) { return false, "the bus's user list is behind", nil }
|
||||
if _, err := r.pr.propose(context.Background(), in); err == nil || !strings.Contains(err.Error(), "user list is behind") {
|
||||
t.Errorf("without the grant: %v", err)
|
||||
}
|
||||
if len(r.sent) != 0 || len(r.store) != 0 {
|
||||
t.Fatalf("asked anyway: %+v %+v", r.sent, r.store)
|
||||
}
|
||||
r.pr.grantHeld = nil
|
||||
r.pr.publish = func(context.Context, string, []byte, string) error { return errors.New("the bus is away") }
|
||||
if _, err := r.pr.propose(context.Background(), in); err == nil || !strings.Contains(err.Error(), "the bus is away") ||
|
||||
!strings.Contains(err.Error(), "never become active") {
|
||||
t.Errorf("a publish that fails: %v", err)
|
||||
}
|
||||
if kept := r.theProposal(t); kept.State != askUnsent {
|
||||
t.Errorf("an unpublished proposal is %s", kept.State)
|
||||
}
|
||||
// The router's refusal, heard by the serving controller and kept here, is said to the caller.
|
||||
r = newProposerRig(t)
|
||||
r.pr.publish = func(_ context.Context, _ string, _ []byte, id string) error {
|
||||
ask := strings.TrimPrefix(id, "ask.")
|
||||
r.store[ask] = func() asked {
|
||||
a := r.store[ask]
|
||||
a.State, a.Acted = string(asks.OutcomeRefused), "nothing: the ask refused: no channel can carry any of its answers now"
|
||||
return a
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
if _, err := r.pr.propose(context.Background(), in); err == nil || !strings.Contains(err.Error(), "did not ask the operator") ||
|
||||
!strings.Contains(err.Error(), "no channel can carry") {
|
||||
t.Errorf("the router's refusal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The bounds: at most three asks open for the controller, and the same change not proposed twice.
|
||||
func TestAProposalIsBounded(t *testing.T) {
|
||||
r := newProposerRig(t)
|
||||
in := proposeInput{module: "mounts", node: "shanks", values: mountsSources}
|
||||
if _, err := r.pr.propose(context.Background(), in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.pr.propose(context.Background(), in); err == nil || !strings.Contains(err.Error(), "already proposed") {
|
||||
t.Errorf("the same change twice: %v", err)
|
||||
}
|
||||
for _, node := range []string{"laptop", "anchor"} {
|
||||
if _, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: node, values: mountsSources}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_, err := r.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: map[string]any{"x": 1}})
|
||||
if err == nil || !strings.Contains(err.Error(), "3 questions already wait") {
|
||||
t.Errorf("a fourth: %v", err)
|
||||
}
|
||||
if len(r.sent) != 3 {
|
||||
t.Errorf("published %d", len(r.sent))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the warrant ------------------------------------------------------------------------------------
|
||||
|
||||
// aProposalAsked keeps a proposal's ask in the asker rig's store, as propose keeps it.
|
||||
func aProposalAsked(t *testing.T, r *askerRig, id string, p settingsProposal) asked {
|
||||
t.Helper()
|
||||
q, options := p.ask(id, nil)
|
||||
if err := q.Check(r.now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := asked{ID: id, Condition: q.About, Ask: q, Actions: p.actions(id), Options: options, State: askOpen, Opened: r.now, Proposal: &p}
|
||||
r.store[id] = a
|
||||
return a
|
||||
}
|
||||
|
||||
func aProposal(now time.Time) settingsProposal {
|
||||
before := map[string]any{"shares": "none"}
|
||||
return settingsProposal{Module: "mounts", Node: "shanks", Values: mountsSources, Replace: true, Before: before, HadLayer: true,
|
||||
From: "g14/claude-code", At: now, Digest: layerDigest(mountsSources), BeforeDigest: layerDigest(before)}
|
||||
}
|
||||
|
||||
// warrantOn is the router's warrant for a kept ask, choosing an option by id.
|
||||
func warrantOn(a asked, option string, now time.Time) asks.Warrant {
|
||||
o, _ := a.Ask.Option(option)
|
||||
return asks.Warrant{Ask: a.ID, Asker: "mesh-controller", About: a.Ask.About, Outcome: asks.OutcomeChosen, Option: o.ID,
|
||||
Label: o.Label, Level: o.Level, Channel: "telegram", Proofs: []string{"P1"}, At: now, AskDigest: a.Ask.Digest(),
|
||||
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}
|
||||
}
|
||||
|
||||
type setCall struct {
|
||||
p settingsProposal
|
||||
ask string
|
||||
setBy string
|
||||
}
|
||||
|
||||
func withSetLayer(r *askerRig) *[]setCall {
|
||||
var calls []setCall
|
||||
r.a.setLayer = func(_ context.Context, p settingsProposal, askID, setBy string) (string, error) {
|
||||
calls = append(calls, setCall{p, askID, setBy})
|
||||
return "+ sources, ~ shares", nil
|
||||
}
|
||||
return &calls
|
||||
}
|
||||
|
||||
// On Approve the layer is set once, with who approved it and through which channel kept beside it, and the warrant
|
||||
// is recorded as the operator's decision; heard again, nothing more happens.
|
||||
func TestTheLayerIsSetOnceOnTheOperatorsApproval(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
calls := withSetLayer(r)
|
||||
a := aProposalAsked(t, r, "s1", aProposal(r.now))
|
||||
if err := r.a.reconcile(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := r.store["s1"]; got.State != askOpen {
|
||||
t.Fatalf("the reconciling of conditions ended the proposal: %+v", got)
|
||||
}
|
||||
w := warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
answerWith(t, r, w)
|
||||
answerWith(t, r, w) // heard again, or replayed
|
||||
if len(*calls) != 1 {
|
||||
t.Fatalf("set %d time(s): %+v", len(*calls), *calls)
|
||||
}
|
||||
c := (*calls)[0]
|
||||
if c.ask != "s1" || c.p.Digest != layerDigest(mountsSources) ||
|
||||
!strings.HasPrefix(c.setBy, "approved by the operator, as telegram identity 42 via telegram (telegram), user id verified at ") ||
|
||||
!strings.Contains(c.setBy, "(ask s1, proposed by g14/claude-code)") {
|
||||
t.Errorf("set by %q for %+v", c.setBy, c.p)
|
||||
}
|
||||
if len(r.called)+len(r.silenced) != 0 {
|
||||
t.Errorf("a verb was called: %v %v", r.called, r.silenced)
|
||||
}
|
||||
if len(r.acts) != 1 {
|
||||
t.Fatalf("hand-acts %+v", r.acts)
|
||||
}
|
||||
act := r.acts[0]
|
||||
if act.Verb != handActWarrant || act.By != "the operator, as telegram identity 42" || act.Ask != "s1" ||
|
||||
!slices.Contains(act.Args, "answer=approve") || !slices.Contains(act.Args, "values="+layerDigest(mountsSources)) ||
|
||||
!strings.HasPrefix(act.Outcome, "done") || !personsDecision(act) {
|
||||
t.Errorf("the record: %+v", act)
|
||||
}
|
||||
if got := r.store["s1"]; got.State != string(asks.OutcomeChosen) || !strings.HasPrefix(got.Acted, "done") {
|
||||
t.Errorf("kept as %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Decline, expiry, the router's refusal, a cancel: nothing is set, and the proposal is recorded as ended.
|
||||
func TestDeclineExpiryAndRefusalDiscardAProposal(t *testing.T) {
|
||||
for name, outcome := range map[string]asks.Outcome{"declined": asks.OutcomeChosen, "expired": asks.OutcomeExpired,
|
||||
"refused": asks.OutcomeRefused, "cancelled": asks.OutcomeCancelled, "replaced": asks.OutcomeReplaced} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
calls := withSetLayer(r)
|
||||
a := aProposalAsked(t, r, "s2", aProposal(r.now))
|
||||
w := warrantOn(a, "decline", r.now.Add(time.Hour))
|
||||
if outcome != asks.OutcomeChosen {
|
||||
w = asks.Warrant{Ask: a.ID, Asker: "mesh-controller", Outcome: outcome, Words: "its time passed", At: r.now.Add(time.Hour)}
|
||||
}
|
||||
answerWith(t, r, w)
|
||||
if len(*calls) != 0 {
|
||||
t.Fatalf("set: %+v", *calls)
|
||||
}
|
||||
got := r.store["s2"]
|
||||
if got.State != string(outcome) || got.Acted == "" || !strings.HasPrefix(got.Acted, "nothing") {
|
||||
t.Errorf("kept as %+v", got)
|
||||
}
|
||||
if outcome == asks.OutcomeChosen && (len(r.acts) != 1 || !strings.Contains(r.acts[0].Outcome, "declined")) {
|
||||
t.Errorf("a decline is a decision too: %+v", r.acts)
|
||||
}
|
||||
// An approval after it ended is refused.
|
||||
answerWith(t, r, warrantOn(a, "approve", r.now.Add(2*time.Hour)))
|
||||
if len(*calls) != 0 {
|
||||
t.Fatalf("set after the end: %+v", *calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The warrant binds the exact values: a record whose values changed after the ask, an action changed, a warrant for
|
||||
// another ask's digest, at another level, or after expiry each set nothing.
|
||||
func TestAWarrantSetsOnlyTheExactValuesTheOperatorWasShown(t *testing.T) {
|
||||
cases := map[string]func(r *askerRig, a asked) asks.Warrant{
|
||||
"the values changed in the record": func(r *askerRig, a asked) asks.Warrant {
|
||||
a.Proposal.Values = map[string]any{"sources": "recalbox=smb://evil/recalbox@/mnt/recalbox", "shares": "library=/mnt/library"}
|
||||
r.store[a.ID] = a
|
||||
return warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
},
|
||||
"the layer it was shown against changed in the record": func(r *askerRig, a asked) asks.Warrant {
|
||||
a.Proposal.Before = map[string]any{"shares": "other"}
|
||||
r.store[a.ID] = a
|
||||
return warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
},
|
||||
"the module changed in the record": func(r *askerRig, a asked) asks.Warrant {
|
||||
a.Proposal.Module = "sshd"
|
||||
r.store[a.ID] = a
|
||||
return warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
},
|
||||
"the machine changed in the record": func(r *askerRig, a asked) asks.Warrant {
|
||||
a.Proposal.Node = "anchor"
|
||||
r.store[a.ID] = a
|
||||
return warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
},
|
||||
"the removal became meant in the record": func(r *askerRig, a asked) asks.Warrant {
|
||||
a.Proposal.Replace = !a.Proposal.Replace
|
||||
r.store[a.ID] = a
|
||||
return warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
},
|
||||
"the set became a clear in the record": func(r *askerRig, a asked) asks.Warrant {
|
||||
a.Proposal.Clear = true
|
||||
r.store[a.ID] = a
|
||||
return warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
},
|
||||
"the action's digest was changed": func(r *askerRig, a asked) asks.Warrant {
|
||||
a.Actions[0].Arguments["values"] = layerDigest(map[string]any{"sources": "x"})
|
||||
r.store[a.ID] = a
|
||||
return warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
},
|
||||
"another ask's digest": func(r *askerRig, a asked) asks.Warrant {
|
||||
w := warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
w.AskDigest = "sha256:0000"
|
||||
return w
|
||||
},
|
||||
"at the level acknowledge": func(r *askerRig, a asked) asks.Warrant {
|
||||
w := warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
w.Level = asks.Acknowledge
|
||||
return w
|
||||
},
|
||||
"after expiry": func(r *askerRig, a asked) asks.Warrant {
|
||||
return warrantOn(a, "approve", r.now.Add(askApproveFor+time.Minute))
|
||||
},
|
||||
"nobody chose": func(r *askerRig, a asked) asks.Warrant {
|
||||
w := warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
w.By = nil
|
||||
return w
|
||||
},
|
||||
"another asker's": func(r *askerRig, a asked) asks.Warrant {
|
||||
w := warrantOn(a, "approve", r.now.Add(time.Hour))
|
||||
w.Asker = "claude-code"
|
||||
return w
|
||||
},
|
||||
}
|
||||
for name, tamper := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
calls := withSetLayer(r)
|
||||
a := aProposalAsked(t, r, "s3", aProposal(r.now))
|
||||
answerWith(t, r, tamper(r, a))
|
||||
if len(*calls) != 0 {
|
||||
t.Fatalf("set: %+v", *calls)
|
||||
}
|
||||
if got := r.store["s3"]; strings.HasPrefix(got.Acted, "done") {
|
||||
t.Errorf("kept as done: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A proposal counts toward what the controller holds open, so a fourth ask is not attempted while three are.
|
||||
func TestOpenProposalsCountTowardTheAsksHeldOpen(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
for _, id := range []string{"s4", "s5", "s6"} {
|
||||
aProposalAsked(t, r, id, aProposal(r.now))
|
||||
}
|
||||
r.open = []conditions.Condition{heldCondition()}
|
||||
if err := r.a.reconcile(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(r.sent) != 0 {
|
||||
t.Errorf("asked beyond the bound: %d", len(r.sent))
|
||||
}
|
||||
for _, id := range []string{"s4", "s5", "s6"} {
|
||||
if r.store[id].State != askOpen {
|
||||
t.Errorf("%s was ended by the reconciling of conditions: %+v", id, r.store[id])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the verb ---------------------------------------------------------------------------------------
|
||||
|
||||
func TestTheSettingsVerbComposesProposeAndProposals(t *testing.T) {
|
||||
for name, c := range map[string]struct {
|
||||
args map[string]any
|
||||
want string
|
||||
}{
|
||||
"propose on a machine": {map[string]any{"module": "mounts", "node": "shanks", "values": `{"sources":"x"}`, "propose": "true"},
|
||||
"settings propose mounts {\"sources\":\"x\"} --node shanks"},
|
||||
"propose with replace": {map[string]any{"module": "mounts", "values": `{"a":1}`, "propose": "true", "replace": "true"},
|
||||
"settings propose mounts {\"a\":1} --replace"},
|
||||
"propose a clear": {map[string]any{"module": "mounts", "node": "shanks", "propose": "true", "clear": "true"},
|
||||
"settings propose mounts --clear --node shanks"},
|
||||
"the proposals": {map[string]any{"proposals": "true"}, "settings proposals"},
|
||||
"one proposal": {map[string]any{"proposal": "s1"}, "settings proposals s1"},
|
||||
} {
|
||||
argv, err := argvFor("settings", c.args)
|
||||
if err != nil || strings.Join(argv, " ") != c.want {
|
||||
t.Errorf("%s: %v %v", name, argv, err)
|
||||
}
|
||||
}
|
||||
for name, args := range map[string]map[string]any{
|
||||
"propose without a module": {"values": `{"a":1}`, "propose": "true"},
|
||||
"propose without values": {"module": "mounts", "propose": "true"},
|
||||
"propose values and clear": {"module": "mounts", "values": `{"a":1}`, "clear": "true", "propose": "true"},
|
||||
"proposals with a module": {"proposals": "true", "module": "mounts"},
|
||||
"proposals and a proposal": {"proposals": "true", "proposal": "s1"},
|
||||
"a proposal with values": {"proposal": "s1", "values": `{"a":1}`},
|
||||
"proposals with a listing": {"proposals": "true", "list": "preferences"},
|
||||
} {
|
||||
if _, err := argvFor("settings", args); err == nil {
|
||||
t.Errorf("%s was composed", name)
|
||||
}
|
||||
}
|
||||
// The generic command verb proposes nothing (it is the settings verb's), and lists proposals (a read).
|
||||
if err := refusedAsTheGenericCommand([]string{"settings", "propose", "mounts", "{}", "--node", "shanks"}); err == nil {
|
||||
t.Error("the generic command proposed")
|
||||
}
|
||||
if err := refusedAsTheGenericCommand([]string{"settings", "proposals"}); err != nil {
|
||||
t.Errorf("the generic command may not list proposals: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- on the real stores -----------------------------------------------------------------------------
|
||||
|
||||
// setLayerIn sets the layer as the terminal does — judged, the removal meant, the history kept — with who approved it
|
||||
// beside it; and refuses once the layer is no longer the one the operator was shown the change against.
|
||||
func TestSetOnAWarrantJudgesTheLayerAndKeepsWhoApprovedIt(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
|
||||
// y is a preference with a default, so a layer may leave it out; x is the operator's own (ADR 0262).
|
||||
Settings: map[string]catalogue.SettingDeclaration{"y": {Kind: "preference", Default: "10", Why: "a size"}},
|
||||
Resources: []map[string]any{{"id": "data", "type": "directory", "mode": "0755"},
|
||||
// A trusted file (unmarked), so its keys are the terminal's — and now the warrant's (novox/hq ADR 0277).
|
||||
{"id": "rc", "type": "file", "path": "/etc/notes.conf", "mode": "0644", "content": "x = ${setting:x}\ny = ${setting:y}\n"}}})
|
||||
if _, err := assign(ctx, open, "laptop", "notes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set := setLayerIn(open)
|
||||
now := time.Now()
|
||||
first := map[string]any{"x": "1", "y": "2"}
|
||||
p := settingsProposal{Module: "notes", Node: "laptop", Values: first, From: "g14/claude-code", At: now,
|
||||
Digest: layerDigest(first), BeforeDigest: layerDigest(nil)}
|
||||
changed, err := set(ctx, p, "s9", "approved by the operator, as telegram identity 42 via telegram at 14:05 (ask s9)")
|
||||
if err != nil || !strings.Contains(changed, "+ x") {
|
||||
t.Fatalf("%q %v", changed, err)
|
||||
}
|
||||
layer, has, err := open.inventory.Layer(ctx, "laptop", "notes")
|
||||
if err != nil || !has || layer["x"] != "1" {
|
||||
t.Fatalf("the layer: %v %v %v", layer, has, err)
|
||||
}
|
||||
setBy, _, has, err := open.inventory.LayerOrigin(ctx, "laptop", "notes")
|
||||
if err != nil || !has || !strings.HasPrefix(setBy, "approved by the operator, as telegram identity 42 via telegram") {
|
||||
t.Fatalf("who set it: %q %v", setBy, err)
|
||||
}
|
||||
// Shown by `settings show`, at the terminal and through the verb.
|
||||
out := captureStdout(t, func() {
|
||||
if err := atTheTerminal(t, "settings", "show", "notes", "--node", "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "approved by the operator, as telegram identity 42 via telegram") {
|
||||
t.Errorf("settings show says:\n%s", out)
|
||||
}
|
||||
// The same proposal again: the layer is no longer the one the operator was shown it against.
|
||||
if _, err := set(ctx, p, "s9", "approved …"); err == nil || !strings.Contains(err.Error(), "changed since the operator was shown") {
|
||||
t.Errorf("a stale proposal: %v", err)
|
||||
}
|
||||
// A removal not meant is refused; one meant is taken, and the history says who had set the layer.
|
||||
second := map[string]any{"x": "1"}
|
||||
q := settingsProposal{Module: "notes", Node: "laptop", Values: second, Before: first, HadLayer: true,
|
||||
From: "g14/claude-code", At: now, Digest: layerDigest(second), BeforeDigest: layerDigest(first)}
|
||||
if _, err := set(ctx, q, "s10", "approved …"); err == nil || !strings.Contains(err.Error(), "removal was not meant") {
|
||||
t.Errorf("a silent removal: %v", err)
|
||||
}
|
||||
// A layer the mesh refuses is refused here too, with the same words as at the terminal.
|
||||
bad := q
|
||||
bad.Values, bad.Digest = map[string]any{"x": "a\nb", "y": "2"}, layerDigest(map[string]any{"x": "a\nb", "y": "2"})
|
||||
if _, err := set(ctx, bad, "s11", "approved …"); err == nil || !strings.Contains(err.Error(), "line break") {
|
||||
t.Errorf("a line break on a warrant: %v", err)
|
||||
}
|
||||
if layer, _, _ := open.inventory.Layer(ctx, "laptop", "notes"); layer["y"] != "2" {
|
||||
t.Fatalf("a refused act changed the layer: %v", layer)
|
||||
}
|
||||
q.Replace = true
|
||||
if _, err := set(ctx, q, "s10", "approved later"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
past, err := open.inventory.SettingsHistory(ctx, "laptop", "notes")
|
||||
if err != nil || len(past) != 1 || !strings.HasPrefix(past[0].SetBy, "approved by the operator") {
|
||||
t.Errorf("the history: %+v %v", past, err)
|
||||
}
|
||||
// And a clear, keeping who cleared it with the copy.
|
||||
c := settingsProposal{Module: "notes", Node: "laptop", Clear: true, Before: second, HadLayer: true, From: "x", At: now,
|
||||
Digest: layerDigest(second), BeforeDigest: layerDigest(second)}
|
||||
if _, err := set(ctx, c, "s12", "approved by the operator at 15:00"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, has, _ := open.inventory.Layer(ctx, "laptop", "notes"); has {
|
||||
t.Error("the layer was not cleared")
|
||||
}
|
||||
past, _ = open.inventory.SettingsHistory(ctx, "laptop", "notes")
|
||||
if len(past) != 2 || !strings.Contains(past[0].SetBy, "cleared approved by the operator at 15:00") {
|
||||
t.Errorf("the history after a clear: %+v", past)
|
||||
}
|
||||
}
|
||||
|
||||
// A layer set at the terminal says so, and one set through a verb names the verb (novox/hq ADR 0277).
|
||||
func TestALayerSaysWhoSetIt(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
|
||||
Resources: []map[string]any{{"id": "rc", "type": "file", "path": "/etc/notes.conf", "mode": "0644", "trusted": false,
|
||||
"content": "x = ${setting:x}\n"}}})
|
||||
if _, err := assign(ctx, open, "laptop", "notes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := atTheTerminal(t, "settings", "set", "notes", `{"x":"1"}`, "--node", "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setBy, _, _, _ := open.inventory.LayerOrigin(ctx, "laptop", "notes")
|
||||
if !strings.HasPrefix(setBy, "set at the controller's terminal by ") {
|
||||
t.Errorf("at the terminal: %q", setBy)
|
||||
}
|
||||
if err := throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop", "values": `{"x":"2"}`}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setBy, _, _, _ = open.inventory.LayerOrigin(ctx, "laptop", "notes")
|
||||
if !strings.HasPrefix(setBy, "set by ") || !strings.Contains(setBy, "through settings") {
|
||||
t.Errorf("through the verb: %q", setBy)
|
||||
}
|
||||
}
|
||||
|
||||
// A trusted setting is still refused through the settings verb (novox/hq issue 339), and the refusal now names the
|
||||
// proposal as the way.
|
||||
func TestATrustedSettingThroughAVerbNamesTheProposal(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
|
||||
Resources: []map[string]any{{"id": "data", "type": "directory", "mode": "0755"}}})
|
||||
if _, err := assign(ctx, open, "laptop", "notes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop",
|
||||
"values": `{"places":{"data":{"path":"/srv/notes","owner":"1001:1001"}}}`})
|
||||
if err == nil || !strings.Contains(err.Error(), "issue 339") || !strings.Contains(err.Error(), "propose") {
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
if _, has, _ := open.inventory.Layer(ctx, "laptop", "notes"); has {
|
||||
t.Error("a layer was kept")
|
||||
}
|
||||
}
|
||||
|
||||
// captureStdout runs f and answers what it printed to standard output.
|
||||
func captureStdout(t *testing.T, f func()) string {
|
||||
t.Helper()
|
||||
before := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = w
|
||||
done := make(chan string)
|
||||
go func() {
|
||||
var b strings.Builder
|
||||
_, _ = io.Copy(&b, r)
|
||||
done <- b.String()
|
||||
}()
|
||||
f()
|
||||
os.Stdout = before
|
||||
_ = w.Close()
|
||||
return <-done
|
||||
}
|
||||
@@ -76,6 +76,21 @@ func serve(ctx context.Context) (err error) {
|
||||
}
|
||||
defer open.Close()
|
||||
inv := open.inventory
|
||||
// **What this build reads of the store's schema, on record** (novox/hq issue 352): the highest migration
|
||||
// it carries, by its version, so a gate that would put this build back later knows it reads the store
|
||||
// as it is then. A build that does not know its version records nothing, and is never put back.
|
||||
if build := runningBuild(); build != "" {
|
||||
if reach, err := schemaReach(); err != nil {
|
||||
fmt.Printf("what this build reads of the store's schema is not recorded: %v\n", err)
|
||||
} else if err := inv.RecordSchemaReach(ctx, build, reach); err != nil {
|
||||
fmt.Printf("what this build (%s) reads of the store's schema is not recorded: %v\n", build, err)
|
||||
} else {
|
||||
fmt.Printf("this build (%s) reads the store's schema up to migration %04d; recorded\n", build, reach)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("this process was not told which build it is (%s), so what it reads of the store's schema is not "+
|
||||
"recorded, and a gate will never put it back\n", RunningBuildVar)
|
||||
}
|
||||
|
||||
ident, err := openIdentity(ctx)
|
||||
if err != nil {
|
||||
@@ -167,6 +182,9 @@ func serve(ctx context.Context) (err error) {
|
||||
// Open plans move on a timer as well as on outcomes (novox/hq ADR 0162): a tier waiting for
|
||||
// machines to report moves when they have, and a plan left by a replaced controller resumes.
|
||||
go planTicker(ctx, open)
|
||||
// And the merge window (novox/hq ADR 0276): a batch is cut into its walk when the window closes, which no
|
||||
// merge or outcome says.
|
||||
go batchCutter(ctx, open)
|
||||
// And the pending assignments settled on a tick of their own (novox/hq ADR 0261): made once their module
|
||||
// is registered, ended with why when its build will not register it, raised and cleared as conditions.
|
||||
// Never by a read.
|
||||
@@ -1250,11 +1268,14 @@ func issueMemberships(ctx context.Context, open *stores, server *link.Server, se
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
where := broker.PlacementsOf(records, records.Interchangeable)
|
||||
bus, ok := server.Bus().(link.OverNATS)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// Which machines are root-free now (novox/hq ADR 0259 §8): a channel's verified sender is composed for the
|
||||
// router only from one, beside a router on one. Judged once per push, by the root-free verb's judgement.
|
||||
records.RootFree = rootFreeNow(ctx, newRootReader(ctx, open.inventory, bus.Conn), records.Nodes, time.Now())
|
||||
where := broker.PlacementsOf(records, records.Interchangeable)
|
||||
// **Every declared state's bucket, before the memberships that name it** (novox/hq ADR 0201). The
|
||||
// raise at start asserts them too, but a module registered and assigned since would otherwise have
|
||||
// its bucket only after the control plane next restarts — found the first time a module declared
|
||||
@@ -1595,3 +1616,18 @@ func reportUnheldPushed(w io.Writer, named bool, asked []string, unheld map[stri
|
||||
fmt.Fprintf(w, "%s: %d unmet seat dependenc(ies) — see `status`\n", node, len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
// schemaReach is the highest migration this build carries for the inventory's store (novox/hq issue 352).
|
||||
func schemaReach() (int, error) {
|
||||
migrations, err := inventory.Migrations()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
reach := 0
|
||||
for _, m := range migrations {
|
||||
if m.Number > reach {
|
||||
reach = m.Number
|
||||
}
|
||||
}
|
||||
return reach, nil
|
||||
}
|
||||
|
||||
@@ -135,12 +135,19 @@ func TestRetryRefusesWhatItCannotResume(t *testing.T) {
|
||||
if err := retryRefusal(stopped, nil); err == nil || !strings.Contains(err.Error(), "nothing in tier 0") {
|
||||
t.Errorf("a plan with nothing failed to build was retried: %v", err)
|
||||
}
|
||||
// Another branch's newer plan, an older one, and a failed one do not supersede it.
|
||||
// A failed or done plan does not supersede it.
|
||||
if err := retryRefusal(failed, []inventory.Plan{plan("plan-4", inventory.PlanFailed, at.Add(time.Hour)),
|
||||
plan("plan-5", inventory.PlanDone, at.Add(time.Hour))}); err != nil {
|
||||
t.Errorf("refused for a plan that does not supersede it: %v", err)
|
||||
}
|
||||
// Another branch's open walk, or an older one, does not supersede it, and is one walk open: one at a time
|
||||
// (novox/hq ADR 0276).
|
||||
other := plan("plan-3", inventory.PlanBuilding, at.Add(time.Hour))
|
||||
other.Branch = "release"
|
||||
if err := retryRefusal(failed, []inventory.Plan{other, plan("plan-0", inventory.PlanBuilding, at.Add(-time.Hour)),
|
||||
plan("plan-4", inventory.PlanFailed, at.Add(time.Hour))}); err != nil {
|
||||
t.Errorf("refused for a plan that does not supersede it: %v", err)
|
||||
for _, open := range []inventory.Plan{other, plan("plan-0", inventory.PlanBuilding, at.Add(-time.Hour))} {
|
||||
if err := retryRefusal(failed, []inventory.Plan{open}); err == nil || !strings.Contains(err.Error(), "one walk at a time") {
|
||||
t.Errorf("retried beside the open walk %s: %v", open.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package main
|
||||
|
||||
// The rehearsal of the operator's answers — not a drill, which in the glossary is something broken on purpose (novox/hq ADR 0259, the live acceptance after rollout): an ask the
|
||||
// operator starts at the controller's terminal, answered on the phone, whose approval changes nothing and is
|
||||
// recorded as a person's decision like any other.
|
||||
//
|
||||
// mesh-controller rehearse [--for 15m]
|
||||
//
|
||||
// It asks with two answers, Approve and Decline, each bound to the rehearsal's own act and **both at the level
|
||||
// approve** (the review of 2026-10-09, M1: an acknowledgement never shares an ask with an approval), so only a
|
||||
// channel that proves who answered carries either — the rehearsal is of exactly that. The serving controller acts on the warrant
|
||||
// as on any other: it claims the ask once, checks the act is the one bound, performs nothing, and records the
|
||||
// hand-act `warrant` with who answered, through which channel, and the proofs. `hand-acts` then shows it.
|
||||
//
|
||||
// **The terminal's alone** (startedAtTheTerminal): a command a verb runs, an ordinary mesh-cli line and anything the
|
||||
// serving controller started are refused, so no agent starts a rehearsal — a
|
||||
// rehearsal is a question the operator expects, and one an agent could start would teach them to approve what they
|
||||
// did not ask for.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
)
|
||||
|
||||
// rehearsalVerb is the act a rehearsal's answers bind: nothing is called.
|
||||
const rehearsalVerb = "rehearsal"
|
||||
|
||||
// rehearsalActions are the rehearsal's two answers.
|
||||
func rehearsalActions() []conditions.Action {
|
||||
return []conditions.Action{
|
||||
{Label: "Approve", Verb: rehearsalVerb, Level: conditions.LevelApprove, Arguments: map[string]string{"rehearsal": "approve"}},
|
||||
{Label: "Decline", Verb: rehearsalVerb, Level: conditions.LevelApprove, Arguments: map[string]string{"rehearsal": "decline"}},
|
||||
}
|
||||
}
|
||||
|
||||
// rehearsalAsk is the rehearsal's ask, as the router is sent it.
|
||||
func rehearsalAsk(id string, now time.Time, lasts time.Duration) (asks.Ask, map[string]int) {
|
||||
q := asks.Ask{ID: id, Headline: "Rehearsal: approve this test question?", Who: asks.Operator,
|
||||
Explanation: "Needs you: approve or decline. You started this rehearsal at the controller's terminal. Approving " +
|
||||
"changes nothing on the mesh; it is recorded as your decision, so you can check the record.",
|
||||
OnExpiry: "nothing is done", Expires: now.Add(lasts), About: "rehearsal." + id}
|
||||
options := map[string]int{}
|
||||
for i, act := range rehearsalActions() {
|
||||
binds, _ := asks.ActDigest(boundAct(act))
|
||||
oid := optionID(act.Label)
|
||||
options[oid] = i
|
||||
q.Options = append(q.Options, asks.Option{ID: oid, Label: act.Label, Does: doesRehearsal(act), Level: asks.Level(act.Level),
|
||||
Binds: binds})
|
||||
}
|
||||
return q, options
|
||||
}
|
||||
|
||||
func doesRehearsal(act conditions.Action) string {
|
||||
if act.Arguments["rehearsal"] == "approve" {
|
||||
return "nothing changes; your approval is recorded"
|
||||
}
|
||||
return "nothing changes; your answer is recorded"
|
||||
}
|
||||
|
||||
func rehearseCommand(ctx context.Context, args []string) error {
|
||||
// The terminal as main judges it (startedAtTheTerminal): not a verb, not the serving controller or anything it
|
||||
// started, and a mesh-cli line only when it is the control-node's operator's (novox/hq ADR 0272 §4).
|
||||
if !startedAtTheTerminal() {
|
||||
return errors.New("rehearse is the controller's terminal's alone: a verb, a mesh-cli line from anybody but " +
|
||||
"the control-node's operator, or a process the serving controller started may not start one, so no agent " +
|
||||
"asks the operator a question they did not start (novox/hq ADR 0259)")
|
||||
}
|
||||
set := flag.NewFlagSet("rehearse", flag.ContinueOnError)
|
||||
lasts := set.Duration("for", 15*time.Minute, "how long the question waits for an answer")
|
||||
if err := set.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *lasts < time.Minute || *lasts > askApproveFor {
|
||||
return fmt.Errorf("a rehearsal waits between a minute and %s", askApproveFor)
|
||||
}
|
||||
js, err := aBus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer js.Close()
|
||||
now := time.Now()
|
||||
id := newAskID()
|
||||
q, options := rehearsalAsk(id, now, *lasts)
|
||||
if err := q.Check(now); err != nil {
|
||||
return err
|
||||
}
|
||||
store := busAsked{conn: js.Conn()}
|
||||
// Kept before it is published, as the asker keeps every ask, so a warrant always finds it.
|
||||
if err := store.Create(ctx, asked{ID: id, Condition: q.About, Ask: q, Actions: rehearsalActions(), Options: options,
|
||||
State: askOpen, Opened: now, Rehearsal: true}); err != nil {
|
||||
return fmt.Errorf("the rehearsal could not be kept in the controller's asks: %w", err)
|
||||
}
|
||||
body, err := json.Marshal(q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := js.Context().Publish(asks.AskSubject(askerName), body, nats.MsgId("ask."+id), nats.Context(ctx)); err != nil {
|
||||
return fmt.Errorf("the rehearsal could not be asked: %w", err)
|
||||
}
|
||||
fmt.Printf("rehearsal %s asked: answer it on your phone before %s. Then `mesh-controller hand-acts` shows the "+
|
||||
"answer as a warrant, with who answered, through which channel and the proofs; nothing else changes.\n",
|
||||
id, q.Expires.Local().Format("15:04"))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// A rehearsal (the live acceptance of novox/hq ADR 0259): its approval is a warrant like any other — claimed once,
|
||||
// its act checked against what the option bound, recorded as the operator's decision with who, how and the
|
||||
// proofs — and it performs nothing. The reconciling of conditions leaves it open.
|
||||
func TestARehearsalsApprovalIsRecordedAndPerformsNothing(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
q, options := rehearsalAsk("crehearsal", r.now, askerRehearsalFor)
|
||||
if err := q.Check(r.now); err != nil {
|
||||
t.Fatalf("the rehearsal's ask is refused: %v", err)
|
||||
}
|
||||
r.store["crehearsal"] = asked{ID: "crehearsal", Condition: q.About, Ask: q, Actions: rehearsalActions(), Options: options,
|
||||
State: askOpen, Opened: r.now, Rehearsal: true}
|
||||
if err := r.a.reconcile(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := r.store["crehearsal"]; got.State != askOpen {
|
||||
t.Fatalf("the reconciling of conditions ended the rehearsal: %+v", got)
|
||||
}
|
||||
approve, _ := q.Option("approve")
|
||||
w := asks.Warrant{Ask: "crehearsal", Asker: "mesh-controller", Outcome: asks.OutcomeChosen, Option: approve.ID,
|
||||
Label: approve.Label, Level: approve.Level, Channel: "telegram", Proofs: []string{"P1"}, At: r.now,
|
||||
AskDigest: q.Digest(), By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}
|
||||
answerWith(t, r, w)
|
||||
answerWith(t, r, w) // heard again
|
||||
if len(r.called)+len(r.silenced) != 0 {
|
||||
t.Errorf("a rehearsal performed something: %v %v", r.called, r.silenced)
|
||||
}
|
||||
if len(r.acts) != 1 {
|
||||
t.Fatalf("hand-acts %+v", r.acts)
|
||||
}
|
||||
act := r.acts[0]
|
||||
if act.Verb != handActWarrant || act.By != "the operator, as telegram identity 42" || act.Ask != "crehearsal" ||
|
||||
strings.Join(act.Args, " ") != "rehearsal rehearsal=approve" || act.Outcome != "done" || strings.Join(act.Proofs, ",") != "P1" {
|
||||
t.Errorf("the rehearsal's record: %+v", act)
|
||||
}
|
||||
if !personsDecision(act) {
|
||||
t.Error("a rehearsal's answer counts as a repair")
|
||||
}
|
||||
}
|
||||
|
||||
// Only the terminal starts a rehearsal: a verb's process is refused before anything is asked.
|
||||
func TestARehearsalIsTheTerminalsAlone(t *testing.T) {
|
||||
t.Setenv(verbVar, "mesh-controller.command")
|
||||
if err := rehearseCommand(context.Background(), nil); err == nil || !strings.Contains(err.Error(), "terminal") {
|
||||
t.Fatalf("a verb started a rehearsal: %v", err)
|
||||
}
|
||||
// Nor a mesh-cli line from anybody but the control-node's operator (hq ADR 0272 §4): run without a verb,
|
||||
// naming its caller, and without the terminal's mark — and nor anything the serving controller started.
|
||||
for name, env := range map[string]map[string]string{
|
||||
"an ordinary mesh-cli line": {verbVar: "", link.CallerVar: "laptop/agent"},
|
||||
"a process the serving controller ran": {verbVar: "", servedVar: "1"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
for k, v := range env {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
if err := rehearseCommand(context.Background(), nil); err == nil || !strings.Contains(err.Error(), "terminal") {
|
||||
t.Fatalf("%s started a rehearsal: %v", name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// askerRehearsalFor is how long the test's rehearsal waits.
|
||||
const askerRehearsalFor = 15 * time.Minute
|
||||
@@ -180,12 +180,35 @@ func (f moveFacts) moves(node string, modules []string, sent map[string]string,
|
||||
return out
|
||||
}
|
||||
|
||||
// walkedBy is the open plan that has started walking a module's build — sent it to a first machine,
|
||||
// not yet passed — other than to this machine; empty when none does.
|
||||
func (f moveFacts) walkedBy(module, node string) string {
|
||||
// walkedBy is the open plan that has started walking a module's build — sent it to a first machine, not
|
||||
// yet passed — and whose walk this move would cross; empty when none does. A move of the same build to a
|
||||
// machine that plan already sent it is not a crossing: the build is there. A move of **another** build of
|
||||
// the module to that machine is (novox/hq issue 352): on 2026-10-09 a merge's plan sent the control node a
|
||||
// newer controller while a release's gate was judging the controller there, the release's gate read the
|
||||
// machine's report against the newer send, failed three builds and put them back under the new plan's
|
||||
// feet. A release keeps no module records: its open gate's carried moves are its walk.
|
||||
func (f moveFacts) walkedBy(module, node, to string) string {
|
||||
for _, p := range f.plans {
|
||||
if !p.Open() {
|
||||
continue
|
||||
}
|
||||
if p.Release != nil {
|
||||
g := p.Release.Gate
|
||||
if g == nil || g.Verdict != "" {
|
||||
continue
|
||||
}
|
||||
for _, c := range g.Carried {
|
||||
if c.Module == module && !(slices.Contains(g.Machines, node) && sameCommit(c.To, to)) {
|
||||
return p.ID
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
s, holds := p.Modules[module]
|
||||
if !p.Open() || !holds || s == nil || s.FirstAt == nil || s.SentAt != nil || slices.Contains(s.First, node) {
|
||||
if !holds || s == nil || s.FirstAt == nil || s.SentAt != nil {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(s.First, node) && sameCommit(s.Commit, to) {
|
||||
continue
|
||||
}
|
||||
if s.Gate != nil && s.Gate.Verdict == inventory.GatePassed {
|
||||
@@ -277,11 +300,19 @@ func gatedSend(ctx context.Context, open *stores, node string, owns []inventory.
|
||||
if own(mv.Module) {
|
||||
continue
|
||||
}
|
||||
if id := f.walkedBy(mv.Module, node); id != "" {
|
||||
if id := f.walkedBy(mv.Module, node, mv.To); id != "" {
|
||||
return nil, nil, fmt.Errorf("%w: %s's build %s waits on %s, which %s is walking", errWalkedElsewhere,
|
||||
mv.Module, short(mv.To), node, id)
|
||||
}
|
||||
}
|
||||
// **And the plan's own modules wait too** (novox/hq issue 352): a walk of the same module by another
|
||||
// plan, or a release, on this machine is not crossed with a newer build; this send waits for its gate.
|
||||
for _, o := range owns {
|
||||
if id := f.walkedBy(o.Module, node, o.To); id != "" {
|
||||
return nil, nil, fmt.Errorf("%w: %s's build %s waits on %s, which %s is walking", errWalkedElsewhere,
|
||||
o.Module, short(o.To), node, id)
|
||||
}
|
||||
}
|
||||
for _, o := range owns {
|
||||
i := slices.IndexFunc(moves, func(mv inventory.CarriedMove) bool { return mv.Module == o.Module })
|
||||
switch {
|
||||
@@ -526,7 +557,7 @@ func waitingMoves(ctx context.Context, open *stores, all bool) (map[string][]inv
|
||||
return nil, err
|
||||
}
|
||||
for _, mv := range moves {
|
||||
if f.walkedBy(mv.Module, n.Name) == "" {
|
||||
if f.walkedBy(mv.Module, n.Name, mv.To) == "" {
|
||||
out[n.Name] = append(out[n.Name], mv)
|
||||
}
|
||||
}
|
||||
@@ -658,7 +689,7 @@ func advanceRelease(ctx context.Context, open *stores, p *inventory.Plan) (bool,
|
||||
r.Next++
|
||||
continue
|
||||
}
|
||||
r.Gate = &inventory.PlanGate{Machines: sent, Since: &now, Carried: moves}
|
||||
r.Gate = &inventory.PlanGate{Machines: sent, Since: &now, Carried: moves, Sent: sentNow(ctx, open.inventory, sent)}
|
||||
p.Note = fmt.Sprintf("sent %s %d build(s) that waited for a gate; judging them there", node, len(moves))
|
||||
if said := recreationsSaid(moves); said != "" {
|
||||
p.Note += "; " + said
|
||||
@@ -693,6 +724,15 @@ func advanceRelease(ctx context.Context, open *stores, p *inventory.Plan) (bool,
|
||||
r.Next++
|
||||
r.Gate = nil
|
||||
return true, nil
|
||||
case inventory.GateSuperseded:
|
||||
// Another send moved a judged module on the judged machine (novox/hq issue 352): no verdict on what
|
||||
// was carried, nothing put back, and this release ends; the builds still waiting are released again
|
||||
// by the next pass, judged afresh.
|
||||
p.State = inventory.PlanSuperseded
|
||||
p.Note = fmt.Sprintf("superseded on %s: %s — nothing judged, nothing put back; what still waits is released again",
|
||||
strings.Join(g.Machines, ", "), g.Why)
|
||||
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
||||
return true, nil
|
||||
}
|
||||
p.Note = ""
|
||||
batched, back := batchingRollbacks(ctx)
|
||||
|
||||
@@ -138,9 +138,11 @@ func reachableFrom(moved []string, edges []inventory.Edge) []string {
|
||||
grew = false
|
||||
for _, e := range edges {
|
||||
// Built-by and worker-of order a plan; neither widens it. A new build machine changes
|
||||
// nothing it builds, and a new controller changes nothing about the holder it orders —
|
||||
// what packages the controller's source is already a code edge.
|
||||
if e.Kind == inventory.EdgeBuiltBy || e.Kind == inventory.EdgeWorkerOf {
|
||||
// nothing it builds, and a new controller changes nothing about the holder it orders. A
|
||||
// packages edge, read from a record made before novox/hq ADR 0267, widens nothing either:
|
||||
// a shared file moves each module whose build source holds it, directly.
|
||||
if e.Kind == inventory.EdgeBuiltBy || e.Kind == inventory.EdgeWorkerOf || e.Kind == inventory.EdgePackages ||
|
||||
e.Kind == edgeGroupOrder {
|
||||
continue
|
||||
}
|
||||
if in[e.To] && !in[e.From] {
|
||||
@@ -179,9 +181,18 @@ func hasCycle(tiers [][]string, edges []inventory.Edge) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// planFor is the plan a merge produces: the moved modules and everything reachable from them,
|
||||
// planOfMerge is the plan one merge produces: the moved modules and everything reachable from them,
|
||||
// tiered, with the merge it answers.
|
||||
func planOfMerge(m link.SourceMoved, moved []string, edges []inventory.Edge) inventory.Plan {
|
||||
p := planOfMoves(moved, edges)
|
||||
merged, _ := time.Parse(time.RFC3339Nano, m.MergedAt)
|
||||
p.Repository, p.Branch, p.Commit, p.Merged = m.Owner+"/"+m.Repo, m.Base, m.Commit, merged.UTC()
|
||||
return p
|
||||
}
|
||||
|
||||
// planOfMoves is the walk of a set of moved modules (novox/hq ADR 0162, 0276): they and everything reachable
|
||||
// from them, tiered along the graph, with no merge named yet.
|
||||
func planOfMoves(moved []string, edges []inventory.Edge) inventory.Plan {
|
||||
set := reachableFrom(moved, edges)
|
||||
tiers := tiersOf(set, edges)
|
||||
modules := map[string]*inventory.PlanModule{}
|
||||
@@ -190,9 +201,6 @@ func planOfMerge(m link.SourceMoved, moved []string, edges []inventory.Edge) inv
|
||||
}
|
||||
return inventory.Plan{
|
||||
ID: fmt.Sprintf("plan-%d", time.Now().UnixNano()),
|
||||
Repository: m.Owner + "/" + m.Repo,
|
||||
Branch: m.Base,
|
||||
Commit: m.Commit,
|
||||
Created: time.Now().UTC(),
|
||||
State: inventory.PlanBuilding,
|
||||
Tiers: tiers,
|
||||
@@ -200,60 +208,6 @@ func planOfMerge(m link.SourceMoved, moved []string, edges []inventory.Edge) inv
|
||||
}
|
||||
}
|
||||
|
||||
// supersededBy is what a newer plan takes over from the open plans it supersedes (novox/hq issue
|
||||
// 254, ADR 0218): the modules they had not finished, and those plans closed as superseded.
|
||||
//
|
||||
// **A merge looked at no plan but its own.** Two merges of one repository a few minutes apart were
|
||||
// two open plans asking for the same modules, each sending machines what it built; and a plan that
|
||||
// would never move again — waiting on a report that could not come, at 97b1b2b — stayed open for
|
||||
// ever beside the newer ones, read as work in progress by everyone who looked. The newer merge is the
|
||||
// newer intent for that repository and branch, so its plan takes over: every open plan of the same
|
||||
// repository and branch **created before it** — by the time the plans were made, never by comparing
|
||||
// commits, which have no order of their own — gives up the modules it had not built, and those are
|
||||
// planned again in the newer plan beside what the newer merge moved.
|
||||
//
|
||||
// "Not built" is a module not yet asked, or asked and not answered; **and a module built and not
|
||||
// yet sent to its machines**, where its policy rolls it out: closed, the older plan would never send
|
||||
// it, and the catalogue announces no move for a rebuild (issue 189), so the newer plan builds and
|
||||
// sends it. A build the older plan asked still finishes and registers as any build does — ordered by
|
||||
// when it was asked (issue 219), so the newer plan's ask, made later, is the one that stands.
|
||||
//
|
||||
// A plan with no branch recorded is from before branches were kept, and is superseded by the next
|
||||
// plan of its repository: what it had not built is folded in, so nothing is lost by it.
|
||||
func supersededBy(newer inventory.Plan, open []inventory.Plan, rollsOut func(string) bool) ([]string, []inventory.Plan) {
|
||||
folded := map[string]bool{}
|
||||
var closed []inventory.Plan
|
||||
for _, old := range open {
|
||||
if old.ID == newer.ID || !old.Open() || !strings.EqualFold(old.Repository, newer.Repository) ||
|
||||
(old.Branch != "" && old.Branch != newer.Branch) || !old.Created.Before(newer.Created) {
|
||||
continue
|
||||
}
|
||||
var took []string
|
||||
for name, s := range old.Modules {
|
||||
if s != nil && s.State == planDeleted {
|
||||
continue // deleted at its source: nothing to plan again
|
||||
}
|
||||
if s == nil || s.State != "built" || (s.SentAt == nil && rollsOut(name)) {
|
||||
folded[name] = true
|
||||
took = append(took, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(took)
|
||||
old.State = inventory.PlanSuperseded
|
||||
old.Note = fmt.Sprintf("superseded at tier %d by %s (%s at %s)", old.Tier, newer.ID, newer.Repository, short(newer.Commit))
|
||||
if len(took) > 0 {
|
||||
old.Note += "; " + strings.Join(took, ", ") + " planned there again"
|
||||
}
|
||||
closed = append(closed, old)
|
||||
}
|
||||
out := make([]string, 0, len(folded))
|
||||
for name := range folded {
|
||||
out = append(out, name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, closed
|
||||
}
|
||||
|
||||
// gates is what the next tier needs running from this one: a module of the tier that a later
|
||||
// tier is built by — the runtime dependency — and whose policy rolls it out, must be applied by
|
||||
// the machines running it before the next tier is asked. A base an image stands on need only be
|
||||
@@ -281,8 +235,11 @@ func gates(p inventory.Plan, edges []inventory.Edge, rollsOut func(string) bool)
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, e := range edges {
|
||||
if later[e.From] && inTier[e.To] && e.Kind == inventory.EdgeBuiltBy && !seen[e.To] && rollsOut(e.To) &&
|
||||
!isBaseOf(e.From, e.To, edges, all) {
|
||||
// A delivery group's order inside a walk (novox/hq ADR 0276 decision 5) gates as built-by does: the
|
||||
// member before is running on its machines before the member after is asked.
|
||||
ordered := e.Kind == edgeGroupOrder ||
|
||||
e.Kind == inventory.EdgeBuiltBy && !isBaseOf(e.From, e.To, edges, all)
|
||||
if later[e.From] && inTier[e.To] && ordered && !seen[e.To] && rollsOut(e.To) {
|
||||
seen[e.To] = true
|
||||
out = append(out, e.To)
|
||||
}
|
||||
@@ -373,8 +330,15 @@ func askModule(ctx context.Context, p *inventory.Plan, name string, byName map[s
|
||||
}
|
||||
source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat}
|
||||
fmt.Printf(" tier %d: ", p.Tier)
|
||||
// The branch it follows, never a commit a build once named (novox/hq 04-ISSUES/215).
|
||||
id, err := askABuild(ctx, source, e.Source.Path, followedBranch(e.Source.Ref))
|
||||
// The branch it follows, never a commit a build once named (novox/hq 04-ISSUES/215): the branch contains every
|
||||
// commit a batch's walk carries — but for a merge walked alone after a failed walk (novox/hq ADR 0276
|
||||
// decision 3), built on its own commit to find which merge brought the failure.
|
||||
ref := followedBranch(e.Source.Ref)
|
||||
carried := p.CommitOn(e.Source.Repository, ref)
|
||||
if p.Delivery != nil && p.Delivery.Alone && carried != "" {
|
||||
ref = carried
|
||||
}
|
||||
id, err := askABuild(ctx, source, e.Source.Path, ref)
|
||||
if err != nil {
|
||||
state.State = "failed"
|
||||
state.Why = err.Error()
|
||||
@@ -382,8 +346,14 @@ func askModule(ctx context.Context, p *inventory.Plan, name string, byName map[s
|
||||
p.Note = fmt.Sprintf("%s could not be asked for: %v", name, err)
|
||||
return
|
||||
}
|
||||
// The commit of the module's own repository the walk carries (novox/hq ADR 0276): a batch's walk carries
|
||||
// one per repository.
|
||||
commit := carried
|
||||
if commit == "" {
|
||||
commit = p.Commit
|
||||
}
|
||||
recordAsked(ctx, inventory.BuildRequest{ID: id, Repository: e.Source.Repository, Seat: e.Source.Seat,
|
||||
Path: e.Source.Path, Ref: followedBranch(e.Source.Ref), Commit: p.Commit, For: "plan"})
|
||||
Path: e.Source.Path, Ref: followedBranch(e.Source.Ref), Commit: commit, For: "plan"})
|
||||
state.State = "asked"
|
||||
state.AskedAt = &now
|
||||
state.Build = id
|
||||
@@ -476,6 +446,47 @@ func planBuilt(ctx context.Context, open *stores, module, commit, failed string,
|
||||
advanceHeld(ctx, open)
|
||||
}
|
||||
|
||||
// startedBeside is the id of a started walk open beside this one — a merge's walk past its wait, not the
|
||||
// backlog's — or empty: one walk at a time (novox/hq ADR 0276).
|
||||
func startedBeside(ctx context.Context, inv *inventory.Inventory, id string, created time.Time) (string, error) {
|
||||
plans, err := inv.OpenPlans(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, q := range plans {
|
||||
if q.ID == id || q.Release != nil || q.Waiting() {
|
||||
continue
|
||||
}
|
||||
// Started: a tier asked, or on its way (let go, or waiting for nobody) before this one.
|
||||
if q.Tier > 0 || askedAny(q) || q.Created.Before(created) {
|
||||
return q.ID, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// deferredNote begins the note of a walk deferred behind another.
|
||||
const deferredNote = "let go; starts once "
|
||||
|
||||
// deferred says a walk has its word and has not started: let go while another walk was started, it waits for
|
||||
// that one to end (startedBeside), and its note says so. Read as a wait, not as a tier running late: `plans`
|
||||
// and `status` say its note, and S3 leaves it out. A let-go walk whose first ask failed carries that error as
|
||||
// its note instead, and is watched as before.
|
||||
func deferred(p inventory.Plan) bool {
|
||||
return p.Open() && p.Release == nil && p.Tier == 0 && !askedAny(p) && p.Delivery != nil &&
|
||||
p.Delivery.Awaits != "" && p.Delivery.Go != nil && strings.HasPrefix(p.Note, deferredNote)
|
||||
}
|
||||
|
||||
// askedAny says a plan asked any module.
|
||||
func askedAny(p inventory.Plan) bool {
|
||||
for _, s := range p.Modules {
|
||||
if s != nil && s.State != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// advancePlans moves every open plan as far as the facts allow: a tier whose modules are all built
|
||||
// and whose gates are applied gives way to the next; the last tier done is the plan done. Called
|
||||
// after every outcome and on a timer, so a plan waiting on a machine's report moves when it comes.
|
||||
@@ -502,6 +513,12 @@ func advancePlans(ctx context.Context, open *stores) {
|
||||
// advanceHeld is advancePlans for a caller already holding the plans.
|
||||
func advanceHeld(ctx context.Context, open *stores) {
|
||||
inv := open.inventory
|
||||
// A walk that ended lets the next batch be cut at once, not at the cutter's next look (novox/hq ADR 0276).
|
||||
defer func() {
|
||||
if err := cutBatchesHeld(ctx, open, time.Now().UTC()); err != nil {
|
||||
fmt.Printf("batches: %v\n", err)
|
||||
}
|
||||
}()
|
||||
plans, err := inv.OpenPlans(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("plans: cannot read them: %v\n", err)
|
||||
@@ -602,6 +619,18 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
||||
}
|
||||
}
|
||||
if unasked == len(tier) {
|
||||
// **One walk at a time** (novox/hq ADR 0276): a walk about to ask its first tier while another started
|
||||
// walk is open waits for that one to end, let go or not, and says so.
|
||||
if p.Tier == 0 {
|
||||
if behind, err := startedBeside(ctx, inv, p.ID, p.Created); err != nil {
|
||||
return false, err
|
||||
} else if behind != "" {
|
||||
note := fmt.Sprintf("%s%s ended — one walk at a time", deferredNote, behind)
|
||||
changed := p.Note != note
|
||||
p.Note = note
|
||||
return changed, nil
|
||||
}
|
||||
}
|
||||
if err := askTier(ctx, inv, p); err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -759,6 +788,15 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
||||
case inventory.GateFailed:
|
||||
failFirstSend(ctx, open, p, m, state, state.Gate.Machines, state.Gate.Why, step.rest)
|
||||
return true, nil
|
||||
case inventory.GateSuperseded:
|
||||
// Another send moved this module on its first machine (novox/hq issue 352): the build is not
|
||||
// judged, not marked, not put back; the plan ends here, said, and a newer plan carries on.
|
||||
state.Why = "superseded: " + state.Gate.Why
|
||||
p.State = inventory.PlanSuperseded
|
||||
p.Note = fmt.Sprintf("%s's judging on %s was superseded: %s", m, strings.Join(state.Gate.Machines, ", "),
|
||||
state.Gate.Why)
|
||||
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
||||
return true, nil
|
||||
case inventory.GatePassed:
|
||||
if !state.Gate.Kept {
|
||||
gatePassed(ctx, open, p, m, state)
|
||||
@@ -930,6 +968,9 @@ func firstSend(ctx context.Context, open *stores, p *inventory.Plan, node string
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
lead := modules[0]
|
||||
// What each machine was just sent, kept on the gate (novox/hq issue 352): its report is held against
|
||||
// this send, whatever it is sent after.
|
||||
sentWhat := sentNow(ctx, inv, sent)
|
||||
for _, m := range modules {
|
||||
s := p.Modules[m]
|
||||
// What the first machine ran before: what a failed gate puts back (ADR 0236).
|
||||
@@ -938,7 +979,7 @@ func firstSend(ctx context.Context, open *stores, p *inventory.Plan, node string
|
||||
}
|
||||
s.First, s.FirstAt = sent, &now
|
||||
s.Gate = &inventory.PlanGate{Component: coreComponent(m), Machines: firstRunning(sent, runningOf[m]),
|
||||
From: s.Previous, To: s.Commit, Since: &now}
|
||||
From: s.Previous, To: s.Commit, Since: &now, Sent: sentWhat}
|
||||
s.GatedBy = ""
|
||||
if m == lead {
|
||||
s.Gate.Carried = carried
|
||||
@@ -1097,8 +1138,15 @@ func nextRollout(s inventory.PlanModule, running []string, together bool, report
|
||||
var waiting, failed []string
|
||||
for _, n := range s.First {
|
||||
r, said := byNode[n]
|
||||
// Only a report about what it was last sent says anything about this build.
|
||||
if !said || r.At == nil || !r.Current {
|
||||
// Only a report about what this plan sent it — or what it was sent after that — says anything about
|
||||
// this build (novox/hq issue 352); a plan from before sends were kept on the gate reads the report
|
||||
// against the send made last, as before.
|
||||
reported := r.Current
|
||||
if s.Gate != nil && s.Gate.Sent != nil {
|
||||
sent, kept := s.Gate.Sent[n]
|
||||
reported = kept && sent.ReportsOn(r)
|
||||
}
|
||||
if !said || r.At == nil || !reported {
|
||||
waiting = append(waiting, n)
|
||||
continue
|
||||
}
|
||||
@@ -1186,24 +1234,38 @@ func inTierSince(p inventory.Plan) time.Time {
|
||||
// planLineWith is planLine knowing whether the build seat is paused (novox/hq ADR 0219): a plan
|
||||
// waiting on builds nobody will take until a person resumes the seat says so, and is not late. bound is
|
||||
// the plan's tier bound, the one its stalled condition is raised at (tierBounds): LATE is that condition
|
||||
// said on the line (novox/hq issue 296).
|
||||
// said on the line (novox/hq issue 296). The machines running what it moves are not named: planLineOn.
|
||||
func planLineWith(p inventory.Plan, now time.Time, pause pauseView, bound time.Duration) string {
|
||||
return planLineOn(p, now, pause, bound, nil)
|
||||
}
|
||||
|
||||
// planLineOn is planLineWith naming the plan by what it moves and where (planHeadline), given what runs each
|
||||
// module; nil names no machine.
|
||||
func planLineOn(p inventory.Plan, now time.Time, pause pauseView, bound time.Duration, running func(string) []string) string {
|
||||
name := planHeadline(p, running)
|
||||
where := fmt.Sprintf("tier %d of %d", min(p.Tier+1, len(p.Tiers)), len(p.Tiers))
|
||||
switch p.State {
|
||||
case inventory.PlanAssembling, inventory.PlanQueued:
|
||||
// A batch not yet a walk (novox/hq ADR 0276): what it holds and how long is left.
|
||||
return batchWords(p, now)
|
||||
case inventory.PlanDone:
|
||||
return fmt.Sprintf("%s %s done, %d tier(s)", p.Repository, short(p.Commit), len(p.Tiers))
|
||||
return fmt.Sprintf("%s · done, %d tier(s)", name, len(p.Tiers))
|
||||
case inventory.PlanFailed:
|
||||
return fmt.Sprintf("%s %s FAILED at %s: %s", p.Repository, short(p.Commit), where, p.Note)
|
||||
return fmt.Sprintf("%s · FAILED at %s: %s", name, where, p.Note)
|
||||
case inventory.PlanSuperseded:
|
||||
return fmt.Sprintf("%s %s %s", p.Repository, short(p.Commit), p.Note)
|
||||
return fmt.Sprintf("%s · %s", name, p.Note)
|
||||
}
|
||||
since := now.Sub(inTierSince(p)).Round(time.Second)
|
||||
if p.Waiting() {
|
||||
// Waiting for its delivery's word is no lateness of the walk's (novox/hq ADR 0239).
|
||||
return fmt.Sprintf("%s %s %s, %s, for %s", p.Repository, short(p.Commit), where, waitingNote(p), since)
|
||||
return fmt.Sprintf("%s · %s, %s, for %s", name, where, waitingNote(p), since)
|
||||
}
|
||||
if deferred(p) {
|
||||
// Nor is waiting for the walk before it to end (one walk at a time, novox/hq ADR 0276).
|
||||
return fmt.Sprintf("%s · %s, %s, for %s", name, where, p.Note, since)
|
||||
}
|
||||
if waiting, paused := pausedWaiting(p, pause, now); paused {
|
||||
return fmt.Sprintf("%s %s %s, %s", p.Repository, short(p.Commit), where, waiting)
|
||||
return fmt.Sprintf("%s · %s, %s", name, where, waiting)
|
||||
}
|
||||
late := ""
|
||||
if since > bound {
|
||||
@@ -1213,7 +1275,53 @@ func planLineWith(p inventory.Plan, now time.Time, pause pauseView, bound time.D
|
||||
if p.State == inventory.PlanRolling {
|
||||
what = p.Note
|
||||
}
|
||||
return fmt.Sprintf("%s %s %s, %s for %s%s", p.Repository, short(p.Commit), where, what, since, late)
|
||||
return fmt.Sprintf("%s · %s, %s for %s%s", name, where, what, since, late)
|
||||
}
|
||||
|
||||
// planHeadline names a plan as a person knows it (asked by the operator, 2026-10-10: a plan called by its
|
||||
// repository alone said nothing of what it delivers): each repository as its pull request and title, what the
|
||||
// plan moves, and the machines running that — "mesh-catalog #175 a tap shows its outcome · messenger,
|
||||
// telegram → novox". A record naming no pull request is named by its commit, as before; one naming no moves
|
||||
// says none; running nil names no machine.
|
||||
func planHeadline(p inventory.Plan, running func(string) []string) string {
|
||||
var repos []string
|
||||
moves := map[string]bool{}
|
||||
for _, c := range p.Carried() {
|
||||
seg := repoName(c.Repository) + " " + short(c.Commit)
|
||||
if p.Delivery != nil {
|
||||
for _, m := range p.Delivery.Merges {
|
||||
if !strings.EqualFold(m.Repository, c.Repository) {
|
||||
continue
|
||||
}
|
||||
for _, n := range m.Moves {
|
||||
moves[n] = true
|
||||
}
|
||||
if m.Commit == c.Commit && m.Number > 0 {
|
||||
seg = repoName(c.Repository) + " " + pullWords(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
repos = append(repos, seg)
|
||||
}
|
||||
out := strings.Join(repos, " + ")
|
||||
if len(moves) == 0 {
|
||||
return out
|
||||
}
|
||||
names := sortedKeysOf(boolsToStrings(moves))
|
||||
out += " · " + strings.Join(names, ", ")
|
||||
if running == nil {
|
||||
return out
|
||||
}
|
||||
nodes := map[string]bool{}
|
||||
for _, n := range names {
|
||||
for _, node := range running(n) {
|
||||
nodes[node] = true
|
||||
}
|
||||
}
|
||||
if len(nodes) > 0 {
|
||||
out += " → " + strings.Join(sortedKeysOf(boolsToStrings(nodes)), ", ")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// planFailedBuild marks the module a failed build was for when the result names no module: by the
|
||||
@@ -1355,8 +1463,11 @@ func plansCommand(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
bounds := readTierBounds(ctx, inv, now)
|
||||
fmt.Printf("%s — %s\n", p.ID, planLineWith(p, now, buildSeatPause(ctx, inv, []inventory.Plan{p}),
|
||||
bounds.of(p.Repository)))
|
||||
fmt.Printf("%s — %s\n", p.ID, planLineOn(p, now, buildSeatPause(ctx, inv, []inventory.Plan{p}),
|
||||
bounds.of(p.Repository), func(module string) []string {
|
||||
on, _ := inv.Running(ctx, module)
|
||||
return on
|
||||
}))
|
||||
if r := p.Release; r != nil {
|
||||
// A release plan's walk (ADR 0236): machines done, the one judged, those to come.
|
||||
fmt.Printf(" machines in order: %s; done: %s; skipped: %s\n", strings.Join(r.Order, ", "),
|
||||
@@ -1480,14 +1591,34 @@ func plansCommand(ctx context.Context, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(plans) == 0 {
|
||||
// **The batch being assembled first** (novox/hq ADR 0276 decision 7): what it holds, how long is left,
|
||||
// and that its plan is not yet calculated.
|
||||
batches, err := inv.Batches(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(plans) == 0 && len(batches) == 0 {
|
||||
fmt.Println("no merge has produced a plan yet")
|
||||
return nil
|
||||
}
|
||||
for _, b := range batches {
|
||||
fmt.Printf("%-28s %s\n", b.ID, batchWords(b, now))
|
||||
// One line per repository: its pull request, what it answers, what it moves.
|
||||
for _, line := range batchLines(b) {
|
||||
fmt.Printf("%-28s %s\n", "", line)
|
||||
}
|
||||
}
|
||||
pause := buildSeatPause(ctx, inv, plans)
|
||||
bounds := readTierBounds(ctx, inv, now)
|
||||
running := func(module string) []string {
|
||||
on, _ := inv.Running(ctx, module)
|
||||
return on
|
||||
}
|
||||
for _, p := range plans {
|
||||
fmt.Printf("%-28s %s\n", p.ID, planLineWith(p, now, pause, bounds.of(p.Repository)))
|
||||
if p.Batch() {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%-28s %s\n", p.ID, planLineOn(p, now, pause, bounds.of(p.Repository), running))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1508,11 +1639,12 @@ func planWhatIf(ctx context.Context, inv *inventory.Inventory, repository string
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
read, err := inv.ReadRepositories(ctx)
|
||||
read, err := readForPlanning(ctx, inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var from, packaging []inventory.Entry
|
||||
deleted := map[string]bool{}
|
||||
named := map[string]bool{}
|
||||
for _, name := range modules {
|
||||
named[name] = true
|
||||
@@ -1522,6 +1654,9 @@ func planWhatIf(ctx context.Context, inv *inventory.Inventory, repository string
|
||||
// request's check ask it (novox/hq ADR 0238).
|
||||
r := reachOfMerge(m, entries, read, nil)
|
||||
from, packaging = append(append([]inventory.Entry{}, r.Touched...), r.Deleted...), r.Packaging
|
||||
for _, e := range r.Deleted {
|
||||
deleted[e.Manifest.Module] = true
|
||||
}
|
||||
} else {
|
||||
for _, e := range entries {
|
||||
switch {
|
||||
@@ -1548,6 +1683,18 @@ func planWhatIf(ctx context.Context, inv *inventory.Inventory, repository string
|
||||
}
|
||||
p := planOfMerge(m, names, edges)
|
||||
fmt.Printf("a merge of %s would build %d module(s) in %d tier(s):\n", repository, len(p.Modules), len(p.Tiers))
|
||||
why := map[string]string{}
|
||||
for _, e := range packaging {
|
||||
why[e.Manifest.Module] = whyMoved(e, read[e.Manifest.Module], m)
|
||||
}
|
||||
if len(named) == 0 {
|
||||
for _, e := range from {
|
||||
why[e.Manifest.Module] = whyMoved(e, read[e.Manifest.Module], m)
|
||||
if deleted[e.Manifest.Module] {
|
||||
why[e.Manifest.Module] = "its manifest is removed: deleted at its source, forgotten where nothing holds it, not built"
|
||||
}
|
||||
}
|
||||
}
|
||||
rolls := map[string]string{}
|
||||
for i, tier := range p.Tiers {
|
||||
fmt.Printf(" tier %d\n", i)
|
||||
@@ -1565,19 +1712,19 @@ func planWhatIf(ctx context.Context, inv *inventory.Inventory, repository string
|
||||
rolls[name] = how
|
||||
}
|
||||
fmt.Printf(" %-22s %s\n", name, how)
|
||||
reason := why[name]
|
||||
switch {
|
||||
case reason == "" && len(named) > 0 && named[name]:
|
||||
reason = "named"
|
||||
case reason == "":
|
||||
reason = "it stands on a module the merge moves"
|
||||
}
|
||||
fmt.Printf(" %-22s why: %s\n", "", reason)
|
||||
}
|
||||
}
|
||||
if hasCycle(p.Tiers, edges) {
|
||||
fmt.Println(" the last tier depends on itself and would be built together, in no order")
|
||||
}
|
||||
if len(packaging) > 0 {
|
||||
var also []string
|
||||
for _, e := range packaging {
|
||||
also = append(also, e.Manifest.Module)
|
||||
}
|
||||
fmt.Printf(" %s package source from %s, so they are rebuilt without their own source moving\n",
|
||||
strings.Join(also, ", "), repository)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -59,17 +59,18 @@ func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) {
|
||||
t.Fatalf("no cycle here: %v", tiers)
|
||||
}
|
||||
|
||||
// The controller alone moved: the proxy with it, nothing else.
|
||||
small := reachableFrom([]string{"mesh-controller"}, edges)
|
||||
if len(small) != 3 {
|
||||
t.Fatalf("a controller merge rebuilds the controller and what packages it: %v", small)
|
||||
// The controller alone moved: the controller alone — what packages its repository moves only when the
|
||||
// change is in its own build source (novox/hq ADR 0267), so a packages edge widens nothing.
|
||||
if alone := reachableFrom([]string{"mesh-controller"}, edges); len(alone) != 1 {
|
||||
t.Fatalf("a controller merge rebuilds the controller alone: %v", alone)
|
||||
}
|
||||
// The builder and the proxy package the controller's source, which orders nothing. The builder holds
|
||||
// A change to a package all three build from moves all three. The builder holds
|
||||
// the build seat, whose worker the controller defines, so it follows the controller (worker-of,
|
||||
// novox/hq issue 206), and the controller's built-by edge to it yields: the controller is built by the
|
||||
// build machine that is running. The proxy is built by the new builder: the controller, the builder,
|
||||
// the proxy — the live plan of every controller merge. (This read "the builder, then the controller
|
||||
// and the proxy together" before issue 206, and the fixture had no worker-of edge.)
|
||||
small := reachableFrom([]string{"mesh-controller", "builder", "route-proxy"}, edges)
|
||||
smallTiers := tiersOf(small, edges)
|
||||
if got := tiered(smallTiers); got != "mesh-controller | builder | route-proxy" {
|
||||
t.Fatalf("the controller, then the builder, then the proxy: %v", smallTiers)
|
||||
@@ -243,7 +244,7 @@ func TestAChangeToTheBuildAgentRebuildsTheBuildAgentAlone(t *testing.T) {
|
||||
} {
|
||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "abc", Paths: paths,
|
||||
ModuleDirs: []string{"modules/build-agent"}, ModuleDirsSaid: true}
|
||||
touched := whatTheMergeTouched(entries, entries, m)
|
||||
touched := whatTheMergeTouched(entries, entries, m, nil)
|
||||
if len(touched) != 1 || touched[0].Manifest.Module != "build-agent" {
|
||||
t.Fatalf("%v touched %v", paths, touched)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,11 @@ import (
|
||||
func TestMain(m *testing.M) {
|
||||
// The process a mesh-cli test runs as a command line: it says the verb and the caller it was given, and
|
||||
// ends (meshcli_test.go).
|
||||
// The process a mesh-cli test runs as `secret accept`: it reads its value exactly as `secret accept` does
|
||||
// (valueFor) and says whether it is the one the test gave, never the value (meshcli_stdin_test.go).
|
||||
if want := os.Getenv(secretAcceptWants); want != "" {
|
||||
os.Exit(readAsSecretAccept(want, os.Args[1:]))
|
||||
}
|
||||
if os.Getenv(echoEnvironment) != "" {
|
||||
fmt.Printf("verb=%q caller=%q terminal=%v\n", os.Getenv("MESH_VERB"), os.Getenv("MESH_CALLER"), startedAtTheTerminal())
|
||||
os.Exit(0)
|
||||
|
||||
@@ -135,6 +135,12 @@ func handOver(ctx context.Context, seatName, to string, adding bool) error {
|
||||
if !ok || nodeName == "" || module == "" {
|
||||
return fmt.Errorf("the new holder is named <node>/<module>, not %q", to)
|
||||
}
|
||||
// A kinded bench is held once per kind, by the claims themselves (novox/hq ADR 0234 §2, ADR 0259): the
|
||||
// record of who holds a seat has no kind, so a handover would name one holder for every kind.
|
||||
if catalogue.KindedBenches[seatName] {
|
||||
return fmt.Errorf("%s is a kinded bench: each kind is held by the module claiming it, and is not handed "+
|
||||
"over by `seat` — assign the module that claims the kind, or unassign the one that does", seatName)
|
||||
}
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
@@ -62,3 +64,13 @@ func TestAClaimOutsideTheSetIsShownNotHidden(t *testing.T) {
|
||||
t.Fatalf("a claim outside the set was not shown: %+v", outside)
|
||||
}
|
||||
}
|
||||
|
||||
// A kinded bench is not handed over by `seat`: each kind is held by its claim (novox/hq ADR 0259).
|
||||
func TestAKindedBenchIsNotHandedOver(t *testing.T) {
|
||||
for _, bench := range []string{"channel", "intake"} {
|
||||
err := handOver(context.Background(), bench, "anchor/telegram", false)
|
||||
if err == nil || !strings.Contains(err.Error(), "is a kinded bench") {
|
||||
t.Errorf("%s: %v", bench, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,14 @@ func declaredArguments(v catalogue.Verb) (names []string, switches map[string]bo
|
||||
return names, switches
|
||||
}
|
||||
|
||||
// isList says a verb's argument is declared a list of text (catalogue's listed): given as a JSON array, it is
|
||||
// read as its items joined by commas, as the same argument given as one text would be.
|
||||
func isList(v catalogue.Verb, name string) bool {
|
||||
props, _ := v.Input["properties"].(map[string]any)
|
||||
p, _ := props[name].(map[string]any)
|
||||
return p != nil && p["type"] == "array"
|
||||
}
|
||||
|
||||
// readArguments refuses what the verb does not take, before anything is composed.
|
||||
func readArguments(verb string, args map[string]any) (*verbArguments, error) {
|
||||
v, known := controllerVerb(verb)
|
||||
@@ -150,6 +158,19 @@ func readArguments(verb string, args map[string]any) (*verbArguments, error) {
|
||||
return nil, fmt.Errorf("%s: %q is text, not true or false", verb, k)
|
||||
}
|
||||
value = fmt.Sprint(x)
|
||||
case []any:
|
||||
if !isList(v, k) {
|
||||
return nil, fmt.Errorf("%s: %q is text, and was given a list", verb, k)
|
||||
}
|
||||
items := make([]string, 0, len(x))
|
||||
for _, item := range x {
|
||||
text, ok := item.(string)
|
||||
if !ok || strings.TrimSpace(text) == "" || strings.Contains(text, ",") {
|
||||
return nil, fmt.Errorf("%s: %q is a list of names, and holds %v", verb, k, item)
|
||||
}
|
||||
items = append(items, strings.TrimSpace(text))
|
||||
}
|
||||
value = strings.Join(items, ",")
|
||||
default:
|
||||
return nil, fmt.Errorf("%s: %q is text, and was given %T", verb, k, x)
|
||||
}
|
||||
@@ -239,10 +260,10 @@ func refusedAsTheGenericCommand(argv []string) error {
|
||||
// A layer is written through the settings verb, never the generic one (novox/hq issue 339): the
|
||||
// settings verb is where what a verb may not set is refused, and one route is one set of words.
|
||||
// The command refuses places and accesses through any verb as well; this says so before it runs.
|
||||
if argv[0] == "settings" && slices.ContainsFunc(argv[1:], func(w string) bool { return w == "set" || w == "clear" }) {
|
||||
return &heldAtTheTerminal{msg: "settings are set and cleared through the settings verb, not the " +
|
||||
"generic command; and places and accesses only at the controller's terminal (novox/hq issue 339). " +
|
||||
"Nothing was done"}
|
||||
if argv[0] == "settings" && slices.ContainsFunc(argv[1:], func(w string) bool { return w == "set" || w == "clear" || w == "propose" }) {
|
||||
return &heldAtTheTerminal{msg: "settings are set, cleared and proposed through the settings verb, not the " +
|
||||
"generic command; and places and accesses are set at the controller's terminal or on the operator's " +
|
||||
"warrant (novox/hq issue 339, ADR 0277). Nothing was done"}
|
||||
}
|
||||
// The generic verb only reads (novox/hq ADR 0266): what writes has a named verb that composes its own
|
||||
// line, or is the operator's at the controller's terminal.
|
||||
@@ -282,6 +303,8 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
return nil, errors.New("tools is answered from the records, not by a command")
|
||||
case "dead-letters":
|
||||
return nil, errors.New("dead-letters is answered by the serving controller, on its own connection, not by a command")
|
||||
case "root-free":
|
||||
return nil, errors.New("root-free is judged by the serving controller, on its own connection, not by a command")
|
||||
case "status":
|
||||
return []string{"status", "--json"}, nil
|
||||
case "nodes":
|
||||
@@ -513,6 +536,11 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
argv = append(argv, "--condition", c)
|
||||
}
|
||||
return argv, nil
|
||||
case "warranted":
|
||||
if err := need("asker", "ask"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{"hand-act", "warrant", "--asker", str("asker"), "--ask", str("ask")}, nil
|
||||
case "hand-acts":
|
||||
argv := []string{"hand-acts", "--json"}
|
||||
if d := str("days"); d != "" {
|
||||
@@ -739,6 +767,23 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
argv = append(argv, "--probe", p)
|
||||
}
|
||||
return append(argv, "--json"), nil
|
||||
case "give":
|
||||
// The same line as secret-ask with the desk named (novox/hq ADR 0277): one path, one set of bounds.
|
||||
if err := need("node", "module", "secret", "at"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{"secret", "ask", str("node"), str("module"), str("secret"), "--at", str("at")}, nil
|
||||
case "secret-ask":
|
||||
// A module's own secret asked for, typed by the operator at the desk (novox/hq ADR 0277): never a value
|
||||
// in the arguments. The desk is the module's machine unless at names another.
|
||||
if err := need("node", "module", "secret"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
argv := []string{"secret", "ask", str("node"), str("module"), str("secret")}
|
||||
if at := str("at"); at != "" {
|
||||
argv = append(argv, "--at", at)
|
||||
}
|
||||
return argv, nil
|
||||
case "rotate":
|
||||
if p := str("provision"); p != "" {
|
||||
argv := []string{"rotate", p}
|
||||
@@ -802,6 +847,21 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
if str("module") == "" && on("clear") {
|
||||
return nil, errors.New("settings: a module is needed to clear a layer; name it with module")
|
||||
}
|
||||
// The proposals, listed or one whole (novox/hq ADR 0277): a read, needing no module.
|
||||
if on("proposals") || str("proposal") != "" {
|
||||
if str("module") != "" || str("values") != "" || str("node") != "" || on("clear") || on("replace") ||
|
||||
on("history") || str("list") != "" || on("propose") || (on("proposals") && str("proposal") != "") {
|
||||
return nil, errors.New("settings: proposals lists what was proposed and proposal shows one; either takes nothing else")
|
||||
}
|
||||
argv := []string{"settings", "proposals"}
|
||||
if id := str("proposal"); id != "" {
|
||||
argv = append(argv, id)
|
||||
}
|
||||
return argv, nil
|
||||
}
|
||||
if str("module") == "" && on("propose") {
|
||||
return nil, errors.New("settings: a module is needed to propose a layer; name it with module")
|
||||
}
|
||||
if list := str("list"); list != "" || str("module") == "" {
|
||||
if list != "" && list != "preferences" {
|
||||
return nil, fmt.Errorf("settings lists %q only; %q is not a listing", "preferences", list)
|
||||
@@ -822,6 +882,22 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
}
|
||||
var argv []string
|
||||
switch {
|
||||
case on("propose"):
|
||||
// A proposal: the values, or clear, put to the operator (novox/hq ADR 0277). Nothing is set here.
|
||||
argv = []string{"settings", "propose", str("module")}
|
||||
switch {
|
||||
case on("clear") && str("values") != "":
|
||||
return nil, errors.New("settings: a proposal gives values or says clear, not both")
|
||||
case on("clear"):
|
||||
argv = append(argv, "--clear")
|
||||
case str("values") != "":
|
||||
argv = append(argv, str("values"))
|
||||
if on("replace") {
|
||||
argv = append(argv, "--replace")
|
||||
}
|
||||
default:
|
||||
return nil, errors.New("settings: a proposal gives the values, or says clear")
|
||||
}
|
||||
case on("clear"):
|
||||
argv = []string{"settings", "clear", str("module")}
|
||||
case str("values") != "":
|
||||
@@ -907,6 +983,8 @@ func repairingCommand(argv []string) string {
|
||||
return "plans " + argv[1]
|
||||
case argv[0] == "broker" && len(argv) > 1 && argv[1] == "consumer-reset":
|
||||
return "broker consumer-reset"
|
||||
case argv[0] == "hand-act" && len(argv) > 1 && argv[1] == "warrant":
|
||||
return "" // the router's record of a person's answer, never a repair (novox/hq ADR 0274)
|
||||
case argv[0] == "hand-act" && len(argv) > 1 && argv[1] == "drill":
|
||||
return "hand-act drill"
|
||||
case argv[0] == "hand-act":
|
||||
@@ -1092,6 +1170,9 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) {
|
||||
if verb == "dead-letters" {
|
||||
return deadLettersAnswer(ctx, a)
|
||||
}
|
||||
if verb == "root-free" {
|
||||
return rootFreeAnswer(ctx, a.given["machines"], rootClock())
|
||||
}
|
||||
if verb == "doctor" {
|
||||
// From the serving controller, which runs the self-check and hears the signals
|
||||
// (novox/hq to-be 45 §4): the last verdict at once, or a run now.
|
||||
@@ -1182,7 +1263,10 @@ func actsOnAPlan(args map[string]any) bool {
|
||||
var inProcess = map[string]bool{"tools": true, "calls": true, "doctor": true,
|
||||
// What a consumer gave up on, read and changed on the serving controller's own connection (novox/hq
|
||||
// issue 330).
|
||||
"dead-letters": true}
|
||||
"dead-letters": true,
|
||||
// Whether a machine is root-free, judged live on the serving controller's store and connection (novox/hq ADR
|
||||
// 0259 §8): the router asks it before an approval.
|
||||
"root-free": true}
|
||||
|
||||
// answersFirst is a command line whose caller is answered before it runs: a push, by its verb or
|
||||
// through `command`. A push sends the machine holding the bus first when its user list changed, the
|
||||
@@ -1282,7 +1366,7 @@ func splitCommandLine(line string) ([]string, error) {
|
||||
var words []string
|
||||
var cur strings.Builder
|
||||
inWord := false
|
||||
quote := rune(0)
|
||||
quote, opened := rune(0), 0
|
||||
runes := []rune(line)
|
||||
for i := 0; i < len(runes); i++ {
|
||||
r := runes[i]
|
||||
@@ -1303,7 +1387,7 @@ func splitCommandLine(line string) ([]string, error) {
|
||||
cur.WriteRune(r)
|
||||
}
|
||||
case r == '\'' || r == '"':
|
||||
quote = r
|
||||
quote, opened = r, i
|
||||
inWord = true
|
||||
case r == '\\' && i+1 < len(runes):
|
||||
i++
|
||||
@@ -1321,7 +1405,7 @@ func splitCommandLine(line string) ([]string, error) {
|
||||
}
|
||||
}
|
||||
if quote != 0 {
|
||||
return nil, fmt.Errorf("command has an unclosed %c quote", quote)
|
||||
return nil, unclosedQuote(quote, opened)
|
||||
}
|
||||
if inWord {
|
||||
words = append(words, cur.String())
|
||||
@@ -1329,6 +1413,17 @@ func splitCommandLine(line string) ([]string, error) {
|
||||
return words, nil
|
||||
}
|
||||
|
||||
// unclosedQuote is the refusal of a line whose quote is never closed. Most often an apostrophe inside
|
||||
// a single-quoted value ended that quote early and a later quote was left open, so the refusal says
|
||||
// where the open quote is and how a quote is written inside a quoted value — the shell's own two
|
||||
// ways, which this splitter already reads (novox/hq issue 294). It quotes none of the line: a refusal
|
||||
// is kept on the bus as the call's answer, and the line may carry a setting's value.
|
||||
func unclosedQuote(quote rune, opened int) error {
|
||||
return fmt.Errorf("command has an unclosed %c quote, opened at character %d — an apostrophe "+
|
||||
"inside a single-quoted value ends it; write a ' inside single quotes as '\\'' (it'\\''s), or use "+
|
||||
"double quotes and write \\\" for a \" and \\\\ for a \\ inside them", quote, opened+1)
|
||||
}
|
||||
|
||||
// seatAnnouncement is what the controller says it serves on the bus (novox/hq ADR 0197): the
|
||||
// mesh-controller seat, one endpoint per verb it answers, each with the seat's own description and
|
||||
// argument schema — the same facts `tools` answers from the records, as NATS's services format.
|
||||
@@ -1406,7 +1501,7 @@ var commandReadForms = map[string]func(rest []string) bool{
|
||||
"conditions": func(r []string) bool { return flagsOnly(r) || subIn(r, "list", "show", "history") },
|
||||
"node": func(r []string) bool { return subIn(r, "list", "show") },
|
||||
"module": func(r []string) bool { return subIn(r, "list") },
|
||||
"settings": func(r []string) bool { return subIn(r, "show", "preferences") },
|
||||
"settings": func(r []string) bool { return subIn(r, "show", "preferences", "proposals") },
|
||||
"retire": func(r []string) bool { return subIn(r, "list") },
|
||||
"cleanup": func(r []string) bool { return subIn(r, "list") },
|
||||
"delivery": func(r []string) bool { return subIn(r, "plan", "walks") },
|
||||
@@ -1466,6 +1561,24 @@ var terminalOnlyCommands = map[string]string{
|
||||
"licence": "the licences' secrets",
|
||||
}
|
||||
|
||||
// givenAtTheDesk is exactly the line the `give` and `secret-ask` verbs compose, and nothing beside it: `secret ask
|
||||
// <node> <module> <secret>`, with `--at <machine>` or no other word — no value, no file, no provider (novox/hq
|
||||
// ADR 0277). The terminal's own `secret accept … --at-desk` is the terminal's.
|
||||
func givenAtTheDesk(argv []string) bool {
|
||||
if (len(argv) != 5 && len(argv) != 7) || argv[0] != "secret" || argv[1] != "ask" {
|
||||
return false
|
||||
}
|
||||
for _, w := range argv[2:5] {
|
||||
if w == "" || strings.HasPrefix(w, "-") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if len(argv) == 5 {
|
||||
return true
|
||||
}
|
||||
return argv[5] == "--at" && argv[6] != "" && !strings.HasPrefix(argv[6], "-")
|
||||
}
|
||||
|
||||
// terminalOnly refuses, through any verb, a command that is the operator's at the controller's terminal
|
||||
// alone (novox/hq ADR 0266). **Every `node` subcommand that is not a read**: `node account` and
|
||||
// `node agent-account` above all. Whoever may call a verb includes agents, and an agent that named itself
|
||||
@@ -1479,8 +1592,10 @@ func terminalOnly(argv []string) error {
|
||||
return terminalRefusal("%s is run at the controller's terminal only, never through a verb: it holds %s, and "+
|
||||
"whoever may call a verb includes agents (novox/hq ADR 0266). Nothing was done", argv[0], what)
|
||||
}
|
||||
// Of a secret's commands only rotation, which seals the new value to the machine that uses it.
|
||||
if argv[0] == "secret" && (len(argv) < 2 || argv[1] != "rotate") {
|
||||
// Of a secret's commands only rotation, which seals the new value to the machine that uses it, and the
|
||||
// `give` verb's own line: an own secret typed by the operator into the desk's hidden prompt, sealed to this
|
||||
// call and then to the module's machine, so no value travels in the verb or its answer (hq ADR 0259 §10).
|
||||
if argv[0] == "secret" && (len(argv) < 2 || argv[1] != "rotate") && !givenAtTheDesk(argv) {
|
||||
return terminalRefusal("secret %s is run at the controller's terminal only, never through a verb: accepting, "+
|
||||
"recovering or exporting a secret hands it to whoever asks, and that includes agents (novox/hq ADR "+
|
||||
"0266). Nothing was done", strings.Join(argv[1:], " "))
|
||||
|
||||
@@ -275,6 +275,7 @@ var accountedFlags = map[string]map[string]string{
|
||||
"json": "set by the verb: the answer is data",
|
||||
"all": "withheld: every measurement of a fortnight is more than a call should carry; `command` reaches it",
|
||||
},
|
||||
// The desk path of `secret accept` (novox/hq ADR 0259 §10): a value is never an argument of a call.
|
||||
"hand-acts": {"json": "set by the verb: the answer is data"},
|
||||
"conditions": {"json": "set by the verb: the answer is data"},
|
||||
"retire": {"json": "set by the verb: the answer is data"},
|
||||
|
||||
@@ -415,3 +415,48 @@ func TestNoVerbSetsTheOperatorsKeyOrRevealsASecret(t *testing.T) {
|
||||
t.Fatalf("behind %v: %v", behind, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A value with a quote in it can be said on a `command` line, as in a shell, and a line whose quote
|
||||
// is left open is refused naming where it opened and how a quote is written, quoting none of it (novox/hq issue 294: an
|
||||
// apostrophe inside a single-quoted JSON value cut the line, and the refusal named no cause).
|
||||
func TestAQuotedValueCanHoldAQuote(t *testing.T) {
|
||||
for _, c := range []struct{ line, want string }{
|
||||
{`settings set claude-code '{"role":"the operator'\''s laptop"}'`, `{"role":"the operator's laptop"}`},
|
||||
{`settings set claude-code "{\"role\":\"the operator's laptop\"}"`, `{"role":"the operator's laptop"}`},
|
||||
{"x \"a \\\\ b\nc\"", "a \\ b\nc"},
|
||||
{`x 'a\b'`, `a\b`},
|
||||
} {
|
||||
argv, err := splitCommandLine(c.line)
|
||||
if err != nil || argv[len(argv)-1] != c.want {
|
||||
t.Errorf("%s: read back %q %v, want %q", c.line, argv, err, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := splitCommandLine(`settings set claude-code '{"role":"the operator's laptop"}' --node g14`)
|
||||
if err == nil {
|
||||
t.Fatal("an apostrophe that leaves a quote open was accepted")
|
||||
}
|
||||
for _, want := range []string{"character 57", `'\''`, `\"`} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not say %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), "laptop") || strings.Contains(err.Error(), "g14") {
|
||||
t.Errorf("the refusal quotes the line, which may carry a setting's value: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Every value reads back as it was when written the way the refusal says: single-quoted with '\”
|
||||
// for each quote, or double-quoted with \ before each " and \ — newlines and backslashes included.
|
||||
func TestAQuotedValueRoundTrips(t *testing.T) {
|
||||
for _, v := range []string{`plain`, `it's`, `say "hi"`, `back\slash\`, "two\nlines", `'"\'\"`, `''`, ``} {
|
||||
single := "x '" + strings.ReplaceAll(v, "'", `'\''`) + "'"
|
||||
double := `x "` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(v) + `"`
|
||||
for _, line := range []string{single, double} {
|
||||
argv, err := splitCommandLine(line)
|
||||
if err != nil || len(argv) != 2 || argv[1] != v {
|
||||
t.Errorf("%s: read back %q %v, want %q", line, argv, err, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ func secretCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
switch args[0] {
|
||||
case "accept":
|
||||
case "ask":
|
||||
return secretAsk(ctx, args[1:])
|
||||
case "rotate":
|
||||
return secretRotate(ctx, args[1:])
|
||||
case "recover":
|
||||
@@ -55,6 +57,9 @@ func secretCommand(ctx context.Context, args []string) error {
|
||||
provider := set.String("provider", "",
|
||||
"the node providing <name>: the value becomes the PAIR credential between <module> on <node> "+
|
||||
"and that provider, sealed to both — the vault's operator-delivered secret (ADR 0092)")
|
||||
desk := set.String("at-desk", "",
|
||||
"ask the operator for the value in a prompt that does not show it, on this machine's desk; the "+
|
||||
"answer comes back sealed to this call alone (novox/hq ADR 0259 §10)")
|
||||
local := set.String("local", "",
|
||||
"with --provider: the name the credential goes by inside <module>, where its manifest keeps "+
|
||||
"several for <name> (ADR 0094)")
|
||||
@@ -65,6 +70,18 @@ func secretCommand(ctx context.Context, args []string) error {
|
||||
return errors.New(secretUsage)
|
||||
}
|
||||
node, module, name := rest[0], rest[1], rest[2]
|
||||
// A value comes from the terminal or the desk, never through a verb (the review of 2026-10-09, M4): a
|
||||
// verb's caller may be an agent, and a value it chose would become what a module acts with.
|
||||
if verb, through := throughAVerb(); through && *desk == "" {
|
||||
return fmt.Errorf("a secret's value is given at the controller's terminal or at the desk (`give`), never "+
|
||||
"through a verb (this line came through %q): nothing was read or sealed", verb)
|
||||
}
|
||||
if *desk != "" {
|
||||
if *from != "" || *provider != "" {
|
||||
return errors.New("--at-desk gives a module's own secret, and takes neither --from nor --provider")
|
||||
}
|
||||
return askAtDesk(ctx, node, module, name, *desk)
|
||||
}
|
||||
|
||||
value, err := valueFor(node, module, name, *from)
|
||||
if err != nil {
|
||||
@@ -93,10 +110,26 @@ func secretCommand(ctx context.Context, args []string) error {
|
||||
fmt.Printf(" run `push %s` and `push %s` to send it\n", *provider, node)
|
||||
return nil
|
||||
}
|
||||
untilStart, err := open.inventory.AcceptGivenSecret(ctx, node, module, name, value)
|
||||
// A trusted party's secret is announced before it is kept (the confirmation review of 2026-10-09, N1-give):
|
||||
// on every channel, the one it replaces among them, which still runs on its old value until the next push.
|
||||
// Not announced, it is not kept: a channel whose token changed unheard of answers for somebody else.
|
||||
trusted, err := open.inventory.RunsAsItsOwnAccount(ctx, module)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
untilStart, unannounced, err := keepGiven(trusted,
|
||||
func() error { return announceSecretGiven(ctx, node, module, name, "at the controller's terminal") },
|
||||
func() (bool, error) { return open.inventory.AcceptGivenSecret(ctx, node, module, name, value) })
|
||||
if err != nil {
|
||||
if trusted && unannounced != nil {
|
||||
return fmt.Errorf("%s runs as an account of its own, and the change of its %s could not be announced on "+
|
||||
"your channels first, so nothing was kept: %w", module, name, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if unannounced != nil {
|
||||
fmt.Printf(" this change could NOT be announced on the operator's channels: %v\n", unannounced)
|
||||
}
|
||||
// Not printed back, and there is nowhere it could be printed from: it is sealed to that
|
||||
// machine and the mesh cannot read it again.
|
||||
fmt.Printf("%s on %s now holds %q, sealed to that machine.\n", module, node, name)
|
||||
@@ -117,8 +150,25 @@ func secretCommand(ctx context.Context, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// secretAsk is `secret ask <node> <module> <name> [--at <machine>]` (novox/hq ADR 0277): the operator is asked
|
||||
// for a module's own secret in a prompt at the desk, which only they answer. The one `secret` line a verb may
|
||||
// run beside rotate (givenAtTheDesk): it carries no value and answers none.
|
||||
func secretAsk(ctx context.Context, args []string) error {
|
||||
rest, flags := split(args)
|
||||
set := flag.NewFlagSet("secret ask", flag.ContinueOnError)
|
||||
at := set.String("at", "", "the machine the operator sits at, where the prompt opens; the module's machine when absent")
|
||||
if err := set.Parse(flags); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rest) != 3 {
|
||||
return errors.New("secret ask <node> <module> <name> [--at <machine>]")
|
||||
}
|
||||
return askAtDesk(ctx, rest[0], rest[1], rest[2], *at)
|
||||
}
|
||||
|
||||
const secretUsage = "secret rotate <node> <module> <name> [--why <text> [--cause <word>]]\n" +
|
||||
"secret accept <node> <module> <name> [--from <file>] [--provider <node> [--local <name>]]\n" +
|
||||
"secret ask <node> <module> <name> [--at <machine>]\n" +
|
||||
"secret accept <node> <module> <name> [--from <file> | --at-desk <machine>] [--provider <node> [--local <name>]]\n" +
|
||||
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
|
||||
"secret export [--out <file>]"
|
||||
|
||||
@@ -369,6 +419,8 @@ func valueFor(node, module, name, from string) (string, error) {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"reading %s's %q for %s from standard input; it is not echoed anywhere\n",
|
||||
module, name, node)
|
||||
// At a terminal, what is typed is not shown either: echo off while it is read.
|
||||
defer hideTyping(os.Stdin)()
|
||||
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
if err != nil && line == "" {
|
||||
return "", fmt.Errorf("nothing was given on standard input: %w", err)
|
||||
@@ -452,3 +504,23 @@ func whoAsked() string {
|
||||
}
|
||||
return "the mesh"
|
||||
}
|
||||
|
||||
// keepGiven keeps a value given at the controller's terminal, and announces it on the operator's channels
|
||||
// (the confirmation review of 2026-10-09, N1-give). **A trusted party's — a module running as an account of its
|
||||
// own: the router, a verified channel — is announced before it is kept, and not kept when the announcement
|
||||
// fails**: a channel whose token changed unheard of answers for somebody else. Any other module's is kept first
|
||||
// and announced after, and a failed announcement is said (unannounced) without undoing it.
|
||||
func keepGiven(trusted bool, announce func() error, keep func() (bool, error)) (untilStart bool, unannounced, err error) {
|
||||
if trusted {
|
||||
if err := announce(); err != nil {
|
||||
return false, err, err
|
||||
}
|
||||
untilStart, err = keep()
|
||||
return untilStart, nil, err
|
||||
}
|
||||
untilStart, err = keep()
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
return untilStart, announce(), nil
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ const (
|
||||
|
||||
// callBounds are the verbs that may run longer than callDefault, and how long (S7).
|
||||
var callBounds = map[string]time.Duration{
|
||||
"push": 30 * time.Minute, "rotate": 30 * time.Minute, "assign": 15 * time.Minute,
|
||||
"push": 30 * time.Minute, "rotate": 30 * time.Minute, "give": 5 * time.Minute, "assign": 15 * time.Minute,
|
||||
"unassign": 15 * time.Minute, "command": 30 * time.Minute, "doctor": 3 * time.Minute,
|
||||
}
|
||||
|
||||
@@ -210,6 +210,22 @@ var signalsTable = []signalRow{
|
||||
newest: func(f *signalFacts) time.Time {
|
||||
return newestOf(f.waits, func(w waitFacts) time.Time { return w.since })
|
||||
}},
|
||||
{Row: "S18", Signal: "a batch of merges is cut into its walk", Emitter: "controller's merge window",
|
||||
Trigger: "each batch (novox/hq ADR 0276)",
|
||||
Bound: "a minute past merge-window-at-most, while no walk is open: the controller failed to cut it",
|
||||
Kind: kindBatchNotCut, Severity: conditions.Warning, Phase: 3,
|
||||
needs: func(f *signalFacts) error { return f.plansErr }, watch: watchBatchesNotCut,
|
||||
newest: func(f *signalFacts) time.Time {
|
||||
return newestOf(f.batches, func(b batchFacts) time.Time { return b.atMost })
|
||||
}},
|
||||
{Row: "S19", Signal: "a batch waiting behind an open walk is cut when it ends", Emitter: "controller's merge window",
|
||||
Trigger: "each batch closed while a walk is open (novox/hq ADR 0276)",
|
||||
Bound: "the walk's bound, a tier's bound for each of its tiers, from when the batch closed: naming the walk",
|
||||
Kind: kindBatchBehindWalk, Severity: conditions.Warning, Phase: 3,
|
||||
needs: func(f *signalFacts) error { return f.plansErr }, watch: watchBatchesBehind,
|
||||
newest: func(f *signalFacts) time.Time {
|
||||
return newestOf(f.batches, func(b batchFacts) time.Time { return b.closed })
|
||||
}},
|
||||
{Row: "S17", Signal: "a send held for the bus's planned step is told to a person", Emitter: "controller's plan",
|
||||
Trigger: "each send refused because it would replace the bus outside its step (novox/hq issue 336)",
|
||||
Bound: "none: raised at the first refusal, for the operator, naming what waits, the bus build from and to, " +
|
||||
@@ -372,17 +388,31 @@ func watchWaits(f *signalFacts) []conditions.Observation {
|
||||
out = append(out, conditions.Observation{Scope: conditions.ScopePlan, ID: w.id, Kind: kindWalkWaiting,
|
||||
Severity: severity,
|
||||
Summary: fmt.Sprintf("the walk of %s %s has waited %s for %s's word to start: `mesh-delivery.show` for the "+
|
||||
"delivery that landed as %s says why; `plans go %s --why …` starts it by hand", w.repository,
|
||||
short(w.commit), ago(in), w.awaits, short(w.commit), w.id),
|
||||
"deliveries that landed as %s says why; `plans go %s --why …` starts it by hand", w.repository,
|
||||
short(w.commit), ago(in), w.awaits, mergesWords(w), w.id),
|
||||
Said: fmt.Sprintf("waiting since %s for %s", w.since.UTC().Format(time.RFC3339), w.awaits),
|
||||
Headline: deliveryName(w.modules, w.repository) + " waiting to start",
|
||||
Explanation: walkWaitingWords(w, in, severity),
|
||||
Needs: waitingNeeds(severity),
|
||||
Actions: waitingActions(w.id, severity),
|
||||
Resolved: deliveryName(w.modules, w.repository) + " no longer waiting"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergesWords is the merges a waiting walk answers (novox/hq ADR 0276): every delivery it carries, not one
|
||||
// commit that a supersession may already have ended (issue 362). Its own commit for a walk naming none.
|
||||
func mergesWords(w waitFacts) string {
|
||||
if len(w.merges) == 0 {
|
||||
return short(w.commit)
|
||||
}
|
||||
var out []string
|
||||
for _, m := range w.merges {
|
||||
out = append(out, m.Repository+"@"+short(m.Commit))
|
||||
}
|
||||
return readableList(out)
|
||||
}
|
||||
|
||||
func watchLoop(f *signalFacts) []conditions.Observation {
|
||||
if f.loop.pending == 0 {
|
||||
return nil
|
||||
|
||||
@@ -142,6 +142,28 @@ var suppressions = map[string]suppression{
|
||||
since: f.now.Add(-31 * time.Minute)}}
|
||||
},
|
||||
},
|
||||
// A batch still assembling past its maximum with no walk open (novox/hq ADR 0276): a minute's grace.
|
||||
"S18": {
|
||||
inside: func(f *signalFacts) {
|
||||
f.batches = []batchFacts{{id: "plan-3", state: inventory.PlanAssembling, grouped: "novox/app@c0ffee11",
|
||||
atMost: f.now.Add(-59 * time.Second), closed: f.now.Add(-59 * time.Second)}}
|
||||
},
|
||||
past: func(f *signalFacts) {
|
||||
f.batches = []batchFacts{{id: "plan-3", state: inventory.PlanAssembling, grouped: "novox/app@c0ffee11",
|
||||
atMost: f.now.Add(-61 * time.Second), closed: f.now.Add(-61 * time.Second)}}
|
||||
},
|
||||
},
|
||||
// A batch queued behind an open walk past that walk's bound, named.
|
||||
"S19": {
|
||||
inside: func(f *signalFacts) {
|
||||
f.batches = []batchFacts{{id: "plan-3", state: inventory.PlanQueued, grouped: "novox/app@c0ffee11",
|
||||
closed: f.now.Add(-59 * time.Minute), behind: "plan-1", walkBound: time.Hour}}
|
||||
},
|
||||
past: func(f *signalFacts) {
|
||||
f.batches = []batchFacts{{id: "plan-3", state: inventory.PlanQueued, grouped: "novox/app@c0ffee11",
|
||||
closed: f.now.Add(-61 * time.Minute), behind: "plan-1", walkBound: time.Hour}}
|
||||
},
|
||||
},
|
||||
// A send refused because it would replace the bus outside its planned step: said at its first refusal,
|
||||
// whatever the bound (novox/hq issue 336). Inside: a new bus build waits, and no send was refused for it.
|
||||
"S17": {
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// novox/hq issue 254, ADR 0218: a newer plan takes over what the older open plans of its repository
|
||||
// and branch had not built, and closes them as superseded; another repository's plan, another
|
||||
// branch's, and a plan made after it are left alone.
|
||||
func TestANewerPlanSupersedesTheOlderOpenPlansOfItsRepository(t *testing.T) {
|
||||
at := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
|
||||
sent := at.Add(time.Minute)
|
||||
plan := func(id, repository, branch string, created time.Time, modules map[string]*inventory.PlanModule) inventory.Plan {
|
||||
return inventory.Plan{ID: id, Repository: repository, Branch: branch, Commit: id + "-commit",
|
||||
Created: created, State: inventory.PlanRolling, Modules: modules}
|
||||
}
|
||||
older := plan("plan-1", "novox/mesh-catalog", "main", at, map[string]*inventory.PlanModule{
|
||||
"gitea": {State: "built", SentAt: &sent}, // done with: stays done
|
||||
"keycloak": {State: "asked"}, // asked, not answered: folded
|
||||
"plex": {}, // not yet asked: folded
|
||||
"agent": {State: "built"}, // built, rolls out, not sent: folded
|
||||
"notes": {State: "built"}, // built, records: nothing to send
|
||||
})
|
||||
stuck := plan("plan-0", "Novox/Mesh-Catalog", "", at.Add(-time.Hour), map[string]*inventory.PlanModule{
|
||||
"runtime": {State: "asked"},
|
||||
})
|
||||
other := plan("plan-2", "novox/mesh-controller", "main", at, map[string]*inventory.PlanModule{"mesh-controller": {}})
|
||||
release := plan("plan-3", "novox/mesh-catalog", "release", at, map[string]*inventory.PlanModule{"lemurs": {}})
|
||||
later := plan("plan-5", "novox/mesh-catalog", "main", at.Add(2*time.Hour), map[string]*inventory.PlanModule{"later": {}})
|
||||
done := plan("plan-6", "novox/mesh-catalog", "main", at, map[string]*inventory.PlanModule{"finished": {}})
|
||||
done.State = inventory.PlanDone
|
||||
|
||||
newer := plan("plan-4", "novox/mesh-catalog", "main", at.Add(time.Hour), nil)
|
||||
newer.Commit = "97b1b2b0c0ffee"
|
||||
rollsOut := func(m string) bool { return m != "notes" }
|
||||
folded, closed := supersededBy(newer, []inventory.Plan{stuck, older, other, release, later, done, newer}, rollsOut)
|
||||
|
||||
if want := []string{"agent", "keycloak", "plex", "runtime"}; !reflect.DeepEqual(folded, want) {
|
||||
t.Fatalf("folded %v, wanted %v", folded, want)
|
||||
}
|
||||
var ids []string
|
||||
for _, p := range closed {
|
||||
ids = append(ids, p.ID)
|
||||
if p.State != inventory.PlanSuperseded || p.Open() {
|
||||
t.Errorf("%s was left %s", p.ID, p.State)
|
||||
}
|
||||
if !strings.Contains(p.Note, "plan-4") || !strings.Contains(p.Note, "97b1b2b0") {
|
||||
t.Errorf("%s does not name the plan that superseded it: %q", p.ID, p.Note)
|
||||
}
|
||||
}
|
||||
if want := []string{"plan-0", "plan-1"}; !reflect.DeepEqual(ids, want) {
|
||||
t.Fatalf("superseded %v, wanted %v — another repository, another branch, a later plan and a "+
|
||||
"finished one are left alone", ids, want)
|
||||
}
|
||||
if other.State != inventory.PlanRolling {
|
||||
t.Fatal("the plan handed in was changed in place")
|
||||
}
|
||||
if line := planLine(closed[1], time.Now()); !strings.Contains(line, "superseded") {
|
||||
t.Fatalf("a superseded plan reads %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq issue 254: a person closes a plan that will not move again, by its id.
|
||||
func TestAPersonClosesAStuckPlan(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
stuck := inventory.Plan{ID: "plan-97b1b2b", Repository: "novox/mesh-catalog", Commit: "97b1b2b",
|
||||
Created: time.Now().UTC(), State: inventory.PlanRolling, Tier: 1, Tiers: [][]string{{"a"}, {"b"}},
|
||||
Modules: map[string]*inventory.PlanModule{"a": {State: "built"}, "b": {}}}
|
||||
if err := open.inventory.SavePlan(ctx, &stuck); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := plansCommand(ctx, []string{"close", stuck.ID}); err == nil || !strings.Contains(err.Error(), "--why") {
|
||||
t.Fatalf("a plan was closed by hand without saying why: %v", err)
|
||||
}
|
||||
if err := plansCommand(ctx, []string{"close", stuck.ID, "--why", "its report will not come"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
closed, err := open.inventory.PlanByID(ctx, stuck.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if closed.State != inventory.PlanFailed || !strings.Contains(closed.Note, "closed by hand") ||
|
||||
!strings.Contains(closed.Note, "its report will not come") {
|
||||
t.Fatalf("the plan was left %s: %q", closed.State, closed.Note)
|
||||
}
|
||||
if err := plansCommand(ctx, []string{"close", stuck.ID, "--why", "again"}); err == nil {
|
||||
t.Fatal("a plan already closed was closed again")
|
||||
}
|
||||
if argv, err := argvFor("plans", map[string]any{"close": stuck.ID, "why": "w"}); err != nil ||
|
||||
!reflect.DeepEqual(argv, []string{"plans", "close", stuck.ID, "--why", "w"}) {
|
||||
t.Fatalf("the seat's verb does not close a plan: %v %v", argv, err)
|
||||
}
|
||||
}
|
||||
+302
-163
@@ -12,6 +12,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/builder"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
@@ -297,27 +298,20 @@ func notNow(err error) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// SourceMoved is the forge announcing a merge: every module recorded as built from that
|
||||
// repository and branch is marked as moved to the merge commit, and built — bases first, so a
|
||||
// module that stands on another's artifact is built after it and not against the old one
|
||||
// (novox/hq 04-ISSUES/131). Nothing is pushed here: what a finished build does to the machines
|
||||
// running the module is the upgrade's decision, taken when the catalogue announces it.
|
||||
// SourceMoved is the forge announcing a merge. It is put into the open batch (novox/hq ADR 0276), which
|
||||
// becomes one walk when its merge window closes: hearMerge, and cutBatchesHeld in batches.go.
|
||||
func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
||||
// One merge acted on at a time, whoever hands it over: the bus, or the catch-up that reads back
|
||||
// what the bus did not hand over (novox/hq issue 266). Each judges against what the other wrote.
|
||||
actingOnMerges.Lock()
|
||||
defer actingOnMerges.Unlock()
|
||||
|
||||
inv := f.open.inventory
|
||||
entries, err := inv.Catalogued(ctx)
|
||||
if err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
read, err := inv.ReadRepositories(ctx)
|
||||
if err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
return f.hearMerge(ctx, m, time.Now().UTC())
|
||||
}
|
||||
|
||||
// movesOfMerge is what one merge moves, acted on: every module recorded as built from that repository and
|
||||
// branch is marked as moved to the merge commit; a module the merge deleted is forgotten or said; a new module
|
||||
// is asked for and sent nowhere. The names returned are what the walk builds, bases first along the graph
|
||||
// (novox/hq 04-ISSUES/131). Nothing is built or pushed here: the walk asks its tiers. Called when a batch is
|
||||
// cut, once per repository, with the latest merge of the repository's branch and every file the batch's
|
||||
// merges of it changed.
|
||||
func movesOfMerge(ctx context.Context, inv *inventory.Inventory, m link.SourceMoved, entries []inventory.Entry,
|
||||
read map[string][]inventory.ReadRepository) ([]string, error) {
|
||||
from, packaging, already := mergeCandidates(m, entries, read)
|
||||
if len(from) == 0 && len(packaging) == 0 {
|
||||
// "Already built from it" and "nothing reads it" are different facts, and reading the first
|
||||
@@ -325,17 +319,11 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
||||
if already > 0 {
|
||||
fmt.Printf("%s/%s merged into %s (%.8s); %d module(s) the mesh holds are already built "+
|
||||
"from it\n", m.Owner, m.Repo, m.Base, m.Commit, already)
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
fmt.Printf("%s/%s merged into %s (%.8s); nothing the mesh holds reads it\n",
|
||||
m.Owner, m.Repo, m.Base, m.Commit)
|
||||
return nil
|
||||
}
|
||||
// The same judgement for the packaging kind, against the newest look at that repository by
|
||||
// anything built from it: they keep no record of it themselves, and a replayed old merge should
|
||||
// not rebuild them either.
|
||||
if isHistory(m.MergedAt, lastLookAt(entries, m)) {
|
||||
packaging = nil
|
||||
return nil, nil
|
||||
}
|
||||
// Said, never silent (novox/hq 04-ISSUES/215): a module built from this repository that follows
|
||||
// another branch is not part of this merge, and whoever is waiting for its change should read why.
|
||||
@@ -345,7 +333,7 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
||||
e.Manifest.Module, m.Owner, m.Repo, e.Source.Ref, m.Base)
|
||||
}
|
||||
}
|
||||
touched, added, _ := touchedBy(from, entries, m)
|
||||
touched, added, _ := touchedBy(from, entries, m, read)
|
||||
// **A module the merge deleted is not built** (novox/hq ADR 0236): its manifest is gone, so the build
|
||||
// seat finds nothing saying what it is, and the plan failed on it (`has no module.json at …`) with
|
||||
// every other module of its tier left unsent. It is forgotten where nothing holds it, said otherwise.
|
||||
@@ -355,7 +343,7 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
||||
}
|
||||
for _, e := range touched {
|
||||
if err := inv.SourceMoved(ctx, e.Manifest.Module, m.Commit); err != nil {
|
||||
return notNow(err)
|
||||
return nil, notNow(err)
|
||||
}
|
||||
}
|
||||
// **A new module is built and registered, and sent nowhere** (novox/hq issue 300): the delivery plan
|
||||
@@ -367,117 +355,25 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
||||
fmt.Printf("%s/%s merged into %s (%.8s); it changed no module the mesh holds, and adds %s: "+
|
||||
"built, registered when the build lands, and sent nowhere\n", m.Owner, m.Repo, m.Base, m.Commit,
|
||||
strings.Join(built, ", "))
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
fmt.Printf("%s/%s merged into %s (%.8s); it changed nothing any module the mesh holds is "+
|
||||
"built from\n", m.Owner, m.Repo, m.Base, m.Commit)
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
// A merge produces a plan the mesh keeps (novox/hq ADR 0162): what moved and everything that
|
||||
// depends on it, along the catalogue's one dependency relation, sorted into tiers. The plan is
|
||||
// written before any build is asked; the first tier is asked; this returns. Outcomes advance it.
|
||||
edges, err := inv.Dependencies(ctx)
|
||||
if err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
var movedNames []string
|
||||
// Why each is in it (issue 363): the files of its build source the merge changed, or why it is read whole.
|
||||
for _, e := range moved {
|
||||
movedNames = append(movedNames, e.Manifest.Module)
|
||||
fmt.Printf(" %s: %s\n", e.Manifest.Module, whyMoved(e, read[e.Manifest.Module], m))
|
||||
}
|
||||
// Written and its first tier asked as one act on the plans (novox/hq issue 213): a timer on
|
||||
// another controller reading it between the two would ask the tier again.
|
||||
release, err := inv.HoldPlans(ctx, true)
|
||||
if err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
defer release()
|
||||
plan := planOfMerge(m, movedNames, edges)
|
||||
// **A newer plan supersedes the older open plans of this repository and branch** (novox/hq issue
|
||||
// 254, ADR 0218): what they had not built is planned here again, and they are closed, so one plan
|
||||
// works a repository's modules at a time and a stuck one ends at the next merge.
|
||||
working, err := inv.OpenPlans(ctx)
|
||||
if err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
rollsOut := func(module string) bool {
|
||||
u, err := inv.UpgradeOf(ctx, module)
|
||||
return err == nil && u.RollOut
|
||||
}
|
||||
folded, superseded := supersededBy(plan, working, rollsOut)
|
||||
if len(folded) > 0 {
|
||||
held := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
held[e.Manifest.Module] = true
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, name := range movedNames {
|
||||
names[name] = true
|
||||
}
|
||||
var also []string
|
||||
for _, name := range folded {
|
||||
// One the catalogue no longer holds would fail the newer plan's ask; it is not this
|
||||
// merge's to build.
|
||||
if held[name] && !names[name] {
|
||||
names[name] = true
|
||||
movedNames = append(movedNames, name)
|
||||
also = append(also, name)
|
||||
}
|
||||
}
|
||||
if len(also) > 0 {
|
||||
again := planOfMerge(m, movedNames, edges)
|
||||
again.ID, again.Created = plan.ID, plan.Created
|
||||
plan = again
|
||||
fmt.Printf(" %s, left unbuilt by an older plan of %s, are planned here again\n",
|
||||
strings.Join(also, ", "), plan.Repository)
|
||||
}
|
||||
}
|
||||
// **Whether it waits for its delivery's word** (novox/hq ADR 0239): while the mesh-delivery seat has a
|
||||
// holder on record, a walk that moves no module on the controller's own path is opened and waits.
|
||||
plan.Delivery = awaitsFor(entries, movedNames)
|
||||
if hasCycle(plan.Tiers, edges) {
|
||||
fmt.Printf(" the last tier depends on itself: %s — built together, in no order\n",
|
||||
strings.Join(plan.Tiers[len(plan.Tiers)-1], ", "))
|
||||
}
|
||||
if err := inv.SavePlan(ctx, &plan); err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
// Closed after the newer plan is kept, never before: a controller replaced between the two leaves
|
||||
// both open, which the next merge settles, rather than neither.
|
||||
for _, old := range superseded {
|
||||
if err := inv.SavePlan(ctx, &old); err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
fmt.Printf(" %s (%s at %s) is %s\n", old.ID, old.Repository, short(old.Commit), old.Note)
|
||||
}
|
||||
var tiers []string
|
||||
for i, t := range plan.Tiers {
|
||||
tiers = append(tiers, fmt.Sprintf("%d: %s", i, strings.Join(t, ", ")))
|
||||
}
|
||||
fmt.Printf("%s/%s merged into %s (%.8s); plan %s, %d module(s) in %d tier(s)\n %s\n",
|
||||
m.Owner, m.Repo, m.Base, m.Commit, plan.ID, len(plan.Modules), len(plan.Tiers), strings.Join(tiers, "\n "))
|
||||
if len(packaging) > 0 {
|
||||
var also []string
|
||||
for _, e := range packaging {
|
||||
also = append(also, e.Manifest.Module)
|
||||
fmt.Printf(" %s reads %s/%s through its build context: its own source record is left where it is\n",
|
||||
e.Manifest.Module, m.Owner, m.Repo)
|
||||
}
|
||||
fmt.Printf(" %s package source from it, so they are rebuilt and their own source record "+
|
||||
"is left where it is\n", strings.Join(also, ", "))
|
||||
var names []string
|
||||
for _, e := range moved {
|
||||
names = append(names, e.Manifest.Module)
|
||||
}
|
||||
if plan.Waiting() {
|
||||
plan.Note = waitingNote(plan)
|
||||
if err := inv.SavePlan(ctx, &plan); err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
fmt.Printf(" %s waits for %s's word before its first tier is asked\n", plan.ID, plan.Delivery.Awaits)
|
||||
return nil
|
||||
}
|
||||
if err := askTier(ctx, inv, &plan); err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
if err := inv.SavePlan(ctx, &plan); err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
return nil
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// askNewModules asks the build seat for every module a merge adds to the repository — a directory holding a
|
||||
@@ -555,25 +451,119 @@ func mergeCandidates(m link.SourceMoved, entries []inventory.Entry,
|
||||
}
|
||||
from = append(from, e)
|
||||
case readsFrom(read[e.Manifest.Module], m):
|
||||
// **A merge older than the module's last look is history for it** (novox/hq ADR 0267): a
|
||||
// build or plan of it after the merge already read the repository with the merge in it. Per
|
||||
// module, since a merge that moved only the module built from the repository says nothing
|
||||
// about the ones packaging it.
|
||||
// Judged with a margin for the forge's clock running behind the store's: too late a look
|
||||
// rebuilds once more, too early one would miss the merge.
|
||||
if lookedAtCommit(read[e.Manifest.Module], m.Commit) {
|
||||
continue
|
||||
}
|
||||
if looked := lookedOf(read[e.Manifest.Module]); !looked.IsZero() &&
|
||||
isHistory(m.MergedAt, looked.Add(-historyMargin)) {
|
||||
continue
|
||||
}
|
||||
packaging = append(packaging, e)
|
||||
}
|
||||
}
|
||||
return from, packaging, already
|
||||
}
|
||||
|
||||
// wouldMove is the modules built from the merged repository that acting on this merge would mark as
|
||||
// moved and rebuild — SourceMoved's judgement, made without acting (novox/hq issue 266). Empty for a
|
||||
// merge already acted on: acting marks each of them as looked at, so the merge then reads as history.
|
||||
// lookedAtCommit is whether a plan that built a module, or is building it, answered this merge commit.
|
||||
func lookedAtCommit(read []inventory.ReadRepository, commit string) bool {
|
||||
for _, r := range read {
|
||||
if slices.Contains(r.LookedAt, commit) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// historyMargin is how far a packaging module's last look is taken back before a merge is history for it.
|
||||
const historyMargin = time.Minute
|
||||
|
||||
// whyMoved is why a merge moves a module, as a plan says it (novox/hq ADR 0267, issue 363): the changed
|
||||
// files in its build source, or why it is read whole. For a module built from the merged repository or one
|
||||
// whose build context is that repository; a dependent is in a plan for what it stands on.
|
||||
func whyMoved(e inventory.Entry, read []inventory.ReadRepository, m link.SourceMoved) string {
|
||||
if len(m.Paths) == 0 || m.PathsTruncated {
|
||||
return "read whole: the merge's changed files were not all said"
|
||||
}
|
||||
whole := "no build source recorded"
|
||||
for _, r := range read {
|
||||
if r.Own && r.Whole != "" {
|
||||
whole = r.Whole
|
||||
}
|
||||
}
|
||||
held := func(paths []string) []string {
|
||||
var in []string
|
||||
for _, p := range m.Paths {
|
||||
if builder.SourceHolds(paths, p) {
|
||||
in = append(in, p)
|
||||
}
|
||||
}
|
||||
return in
|
||||
}
|
||||
changed := func(where string, in []string) string {
|
||||
return fmt.Sprintf("its build source%s changed: %d changed file(s) in it, e.g. %s", where, len(in), in[0])
|
||||
}
|
||||
if sameRepository(e.Source.Repository, m) {
|
||||
if own := ownSource(read); own != nil {
|
||||
if in := held(own); len(in) > 0 {
|
||||
return changed("", in)
|
||||
}
|
||||
}
|
||||
dir := strings.Trim(e.Source.Path, "/")
|
||||
if dir == "" {
|
||||
return "read whole: " + whole + ", so every file of its repository is its build source"
|
||||
}
|
||||
for _, p := range m.Paths {
|
||||
if inside(p, dir) {
|
||||
return "read whole: " + whole + ", so its directory is its build source; e.g. " + p
|
||||
}
|
||||
}
|
||||
return "read whole: " + whole
|
||||
}
|
||||
for _, r := range read {
|
||||
if r.Own || !sameRepository(r.Repository, m) || (r.Ref != "" && r.Ref != m.Base) {
|
||||
continue
|
||||
}
|
||||
if len(r.Paths) > 0 {
|
||||
if in := held(r.Paths); len(in) > 0 {
|
||||
return changed(" in "+m.Owner+"/"+m.Repo, in)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return "read whole: " + whole + ", so every file of " + m.Owner + "/" + m.Repo + ", which its build context is, is its build source"
|
||||
}
|
||||
return "read whole: " + whole
|
||||
}
|
||||
|
||||
// lookedOf is when a module packaging another repository was last looked at, as readForPlanning says.
|
||||
func lookedOf(read []inventory.ReadRepository) time.Time {
|
||||
var at time.Time
|
||||
for _, r := range read {
|
||||
if r.Looked.After(at) {
|
||||
at = r.Looked
|
||||
}
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
// wouldMove is the modules acting on this merge would move and rebuild — SourceMoved's judgement, made
|
||||
// without acting (novox/hq issue 266). Empty for a merge already acted on: acting marks each module built
|
||||
// from the repository as looked at, so the merge then reads as history for it.
|
||||
//
|
||||
// **Only the modules built from it, never the ones that merely package source from it.** Acting
|
||||
// records nothing about those, so a merge acted on would go on reading as unacted for them, and be
|
||||
// acted on again on every look. A merge that moves both is caught by the first kind, and acting on it
|
||||
// rebuilds the second as well.
|
||||
// **The ones packaging source from it too** (novox/hq ADR 0267): with a module moved only by the files of
|
||||
// its build source, a merge can move a packaging module and nothing built from the repository, and a missed
|
||||
// one of those was never acted on. A packaging module's look is its newest build or plan (lookedAt), so a
|
||||
// merge acted on for it reads as history once its plan is made.
|
||||
func wouldMove(m link.SourceMoved, entries []inventory.Entry,
|
||||
read map[string][]inventory.ReadRepository) []inventory.Entry {
|
||||
from, _, _ := mergeCandidates(m, entries, read)
|
||||
touched, _ := splitDeleted(whatTheMergeTouched(from, entries, m), m)
|
||||
return touched
|
||||
from, packaging, _ := mergeCandidates(m, entries, read)
|
||||
touched, _ := splitDeleted(whatTheMergeTouched(from, entries, m, read), m)
|
||||
return append(touched, packaging...)
|
||||
}
|
||||
|
||||
// splitDeleted parts the modules a merge touched into those it changed and those whose manifest it
|
||||
@@ -657,34 +647,171 @@ func sameRepository(repository string, m link.SourceMoved) bool {
|
||||
(m.CloneURL != "" && repo == strings.ToLower(strings.TrimSuffix(m.CloneURL, ".git")))
|
||||
}
|
||||
|
||||
// readsFrom is whether a module's build read the repository a merge names: the second repository its
|
||||
// recipe packages source from. Its ref must be the branch that moved, or unset — the same rule a
|
||||
// module's own source follows.
|
||||
// readsFrom is whether a merge changed what a module's build read in another repository: the second
|
||||
// repository its recipe packages source from. Its ref must be the branch that moved, or unset — the same
|
||||
// rule a module's own source follows.
|
||||
//
|
||||
// **Only a changed file in what the build read there** (novox/hq ADR 0267 rule 2): where the module's
|
||||
// newest trunk build said its build source in that repository, a merge touching none of it is no change
|
||||
// to the module (issue 338: every merge to the controller's repository moved the route proxy and the
|
||||
// build seat's holder). Where it said none, or the merge's files are not all said, the whole repository is
|
||||
// read, as before.
|
||||
func readsFrom(read []inventory.ReadRepository, m link.SourceMoved) bool {
|
||||
for _, r := range read {
|
||||
if sameRepository(r.Repository, m) && (r.Ref == "" || r.Ref == m.Base) {
|
||||
if r.Own || !sameRepository(r.Repository, m) || (r.Ref != "" && r.Ref != m.Base) {
|
||||
continue
|
||||
}
|
||||
if len(r.Paths) == 0 || len(m.Paths) == 0 || m.PathsTruncated {
|
||||
return true
|
||||
}
|
||||
for _, p := range m.Paths {
|
||||
if builder.SourceHolds(r.Paths, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// lastLookAt is the most recent look at this repository by anything built from it.
|
||||
func lastLookAt(entries []inventory.Entry, m link.SourceMoved) time.Time {
|
||||
var newest time.Time
|
||||
for _, e := range entries {
|
||||
if sameRepository(e.Source.Repository, m) && e.Source.Seen.After(newest) {
|
||||
newest = e.Source.Seen
|
||||
// ownSource is the build source a module's newest trunk build said it read in its own repository; nil
|
||||
// when it said none, and the module's own directory — or, built from the root, its whole repository — is
|
||||
// its build source, as before (novox/hq ADR 0267).
|
||||
func ownSource(read []inventory.ReadRepository) []string {
|
||||
for _, r := range read {
|
||||
if r.Own && len(r.Paths) > 0 {
|
||||
return r.Paths
|
||||
}
|
||||
}
|
||||
return newest
|
||||
return nil
|
||||
}
|
||||
|
||||
// readsFile is whether a module built from the merged repository reads one of its changed files: in its
|
||||
// build source where its newest trunk build said one, else anywhere in its directory, or anywhere at all
|
||||
// for a module built from the repository's root.
|
||||
func readsFile(e inventory.Entry, read []inventory.ReadRepository, p string) bool {
|
||||
if own := ownSource(read); own != nil {
|
||||
return builder.SourceHolds(own, p)
|
||||
}
|
||||
return strings.Trim(e.Source.Path, "/") == "" || inside(p, e.Source.Path)
|
||||
}
|
||||
|
||||
// staleIn is the modules whose recorded build source a plan has overtaken (novox/hq ADR 0267): a plan still
|
||||
// working that has yet to build one, or a plan made after that build which never built it — failed, stopped
|
||||
// or superseded. What such a module is built from is changing, or changed without a build to say so: a merge
|
||||
// that added an import to it, and a later one changing only what that import names, would otherwise move
|
||||
// nothing. Each is read whole, as before, until a build of it works again.
|
||||
//
|
||||
// Each is answered with the plan that overtook it, for saying why it is read whole.
|
||||
func staleIn(read map[string][]inventory.ReadRepository, plans []inventory.Plan) map[string]string {
|
||||
stale := map[string]string{}
|
||||
for name, rs := range read {
|
||||
var since time.Time
|
||||
for _, r := range rs {
|
||||
if r.Built.After(since) {
|
||||
since = r.Built
|
||||
}
|
||||
}
|
||||
for _, p := range plans {
|
||||
s, in := p.Modules[name]
|
||||
if !in || (s != nil && (s.State == "built" || s.State == planDeleted)) {
|
||||
continue
|
||||
}
|
||||
if p.Open() || p.Created.After(since) {
|
||||
stale[name] = p.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
return stale
|
||||
}
|
||||
|
||||
// lookedAt is when a merge was last acted on for a module that packages another repository's source: its
|
||||
// newest build, or the newest plan that built it or is still building it, whichever is later. A build asked
|
||||
// after a merge clones that repository with the merge in it, so an older merge is history for it; a plan
|
||||
// that closed without building it looked at nothing.
|
||||
func lookedAt(name string, read []inventory.ReadRepository, plans []inventory.Plan) time.Time {
|
||||
var at time.Time
|
||||
for _, r := range read {
|
||||
if r.Looked.After(at) {
|
||||
at = r.Looked
|
||||
}
|
||||
}
|
||||
for _, p := range plans {
|
||||
s, in := p.Modules[name]
|
||||
if in && (p.Open() || (s != nil && s.State == "built")) && p.Created.After(at) {
|
||||
at = p.Created
|
||||
}
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
// readForPlanning is what each module's build read, as the planner maps a change onto it — for a merge
|
||||
// acting now, the merge gate, a pull request's check, a delivery's order and the what-if alike, so planning
|
||||
// and gating cannot disagree (novox/hq ADR 0238): the build sources the newest trunk builds said, but for
|
||||
// the modules a plan has overtaken (staleIn), and with when each was last looked at (lookedAt).
|
||||
func readForPlanning(ctx context.Context, inv *inventory.Inventory) (map[string][]inventory.ReadRepository, error) {
|
||||
read, err := inv.ReadRepositories(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Every plan since the oldest build whose source is recorded: one made after a module's build can have
|
||||
// overtaken it, however long ago, so no window of recent plans would do.
|
||||
var oldest time.Time
|
||||
for _, rs := range read {
|
||||
for _, r := range rs {
|
||||
if !r.Built.IsZero() && (oldest.IsZero() || r.Built.Before(oldest)) {
|
||||
oldest = r.Built
|
||||
}
|
||||
}
|
||||
}
|
||||
plans, err := inv.PlansSince(ctx, oldest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return planningView(read, plans), nil
|
||||
}
|
||||
|
||||
// planningView is readForPlanning over what was read, so a test can hand it records.
|
||||
func planningView(read map[string][]inventory.ReadRepository, plans []inventory.Plan) map[string][]inventory.ReadRepository {
|
||||
stale := staleIn(read, plans)
|
||||
out := make(map[string][]inventory.ReadRepository, len(read))
|
||||
for name, rs := range read {
|
||||
looked := lookedAt(name, rs, plans)
|
||||
var commits []string
|
||||
for _, p := range plans {
|
||||
if st, in := p.Modules[name]; in && p.Commit != "" && (p.Open() || (st != nil && st.State == "built")) {
|
||||
// Every commit the walk carries (novox/hq ADR 0276): a batch's walk answers one per repository.
|
||||
for _, c := range p.Carried() {
|
||||
commits = append(commits, c.Commit)
|
||||
}
|
||||
}
|
||||
}
|
||||
var kept []inventory.ReadRepository
|
||||
overtaken, isStale := stale[name]
|
||||
for _, r := range rs {
|
||||
if isStale {
|
||||
if r.Own {
|
||||
continue
|
||||
}
|
||||
r.Paths = nil
|
||||
}
|
||||
r.Looked, r.LookedAt = looked, commits
|
||||
kept = append(kept, r)
|
||||
}
|
||||
if isStale {
|
||||
kept = append(kept, inventory.ReadRepository{Own: true, Whole: "plan " + overtaken + " has not built it yet",
|
||||
Looked: looked, LookedAt: commits})
|
||||
}
|
||||
out[name] = kept
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// whatTheMergeTouched narrows the modules built from a repository to the ones the merge changed: **a
|
||||
// changed file touches exactly the modules whose build reads it** (novox/hq issue 280, ADR 0238). It is
|
||||
// touchedBy's first answer; touchedBy is the one place the mesh maps a changed file onto its modules.
|
||||
func whatTheMergeTouched(candidates, known []inventory.Entry, m link.SourceMoved) []inventory.Entry {
|
||||
touched, _, _ := touchedBy(candidates, known, m)
|
||||
func whatTheMergeTouched(candidates, known []inventory.Entry, m link.SourceMoved,
|
||||
read map[string][]inventory.ReadRepository) []inventory.Entry {
|
||||
touched, _, _ := touchedBy(candidates, known, m, read)
|
||||
return touched
|
||||
}
|
||||
|
||||
@@ -692,8 +819,11 @@ func whatTheMergeTouched(candidates, known []inventory.Entry, m link.SourceMoved
|
||||
// merge handler, the release planner's what-if, the merge gate and a pull request's check alike (novox/hq
|
||||
// ADR 0238), so planning and gating cannot disagree about what a change touches.
|
||||
//
|
||||
// **A changed file touches exactly the modules whose build reads it.** What a build reads is the module's
|
||||
// own directory — the builder clones the repository and builds within that directory alone: the manifest,
|
||||
// **A changed file touches exactly the modules whose build reads it.** What a build reads is its build
|
||||
// source, where the module's newest trunk build said one (novox/hq ADR 0267): a Go program's import closure,
|
||||
// an archive's directory, a recipe, its manifest — so a README at the root of a repository whose module is
|
||||
// built from its root, or another program's package beside it, touches nothing. Where none was said, it is
|
||||
// the module's own directory — the builder clones the repository and builds within that directory alone: the manifest,
|
||||
// the recipes, the bundles' sources, the Docker context — or the whole repository for a module built from
|
||||
// its root. A second repository a recipe packages (an artifact's `context`) is read too; that is the build
|
||||
// record's `read`, answered by readsFrom in mergeCandidates. So a changed file inside a module's directory
|
||||
@@ -713,7 +843,8 @@ func whatTheMergeTouched(candidates, known []inventory.Entry, m link.SourceMoved
|
||||
//
|
||||
// Nothing said about the files, or not all of them said, is still everything: what is not known cannot
|
||||
// be narrowed.
|
||||
func touchedBy(candidates, known []inventory.Entry, m link.SourceMoved) (touched []inventory.Entry, added, unread []string) {
|
||||
func touchedBy(candidates, known []inventory.Entry, m link.SourceMoved,
|
||||
read map[string][]inventory.ReadRepository) (touched []inventory.Entry, added, unread []string) {
|
||||
knownDirs := map[string]bool{}
|
||||
for _, e := range known {
|
||||
if !e.Provided && sameRepository(e.Source.Repository, m) {
|
||||
@@ -748,19 +879,27 @@ func touchedBy(candidates, known []inventory.Entry, m link.SourceMoved) (touched
|
||||
return candidates, added, nil
|
||||
}
|
||||
for _, e := range candidates {
|
||||
if strings.Trim(e.Source.Path, "/") == "" || anyInside(m.Paths, e.Source.Path) {
|
||||
for _, p := range m.Paths {
|
||||
if readsFile(e, read[e.Manifest.Module], p) {
|
||||
touched = append(touched, e)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, p := range m.Paths {
|
||||
read := newDir["."]
|
||||
isRead := newDir["."]
|
||||
for _, e := range candidates {
|
||||
read = read || strings.Trim(e.Source.Path, "/") == "" || inside(p, e.Source.Path)
|
||||
isRead = isRead || readsFile(e, read[e.Manifest.Module], p)
|
||||
}
|
||||
for d := range newDir {
|
||||
read = read || inside(p, d)
|
||||
isRead = isRead || inside(p, d)
|
||||
}
|
||||
if !read {
|
||||
// A file a module packages from this repository is read too, by that module's build.
|
||||
for _, e := range known {
|
||||
isRead = isRead || readsFrom(read[e.Manifest.Module], link.SourceMoved{Owner: m.Owner, Repo: m.Repo,
|
||||
Base: m.Base, CloneURL: m.CloneURL, Paths: []string{p}})
|
||||
}
|
||||
if !isRead {
|
||||
unread = append(unread, p)
|
||||
}
|
||||
}
|
||||
@@ -819,7 +958,7 @@ func (r mergeReach) Dependents() []string {
|
||||
func reachOfMerge(m link.SourceMoved, entries []inventory.Entry, read map[string][]inventory.ReadRepository,
|
||||
edges []inventory.Edge) mergeReach {
|
||||
from, packaging, already := mergeCandidates(m, entries, read)
|
||||
touched, added, unread := touchedBy(from, entries, m)
|
||||
touched, added, unread := touchedBy(from, entries, m, read)
|
||||
kept, deleted := splitDeleted(touched, m)
|
||||
r := mergeReach{Touched: kept, Deleted: deleted, Packaging: packaging, Already: already, Added: added, Unread: unread}
|
||||
var building []string
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package main
|
||||
|
||||
// A module's act on the operator's warrant, recorded in the hand-act log (novox/hq ADR 0274, ADR 0259 §6).
|
||||
//
|
||||
// mesh-controller hand-act warrant --asker <module> --ask <id>
|
||||
//
|
||||
// The verb `warranted` runs it. A module that asks the operator (an asker) acts on the warrant with its own grants;
|
||||
// the controller's log is where a person's decisions are read back, so the module asks the controller to record
|
||||
// it. **What is recorded is the router's word, never the caller's**: the controller reads the router's own record
|
||||
// of that asker's ask — the bus lets only the router write it — and records who chose, through which channel, with
|
||||
// which proofs, and which answer. The caller gives nothing but which ask: a word of its own, recorded first under
|
||||
// the one id, would stand for every node's (the review of 2026-10-10). Recorded once per
|
||||
// ask, under an id the ask decides, however many of the module's instances ask; an ask still open, ended without
|
||||
// a choice, or another asker's is refused and nothing is written.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
var askerModule = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
||||
|
||||
// warrantedID is the one entry an ask's warrant is recorded under.
|
||||
func warrantedID(asker, ask string) string { return "warrant-" + asker + "-" + ask }
|
||||
|
||||
// warrantedAct is the entry for an asker's act on the warrant the router recorded for its ask (state, w), or why
|
||||
// none is written.
|
||||
func warrantedAct(asker, ask, caller, state string, w *asks.Warrant) (link.HandAct, error) {
|
||||
switch {
|
||||
case !askerModule.MatchString(asker):
|
||||
return link.HandAct{}, fmt.Errorf("%q is not a module's name", asker)
|
||||
case asker == askerName:
|
||||
return link.HandAct{}, errors.New("the controller records its own acts on a warrant as it performs them")
|
||||
case !asks.UsableID(ask):
|
||||
return link.HandAct{}, fmt.Errorf("%q is not an ask's id", ask)
|
||||
case state == "" || w == nil:
|
||||
return link.HandAct{}, fmt.Errorf("the router holds no closed record of %s's ask %s", asker, ask)
|
||||
case state == "open":
|
||||
return link.HandAct{}, fmt.Errorf("%s's ask %s is still open: nobody has answered it", asker, ask)
|
||||
case w.Asker != asker || w.Ask != ask:
|
||||
return link.HandAct{}, fmt.Errorf("the router's record is for %s's ask %s", w.Asker, w.Ask)
|
||||
case w.Outcome != asks.OutcomeChosen || w.By == nil:
|
||||
return link.HandAct{}, fmt.Errorf("%s's ask %s ended %s: no person chose, so there is no warrant to record", asker, ask, w.Outcome)
|
||||
case w.AskDigest == "":
|
||||
return link.HandAct{}, fmt.Errorf("the router's warrant for %s's ask %s names no ask digest", asker, ask)
|
||||
}
|
||||
return link.HandAct{ID: warrantedID(asker, ask), Verb: handActWarrant, Args: []string{fmt.Sprintf("the operator chose %s on %s's ask %s", w.Label, asker, ask)},
|
||||
Why: fmt.Sprintf("%s (ask %s of %s)", w.Says(), ask, asker), By: byWords(*w), Cause: conditions.CauseOperatorAnswer,
|
||||
Via: viaWords(*w), Ask: ask, Proofs: w.Proofs, RequestedBy: asker + ", recorded at the word of " + caller,
|
||||
Outcome: "chosen; what " + asker + " did with it is in its own record", At: w.At.UTC()}, nil
|
||||
}
|
||||
|
||||
// readRouterRecord reads the router's record of one asker's ask: its state and warrant, or "" when there is none.
|
||||
// The controller's grant reaches the JetStream API whole (`$JS.API.>`), so it reads any asker's record.
|
||||
func readRouterRecord(ctx context.Context, conn *nats.Conn, bucket, asker, ask string) (string, *asks.Warrant, error) {
|
||||
reply, err := conn.RequestWithContext(ctx, "$JS.API.DIRECT.GET.KV_"+bucket+".$KV."+bucket+"."+asker+"."+ask, nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if status := reply.Header.Get("Status"); status != "" {
|
||||
if status == "404" {
|
||||
return "", nil, nil
|
||||
}
|
||||
return "", nil, fmt.Errorf("the router's record could not be read: %s %s", status, reply.Header.Get("Description"))
|
||||
}
|
||||
var rec struct {
|
||||
State string `json:"state"`
|
||||
Warrant *asks.Warrant `json:"warrant"`
|
||||
}
|
||||
if err := json.Unmarshal(reply.Data, &rec); err != nil {
|
||||
return "", nil, fmt.Errorf("the router's record of %s's ask %s cannot be read: %w", asker, ask, err)
|
||||
}
|
||||
return rec.State, rec.Warrant, nil
|
||||
}
|
||||
|
||||
func handActWarrantCommand(ctx context.Context, args []string) error {
|
||||
set := flag.NewFlagSet("hand-act warrant", flag.ContinueOnError)
|
||||
asker := set.String("asker", "", "the module that asked")
|
||||
ask := set.String("ask", "", "its ask's id")
|
||||
positionals, err := parseAround(set, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *asker == "" || *ask == "" || len(positionals) > 0 {
|
||||
return errors.New("hand-act warrant --asker <module> --ask <id>: what is recorded is the router's record, and nothing else")
|
||||
}
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer open.Close()
|
||||
bucket, err := asksRecords(ctx, open.inventory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if bucket == "" {
|
||||
return errors.New("no module declares the operator channel's records, so no warrant can be read")
|
||||
}
|
||||
return onTheBus(func(conn *nats.Conn) error {
|
||||
state, w, err := readRouterRecord(ctx, conn, bucket, *asker, *ask)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
act, err := warrantedAct(*asker, *ask, link.Caller(), state, w)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w. Nothing was recorded", err)
|
||||
}
|
||||
written, err := link.RecordHandActOnce(ctx, conn, act)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the warrant could not be recorded: %w", err)
|
||||
}
|
||||
if !written {
|
||||
fmt.Printf("already recorded as %s: %s\n", act.ID, act.Why)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("recorded as %s: %s, through %s\n", act.ID, act.Why, act.Via)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
)
|
||||
|
||||
func chosenWarrant() *asks.Warrant {
|
||||
return &asks.Warrant{Ask: "instr-1", Asker: "claude-code", Outcome: asks.OutcomeChosen, Option: "approve",
|
||||
Label: "Approve", Level: asks.Approve, Channel: "telegram", Proofs: []string{"P1"},
|
||||
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
|
||||
At: time.Date(2026, 10, 10, 4, 0, 0, 0, time.UTC), AskDigest: "sha256:ab"}
|
||||
}
|
||||
|
||||
// What is recorded of a module's act on a warrant is the router's word (novox/hq ADR 0274): who chose, how and
|
||||
// with which proofs; the caller gives only what it did. Nothing is recorded without a person's choice.
|
||||
func TestAWarrantIsRecordedFromTheRoutersRecordAlone(t *testing.T) {
|
||||
act, err := warrantedAct("claude-code", "instr-1", "node-tools.shanks", "chosen", chosenWarrant())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if act.ID != "warrant-claude-code-instr-1" || act.Verb != handActWarrant || act.Cause != conditions.CauseOperatorAnswer ||
|
||||
act.By != "the operator, as telegram identity 42" || act.Ask != "instr-1" || !slices.Equal(act.Proofs, []string{"P1"}) ||
|
||||
!strings.Contains(act.Why, "the operator, via telegram (user id verified), chose Approve") ||
|
||||
!strings.Contains(act.RequestedBy, "node-tools.shanks") ||
|
||||
!slices.Equal(act.Args, []string{"the operator chose Approve on claude-code's ask instr-1"}) {
|
||||
t.Fatalf("recorded as %+v", act)
|
||||
}
|
||||
for name, c := range map[string]struct {
|
||||
asker, ask, state string
|
||||
w func() *asks.Warrant
|
||||
}{
|
||||
"no record": {"claude-code", "instr-1", "", func() *asks.Warrant { return nil }},
|
||||
"still open": {"claude-code", "instr-1", "open", chosenWarrant},
|
||||
"another asker's": {"messenger", "instr-1", "chosen", chosenWarrant},
|
||||
"another ask's": {"claude-code", "instr-2", "chosen", chosenWarrant},
|
||||
"the controller's": {"mesh-controller", "instr-1", "chosen", chosenWarrant},
|
||||
"not a module": {"Claude Code", "instr-1", "chosen", chosenWarrant},
|
||||
"expired": {"claude-code", "instr-1", "expired", func() *asks.Warrant {
|
||||
w := chosenWarrant()
|
||||
w.Outcome, w.By = asks.OutcomeExpired, nil
|
||||
return w
|
||||
}},
|
||||
"no digest": {"claude-code", "instr-1", "chosen", func() *asks.Warrant {
|
||||
w := chosenWarrant()
|
||||
w.AskDigest = ""
|
||||
return w
|
||||
}},
|
||||
} {
|
||||
if _, err := warrantedAct(c.asker, c.ask, "x", c.state, c.w()); err == nil {
|
||||
t.Errorf("%s: recorded", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheWarrantedVerbRunsTheWarrantLineWithoutAWhy(t *testing.T) {
|
||||
if _, err := argvFor("warranted", map[string]any{"asker": "claude-code", "ask": "instr-1", "what": "a word of the caller's"}); err == nil {
|
||||
t.Error("the caller's own words were taken into the record")
|
||||
}
|
||||
argv, err := argvFor("warranted", map[string]any{"asker": "claude-code", "ask": "instr-1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(argv, []string{"hand-act", "warrant", "--asker", "claude-code", "--ask", "instr-1"}) {
|
||||
t.Fatalf("%v", argv)
|
||||
}
|
||||
if repairingCommand(argv) != "" {
|
||||
t.Error("recording a person's answer is taken for a repair")
|
||||
}
|
||||
if terminalOnly(argv) != nil {
|
||||
t.Error("the verb is kept for the terminal")
|
||||
}
|
||||
if _, err := argvFor("warranted", map[string]any{"asker": "claude-code"}); err == nil {
|
||||
t.Error("a call naming no ask was taken")
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,8 @@ type signalFacts struct {
|
||||
plansErr error
|
||||
// waits are the walks waiting for their delivery's word (S16, novox/hq ADR 0239).
|
||||
waits []waitFacts
|
||||
// batches are the batches not yet cut (S18, S19, novox/hq ADR 0276).
|
||||
batches []batchFacts
|
||||
|
||||
loop loopFacts
|
||||
loopErr error
|
||||
@@ -159,6 +161,8 @@ type waitFacts struct {
|
||||
since time.Time
|
||||
// modules are the modules the walk moves, as a person names the delivery.
|
||||
modules []string
|
||||
// merges are the merges it answers (novox/hq ADR 0276), as the deliveries to read are found.
|
||||
merges []inventory.PlanMerge
|
||||
}
|
||||
|
||||
type loopFacts struct {
|
||||
@@ -326,6 +330,9 @@ func (w *watchdogs) gather(ctx context.Context) *signalFacts {
|
||||
f.machines, f.machinesErr = w.gatherMachines(ctx, inv, now)
|
||||
f.bus, f.busErr = gatherBus(ctx, inv)
|
||||
f.plans, f.waits, f.plansErr = gatherPlans(ctx, inv, now, f.bus.heldByTheBus())
|
||||
if f.plansErr == nil {
|
||||
f.batches, f.plansErr = gatherBatches(ctx, inv, now)
|
||||
}
|
||||
f.loop, f.loopErr = w.gatherLoop()
|
||||
f.mergesPassed, f.merges, f.mergesErr = watchedMerges.last()
|
||||
if f.mergesErr == nil && !f.mergesPassed.IsZero() && now.Sub(f.mergesPassed) > 3*mergeCatchUpEvery {
|
||||
@@ -480,7 +487,12 @@ func gatherPlans(ctx context.Context, inv *inventory.Inventory, now time.Time, b
|
||||
// S16's, whatever mesh-delivery says or does not say.
|
||||
if p.Waiting() {
|
||||
waits = append(waits, waitFacts{id: p.ID, repository: p.Repository, commit: p.Commit,
|
||||
awaits: p.Delivery.Awaits, since: p.Created, modules: planModules(p)})
|
||||
awaits: p.Delivery.Awaits, since: p.Created, modules: planModules(p), merges: p.Delivery.Merges})
|
||||
continue
|
||||
}
|
||||
// A walk let go and waiting for the walk before it to end (ADR 0276) is no tier late either: the walk
|
||||
// before it is the one S3 watches.
|
||||
if deferred(p) {
|
||||
continue
|
||||
}
|
||||
_, paused := pausedWaiting(p, pause, now)
|
||||
@@ -700,6 +712,9 @@ func watchTheMesh(ctx context.Context, open *stores, server *link.Server, bus li
|
||||
// under the lease and the brake, every act said.
|
||||
healers := newHealing(open, keeper, bus, server.JetStream())
|
||||
go healers.keep(watching)
|
||||
// And the asker (novox/hq ADR 0259): what needs the operator and names its answers is asked of them,
|
||||
// and the answer chosen is performed on its warrant.
|
||||
startAsking(watching, open, server, bus.Conn, keeper)
|
||||
go forgettingOldHeals(watching, open.inventory)
|
||||
fmt.Printf("watching the mesh: %d signal(s) every %s, %d probe(s) every %s; what is wrong is kept in %s "+
|
||||
"and said as %s events\n", len(watchedRows()), watchEvery, len(runnableProbes()), doctorEvery,
|
||||
|
||||
@@ -19,10 +19,12 @@ func TestTheBuildSeatsHolderFollowsTheControllerThatDefinesItsWorker(t *testing.
|
||||
{From: "route-proxy", To: "mesh-controller", Kind: inventory.EdgePackages},
|
||||
{From: "route-proxy", To: "build-agent", Kind: inventory.EdgeBuiltBy},
|
||||
}
|
||||
set := reachableFrom([]string{"mesh-controller"}, edges)
|
||||
if len(set) != 3 {
|
||||
t.Fatalf("the controller, what packages it, and nothing more: %v", set)
|
||||
// A packages edge recorded before novox/hq ADR 0267 widens nothing: the controller moved alone moves
|
||||
// alone, and a change to a package all three build from moves all three, each by its own build source.
|
||||
if alone := reachableFrom([]string{"mesh-controller"}, edges); len(alone) != 1 {
|
||||
t.Fatalf("the controller alone, whatever packages its repository: %v", alone)
|
||||
}
|
||||
set := reachableFrom([]string{"mesh-controller", "build-agent", "route-proxy"}, edges)
|
||||
tiers := tiersOf(set, edges)
|
||||
pos := map[string]int{}
|
||||
for i, tier := range tiers {
|
||||
|
||||
@@ -3,11 +3,14 @@ module github.com/novox/mesh-controller
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970
|
||||
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
|
||||
github.com/novox/mesh-host v0.0.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/sys v0.48.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -19,12 +22,9 @@ require (
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
github.com/minio/highwayhash v1.0.4 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
||||
github.com/nats-io/nats-server/v2 v2.11.17 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
@@ -35,4 +35,4 @@ require (
|
||||
// committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and
|
||||
// fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without
|
||||
// `go mod vendor` fails loudly, at once, everywhere.
|
||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d
|
||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009231844-b8c854611812
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261006095519-3e80b7ae325e h1:g9h4QRaAMg5yaJLwqtb0FoOs23DVGUYpW6qvnQ3oY5A=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261006095519-3e80b7ae325e/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261007120832-bdd44154ccac h1:KvnKtJ2rWeIE/t4GweK+JL0OjKSNxsrVP3/nMdpii8o=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261007120832-bdd44154ccac/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac h1:yLtFS0pDCCqIE9Zx8hgXEFG9fUWzf8L9WQoKV+Amk1E=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261009081005-b28d7bbcbff4 h1:f4rBnKSemuN0Z9dTtRJMigIGfEs6ltFPOILJGHGab74=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261009081005-b28d7bbcbff4/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261009101157-2673e7a2c95e h1:H7eVqDILL6e9cMbWSLHTbCqu9ZxDOmyeQhUmWl9QBV0=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261009101157-2673e7a2c95e/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d h1:IrmJ+lz21n+eSqKrmXREtR/7raUCBJ+fZvs+BNhuXVI=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261009231844-b8c854611812 h1:pzVzwF5VMWaTECxu8+Pd1dNoOHNEm7upC5wPadQTkBw=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261009231844-b8c854611812/go.mod h1:K3/xEzVgmrNKLMV2vv4M80MwmPnQNXqvQ4C5Jj0fJT4=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970 h1:9tFDQsgmI+4X7/BpZGXIr+HemPKE7YddYGqWV0lINAI=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970/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=
|
||||
@@ -46,8 +38,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0259 §6: the controller asks the operator through the router's seat as any user of it, under
|
||||
// its own name, hears its own warrants, reads its own record, and calls the verbs a warrant chooses.
|
||||
func TestTheControllerAsksUnderItsOwnNameAndCallsTheVerbsAWarrantChooses(t *testing.T) {
|
||||
records := Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": {
|
||||
{Module: "messenger", Holds: []Seat{operatorChannel()}},
|
||||
}}}
|
||||
users, err := Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := perms(t, users[0])
|
||||
for _, s := range []string{
|
||||
"mesh.seat.operator-channel.accept.ask.mesh-controller",
|
||||
"mesh.seat.operator-channel.accept.cancel.mesh-controller",
|
||||
"$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.mesh-controller.c1",
|
||||
"mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stop",
|
||||
"mesh.seat.node-service-manager.tool.restart.g14", "mesh.seat.mesh-controller.tool.plans",
|
||||
} {
|
||||
if !allowed(got.Publish, s) {
|
||||
t.Errorf("the controller may not publish %s", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{
|
||||
"mesh.seat.operator-channel.accept.ask.mesh-delivery",
|
||||
"mesh.seat.operator-channel.event.decided.mesh-controller",
|
||||
// (A direct get of another asker's record is not refused here: the controller holds the whole
|
||||
// JetStream API, as the only writer of stream definitions.)
|
||||
"mesh.seat.node-service-manager.tool.stop.g14",
|
||||
} {
|
||||
if allowed(got.Publish, s) {
|
||||
t.Errorf("the controller may publish %s", s)
|
||||
}
|
||||
}
|
||||
if !allowed(got.Subscribe, DecidedSubject) || allowed(got.Subscribe, "mesh.seat.operator-channel.event.decided.mesh-delivery") {
|
||||
t.Error("the controller does not hear exactly its own warrants")
|
||||
}
|
||||
// Its events consumer carries them, so a controller that was away hears what was decided meanwhile.
|
||||
if !slices.Contains(ControllerFollows, DecidedSubject) {
|
||||
t.Error("the controller does not follow its warrants")
|
||||
}
|
||||
// Without a holder of the seat it is granted no ask at all.
|
||||
alone, _ := Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{}})
|
||||
if allowed(perms(t, alone[0]).Publish, "mesh.seat.operator-channel.accept.ask.mesh-controller") {
|
||||
t.Error("asked a seat nobody holds")
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,15 @@ var (
|
||||
// LeaseBucket holds the controller's lease (to-be 45 §6): one key, `holder`, which the instance
|
||||
// allowed to act writes by compare-and-set and renews; its revision when taken is the epoch.
|
||||
LeaseBucket = BucketName(ControllerSeat, "lease")
|
||||
// AskedBucket keeps what the controller asked the operator about its conditions (novox/hq ADR 0259):
|
||||
// each ask by its id, its options and the actions they stand for, how it ended and whether the
|
||||
// controller acted on its warrant — so a restart neither asks twice nor acts twice.
|
||||
AskedBucket = BucketName(ControllerSeat, "asked")
|
||||
)
|
||||
|
||||
// AskedKeptFor is how long an ask is kept after it was made: a month, as the router keeps its own.
|
||||
const AskedKeptFor = 30 * 24 * time.Hour
|
||||
|
||||
// LeaseTTL is how long the lease's key lives unrenewed (to-be 45 §6): fifteen seconds, renewed
|
||||
// every five. The bucket's age, so the bus forgets a holder that stopped renewing.
|
||||
const LeaseTTL = 15 * time.Second
|
||||
@@ -59,12 +66,12 @@ const (
|
||||
// IsControllerBucket says a bucket is the controller's own, not a module's state nothing declares.
|
||||
func IsControllerBucket(bucket string) bool {
|
||||
return bucket == CallsBucket || bucket == HandActsBucket || bucket == ConditionsBucket ||
|
||||
bucket == ConditionHistoryBucket || bucket == LeaseBucket
|
||||
bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket
|
||||
}
|
||||
|
||||
// ControllerBuckets are the controller's own buckets, in the order they are asserted.
|
||||
func ControllerBuckets() []string {
|
||||
return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket}
|
||||
return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket}
|
||||
}
|
||||
|
||||
// ControllerBucketsAsserter is what raising the controller's buckets needs of a connection.
|
||||
@@ -149,6 +156,18 @@ func (j *JetStream) EnsureControllerBuckets() error {
|
||||
}); err != nil {
|
||||
return fmt.Errorf("asserting bucket %s: %w", ConditionHistoryBucket, err)
|
||||
}
|
||||
if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
|
||||
Bucket: AskedBucket,
|
||||
Description: "what the controller asked the operator about its conditions, and what came of each (novox/hq " +
|
||||
"ADR 0259): written by the controller alone; an ask acted on is acted on once",
|
||||
History: 1,
|
||||
TTL: AskedKeptFor,
|
||||
MaxValueSize: 32 << 10,
|
||||
MaxBytes: 32 << 20,
|
||||
Storage: jetstream.FileStorage,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("asserting bucket %s: %w", AskedBucket, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
)
|
||||
|
||||
// **The controller may write every bucket it writes** (novox/hq to-be 45 §1, issue 269). Writing a
|
||||
@@ -59,3 +65,34 @@ func TestTheWatchedSignalsMayBeSaidAndHeard(t *testing.T) {
|
||||
t.Error("the controller may not ask who answers, or hears every API call")
|
||||
}
|
||||
}
|
||||
|
||||
// The controller's record of what it asked the operator is bounded (correctness review of 2026-10-08): one
|
||||
// value a key, a month's age, and a size it cannot outgrow.
|
||||
func TestWhatTheControllerAskedIsBounded(t *testing.T) {
|
||||
js, err := Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer js.Close()
|
||||
if err := js.EnsureControllerBuckets(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
kv, err := jetstream.New(js.Conn())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bucket, err := kv.KeyValue(ctx, AskedBucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, err := bucket.Status(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info := status.(*jetstream.KeyValueBucketStatus).StreamInfo()
|
||||
if status.History() != 1 || status.TTL() != AskedKeptFor || info.Config.MaxBytes <= 0 || info.Config.MaxBytes > 64<<20 {
|
||||
t.Errorf("history %d, age %s, bytes %d", status.History(), status.TTL(), info.Config.MaxBytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,9 @@ func ConsumerFor(p Principal) (Consumer, bool) {
|
||||
// A module that reacts to anything — a module's events or a role's (novox/hq ADR 0121). Watching
|
||||
// a role was missing here, so the one module that does it got no consumer at all: it started,
|
||||
// connected, and its graph stayed empty with nothing anywhere reporting why.
|
||||
if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0) {
|
||||
// And one that hears its own answers on a seat it uses (novox/hq ADR 0259 §3): an asker's warrants.
|
||||
hearsItsOwn := len(SeatTrafficOf(p.Module, nil, p.Uses, nil).Subscribe) > 0
|
||||
if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0 && !hearsItsOwn) {
|
||||
return Consumer{}, false
|
||||
}
|
||||
perms, err := PermissionsFor(p)
|
||||
|
||||
@@ -2,6 +2,7 @@ package broker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
@@ -50,6 +51,12 @@ type Membership struct {
|
||||
// and refuses, with the reason, what is not on it — the bus enforces only the union over every
|
||||
// module on the machine.
|
||||
State []StateIssued `json:"state,omitempty"`
|
||||
// SeatTraffic is what this module's code may submit, say, hear, take, ask, answer and read on seats
|
||||
// that name their caller or their kind (novox/hq ADR 0259 §3). The runtime carrying the module
|
||||
// publishes, takes and answers for it only what is listed here: the bus enforces only the union
|
||||
// over every module on the machine, so one module's code reaching another's name or kind through
|
||||
// the runtime is the runtime's to refuse.
|
||||
SeatTraffic *SeatTraffic `json:"seat-traffic,omitempty"`
|
||||
}
|
||||
|
||||
// Served is one address a tool is answered on.
|
||||
@@ -78,6 +85,8 @@ type Placements struct {
|
||||
// Interchangeable is each module whose definition says its instances are the same anywhere,
|
||||
// so the module's plain subject is issued to all of them in one queue.
|
||||
Interchangeable map[string]bool
|
||||
// Kinds is every kind held of a kinded bench, by whom and with what capabilities (ADR 0259 §5).
|
||||
Kinds []KindHeld
|
||||
}
|
||||
|
||||
// AnswersForTheModule says whether an instance of a module on one machine is issued the module's
|
||||
@@ -104,11 +113,28 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
|
||||
m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}", Queue: "serve." + d.Module})
|
||||
}
|
||||
for _, s := range d.Holds {
|
||||
if servedOnlyByTheController(s) {
|
||||
continue // answered by the serving controller alone, never through a membership
|
||||
}
|
||||
for _, verb := range s.Serves {
|
||||
m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)})
|
||||
}
|
||||
}
|
||||
m.State = stateIssuedFor(d, node)
|
||||
t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches)
|
||||
for _, s := range append(append([]Seat{}, d.Uses...), d.Watches...) {
|
||||
if !s.Kinded {
|
||||
continue
|
||||
}
|
||||
for _, k := range where.Kinds {
|
||||
if k.Seat == s.Name && !kindListed(t.Kinds, k) {
|
||||
t.Kinds = append(t.Kinds, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(t.Publish)+len(t.Subscribe)+len(t.Answers)+len(t.Workers)+len(t.Records)+len(t.Kinds) > 0 {
|
||||
m.SeatTraffic = &t
|
||||
}
|
||||
if len(d.Invokes) > 0 {
|
||||
m.Reaches = map[string][]string{}
|
||||
for _, t := range d.Invokes {
|
||||
@@ -145,5 +171,67 @@ func PlacementsOf(r Records, interchangeable map[string]bool) Placements {
|
||||
for _, nodes := range p.Nodes {
|
||||
sort.Strings(nodes)
|
||||
}
|
||||
// Where the router runs: a verified sender is believed only while its machine is root-free too. A router
|
||||
// placed nowhere, or on more than one machine, frees nothing.
|
||||
var routerNodes []string
|
||||
for node, declared := range r.Assigned {
|
||||
for _, d := range declared {
|
||||
for _, s := range d.Holds {
|
||||
if s.Name == routerSeat && !slices.Contains(routerNodes, node) {
|
||||
routerNodes = append(routerNodes, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
routerFree := len(routerNodes) == 1 && r.RootFree[routerNodes[0]]
|
||||
for node, declared := range r.Assigned {
|
||||
for _, d := range declared {
|
||||
for _, s := range d.Holds {
|
||||
if s.Kinded && s.Kind != "" {
|
||||
p.Kinds = append(p.Kinds, KindHeld{Seat: s.Name, Kind: s.Kind, Module: d.Module, Node: node,
|
||||
Capabilities: placedCapabilities(s.Capabilities, d.RunsAs, routerFree && r.RootFree[node])})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(p.Kinds, func(i, j int) bool {
|
||||
a, b := p.Kinds[i], p.Kinds[j]
|
||||
if a.Seat != b.Seat {
|
||||
return a.Seat < b.Seat
|
||||
}
|
||||
if a.Kind != b.Kind {
|
||||
return a.Kind < b.Kind
|
||||
}
|
||||
return a.Node < b.Node
|
||||
})
|
||||
return p
|
||||
}
|
||||
|
||||
// routerSeat is the seat the router of asks holds (novox/hq ADR 0259 §3).
|
||||
const routerSeat = "operator-channel"
|
||||
|
||||
// placedCapabilities is what a kind's claim promises, as far as its placement lets the router believe it
|
||||
// (novox/hq ADR 0259 §8): `verified-sender` only from a holder that runs as an account of its own, on a bus
|
||||
// account of its own — never one the machine's runtime carries as the operator's account — and only while
|
||||
// its machine and the router's were root-free when composed (rootFree; the review of 2026-10-09, H3). The
|
||||
// membership carrying it is composed at a push, so it can outlive a pass that later fails: the router asks
|
||||
// the controller's root-free verb again before it honours an approval, and that is the check that holds.
|
||||
func placedCapabilities(declared []string, runsAs string, rootFree bool) []string {
|
||||
var out []string
|
||||
for _, c := range declared {
|
||||
if c == "verified-sender" && (runsAs == "" || !rootFree) {
|
||||
continue
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func kindListed(list []KindHeld, k KindHeld) bool {
|
||||
for _, x := range list {
|
||||
if x.Seat == k.Seat && x.Kind == k.Kind && x.Module == k.Module && x.Node == k.Node {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
+184
-7
@@ -63,6 +63,23 @@ type Seat struct {
|
||||
Emits []string
|
||||
Serves []string
|
||||
Versions []string // protocol versions served beside the current one; empty for v1 only
|
||||
|
||||
// Kinded says the seat is a kinded bench (novox/hq ADR 0234 §2, ADR 0259 §3): each holder claims one
|
||||
// kind, and its verbs' subjects carry it. Kind is the kind this principal's claim names, for a seat it
|
||||
// holds.
|
||||
Kinded bool
|
||||
Kind string
|
||||
// ByCaller are the accepts and emits whose last token names the calling module (ADR 0259 §3).
|
||||
ByCaller []string
|
||||
// Proofs are the seat's proof verbs: core request and reply, never on a stream (ADR 0259 §3).
|
||||
Proofs []string
|
||||
// Records are the holder's buckets, by their full name, each user reads under its own name.
|
||||
Records []string
|
||||
// Capabilities are what this principal's claim of a kinded bench promises (ADR 0234 §2).
|
||||
Capabilities []string
|
||||
// DeclaredBy is the module that declares the seat. On a kinded bench it alone submits work to a kind
|
||||
// and answers its proofs (novox/hq ADR 0259 §8): the router, not any user or watcher of the bench.
|
||||
DeclaredBy string
|
||||
}
|
||||
|
||||
// A Principal is one user of the bus. Its permissions are derived from what it declares and
|
||||
@@ -166,11 +183,63 @@ var VerbsTheSelfCheckAsks = []SeatVerb{{Seat: "node-intrusion-prevention", Verb:
|
||||
// the controller's grant that acts, and only through the step a person starts.
|
||||
var VerbsTheBusStepAsks = []SeatVerb{{Seat: "node-backup", Verb: "now"}}
|
||||
|
||||
// VerbsTheControllerAsksForASecret are the seat verbs `give` calls (novox/hq ADR 0259 §10): the operator's
|
||||
// desk opens a prompt that does not show what is typed, and answers it sealed to the controller's call.
|
||||
var VerbsTheControllerAsksForASecret = []SeatVerb{{Seat: "node-launcher", Verb: "secret"}}
|
||||
|
||||
// ControllerOnly are the subjects the controller alone may publish, however wide another's grant (the review
|
||||
// of 2026-10-09, M4): the desk's hidden prompt, on its seat's subjects and on any holder's own module
|
||||
// subjects. A grant of every tool — the runtime's, which carries every agent's calls, or a person's `*` — would
|
||||
// otherwise reach it, and the prompt says the controller asks: only the bus makes that true.
|
||||
func ControllerOnly() []string {
|
||||
var out []string
|
||||
for _, v := range VerbsTheControllerAsksForASecret {
|
||||
for _, base := range []string{"mesh.seat." + v.Seat + ".tool." + v.Verb, "mesh.mod.*.tool." + v.Seat + "." + v.Verb} {
|
||||
out = append(out, base, base+".*")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MayPublish says whether permissions let a principal publish one subject: an allow covers it and no deny does.
|
||||
func MayPublish(perms Permissions, subject string) bool {
|
||||
for _, d := range perms.PublishDeny {
|
||||
if SubjectsOverlap(d, subject) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, a := range perms.Publish {
|
||||
if SubjectsOverlap(a, subject) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MaySubscribe says whether a principal with these permissions may subscribe to (and so answer) a subject.
|
||||
func MaySubscribe(perms Permissions, subject string) bool {
|
||||
for _, a := range perms.Subscribe {
|
||||
if SubjectsOverlap(a, subject) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// VerbsTheControllerAsksTheDeliveryOwner are the mesh-delivery seat's verbs the controller calls (novox/hq
|
||||
// ADR 0239): its self-check reads `stalled`, and healer H2 takes the one transition the table allows
|
||||
// through `close`. A mesh seat's verb is flat: no machine in the subject.
|
||||
//
|
||||
// And, since novox/hq ADR 0259, `release` and `stop`: the controller asks the operator for them about a
|
||||
// delivery held past its bound, and calls them on the operator's warrant, with its why.
|
||||
var VerbsTheControllerAsksTheDeliveryOwner = []SeatVerb{{Seat: "mesh-delivery", Verb: "stalled"},
|
||||
{Seat: "mesh-delivery", Verb: "close"}}
|
||||
{Seat: "mesh-delivery", Verb: "close"}, {Seat: "mesh-delivery", Verb: "release"}, {Seat: "mesh-delivery", Verb: "stop"}}
|
||||
|
||||
// VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant
|
||||
// chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the
|
||||
// controller's own `plans`. Named one by one; a node seat's on any machine, a mesh seat's flat.
|
||||
var VerbsTheControllerActsOnAWarrant = []SeatVerb{{Seat: "node-service-manager", Verb: "restart"},
|
||||
{Seat: ControllerSeat, Verb: "plans"}}
|
||||
|
||||
// perMachineEvents are a node-scoped seat's events about the holder itself, whose last token is the
|
||||
// holder's machine (novox/hq ADR 0219): `paused.<node>`, the build agent saying whether it takes work.
|
||||
@@ -220,6 +289,19 @@ func (p Principal) Username() string {
|
||||
// (novox/hq to-be 45 §6): its answer is an ordinary report, on its own report subject.
|
||||
func AskReportSubject(node string) string { return "mesh.node." + node + ".ask.report" }
|
||||
|
||||
// AskHandOverSubject is where the controller's terminal asks one machine's node-engine to hand a directory it
|
||||
// uses as found to the mesh (novox/hq issue 356, issue 339): a request on core NATS, answered once on the reply
|
||||
// it carries. Only the controller is granted a publish here (the writers table holds it), but **that is not who
|
||||
// the engine hears**: the bus lets any principal allowed to answer reply to a message it received, on the reply
|
||||
// subject that message named, so a message can arrive here from any responder. The ask is therefore signed with
|
||||
// the mesh's key (link.SignedHandOver), and the engine verifies it before reading anything out of it.
|
||||
func AskHandOverSubject(node string) string { return "mesh.node." + node + ".ask.hand-over" }
|
||||
|
||||
// AskSetuidSearchSubject is where the controller's terminal asks one machine's node-engine to throw its last
|
||||
// search for setuid programs away and start a full one (novox/hq issue 361): a request answered once, signed as
|
||||
// a hand-over is (link.SetuidSearchContext), for the same reason.
|
||||
func AskSetuidSearchSubject(node string) string { return "mesh.node." + node + ".ask.setuid-search" }
|
||||
|
||||
// inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25
|
||||
// §4): with one account, inbox privacy is the permission list or it is nothing, so each user's
|
||||
// inbox is derived from its own identity and its permissions name that prefix and no other.
|
||||
@@ -228,6 +310,9 @@ func (p Principal) inbox() string { return "_INBOX." + p.Username() + ".>" }
|
||||
// Permissions is what a principal may publish and subscribe, and whether it may answer.
|
||||
type Permissions struct {
|
||||
Publish []string
|
||||
// PublishDeny are subjects refused although an allow covers them: the controller's alone (ControllerOnly),
|
||||
// denied to everybody whose grant is wide enough to reach them. The server's deny outranks its allow.
|
||||
PublishDeny []string
|
||||
Subscribe []string
|
||||
// AllowResponses lets a principal reply to a request it received, on the reply subject that
|
||||
// request carried, once.
|
||||
@@ -366,10 +451,28 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
for _, v := range VerbsTheBusStepAsks {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
|
||||
}
|
||||
// And the operator's desk, for a secret given there (ADR 0259 §10).
|
||||
for _, v := range VerbsTheControllerAsksForASecret {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
|
||||
}
|
||||
// And the delivery's owner, a mesh seat, asked on its flat subjects (ADR 0239).
|
||||
for _, v := range VerbsTheControllerAsksTheDeliveryOwner {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb)
|
||||
}
|
||||
// And the verbs a warrant chooses (novox/hq ADR 0259): a node seat's on any machine, its own flat.
|
||||
for _, v := range VerbsTheControllerActsOnAWarrant {
|
||||
if v.Seat == ControllerSeat {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb)
|
||||
continue
|
||||
}
|
||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
|
||||
}
|
||||
// And asking the operator (novox/hq ADR 0259): an ask and its cancel under its own name, its warrants
|
||||
// heard under its own name, the record of its asks read under its own name — as any user of the seat,
|
||||
// derived the same way, from the seat its holder declares.
|
||||
tp, ts := SeatTrafficOf(ControllerSeat, nil, p.Uses, nil).grants()
|
||||
pub = append(pub, tp...)
|
||||
sub = append(sub, ts...)
|
||||
// And asks who answers (novox/hq to-be 45 §4, D3): the self-check finds every seat's holder by
|
||||
// the same discovery the console reads. The question only; the answers come to its own inbox.
|
||||
pub = append(pub, "$SRV.INFO")
|
||||
@@ -472,7 +575,12 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// And the mesh asking it to say again what it last applied (novox/hq to-be 45 §6, the
|
||||
// `report` verb healer H1 asks): its own machine's, on core NATS and off any stream. It
|
||||
// answers through its report, the one thing it already says — no reply to anybody's inbox.
|
||||
AskReportSubject(p.Node)}
|
||||
AskReportSubject(p.Node),
|
||||
// And the controller's terminal asking it to hand a directory used as found to the mesh (novox/hq
|
||||
// issue 356), which it answers on the request's reply: the one request a node is asked.
|
||||
AskHandOverSubject(p.Node),
|
||||
// And asking it for a fresh search for setuid programs (novox/hq issue 361), answered the same way.
|
||||
AskSetuidSearchSubject(p.Node)}
|
||||
// The node-engine witnesses the core builds it places (novox/hq to-be 45 §8, ADR 0236; the
|
||||
// contract is lease/witness.go): it asks its own machine's node tools PING, and, where the
|
||||
// machine runs the controller, reads the lease's one key — read, never written.
|
||||
@@ -530,6 +638,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// 2b. Events of a role it watches, under the seat's own namespace. Subscribe only: watching a
|
||||
// role is hearing what it announced, not taking part in it.
|
||||
for _, w := range p.Watches {
|
||||
if w.Kinded {
|
||||
continue // composed by SeatTrafficOf below
|
||||
}
|
||||
for _, e := range w.Emits {
|
||||
sub = append(sub, seatSubject(w, "event", e))
|
||||
}
|
||||
@@ -546,8 +657,19 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
"$JS.API.CONSUMER.INFO."+consumerStream(p)+"."+consumerDurable(p),
|
||||
"$JS.API.CONSUMER.MSG.NEXT."+consumerStream(p)+"."+consumerDurable(p))
|
||||
|
||||
// 3. Seats it holds: full participation.
|
||||
// 3. Seats it holds: full participation — but the controller's own seat, whose verbs only the serving
|
||||
// controller answers, on its own connection (servedOnlyByTheController).
|
||||
for _, s := range p.Holds {
|
||||
if servedOnlyByTheController(s) {
|
||||
continue
|
||||
}
|
||||
if s.isNewTraffic() {
|
||||
// Composed by SeatTrafficOf below, worker and all; only its tools are served here.
|
||||
for _, t := range s.Serves {
|
||||
sub = append(sub, seatToolSubject(s, t, p.Node))
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Taking work from the role's queue: the worker consumer every holder shares (asked
|
||||
// about, pulled from, acknowledged), on the seat's own stream (novox/hq ADR 0190). A
|
||||
// holder pulls — asks the consumer for its next message, answered on its own inbox —
|
||||
@@ -590,7 +712,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// seat's inbound subject and watch other modules' traffic, nor publish its outbound
|
||||
// events and lie about outcomes (design 29 §2).
|
||||
for _, s := range p.Uses {
|
||||
for _, a := range s.Accepts {
|
||||
for _, a := range plainVerbs(s, s.Accepts) {
|
||||
pub = append(pub, seatSubject(s, "accept", a))
|
||||
}
|
||||
for _, t := range s.Serves {
|
||||
@@ -603,6 +725,12 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
pub = append(pub, stateGrants(stateAccess{Module: p.Module, Node: p.Node, Keeps: p.State,
|
||||
PerMachine: p.PerMachine, Reads: p.Reads, KeyedReads: p.KeyedReads})...)
|
||||
|
||||
// 6. Its traffic on seats that name their caller or their kind, ask proofs or keep records
|
||||
// (novox/hq ADR 0259 §3).
|
||||
tp, ts := SeatTrafficOf(p.Module, p.Holds, p.Uses, p.Watches).grants()
|
||||
pub = append(pub, tp...)
|
||||
sub = append(sub, ts...)
|
||||
|
||||
case KindNodeTools:
|
||||
// **One process serves what every module on the machine would have served for itself**
|
||||
// (novox/hq ADR 0175). Each carried module's whole tool namespace — the same grant that
|
||||
@@ -628,6 +756,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
pub = append(pub, own+".event."+e)
|
||||
}
|
||||
for _, s := range d.Holds {
|
||||
if servedOnlyByTheController(s) {
|
||||
continue
|
||||
}
|
||||
for _, t := range s.Serves {
|
||||
sub = append(sub, seatToolSubject(s, t, p.Node))
|
||||
}
|
||||
@@ -680,6 +811,25 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
pub = append(pub, stateGrants(stateAccess{Module: d.Module, Node: p.Node, Keeps: stateNames(d.State),
|
||||
PerMachine: perMachineNames(d.State), Reads: d.Reads, KeyedReads: d.KeyedReads})...)
|
||||
}
|
||||
// **Never the traffic of a trusted holder** (novox/hq ADR 0259 §8): the machine's runtime runs as the
|
||||
// operator's account, which every agent runs as, so a module saying warrants or speaking for a kind
|
||||
// that proves its sender is never composed into it — refused here, naming it, whatever registration
|
||||
// let through.
|
||||
for _, d := range p.Carries {
|
||||
if why := trustedTraffic(d); why != "" {
|
||||
return Permissions{}, fmt.Errorf("%s on %s is carried by the machine's runtime, and %s: it runs "+
|
||||
"as an account of its own, never the runtime's (novox/hq ADR 0259)", d.Module, p.Node, why)
|
||||
}
|
||||
}
|
||||
// **And the seat traffic of the modules it carries** (novox/hq ADR 0259 §3): a bundle reaches the
|
||||
// bus only through its runtime, so the runtime is granted the union. That one module's code does
|
||||
// not publish under another's name or kind through it is the runtime's to keep, from the seat
|
||||
// traffic each module's membership lists.
|
||||
for _, d := range p.Carries {
|
||||
tp, ts := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches).grants()
|
||||
pub = append(pub, tp...)
|
||||
sub = append(sub, ts...)
|
||||
}
|
||||
sub = unique(sub)
|
||||
pub = unique(pub)
|
||||
}
|
||||
@@ -714,13 +864,29 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
if err := CheckWriters(p, pub); err != nil {
|
||||
return Permissions{}, err
|
||||
}
|
||||
// What the controller alone may publish is denied to everybody else whose grant reaches it.
|
||||
var deny []string
|
||||
if p.Kind != KindController {
|
||||
for _, only := range ControllerOnly() {
|
||||
for _, a := range pub {
|
||||
if SubjectsOverlap(a, only) {
|
||||
deny = append(deny, only)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Permissions{
|
||||
Publish: pub,
|
||||
PublishDeny: deny,
|
||||
Subscribe: sub,
|
||||
// A module answers what it was asked — a tool call reaches it on its own namespace, so the
|
||||
// authority is bounded by having been asked — and so does the controller. A node and a
|
||||
// person are never asked anything, and are granted nothing here.
|
||||
AllowResponses: p.Kind == KindModule || p.Kind == KindController || p.Kind == KindNodeTools,
|
||||
// authority is bounded by having been asked — and so does the controller. A node is asked one
|
||||
// thing, a hand-over on its own subject (novox/hq issue 356), and answers that: the node is its
|
||||
// machine's engine, root there already, and it is delivered only its own subjects. What it answers is
|
||||
// never trusted for being an answer — the controller reads the engine's words and records nothing. A
|
||||
// person is never asked anything, and is granted nothing here.
|
||||
AllowResponses: p.Kind == KindModule || p.Kind == KindController || p.Kind == KindNodeTools || p.Kind == KindNode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -743,6 +909,13 @@ func seatSubject(s Seat, kind, verb string) string {
|
||||
// seat carries the node it is asked of, because a flat subject would reach every machine's holder
|
||||
// and the queue group would silently pick a winner (novox/hq ADR 0132, design 33 §4). A holder
|
||||
// subscribes its own node's; a user publishes any node's (`*`) and names the machine in the subject.
|
||||
// servedOnlyByTheController says a seat's verbs are answered by the serving controller alone, on its own
|
||||
// connection (the KindController grant), never by a module claiming the seat or a runtime carrying it: the
|
||||
// controller's own seat. Its verbs decide what the mesh is — and `root-free` decides whether the router believes
|
||||
// a verified sender (novox/hq ADR 0259 §8) — so a machine's runtime, whose credential an agent on that machine
|
||||
// may hold, answering one would be an agent answering it (the confirmation review of 2026-10-09).
|
||||
func servedOnlyByTheController(s Seat) bool { return s.Name == ControllerSeat }
|
||||
|
||||
func seatToolSubject(s Seat, verb, node string) string {
|
||||
base := seatSubject(s, "tool", verb)
|
||||
if s.Scope == "node" && node != "" {
|
||||
@@ -931,7 +1104,11 @@ func ComposeAccounts(principals []Principal) (string, error) {
|
||||
return "", fmt.Errorf("%s has no password hash: a user without one is a user anybody is", p.Username())
|
||||
}
|
||||
fmt.Fprintf(&b, " { user: %q, password: %q, permissions: {\n", p.Username(), p.PasswordHash)
|
||||
if len(perms.PublishDeny) > 0 {
|
||||
fmt.Fprintf(&b, " publish: { allow: [%s], deny: [%s] }\n", quoted(perms.Publish), quoted(perms.PublishDeny))
|
||||
} else {
|
||||
fmt.Fprintf(&b, " publish: { allow: [%s] }\n", quoted(perms.Publish))
|
||||
}
|
||||
fmt.Fprintf(&b, " subscribe: { allow: [%s] }\n", quoted(perms.Subscribe))
|
||||
if perms.AllowResponses {
|
||||
fmt.Fprintf(&b, " allow_responses: { max: 1, ttl: \"%dm\" }\n", int(ResponseTTL/time.Minute))
|
||||
|
||||
@@ -103,7 +103,8 @@ func TestAnInboxIsScopedToItsOwner(t *testing.T) {
|
||||
|
||||
// A responder answers on the caller's inbox, which it has no permission for. allow_responses is
|
||||
// what makes a scoped inbox workable at all — the authority is bounded by having been asked. A
|
||||
// module is asked on its own namespace and may answer; a node and a person are never asked.
|
||||
// module is asked on its own namespace and may answer; a node is asked one thing, a hand-over on its own
|
||||
// subject (novox/hq issue 356), and may answer that; a person is never asked.
|
||||
func TestOnlyWhatCanBeAskedMayAnswer(t *testing.T) {
|
||||
module, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "audit",
|
||||
Consumes: []string{"shop.order.placed"}, PasswordHash: "x"})
|
||||
@@ -111,8 +112,14 @@ func TestOnlyWhatCanBeAskedMayAnswer(t *testing.T) {
|
||||
t.Fatal("a module cannot answer a tool call on its own namespace")
|
||||
}
|
||||
node, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", PasswordHash: "x"})
|
||||
if node.AllowResponses {
|
||||
t.Fatal("a node was granted the right to answer, and nothing asks a node anything")
|
||||
if !node.AllowResponses || !slices.Contains(node.Subscribe, AskHandOverSubject("one")) ||
|
||||
!slices.Contains(node.Subscribe, AskSetuidSearchSubject("one")) ||
|
||||
slices.Contains(node.Subscribe, AskSetuidSearchSubject("two")) {
|
||||
t.Fatalf("a node cannot answer the hand-over it is asked: %v %v", node.AllowResponses, node.Subscribe)
|
||||
}
|
||||
person, _ := PermissionsFor(Principal{Kind: KindPerson, Node: "one", Module: "jo", PasswordHash: "x"})
|
||||
if person.AllowResponses {
|
||||
t.Fatal("a person was granted the right to answer, and nothing asks a person anything")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
package broker
|
||||
|
||||
import "sort"
|
||||
|
||||
// What a module may say and take on the seats it holds, uses and watches, beyond tools (novox/hq ADR
|
||||
// 0259 §3, to-be 46 §10).
|
||||
//
|
||||
// Three rules are added to the ones a seat always had, each a subject whose last token names who may
|
||||
// publish it, granted to that publisher alone — the way a node seat's event about a machine carries the
|
||||
// machine (ADR 0219):
|
||||
//
|
||||
// - **A verb named by its caller** (`by-caller`). A user of the seat submits that accept, and hears that
|
||||
// event, under its own module's name and no other: `accept.ask.<module>`, `event.decided.<module>`. The
|
||||
// holder takes every caller's accept and says the event to any caller. So an ask's asker is a fact the
|
||||
// server enforces, and a warrant reaches only the asker it is for.
|
||||
// - **A kinded bench** (ADR 0234 §2). A holder claims one kind and takes its own kind's accepts, says its
|
||||
// own kind's events and asks its own kind's proofs, and nothing of another kind; a user submits to any
|
||||
// kind. Each kind has its own worker on the seat's queue, so a holder that is away keeps its work and
|
||||
// holds up no other kind.
|
||||
// - **A proof** (`proofs`): core request and reply on `mesh.seat.<seat>.proof.<verb>.<kind>`. No stream's
|
||||
// subjects cover it, so what travels there — a code typed by the operator — is never persisted. A kinded
|
||||
// holder asks with its own kind; the modules that watch the seat answer.
|
||||
//
|
||||
// And one read: **a holder's records**, a bucket the seat names, read by each user under its own name only
|
||||
// (`$KV.<bucket>.<module>.>`), so an asker reads the state of its own asks and no other asker's.
|
||||
|
||||
// Worker is one durable consumer a holder pulls a seat's work from.
|
||||
type Worker struct {
|
||||
Stream string
|
||||
Consumer string
|
||||
Filter string
|
||||
}
|
||||
|
||||
// SeatTraffic is one module's seat traffic beyond tools. Publish and Subscribe are subject patterns, in the
|
||||
// server's wildcards; the runtime that carries the module checks a bundle's request against them, since
|
||||
// the runtime's own principal holds the union of every module it carries.
|
||||
type SeatTraffic struct {
|
||||
// Publish is what it submits (accepts of seats it uses), says (events of seats it holds) and asks
|
||||
// (proofs of seats it holds a kind of).
|
||||
Publish []string `json:"publish,omitempty"`
|
||||
// Subscribe is what it hears: events of seats it uses that are named by caller, events of seats it
|
||||
// watches, and accepts of seats it holds.
|
||||
Subscribe []string `json:"subscribe,omitempty"`
|
||||
// Answers is the proof subjects it answers, as a watcher of a kinded seat.
|
||||
Answers []string `json:"answers,omitempty"`
|
||||
// Workers are the work queues it takes from, as a holder.
|
||||
Workers []Worker `json:"workers,omitempty"`
|
||||
// Records is the direct-get subjects of the records it reads under its own name.
|
||||
Records []string `json:"records,omitempty"`
|
||||
// Kinds are the holders of every kinded bench it uses or watches, with what each promises: the one
|
||||
// account of which channel is which, and what it can carry, that the router judges an answer by. The
|
||||
// controller's, from the claims, never a channel's word (novox/hq ADR 0259 §5).
|
||||
Kinds []KindHeld `json:"kinds,omitempty"`
|
||||
}
|
||||
|
||||
// KindHeld is one kind of a kinded bench and who holds it.
|
||||
type KindHeld struct {
|
||||
Seat string `json:"seat"`
|
||||
Kind string `json:"kind"`
|
||||
Module string `json:"module"`
|
||||
Node string `json:"node"`
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
// KindedBenches are the seats that may be kinded (ADR 0234 §2): making another is a decision, recorded.
|
||||
var KindedBenches = map[string]bool{"channel": true, "intake": true}
|
||||
|
||||
// WorkerName is the worker a seat's holders pull from: one for the seat, or one per kind on a kinded bench.
|
||||
func WorkerName(seat, kind string) string {
|
||||
if kind == "" {
|
||||
return "SEAT_" + upperSnake(seat) + "_worker"
|
||||
}
|
||||
return "SEAT_" + upperSnake(seat) + "_" + upperSnake(kind) + "_worker"
|
||||
}
|
||||
|
||||
func namesVerb(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SeatTrafficOf derives one module's seat traffic from the seats it holds, uses and watches. Only seats
|
||||
// carrying one of the rules above are read: every other seat is composed as it always was.
|
||||
func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic {
|
||||
var t SeatTraffic
|
||||
for _, s := range holds {
|
||||
if !s.isNewTraffic() {
|
||||
continue
|
||||
}
|
||||
kind := ""
|
||||
if s.Kinded {
|
||||
kind = s.Kind
|
||||
if kind == "" || !safeSubject.MatchString(kind) {
|
||||
// A kinded claim without a usable kind is refused at registration; here it is granted
|
||||
// nothing, which is the same answer at the last place it could be asked.
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(s.Accepts) > 0 {
|
||||
stream := seatStreamName(s.Name)
|
||||
filter := "mesh.seat." + s.Name + ".accept.>"
|
||||
if kind != "" {
|
||||
filter = "mesh.seat." + s.Name + ".accept.*." + kind
|
||||
}
|
||||
t.Workers = append(t.Workers, Worker{Stream: stream, Consumer: WorkerName(s.Name, kind), Filter: filter})
|
||||
}
|
||||
for _, a := range s.Accepts {
|
||||
switch {
|
||||
case kind != "":
|
||||
t.Subscribe = append(t.Subscribe, seatSubject(s, "accept", a+"."+kind))
|
||||
case namesVerb(s.ByCaller, a):
|
||||
t.Subscribe = append(t.Subscribe, seatSubject(s, "accept", a+".*"))
|
||||
}
|
||||
}
|
||||
for _, e := range s.Emits {
|
||||
switch {
|
||||
case kind != "":
|
||||
t.Publish = append(t.Publish, seatSubject(s, "event", e+"."+kind))
|
||||
case namesVerb(s.ByCaller, e):
|
||||
t.Publish = append(t.Publish, seatSubject(s, "event", e+".*"))
|
||||
}
|
||||
}
|
||||
if kind != "" {
|
||||
for _, v := range s.Proofs {
|
||||
t.Publish = append(t.Publish, seatSubject(s, "proof", v+"."+kind))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, s := range uses {
|
||||
if !s.isNewTraffic() {
|
||||
continue
|
||||
}
|
||||
for _, a := range s.Accepts {
|
||||
switch {
|
||||
case namesVerb(s.ByCaller, a):
|
||||
t.Publish = append(t.Publish, seatSubject(s, "accept", a+"."+module))
|
||||
case s.Kinded && module == s.DeclaredBy:
|
||||
// Work for a kind is put on its queue by the bench's own router, and by no other user.
|
||||
t.Publish = append(t.Publish, seatSubject(s, "accept", a+".*"))
|
||||
}
|
||||
}
|
||||
for _, e := range s.Emits {
|
||||
if namesVerb(s.ByCaller, e) {
|
||||
t.Subscribe = append(t.Subscribe, seatSubject(s, "event", e+"."+module))
|
||||
}
|
||||
}
|
||||
for _, b := range s.Records {
|
||||
if !safeSubject.MatchString(b) {
|
||||
continue
|
||||
}
|
||||
t.Records = append(t.Records, "$JS.API.DIRECT.GET.KV_"+b+".$KV."+b+"."+module+".>")
|
||||
}
|
||||
}
|
||||
for _, w := range watches {
|
||||
if w.Kinded {
|
||||
for _, e := range w.Emits {
|
||||
t.Subscribe = append(t.Subscribe, seatSubject(w, "event", e+".*"))
|
||||
}
|
||||
if module == w.DeclaredBy {
|
||||
// A code is answered by the bench's own router, and by no other watcher.
|
||||
for _, v := range w.Proofs {
|
||||
t.Answers = append(t.Answers, seatSubject(w, "proof", v+".*"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Publish = unique(t.Publish)
|
||||
t.Subscribe = unique(t.Subscribe)
|
||||
t.Answers = unique(t.Answers)
|
||||
t.Records = unique(t.Records)
|
||||
sort.Slice(t.Workers, func(i, j int) bool { return t.Workers[i].Consumer < t.Workers[j].Consumer })
|
||||
return t
|
||||
}
|
||||
|
||||
// grants is the bus permissions seat traffic needs: the subjects themselves, and the JetStream API a
|
||||
// worker is pulled and acknowledged through and a record is read through.
|
||||
func (t SeatTraffic) grants() (pub, sub []string) {
|
||||
pub = append(pub, t.Publish...)
|
||||
sub = append(sub, t.Subscribe...)
|
||||
sub = append(sub, t.Answers...)
|
||||
for _, w := range t.Workers {
|
||||
pub = append(pub,
|
||||
"$JS.API.CONSUMER.INFO."+w.Stream+"."+w.Consumer,
|
||||
"$JS.API.CONSUMER.MSG.NEXT."+w.Stream+"."+w.Consumer,
|
||||
"$JS.ACK."+w.Stream+"."+w.Consumer+".>")
|
||||
}
|
||||
pub = append(pub, t.Records...)
|
||||
return pub, sub
|
||||
}
|
||||
|
||||
// isNewTraffic says whether a seat carries any of the rules above, so a seat that carries none is
|
||||
// composed exactly as before them.
|
||||
func (s Seat) isNewTraffic() bool {
|
||||
return s.Kinded || len(s.ByCaller) > 0 || len(s.Proofs) > 0 || len(s.Records) > 0
|
||||
}
|
||||
|
||||
// plainVerbs is a seat's accepts or emits with those the rules above compose taken out: a verb named by
|
||||
// its caller and every verb of a kinded bench are composed by SeatTrafficOf and nowhere else.
|
||||
func plainVerbs(s Seat, verbs []string) []string {
|
||||
if s.Kinded {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, v := range verbs {
|
||||
if !namesVerb(s.ByCaller, v) {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SeatTrafficObjects is the work queues and workers the seat traffic of every composed user implies
|
||||
// (novox/hq ADR 0259 §3): a queue for each seat a holder takes work from, and each holder's worker on it —
|
||||
// one per kind on a kinded bench, filtered to that kind, so the kinds never take each other's work. Only
|
||||
// seats carrying the rules above; the mesh's own seats' queues are RaiseSeats'.
|
||||
func SeatTrafficObjects(users []Principal) ([]Stream, []Consumer) {
|
||||
streams := map[string]Stream{}
|
||||
consumers := map[string]Consumer{}
|
||||
add := func(module string, holds []Seat) {
|
||||
for _, w := range SeatTrafficOf(module, holds, nil, nil).Workers {
|
||||
seat := ""
|
||||
for _, s := range holds {
|
||||
if seatStreamName(s.Name) == w.Stream {
|
||||
seat = s.Name
|
||||
}
|
||||
}
|
||||
streams[w.Stream] = Stream{
|
||||
Name: w.Stream,
|
||||
Subjects: []string{"mesh.seat." + seat + ".accept.>"},
|
||||
Retention: RetentionWorkQueue,
|
||||
MaxAge: 7 * 24 * 60 * 60,
|
||||
Why: "work submitted to the " + seat + " seat; its holders take it, each kind its own, and it queues while nobody does",
|
||||
}
|
||||
consumers[w.Consumer] = Consumer{
|
||||
Name: w.Consumer,
|
||||
Stream: w.Stream,
|
||||
Filters: []string{w.Filter},
|
||||
AckWaitSeconds: 60,
|
||||
// No bound on redelivery: a channel away for a day keeps its work, offered again later and
|
||||
// later by its holder's runtime (novox/hq ADR 0259; the correctness review of 2026-10-08).
|
||||
MaxDeliver: 0,
|
||||
Why: module + " holds " + seat + "; it pulls one ask at a time and acknowledges once it has " +
|
||||
"recorded it, so a crash redelivers rather than loses",
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, p := range users {
|
||||
switch p.Kind {
|
||||
case KindModule:
|
||||
add(p.Module, p.Holds)
|
||||
case KindNodeTools:
|
||||
for _, d := range p.Carries {
|
||||
add(d.Module, d.Holds)
|
||||
}
|
||||
}
|
||||
}
|
||||
var ss []Stream
|
||||
for _, s := range streams {
|
||||
ss = append(ss, s)
|
||||
}
|
||||
sort.Slice(ss, func(i, j int) bool { return ss[i].Name < ss[j].Name })
|
||||
var cs []Consumer
|
||||
for _, c := range consumers {
|
||||
cs = append(cs, c)
|
||||
}
|
||||
sort.Slice(cs, func(i, j int) bool { return cs[i].Name < cs[j].Name })
|
||||
return ss, cs
|
||||
}
|
||||
|
||||
// trustedTraffic is why a module's seat traffic is the trusted holder's (novox/hq ADR 0259 §8), or "": it
|
||||
// says a seat's event to one caller each (a warrant), or holds a kind of a kinded bench that proves its sender.
|
||||
func trustedTraffic(d Declared) string {
|
||||
for _, s := range d.Holds {
|
||||
for _, e := range s.Emits {
|
||||
if namesVerb(s.ByCaller, e) {
|
||||
return "it says " + s.Name + "'s " + e + " to one caller each"
|
||||
}
|
||||
}
|
||||
if s.Kinded && namesVerb(s.Capabilities, "verified-sender") {
|
||||
return "it holds " + s.Name + " of kind " + s.Kind + ", which proves its sender"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TrafficQueues is the work queue of every seat naming its caller or its kind that accepts work, held or not
|
||||
// (novox/hq ADR 0259 §3): what is submitted before a holder is assigned waits for it.
|
||||
func TrafficQueues(seats []Seat) []Stream {
|
||||
var out []Stream
|
||||
seen := map[string]bool{}
|
||||
for _, s := range seats {
|
||||
if len(s.Accepts) == 0 || !s.isNewTraffic() || seen[s.Name] {
|
||||
continue
|
||||
}
|
||||
seen[s.Name] = true
|
||||
out = append(out, Stream{Name: seatStreamName(s.Name), Subjects: []string{"mesh.seat." + s.Name + ".accept.>"},
|
||||
Retention: RetentionWorkQueue, MaxAge: 7 * 24 * 60 * 60,
|
||||
Why: "work submitted to the " + s.Name + " seat; its holders take it, each kind its own, and it queues while nobody does"})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The seats of novox/hq ADR 0259 §3, as the messenger declares them.
|
||||
func operatorChannel() Seat {
|
||||
return Seat{Name: "operator-channel", Scope: "mesh", Accepts: []string{"ask", "cancel"},
|
||||
Emits: []string{"decided"}, Serves: []string{"open", "history", "notify"},
|
||||
ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"messenger_asks"}}
|
||||
}
|
||||
|
||||
func channelSeat(kind string) Seat {
|
||||
return Seat{Name: "channel", Scope: "mesh", Accepts: []string{"show", "edit", "send"}, Kinded: true, Kind: kind,
|
||||
DeclaredBy: "messenger"}
|
||||
}
|
||||
|
||||
func intakeSeat(kind string) Seat {
|
||||
return Seat{Name: "intake", Scope: "mesh", Emits: []string{"choice", "link"}, Proofs: []string{"code"},
|
||||
Kinded: true, Kind: kind, DeclaredBy: "messenger"}
|
||||
}
|
||||
|
||||
func allowed(patterns []string, subject string) bool {
|
||||
for _, p := range patterns {
|
||||
if subjectMatches(p, subject) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func perms(t *testing.T, p Principal) Permissions {
|
||||
t.Helper()
|
||||
got, err := PermissionsFor(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestAnAskerAsksAndHearsUnderItsOwnNameOnly(t *testing.T) {
|
||||
asker := Principal{Kind: KindModule, Node: "anchor", Module: "mesh-delivery", Uses: []Seat{operatorChannel()}}
|
||||
got := perms(t, asker)
|
||||
for _, s := range []string{
|
||||
"mesh.seat.operator-channel.accept.ask.mesh-delivery",
|
||||
"mesh.seat.operator-channel.accept.cancel.mesh-delivery",
|
||||
"$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.mesh-delivery.a1",
|
||||
} {
|
||||
if !allowed(got.Publish, s) {
|
||||
t.Errorf("an asker may not publish %s", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{
|
||||
"mesh.seat.operator-channel.accept.ask.mesh-controller",
|
||||
"mesh.seat.operator-channel.accept.ask.*",
|
||||
"mesh.seat.operator-channel.event.decided.mesh-delivery",
|
||||
"$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.mesh-controller.a1",
|
||||
"$KV.messenger_asks.mesh-delivery.a1",
|
||||
} {
|
||||
if allowed(got.Publish, s) {
|
||||
t.Errorf("an asker may publish %s, which is not its own to submit", s)
|
||||
}
|
||||
}
|
||||
if !allowed(got.Subscribe, "mesh.seat.operator-channel.event.decided.mesh-delivery") {
|
||||
t.Error("an asker does not hear its own warrants")
|
||||
}
|
||||
if allowed(got.Subscribe, "mesh.seat.operator-channel.event.decided.mesh-controller") {
|
||||
t.Error("an asker hears another asker's warrants")
|
||||
}
|
||||
// And its own consumer carries its warrants, so a restart catches up.
|
||||
c, ok := ConsumerFor(asker)
|
||||
if !ok || !allowed(c.Filters, "mesh.seat.operator-channel.event.decided.mesh-delivery") {
|
||||
t.Errorf("the asker's consumer does not carry its warrants: %v", c.Filters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyTheHolderPublishesAWarrant(t *testing.T) {
|
||||
users, err := Users(Records{
|
||||
Nodes: []string{"anchor"},
|
||||
Assigned: map[string][]Declared{"anchor": {
|
||||
{Module: "messenger", Holds: []Seat{operatorChannel()}, Uses: []Seat{channelSeat("")},
|
||||
Watches: []Seat{{Name: "intake", Emits: []string{"choice", "link"}, Kinded: true, Proofs: []string{"code"}, DeclaredBy: "messenger"}}},
|
||||
{Module: "mesh-delivery", Uses: []Seat{operatorChannel()}},
|
||||
{Module: "telegram", Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, u := range users {
|
||||
got := perms(t, u)
|
||||
says := allowed(got.Publish, "mesh.seat.operator-channel.event.decided.mesh-delivery")
|
||||
if says != (u.Module == "messenger") {
|
||||
t.Errorf("%s %s publish a warrant", u.Username(), map[bool]string{true: "may", false: "may not"}[says])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheHolderTakesEveryCallersAskThroughItsWorker(t *testing.T) {
|
||||
got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "messenger", Holds: []Seat{operatorChannel()}})
|
||||
if !allowed(got.Subscribe, "mesh.seat.operator-channel.accept.ask.mesh-delivery") {
|
||||
t.Error("the router does not take an ask")
|
||||
}
|
||||
for _, s := range []string{
|
||||
"$JS.API.CONSUMER.MSG.NEXT.SEAT_OPERATOR_CHANNEL.SEAT_OPERATOR_CHANNEL_worker",
|
||||
"$JS.ACK.SEAT_OPERATOR_CHANNEL.SEAT_OPERATOR_CHANNEL_worker.x",
|
||||
"mesh.seat.operator-channel.event.decided.mesh-controller",
|
||||
} {
|
||||
if !allowed(got.Publish, s) {
|
||||
t.Errorf("the router may not publish %s", s)
|
||||
}
|
||||
}
|
||||
if allowed(got.Publish, "mesh.seat.operator-channel.accept.ask.messenger") {
|
||||
t.Error("the holder may ask its own seat without using it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKindedHolderReachesItsOwnKindAndNoOther(t *testing.T) {
|
||||
got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "telegram",
|
||||
Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}})
|
||||
for _, s := range []string{
|
||||
"mesh.seat.intake.event.choice.telegram", "mesh.seat.intake.event.link.telegram",
|
||||
"mesh.seat.intake.proof.code.telegram",
|
||||
"$JS.API.CONSUMER.MSG.NEXT.SEAT_CHANNEL.SEAT_CHANNEL_TELEGRAM_worker",
|
||||
} {
|
||||
if !allowed(got.Publish, s) {
|
||||
t.Errorf("telegram may not publish %s", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{
|
||||
"mesh.seat.intake.event.choice.desktop", "mesh.seat.intake.proof.code.desktop",
|
||||
"mesh.seat.channel.accept.show.telegram",
|
||||
"$JS.API.CONSUMER.MSG.NEXT.SEAT_CHANNEL.SEAT_CHANNEL_DESKTOP_worker",
|
||||
"mesh.seat.operator-channel.event.decided.mesh-delivery",
|
||||
} {
|
||||
if allowed(got.Publish, s) {
|
||||
t.Errorf("telegram may publish %s", s)
|
||||
}
|
||||
}
|
||||
if !allowed(got.Subscribe, "mesh.seat.channel.accept.show.telegram") ||
|
||||
allowed(got.Subscribe, "mesh.seat.channel.accept.show.desktop") {
|
||||
t.Error("telegram does not take exactly its own kind's work")
|
||||
}
|
||||
if allowed(got.Subscribe, "mesh.seat.intake.proof.code.telegram") {
|
||||
t.Error("a channel answers its own proofs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheWatcherAnswersProofsAndHearsEveryKind(t *testing.T) {
|
||||
got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "messenger",
|
||||
Uses: []Seat{channelSeat("")},
|
||||
Watches: []Seat{{Name: "intake", Emits: []string{"choice"}, Kinded: true, Proofs: []string{"code"}, DeclaredBy: "messenger"}}})
|
||||
for _, s := range []string{"mesh.seat.intake.event.choice.telegram", "mesh.seat.intake.proof.code.desktop"} {
|
||||
if !allowed(got.Subscribe, s) {
|
||||
t.Errorf("the router does not hear %s", s)
|
||||
}
|
||||
}
|
||||
if !allowed(got.Publish, "mesh.seat.channel.accept.show.telegram") {
|
||||
t.Error("the router cannot send a channel its work")
|
||||
}
|
||||
if allowed(got.Publish, "mesh.seat.intake.event.choice.telegram") || allowed(got.Publish, "mesh.seat.intake.proof.code.telegram") {
|
||||
t.Error("the router may say a channel's answer or proof")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoStreamKeepsAProof(t *testing.T) {
|
||||
users, _ := Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": {
|
||||
{Module: "telegram", Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}},
|
||||
{Module: "messenger", Holds: []Seat{operatorChannel()}},
|
||||
}}})
|
||||
streams, _ := SeatTrafficObjects(users)
|
||||
streams = append(streams, MeshStreams()...)
|
||||
for _, s := range streams {
|
||||
for _, subject := range s.Subjects {
|
||||
if subjectMatches(subject, "mesh.seat.intake.proof.code.telegram") {
|
||||
t.Errorf("%s keeps a proof (%s)", s.Name, subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachKindHasAWorkerOfItsOwn(t *testing.T) {
|
||||
users, _ := Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": {
|
||||
{Module: "telegram", Holds: []Seat{channelSeat("telegram")}},
|
||||
{Module: "desk-channel", Holds: []Seat{channelSeat("desktop")}},
|
||||
{Module: "messenger", Holds: []Seat{operatorChannel()}},
|
||||
}}})
|
||||
streams, workers := SeatTrafficObjects(users)
|
||||
names := map[string]string{}
|
||||
for _, w := range workers {
|
||||
names[w.Name] = strings.Join(w.Filters, ",")
|
||||
}
|
||||
want := map[string]string{
|
||||
"SEAT_CHANNEL_TELEGRAM_worker": "mesh.seat.channel.accept.*.telegram",
|
||||
"SEAT_CHANNEL_DESKTOP_worker": "mesh.seat.channel.accept.*.desktop",
|
||||
"SEAT_OPERATOR_CHANNEL_worker": "mesh.seat.operator-channel.accept.>",
|
||||
}
|
||||
for n, f := range want {
|
||||
if names[n] != f {
|
||||
t.Errorf("worker %s filters %q, want %q", n, names[n], f)
|
||||
}
|
||||
}
|
||||
if len(streams) != 2 {
|
||||
t.Errorf("want the queues of channel and operator-channel, got %v", streams)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRuntimeIsGrantedTheUnionAndTheMembershipEachModulesShare(t *testing.T) {
|
||||
telegram := Declared{Module: "telegram", Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}}
|
||||
desk := Declared{Module: "desk-channel", Holds: []Seat{channelSeat("desktop"), intakeSeat("desktop")}}
|
||||
got := perms(t, Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule, Carries: []Declared{telegram, desk}})
|
||||
for _, s := range []string{"mesh.seat.intake.event.choice.telegram", "mesh.seat.intake.event.choice.desktop"} {
|
||||
if !allowed(got.Publish, s) {
|
||||
t.Errorf("the runtime may not publish %s for a module it carries", s)
|
||||
}
|
||||
}
|
||||
m := MembershipFor("anchor", telegram, Placements{})
|
||||
if m.SeatTraffic == nil || !allowed(m.SeatTraffic.Publish, "mesh.seat.intake.event.choice.telegram") ||
|
||||
allowed(m.SeatTraffic.Publish, "mesh.seat.intake.event.choice.desktop") {
|
||||
t.Errorf("telegram's membership does not list exactly its own kind: %+v", m.SeatTraffic)
|
||||
}
|
||||
if plain := MembershipFor("anchor", Declared{Module: "plain"}, Placements{}); plain.SeatTraffic != nil {
|
||||
t.Error("a module with no such seat is given seat traffic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestASeatWithoutTheNewRulesIsComposedAsBefore(t *testing.T) {
|
||||
old := Seat{Name: "node-build-agent", Scope: "node", Accepts: []string{"build"}, Emits: []string{"built"}}
|
||||
got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "builder", Holds: []Seat{old}})
|
||||
for _, s := range []string{"mesh.seat.node-build-agent.event.built",
|
||||
"$JS.API.CONSUMER.MSG.NEXT.SEAT_NODE_BUILD_AGENT.SEAT_NODE_BUILD_AGENT_worker"} {
|
||||
if !allowed(got.Publish, s) {
|
||||
t.Errorf("an old seat's holder lost %s", s)
|
||||
}
|
||||
}
|
||||
if !allowed(got.Subscribe, "mesh.seat.node-build-agent.accept.build") {
|
||||
t.Error("an old seat's holder lost its accept")
|
||||
}
|
||||
}
|
||||
|
||||
// The router learns which channel is which, and what each promises, from the controller's membership:
|
||||
// the claims, never a channel's word (ADR 0259 §5).
|
||||
func TestTheRoutersMembershipNamesEveryKindAndItsCapabilities(t *testing.T) {
|
||||
tg := channelSeat("telegram")
|
||||
tg.Capabilities = []string{"choice", "verified-sender"}
|
||||
desk := channelSeat("desktop")
|
||||
desk.Capabilities = []string{"choice"}
|
||||
router := Declared{Module: "messenger", Holds: []Seat{operatorChannel()}, Uses: []Seat{channelSeat("")}}
|
||||
records := Records{Nodes: []string{"anchor", "laptop"}, Assigned: map[string][]Declared{
|
||||
"anchor": {router, {Module: "telegram", Holds: []Seat{tg}, RunsAs: "telegram"}},
|
||||
"laptop": {{Module: "desk-channel", Holds: []Seat{desk}}},
|
||||
}, RootFree: map[string]bool{"anchor": true}}
|
||||
where := PlacementsOf(records, nil)
|
||||
m := MembershipFor("anchor", router, where)
|
||||
if m.SeatTraffic == nil || len(m.SeatTraffic.Kinds) != 2 {
|
||||
t.Fatalf("the router is not told the kinds: %+v", m.SeatTraffic)
|
||||
}
|
||||
byKind := map[string]KindHeld{}
|
||||
for _, k := range m.SeatTraffic.Kinds {
|
||||
byKind[k.Kind] = k
|
||||
}
|
||||
if k := byKind["telegram"]; k.Module != "telegram" || k.Node != "anchor" || !namesVerb(k.Capabilities, "verified-sender") {
|
||||
t.Errorf("telegram is %+v", k)
|
||||
}
|
||||
if k := byKind["desktop"]; k.Module != "desk-channel" || namesVerb(k.Capabilities, "verified-sender") {
|
||||
t.Errorf("the desk is %+v", k)
|
||||
}
|
||||
if other := MembershipFor("anchor", Declared{Module: "mesh-delivery", Uses: []Seat{operatorChannel()}}, where); other.SeatTraffic != nil && len(other.SeatTraffic.Kinds) > 0 {
|
||||
t.Error("an asker is told the channels")
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0259 §8: only the bench's own router answers its proofs and puts work on a kind's queue.
|
||||
func TestOnlyTheBenchsRouterAnswersProofsAndSubmitsWork(t *testing.T) {
|
||||
other := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "eavesdropper",
|
||||
Uses: []Seat{channelSeat("")},
|
||||
Watches: []Seat{{Name: "intake", Emits: []string{"choice"}, Kinded: true, Proofs: []string{"code"}, DeclaredBy: "messenger"}}})
|
||||
if allowed(other.Subscribe, "mesh.seat.intake.proof.code.telegram") {
|
||||
t.Error("a watcher that is not the router answers codes")
|
||||
}
|
||||
if allowed(other.Publish, "mesh.seat.channel.accept.show.telegram") {
|
||||
t.Error("a user that is not the router puts work on a kind's queue")
|
||||
}
|
||||
if !allowed(other.Subscribe, "mesh.seat.intake.event.choice.telegram") {
|
||||
t.Error("a watcher no longer hears the bench's events")
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0259 §8: the machine's runtime runs as the operator's account; it never carries a module
|
||||
// that says warrants or speaks for a kind proving its sender, and such a module has its own account.
|
||||
func TestTheMachinesRuntimeNeverCarriesATrustedHolder(t *testing.T) {
|
||||
tg := channelSeat("telegram")
|
||||
tg.Capabilities = []string{"choice", "verified-sender"}
|
||||
for name, d := range map[string]Declared{
|
||||
"the router": {Module: "messenger", Holds: []Seat{operatorChannel()}},
|
||||
"a verified channel": {Module: "telegram", Holds: []Seat{tg}},
|
||||
} {
|
||||
if _, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule,
|
||||
Carries: []Declared{d}}); err == nil || !strings.Contains(err.Error(), "an account of its own") {
|
||||
t.Errorf("%s was composed into the machine's runtime: %v", name, err)
|
||||
}
|
||||
}
|
||||
desk := channelSeat("desktop")
|
||||
desk.Capabilities = []string{"choice"}
|
||||
if _, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule,
|
||||
Carries: []Declared{{Module: "desk-channel", Holds: []Seat{desk}}}}); err != nil {
|
||||
t.Errorf("a channel proving nothing was refused: %v", err)
|
||||
}
|
||||
users, err := Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": {
|
||||
{Module: RuntimeModule}, {Module: "telegram", Holds: []Seat{tg}, RunsAs: "telegram"},
|
||||
{Module: "messenger", Holds: []Seat{operatorChannel()}, RunsAs: "messenger"},
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.Kind == KindNodeTools {
|
||||
for _, d := range u.Carries {
|
||||
if d.RunsAs != "" {
|
||||
t.Errorf("the machine's runtime carries %s", d.Module)
|
||||
}
|
||||
}
|
||||
if _, err := PermissionsFor(u); err != nil {
|
||||
t.Errorf("the runtime could not be composed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0259 §8: verified-sender reaches the router only from a holder of its own account, on a machine
|
||||
// root-free when composed, with the router's own machine root-free too (the review of 2026-10-09, H3).
|
||||
func TestVerifiedSenderIsBelievedOnlyFromAHolderOfItsOwnAccount(t *testing.T) {
|
||||
if got := placedCapabilities([]string{"choice", "verified-sender"}, "", true); namesVerb(got, "verified-sender") {
|
||||
t.Errorf("a carried holder keeps verified-sender: %v", got)
|
||||
}
|
||||
if got := placedCapabilities([]string{"choice", "verified-sender"}, "telegram", true); !namesVerb(got, "verified-sender") {
|
||||
t.Errorf("a holder of its own account on a root-free machine lost verified-sender: %v", got)
|
||||
}
|
||||
if got := placedCapabilities([]string{"choice", "verified-sender"}, "telegram", false); namesVerb(got, "verified-sender") ||
|
||||
!namesVerb(got, "choice") {
|
||||
t.Errorf("a holder on a machine not root-free keeps verified-sender, or lost the rest: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The kinds the router is told carry verified-sender only while the channel's machine and the router's are
|
||||
// both root-free as composed; no record of a pass is no pass, and neither is a router placed nowhere.
|
||||
func TestVerifiedSenderNeedsTheChannelsAndTheRoutersMachinesRootFree(t *testing.T) {
|
||||
tg := channelSeat("telegram")
|
||||
tg.Capabilities = []string{"choice", "verified-sender"}
|
||||
router := Declared{Module: "messenger", Holds: []Seat{operatorChannel()}, RunsAs: "messenger"}
|
||||
telegram := Declared{Module: "telegram", Holds: []Seat{tg}, RunsAs: "telegram"}
|
||||
verified := func(r Records) bool {
|
||||
for _, k := range PlacementsOf(r, nil).Kinds {
|
||||
if k.Kind == "telegram" {
|
||||
return namesVerb(k.Capabilities, "verified-sender")
|
||||
}
|
||||
}
|
||||
t.Fatal("telegram not placed")
|
||||
return false
|
||||
}
|
||||
same := func(free map[string]bool) Records {
|
||||
return Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": {router, telegram}}, RootFree: free}
|
||||
}
|
||||
apart := func(free map[string]bool) Records {
|
||||
return Records{Nodes: []string{"anchor", "relay"},
|
||||
Assigned: map[string][]Declared{"anchor": {router}, "relay": {telegram}}, RootFree: free}
|
||||
}
|
||||
if !verified(same(map[string]bool{"anchor": true})) {
|
||||
t.Error("both on one root-free machine: verified-sender withheld")
|
||||
}
|
||||
if verified(same(nil)) {
|
||||
t.Error("no record of a pass, and verified-sender kept")
|
||||
}
|
||||
if verified(apart(map[string]bool{"relay": true})) {
|
||||
t.Error("the router's machine not root-free, and verified-sender kept")
|
||||
}
|
||||
if verified(apart(map[string]bool{"anchor": true})) {
|
||||
t.Error("the channel's machine not root-free, and verified-sender kept")
|
||||
}
|
||||
if !verified(apart(map[string]bool{"anchor": true, "relay": true})) {
|
||||
t.Error("both machines root-free: verified-sender withheld")
|
||||
}
|
||||
noRouter := Records{Nodes: []string{"relay"}, Assigned: map[string][]Declared{"relay": {telegram}},
|
||||
RootFree: map[string]bool{"relay": true}}
|
||||
if verified(noRouter) {
|
||||
t.Error("no router placed, and verified-sender kept")
|
||||
}
|
||||
}
|
||||
@@ -363,8 +363,19 @@ var ControllerFollows = []string{
|
||||
// seat to check before it merges — every machine of the facts snapshot composed with the change.
|
||||
// Appended, because the index is a name.
|
||||
moduleEventSubject("gitea", "pull.updated"),
|
||||
// **The operator's answers to what the controller asked** (novox/hq ADR 0259): the router's warrant, or
|
||||
// the end of an ask without one, said to the controller alone under its own name. On the stream, so a
|
||||
// controller that was away hears what was decided meanwhile. Appended, because the index is a name.
|
||||
DecidedSubject,
|
||||
}
|
||||
|
||||
// AsksSeat is the seat an ask is made on and its warrant heard from (novox/hq ADR 0259): the router's.
|
||||
const AsksSeat = "operator-channel"
|
||||
|
||||
// DecidedSubject is where the router says the controller's warrants: the seat's event named by the
|
||||
// controller as its caller.
|
||||
var DecidedSubject = seatEventSubject(AsksSeat, "decided."+ControllerSeat)
|
||||
|
||||
// The provider standing events, by their local names. Written here as well as in the catalogue
|
||||
// (catalogue.ProvisionerEvents), which this package cannot import; a test keeps them agreeing.
|
||||
const (
|
||||
|
||||
+4
-3
@@ -24,8 +24,8 @@ accounts {
|
||||
jetstream: enabled
|
||||
users = [
|
||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*"] }
|
||||
subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built"] }
|
||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*"] }
|
||||
subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
|
||||
@@ -34,7 +34,8 @@ accounts {
|
||||
} }
|
||||
{ user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: {
|
||||
publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.INFO.NODES.one", "$SRV.PING.node-tools.one", "mesh.control.one.>"] }
|
||||
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.ask.report", "mesh.node.one.declare"] }
|
||||
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "one.nats", password: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb", permissions: {
|
||||
publish: { allow: ["$JS.API.STREAM.INFO.*", "$JS.API.STREAM.NAMES", "$JS.API.STREAM.SNAPSHOT.*", "$JS.SNAPSHOT.ACK.>"] }
|
||||
|
||||
@@ -50,6 +50,9 @@ type Declared struct {
|
||||
// Checks are the module's own tools its health asks, each `<module>.<tool>` (novox/hq ADR 0240, to-be
|
||||
// 48 §3): the machine's node-engine asks them of its own node tools, and is granted that and no more.
|
||||
Checks []string
|
||||
// RunsAs is the account the module runs as in a runtime of its own (novox/hq ADR 0259 §8): it is never
|
||||
// carried by the machine's runtime, and reaches the bus on its own account.
|
||||
RunsAs string
|
||||
}
|
||||
|
||||
// Records is what composing a user list needs to know about the mesh, and nothing more.
|
||||
@@ -67,6 +70,10 @@ type Records struct {
|
||||
// Interchangeable is each module whose definition says its instances are the same anywhere
|
||||
// (ADR 0160), which decides whether the module's plain subject is issued to every instance.
|
||||
Interchangeable map[string]bool
|
||||
// RootFree is each machine judged root-free when this was composed (novox/hq ADR 0259 §8): it names an
|
||||
// account agents run as, judged unable to become root by its node-engine, and serves no login shell
|
||||
// execute. A machine absent is not free: no record of a pass is no pass.
|
||||
RootFree map[string]bool
|
||||
}
|
||||
|
||||
// Users is every user the composed file should contain, in the order it will be written.
|
||||
@@ -75,7 +82,7 @@ type Records struct {
|
||||
// is a mesh that cannot be told anything, and there is no state of the records in which that is
|
||||
// correct.
|
||||
func Users(r Records) ([]Principal, error) {
|
||||
out := []Principal{{Kind: KindController}}
|
||||
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r)}}
|
||||
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
witness := false
|
||||
@@ -120,10 +127,13 @@ func Users(r Records) ([]Principal, error) {
|
||||
})
|
||||
}
|
||||
if runtimeHere {
|
||||
out = append(out, Principal{
|
||||
Kind: KindNodeTools, Node: node, Module: RuntimeModule,
|
||||
Carries: append([]Declared(nil), r.Assigned[node]...),
|
||||
})
|
||||
var carried []Declared
|
||||
for _, d := range r.Assigned[node] {
|
||||
if d.RunsAs == "" {
|
||||
carried = append(carried, d)
|
||||
}
|
||||
}
|
||||
out = append(out, Principal{Kind: KindNodeTools, Node: node, Module: RuntimeModule, Carries: carried})
|
||||
}
|
||||
}
|
||||
for _, node := range sortedCopy(r.Enrolling) {
|
||||
@@ -189,3 +199,21 @@ func sortedNames(in map[string][]string) []string {
|
||||
|
||||
// controllerModule is the controller's module: the machine assigned it witnesses its upgrades.
|
||||
const controllerModule = "mesh-controller"
|
||||
|
||||
// asksSeatOf is the seat an ask is made on, as its holder declares it (novox/hq ADR 0259): the controller
|
||||
// asks the operator through it like any other user, and is granted what its declaration names for a caller.
|
||||
// None while nothing holds it.
|
||||
func asksSeatOf(r Records) []Seat {
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
for _, d := range r.Assigned[node] {
|
||||
for _, s := range d.Holds {
|
||||
if s.Name == AsksSeat && namesVerb(s.ByCaller, "ask") {
|
||||
seat := s
|
||||
seat.Kind, seat.Capabilities = "", nil
|
||||
return []Seat{seat}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -84,6 +84,18 @@ func kvOf(bucket string) []string { return []string{"$KV." + bucket + ".>"} }
|
||||
var WritersTable = []WriterRow{
|
||||
{State: "a machine's declaration", Writer: "controller (lease holder)", KeptIn: "the bus, last per subject",
|
||||
Others: "read", Subjects: []string{"mesh.node.*.declare"}, Writes: isController},
|
||||
// The operator's hand-over of a directory used as found, asked of the machine's engine at the controller's
|
||||
// terminal (novox/hq issue 356). One publisher; and because a responder can still reach the subject through a
|
||||
// reply, the ask is signed with the mesh's key and the engine verifies it — the row bounds who is granted the
|
||||
// publish, the signature who is believed.
|
||||
{State: "a hand-over asked of a machine", Writer: "controller, at its terminal", KeptIn: "the machine, beside its state",
|
||||
Others: "the engine verifies the mesh's signature and records it, or refuses",
|
||||
Subjects: []string{"mesh.node.*.ask.hand-over"}, Writes: isController},
|
||||
// The operator asking a machine's engine for a fresh search for setuid programs, at the controller's
|
||||
// terminal (novox/hq issue 361): signed as a hand-over is, under a signing context of its own.
|
||||
{State: "a fresh setuid search asked of a machine", Writer: "controller, at its terminal",
|
||||
KeptIn: "the machine, which throws its last search away", Others: "the engine verifies the mesh's signature and starts it, or refuses",
|
||||
Subjects: []string{"mesh.node.*.ask.setuid-search"}, Writes: isController},
|
||||
{State: "a machine's applied state and its report", Writer: "the node-engine's apply queue",
|
||||
KeptIn: "the machine; the report on the bus", Others: "the reconcile and a delivery enqueue, never apply",
|
||||
// And its health statement between reports (novox/hq ADR 0240): the same writer stating the same
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
// to the table; one dropped from either fails.
|
||||
var designRows = []string{
|
||||
"a machine's declaration",
|
||||
"a hand-over asked of a machine",
|
||||
"a fresh setuid search asked of a machine",
|
||||
"a machine's applied state and its report",
|
||||
"the controller lease",
|
||||
"plans and their tiers",
|
||||
@@ -150,3 +152,37 @@ func TestSubjectsOverlap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **A hand-over asked of a machine has one publisher, the controller** (novox/hq issue 356): a grant that lets any
|
||||
// other principal publish it — a node, the node tools, a module — is refused at composition, naming the state.
|
||||
// (Who the engine believes is the signature's; this bounds who is granted the publish.)
|
||||
func TestAHandOverAskHasOnePublisher(t *testing.T) {
|
||||
for _, p := range []Principal{
|
||||
{Kind: KindNode, Node: "laptop"},
|
||||
{Kind: KindNodeTools, Node: "laptop", Module: RuntimeModule},
|
||||
{Kind: KindModule, Node: "laptop", Module: "notes"},
|
||||
} {
|
||||
err := CheckWriters(p, []string{"mesh.node.laptop.ask.hand-over"})
|
||||
if err == nil || !strings.Contains(err.Error(), "a hand-over asked of a machine") {
|
||||
t.Errorf("%s may publish a hand-over: %v", p.Username(), err)
|
||||
}
|
||||
}
|
||||
if err := CheckWriters(Principal{Kind: KindController}, []string{"mesh.node.>"}); err != nil {
|
||||
t.Fatalf("the controller may not ask a hand-over: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// **A fresh setuid search asked of a machine has one publisher, the controller** (novox/hq issue 361), as a
|
||||
// hand-over has.
|
||||
func TestASetuidSearchAskHasOnePublisher(t *testing.T) {
|
||||
for _, p := range []Principal{
|
||||
{Kind: KindNode, Node: "laptop"},
|
||||
{Kind: KindNodeTools, Node: "laptop", Module: RuntimeModule},
|
||||
{Kind: KindModule, Node: "laptop", Module: "notes"},
|
||||
} {
|
||||
err := CheckWriters(p, []string{AskSetuidSearchSubject("laptop")})
|
||||
if err == nil || !strings.Contains(err.Error(), "a fresh setuid search asked of a machine") {
|
||||
t.Errorf("%s may ask a setuid search: %v", p.Username(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// What a build says it was made from, as files (novox/hq ADR 0267), and what a build compiling a Go
|
||||
// program is handed.
|
||||
|
||||
// onTrunk answers the trunk's questions as a clone of a commit on main would, or off it.
|
||||
type onTrunk struct {
|
||||
*recorded
|
||||
off bool
|
||||
// behind is a commit on the trunk that is not its head: an older commit built by hand.
|
||||
behind bool
|
||||
}
|
||||
|
||||
func (o onTrunk) run(ctx context.Context, dir, name string, args ...string) (string, error) {
|
||||
if name == "git" && len(args) > 0 && args[0] == "symbolic-ref" {
|
||||
return "origin/main\n", nil
|
||||
}
|
||||
if name == "git" && len(args) > 1 && args[0] == "rev-parse" && args[1] == "origin/main" && o.behind {
|
||||
return "feedfacefeedfacefeedfacefeedfacefeedface\n", nil
|
||||
}
|
||||
if name == "git" && len(args) > 0 && args[0] == "merge-base" && o.off {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
return compiling{o.recorded}.run(ctx, dir, name, args...)
|
||||
}
|
||||
|
||||
// aSharedRepository is a repository holding two programs that share a package, as the controller's does.
|
||||
func aSharedRepository(readme, shared string) map[string]string {
|
||||
return map[string]string{
|
||||
"go.mod": "module example.com/ctl\n\ngo 1.22\n",
|
||||
"go.sum": "",
|
||||
"README.md": readme,
|
||||
"cmd/ctl/main.go": "package main\n\nimport _ \"example.com/ctl/internal/shared\"\n\nfunc main() {}\n",
|
||||
"proxy/main.go": "package main\n\nimport _ \"example.com/ctl/internal/shared\"\n\nfunc main() {}\n",
|
||||
"internal/shared/s.go": "package shared\n\nconst S = " + shared + "\n",
|
||||
"internal/only/o.go": "package only\n",
|
||||
}
|
||||
}
|
||||
|
||||
const aProxy = `{"module":"route-proxy","version":"1",
|
||||
"build":{"artifacts":[
|
||||
{"name":"server","kind":"image","from":"Dockerfile","compiles":"proxy",
|
||||
"context":{"repository":"https://forge.invalid/ctl.git","ref":"main"}},
|
||||
{"name":"trust","kind":"upstream","from":"alpine@sha256:` + "3333333333333333333333333333333333333333333333333333333333333333" + `"}]}}`
|
||||
|
||||
func buildTheProxy(t *testing.T, context_ map[string]string, off bool, behind ...bool) (Result, *recorded, string) {
|
||||
t.Helper()
|
||||
r := &recorded{
|
||||
contents: map[string]string{"modules/route-proxy/" + ManifestName: aProxy, "modules/route-proxy/Dockerfile": "FROM scratch\nCOPY . .\n", "modules/route-proxy/README.md": "x"},
|
||||
secondary: map[string]map[string]string{"https://forge.invalid/ctl.git": context_},
|
||||
}
|
||||
workspace := t.TempDir()
|
||||
got, err := Build(context.Background(), onTrunk{r, off, len(behind) > 0 && behind[0]}.run, r,
|
||||
"https://forge.invalid/catalogue.git", "modules/route-proxy", "", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got, r, workspace
|
||||
}
|
||||
|
||||
func TestAnImageCompilingGoIsHandedItsBuildSourceAndSaysIt(t *testing.T) {
|
||||
got, r, workspace := buildTheProxy(t, aSharedRepository("one", "1"), false)
|
||||
|
||||
// Built in the narrowed tree, which holds the program's closure and nothing else.
|
||||
at := ""
|
||||
for i, line := range r.ran {
|
||||
if strings.HasPrefix(line, "docker build ") {
|
||||
at = r.dirs[i]
|
||||
}
|
||||
}
|
||||
if filepath.Base(at) != "narrow-server" {
|
||||
t.Fatalf("docker build ran in %q, not the narrowed build source", at)
|
||||
}
|
||||
for file, want := range map[string]bool{"proxy/main.go": true, "internal/shared/s.go": true, "go.mod": true,
|
||||
"cmd/ctl/main.go": false, "internal/only/o.go": false, "README.md": false} {
|
||||
_, err := os.Stat(filepath.Join(workspace, "narrow-server", filepath.FromSlash(file)))
|
||||
if (err == nil) != want {
|
||||
t.Errorf("%s handed to the recipe: %v, wanted %v", file, err == nil, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Said per repository: its own, the manifest and the recipe; the context's, the closure.
|
||||
if len(got.Sources) != 2 {
|
||||
t.Fatalf("sources %+v", got.Sources)
|
||||
}
|
||||
own, ctx := got.Sources[0], got.Sources[1]
|
||||
if own.Repository != "" || !slices.Equal(own.Paths, []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}) {
|
||||
t.Errorf("its own build source: %+v", own)
|
||||
}
|
||||
if ctx.Repository != "https://forge.invalid/ctl.git" || ctx.Ref != "main" {
|
||||
t.Errorf("the context's build source names %q at %q", ctx.Repository, ctx.Ref)
|
||||
}
|
||||
for file, want := range map[string]bool{"proxy/main.go": true, "internal/shared/s.go": true, "go.mod": true,
|
||||
"cmd/ctl/main.go": false, "README.md": false} {
|
||||
if SourceHolds(ctx.Paths, file) != want {
|
||||
t.Errorf("the context's build source holds %s: %v, wanted %v (%v)", file, !want, want, ctx.Paths)
|
||||
}
|
||||
}
|
||||
|
||||
// **The fingerprint is over the build source** (rule 5): a change outside it is one build, inside it another.
|
||||
readme, _, _ := buildTheProxy(t, aSharedRepository("two", "1"), false)
|
||||
if readme.Source != got.Source {
|
||||
t.Errorf("a README of the context changed the fingerprint: %s %s", got.Source, readme.Source)
|
||||
}
|
||||
shared, _, _ := buildTheProxy(t, aSharedRepository("one", "2"), false)
|
||||
if shared.Source == got.Source {
|
||||
t.Error("a change to the program's closure kept its fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
// A build off the trunk, or of a trunk commit that is not its head, says no build source: the planner maps
|
||||
// a merge onto the trunk head's, and an older commit's closure lacks what was imported since.
|
||||
func TestABuildOffTheTrunksHeadSaysNoBuildSource(t *testing.T) {
|
||||
got, _, _ := buildTheProxy(t, aSharedRepository("one", "1"), true)
|
||||
if len(got.Sources) != 0 {
|
||||
t.Fatalf("a build off the trunk said %+v", got.Sources)
|
||||
}
|
||||
got, _, _ = buildTheProxy(t, aSharedRepository("one", "1"), false, true)
|
||||
if len(got.Sources) != 0 {
|
||||
t.Fatalf("a build of an older trunk commit said %+v", got.Sources)
|
||||
}
|
||||
}
|
||||
|
||||
// An archive of the module's whole directory holds every file of it.
|
||||
func TestAnArchiveOfTheWholeDirectoryHoldsIt(t *testing.T) {
|
||||
manifest := `{"module":"look","version":"1","build":{"artifacts":[{"name":"all","kind":"archive","from":"."}]},
|
||||
"resources":[{"id":"files","type":"archive","path":"/opt/look","artifact":"all"}]}`
|
||||
r := &recorded{contents: map[string]string{"modules/look/" + ManifestName: manifest, "modules/look/a/b.css": "x"}}
|
||||
got, err := Build(context.Background(), onTrunk{recorded: r}.run, r,
|
||||
"https://forge.invalid/catalogue.git", "modules/look", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Sources) != 1 || !SourceHolds(got.Sources[0].Paths, "modules/look/a/b.css") ||
|
||||
SourceHolds(got.Sources[0].Paths, "modules/other/x") {
|
||||
t.Fatalf("sources %+v", got.Sources)
|
||||
}
|
||||
}
|
||||
|
||||
// A recipe reading past its build source fails, naming what it could not find — in the real docker build;
|
||||
// here, the file is simply not in the tree it is handed, which is what makes that so.
|
||||
func TestAnImageCompilingANonexistentPackageFails(t *testing.T) {
|
||||
r := &recorded{
|
||||
contents: map[string]string{ManifestName: strings.Replace(aProxy, `"compiles":"proxy"`, `"compiles":"nowhere"`, 1),
|
||||
"Dockerfile": "FROM scratch\n"},
|
||||
secondary: map[string]map[string]string{"https://forge.invalid/ctl.git": aSharedRepository("one", "1")},
|
||||
}
|
||||
_, err := Build(context.Background(), onTrunk{recorded: r}.run, r,
|
||||
"https://forge.invalid/catalogue.git", "", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "nowhere") {
|
||||
t.Fatalf("a package that is not there built: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A Go bundle of a module built from its repository's root says its import closure, and the manifest;
|
||||
// an image that compiles nothing it was told of leaves the module's source whole, and says none.
|
||||
func TestAGoBundleSaysItsClosureAndAnUntoldImageNothing(t *testing.T) {
|
||||
files := aSharedRepository("one", "1")
|
||||
manifest := `{"module":"ctl","version":"1","build":{"artifacts":[
|
||||
{"name":"controller","kind":"bundle","language":"go","system":"arch","from":"cmd/ctl","binary":"ctl"}]},
|
||||
"resources":[{"id":"controller","type":"process","name":"ctl","artifact":"controller","run":["./ctl"]}]}`
|
||||
r := &recorded{contents: map[string]string{ManifestName: manifest}}
|
||||
for k, v := range files {
|
||||
r.contents[k] = v
|
||||
}
|
||||
held := map[string]string{"mesh-tools-go/build": "registry.invalid/mesh-tools-go/build@sha256:" + strings.Repeat("b", 64)}
|
||||
got, err := Build(context.Background(), onTrunk{recorded: r}.run, r,
|
||||
"https://forge.invalid/ctl.git", "", "", t.TempDir(), held, Npmrc{}, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Sources) != 1 || got.Sources[0].Repository != "" {
|
||||
t.Fatalf("sources %+v", got.Sources)
|
||||
}
|
||||
for file, want := range map[string]bool{"cmd/ctl/main.go": true, "internal/shared/s.go": true, ManifestName: true,
|
||||
"go.sum": true, "proxy/main.go": false, "README.md": false, "internal/only/o.go": false} {
|
||||
if SourceHolds(got.Sources[0].Paths, file) != want {
|
||||
t.Errorf("%s: held %v, wanted %v (%v)", file, !want, want, got.Sources[0].Paths)
|
||||
}
|
||||
}
|
||||
|
||||
untold := `{"module":"ctl","version":"1","build":{"artifacts":[
|
||||
{"name":"server","kind":"image","from":"Dockerfile"}]}}`
|
||||
r = &recorded{contents: map[string]string{ManifestName: untold, "Dockerfile": "FROM scratch\n"}}
|
||||
got, err = Build(context.Background(), onTrunk{recorded: r}.run, r,
|
||||
"https://forge.invalid/ctl.git", "", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Sources) != 0 {
|
||||
t.Fatalf("an image compiling nothing it was told of said a build source: %+v", got.Sources)
|
||||
}
|
||||
}
|
||||
+113
-5
@@ -11,6 +11,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
@@ -87,6 +88,23 @@ type Result struct {
|
||||
// pin the build. Two builds with one fingerprint are one build, whatever digests they made
|
||||
// (novox/hq issue 280).
|
||||
Source string
|
||||
|
||||
// Sources are what this build was made from, as files (novox/hq ADR 0267 rule 1): per repository, the
|
||||
// entries a changed file is tested against (SourceHolds). The module's own repository has an empty
|
||||
// Repository. Said only for a build of a commit on the trunk, and only for a repository whose every
|
||||
// artifact's build source is known — a Go program's import closure, an archive's directory, an image's
|
||||
// recipe and the package it compiles; for any other, nothing is said and the whole of what the build
|
||||
// sees stays its source, as before.
|
||||
Sources []BuildSource
|
||||
}
|
||||
|
||||
// BuildSource is the build source a build read in one repository (novox/hq ADR 0267).
|
||||
type BuildSource struct {
|
||||
// Repository and Ref are a context's, as the manifest names it; empty for the module's own.
|
||||
Repository string
|
||||
Ref string
|
||||
// Paths are the entries, relative to the repository's root (SourceHolds).
|
||||
Paths []string
|
||||
}
|
||||
|
||||
// GitCredential is the forge credential a clone may present when the server asks for one.
|
||||
@@ -206,6 +224,10 @@ func build(ctx context.Context, run Runner, publish Publisher,
|
||||
|
||||
// What it is made from, for its source fingerprint: the module's own tree first.
|
||||
src := newSourceInputs(manifest.Module)
|
||||
src.prefix = strings.Trim(filepath.ToSlash(filepath.Clean(path)), "/")
|
||||
if src.prefix == "." {
|
||||
src.prefix = ""
|
||||
}
|
||||
if src.tree, err = gitTree(ctx, run, tree, path); err != nil {
|
||||
src.notPinned("its tree could not be named: " + err.Error())
|
||||
}
|
||||
@@ -298,9 +320,23 @@ func build(ctx context.Context, run Runner, publish Publisher,
|
||||
if fingerprint == "" {
|
||||
say("source", "no source fingerprint: %s", orNoTree(src.unpinned))
|
||||
}
|
||||
// **Only a build of the trunk's head says its build source** (novox/hq ADR 0267): the planner maps the
|
||||
// next merge onto the build source of the newest build, and a branch's closure — or an older trunk
|
||||
// commit's, built by hand — is not the trunk's. Nor does a build whose context was not its trunk's head.
|
||||
var sources []BuildSource
|
||||
if trunk != "" && onTrunk && src.contextsAtHead && atTrunkHead(ctx, run, tree, commit, trunk) {
|
||||
sources = src.buildSources()
|
||||
for _, s := range sources {
|
||||
where := "its own repository"
|
||||
if s.Repository != "" {
|
||||
where = s.Repository
|
||||
}
|
||||
say("source", "%d path(s) of %s", len(s.Paths), where)
|
||||
}
|
||||
}
|
||||
return Result{Manifest: resolved, Commit: commit, Built: built,
|
||||
Against: against(within, manifest, stoodOn), Read: readBy(manifest), Source: fingerprint,
|
||||
Trunk: trunk, OnTrunk: onTrunk, Branches: branches}, nil
|
||||
Trunk: trunk, OnTrunk: onTrunk, Branches: branches, Sources: sources}, nil
|
||||
}
|
||||
|
||||
// branchesHolding is every branch of a fresh clone's origin the commit is on, without `origin/`.
|
||||
@@ -345,6 +381,28 @@ func trunkOf(ctx context.Context, run Runner, clone, commit string) (string, boo
|
||||
return trunk, err == nil
|
||||
}
|
||||
|
||||
// atTrunkHead is whether a clone's commit is its trunk's head as the clone holds it.
|
||||
func atTrunkHead(ctx context.Context, run Runner, clone, commit, trunk string) bool {
|
||||
head, err := run(ctx, clone, "git", "rev-parse", "origin/"+trunk)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
at, err := run(ctx, clone, "git", "rev-parse", commit)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(head) != "" && strings.TrimSpace(head) == strings.TrimSpace(at)
|
||||
}
|
||||
|
||||
// cleanEntry is a path of the module's directory as an entry: cleaned, relative, `.` for the directory.
|
||||
func cleanEntry(p string) string {
|
||||
c := strings.Trim(path.Clean("/"+filepath.ToSlash(p)), "/")
|
||||
if c == "" {
|
||||
return "."
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// orNoTree is why a build has no source fingerprint, for its log.
|
||||
func orNoTree(why string) string {
|
||||
if why == "" {
|
||||
@@ -648,15 +706,51 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
||||
} else if src != nil {
|
||||
src.contexts[a.Name] = t
|
||||
}
|
||||
// docker build accepts -f outside the context it is given; the recipe stays exactly
|
||||
// where it was read from and validated against, absolute so the working directory
|
||||
// switching to the cloned context does not change which file that is.
|
||||
buildDir = cloned
|
||||
if t, _ := trunkOf(ctx, run, cloned, "HEAD"); t == "" || !atTrunkHead(ctx, run, cloned, "HEAD", t) {
|
||||
src.contextOffHead()
|
||||
}
|
||||
}
|
||||
// docker build accepts -f outside the context it is given; the recipe stays exactly where it was
|
||||
// read from and validated against, absolute so a context elsewhere does not change which file
|
||||
// that is.
|
||||
if buildDir != tree || a.Compiles != "" {
|
||||
absRecipe, err := filepath.Abs(filepath.Join(tree, a.From))
|
||||
if err != nil {
|
||||
return catalogue.Built{}, fmt.Errorf("%s: %s's recipe: %w", module, a.Name, err)
|
||||
}
|
||||
recipePath = absRecipe
|
||||
buildDir = cloned
|
||||
}
|
||||
// **An image that compiles a Go program is handed its build source and nothing else** (novox/hq
|
||||
// ADR 0267 rules 1 and 3): the program's import closure, read from the context it is built in, so a
|
||||
// merge elsewhere in that repository is no change to it — and a recipe that copies a file outside
|
||||
// it fails here, naming the file, rather than building from something no merge is mapped onto.
|
||||
if a.Compiles != "" {
|
||||
paths, err := GoBuildSource(buildDir, a.Compiles)
|
||||
if err != nil {
|
||||
return catalogue.Built{}, fmt.Errorf("%s: %s compiles %s, whose build source cannot be read: %w",
|
||||
module, a.Name, a.Compiles, err)
|
||||
}
|
||||
narrowed := filepath.Join(workspace, "narrow-"+a.Name)
|
||||
sum, err := narrowTree(buildDir, narrowed, paths)
|
||||
if err != nil {
|
||||
return catalogue.Built{}, fmt.Errorf("%s: handing %s its build source: %w", module, a.Name, err)
|
||||
}
|
||||
say("image", "%s is handed its build source: %d path(s) of %s", a.Name, len(paths), a.Compiles)
|
||||
if a.Context != nil {
|
||||
src.contexts[a.Name] = sum
|
||||
src.readIn(*a.Context, paths)
|
||||
} else {
|
||||
src.ownHas(paths...)
|
||||
}
|
||||
buildDir = narrowed
|
||||
} else if a.Context != nil {
|
||||
src.readWhole(*a.Context)
|
||||
}
|
||||
if a.Compiles != "" || a.Context != nil {
|
||||
src.ownHas(cleanEntry(a.From))
|
||||
} else {
|
||||
src.ownWhole()
|
||||
}
|
||||
invocation := append([]string{"build", "-f", recipePath, "-t", local}, args...)
|
||||
if a.Target != "" {
|
||||
@@ -708,6 +802,18 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
||||
if src != nil {
|
||||
src.toolchains[a.Name] = toolchainOf(chain, base)
|
||||
}
|
||||
// A Go program's build source is its import closure (novox/hq ADR 0267 rule 1). Not read, it is the
|
||||
// module's whole directory, as before — said, so the wider plan has a reason a person can find.
|
||||
if chain.Language == "go" {
|
||||
if paths, err := GoBuildSource(tree, a.From); err != nil {
|
||||
say("bundle", "%s's build source is its whole directory: %v", a.Name, err)
|
||||
src.ownWhole()
|
||||
} else {
|
||||
src.ownHas(paths...)
|
||||
}
|
||||
} else {
|
||||
src.ownWhole()
|
||||
}
|
||||
if chain.Language == "typescript" {
|
||||
if own, _ := ownDependencies(tree); len(own) > 0 {
|
||||
src.notPinned(a.Name + " resolves packages of its own at build time")
|
||||
@@ -754,6 +860,7 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
||||
// there is no Publisher call — the container itself publishes, with the credential the
|
||||
// build was handed.
|
||||
say("package", "building and publishing %s (%s)", a.Name, a.Language)
|
||||
src.ownWhole()
|
||||
src.notPinned(a.Name + " is a package, built from what the registry holds when it is built")
|
||||
reference, err := publishPackage(ctx, run, module, tree, a, npmrc, say)
|
||||
if err != nil {
|
||||
@@ -763,6 +870,7 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
||||
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil
|
||||
|
||||
case catalogue.ArtifactArchive:
|
||||
src.ownHas(cleanEntry(a.From) + "/**")
|
||||
body, err := pack(filepath.Join(tree, a.From))
|
||||
if err != nil {
|
||||
return catalogue.Built{}, fmt.Errorf("%s: packing %s failed: %w", module, a.Name, err)
|
||||
|
||||
@@ -543,6 +543,11 @@ func passedSoFar(ran []string) string {
|
||||
return strings.Join(ran, ", ") + " passed; "
|
||||
}
|
||||
|
||||
// SomeManifestsEnv tells the judge's module check that the manifests it is given are only those a change touches,
|
||||
// so a seat another module of the repository declares is a note there, not a refusal (novox/hq issue 364). A judge
|
||||
// that predates it reads nothing of it and refuses as before.
|
||||
const SomeManifestsEnv = "MESH_MODULE_CHECK_SOME=1"
|
||||
|
||||
// gateLayer runs the gate: the touched manifests through `module check`, every machine composed with the
|
||||
// change, and the replays of what the mesh runs. It answers the gate's verdict and summary.
|
||||
func gateLayer(ctx context.Context, spec CheckSpec, tree, root, gate, verdictFile string, env []string,
|
||||
@@ -562,7 +567,7 @@ func gateLayer(ctx context.Context, spec CheckSpec, tree, root, gate, verdictFil
|
||||
checked := func(dir string) (string, error) {
|
||||
var own tail
|
||||
cmd := exec.CommandContext(ctx, "docker", LabelledArgs("docker",
|
||||
inToolchain(dir, env, append([]string{gate, "module", "check"}, manifests...)...), spec.ID)...)
|
||||
inToolchain(dir, append(append([]string{}, env...), SomeManifestsEnv), append([]string{gate, "module", "check"}, manifests...)...), spec.ID)...)
|
||||
inItsOwnGroup(cmd)
|
||||
w := io.MultiWriter(out, &own)
|
||||
cmd.Stdout, cmd.Stderr = w, w
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A Go program's build source (novox/hq ADR 0267 rule 1): the files it is built from, derived from its
|
||||
// import closure rather than listed by hand, because a list drifts from the imports it describes and a
|
||||
// path missing from it is a real change missed — worse than a needless rebuild.
|
||||
//
|
||||
// **The closure read here is never narrower than `go list -deps`.** Every .go file of a package that is
|
||||
// not a test is read, whatever its build constraint, so the imports are the union over every system and
|
||||
// tag; a directory is held whole (but for its tests), so a file added to a package is in it; an embed is
|
||||
// held by the directory its pattern starts in, everything below it. Read with the standard library's
|
||||
// parser and no toolchain: the build machine carries none, and a closure that needed the network to
|
||||
// read would be one a build could not say offline.
|
||||
//
|
||||
// A build source is a list of entries, relative to the root the build sees:
|
||||
//
|
||||
// dir/ a Go package's directory: every file directly in it but its tests (`*_test.go`)
|
||||
// dir/** everything below a directory (an embed)
|
||||
// ** the whole tree
|
||||
// file one file
|
||||
//
|
||||
// The root package's directory is `./`.
|
||||
|
||||
// GoPackageDirEntry is the entry for a Go package's directory.
|
||||
func goPackageDirEntry(dir string) string {
|
||||
if dir == "" || dir == "." {
|
||||
return "./"
|
||||
}
|
||||
return dir + "/"
|
||||
}
|
||||
|
||||
// SourceHolds is whether a changed file — a path relative to the root the build source was read in — is
|
||||
// in that build source.
|
||||
func SourceHolds(entries []string, file string) bool {
|
||||
file = strings.TrimPrefix(path.Clean("/"+strings.TrimSpace(file)), "/")
|
||||
for _, e := range entries {
|
||||
switch {
|
||||
case e == "**":
|
||||
return true
|
||||
case strings.HasSuffix(e, "/**"):
|
||||
dir := strings.TrimSuffix(e, "/**")
|
||||
if dir == "." || dir == "" || file == dir || strings.HasPrefix(file, dir+"/") {
|
||||
return true
|
||||
}
|
||||
case strings.HasSuffix(e, "/"):
|
||||
dir := strings.TrimSuffix(e, "/")
|
||||
parent := path.Dir(file)
|
||||
if (dir == "." && parent == ".") || parent == dir {
|
||||
if !strings.HasSuffix(file, "_test.go") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case e == file:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GoBuildSource is the build source of the Go program whose main package is pkg, a directory relative to
|
||||
// root: the directories of every package of its import closure inside root, the embeds those packages
|
||||
// name, its module's go.mod, go.sum and vendor/modules.txt, and a go.work wherever one would be read. An
|
||||
// entry for a file that does not exist is kept: creating it is a change to the build.
|
||||
//
|
||||
// Refused — so the build source is not narrowed, and nothing is missed — when the closure cannot be told
|
||||
// from the files: no go.mod holds the package, a go.work is present, a local replace leaves root, a file
|
||||
// does not parse, or a cgo preamble reaches outside its directory.
|
||||
func GoBuildSource(root, pkg string) ([]string, error) {
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rel := path.Clean(strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(pkg)), "/"))
|
||||
if rel == ".." || strings.HasPrefix(rel, "../") {
|
||||
return nil, fmt.Errorf("the package %q leaves the tree it is built from", pkg)
|
||||
}
|
||||
if info, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); err != nil || !info.IsDir() {
|
||||
return nil, fmt.Errorf("%q is not a directory of the tree it is built from", pkg)
|
||||
}
|
||||
// The module holding the package: the nearest go.mod at or above it, within root.
|
||||
modRoot := ""
|
||||
for dir := rel; ; dir = path.Dir(dir) {
|
||||
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(dir), "go.mod")); err == nil {
|
||||
modRoot = dir
|
||||
break
|
||||
}
|
||||
if dir == "." {
|
||||
break
|
||||
}
|
||||
}
|
||||
if modRoot == "" {
|
||||
return nil, fmt.Errorf("no go.mod holds %q within the tree it is built from", pkg)
|
||||
}
|
||||
entries := map[string]bool{}
|
||||
file := func(dir, name string) {
|
||||
entries[strings.TrimPrefix(path.Join(dir, name), "./")] = true
|
||||
}
|
||||
file(modRoot, "go.mod")
|
||||
file(modRoot, "go.sum")
|
||||
file(modRoot, "vendor/modules.txt")
|
||||
// A workspace changes how every import resolves; one present is not read past, one created later is a
|
||||
// change to the build.
|
||||
for dir := modRoot; ; dir = path.Dir(dir) {
|
||||
file(dir, "go.work")
|
||||
file(dir, "go.work.sum")
|
||||
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(dir), "go.work")); err == nil {
|
||||
return nil, fmt.Errorf("%s holds a go.work, and a workspace's imports are not read here", path.Join(dir, "go.work"))
|
||||
}
|
||||
if dir == "." {
|
||||
break
|
||||
}
|
||||
}
|
||||
modPath, replaces, err := readGoMod(filepath.Join(root, filepath.FromSlash(modRoot), "go.mod"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vendored := false
|
||||
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(modRoot), "vendor", "modules.txt")); err == nil {
|
||||
vendored = true
|
||||
}
|
||||
// resolve is the directories, relative to root, an import may be read from: none for the standard
|
||||
// library and for a module outside the tree, which go.mod and go.sum pin. A vendored module replaced
|
||||
// by a local directory is both — vendor/ under -mod=vendor, the directory under -mod=mod — and both
|
||||
// are held, so neither way of building it is missed.
|
||||
resolve := func(importPath string) ([]string, error) {
|
||||
within := func(prefix, dir string) (string, bool) {
|
||||
if importPath == prefix {
|
||||
return dir, true
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(importPath, prefix+"/"); ok {
|
||||
return path.Join(dir, rest), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
if dir, ok := within(modPath, modRoot); ok {
|
||||
return []string{dir}, nil
|
||||
}
|
||||
var dirs []string
|
||||
for _, r := range replaces {
|
||||
if sub, ok := within(r.from, ""); ok {
|
||||
target := path.Clean(path.Join(modRoot, r.to))
|
||||
if target == ".." || strings.HasPrefix(target, "../") {
|
||||
return nil, fmt.Errorf("go.mod replaces %s with %s, outside the tree it is built from", r.from, r.to)
|
||||
}
|
||||
dirs = append(dirs, path.Join(target, sub))
|
||||
// Its go.mod states what it requires, read when it is built from there.
|
||||
entries[path.Join(target, "go.mod")] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
first, _, _ := strings.Cut(importPath, "/")
|
||||
if len(dirs) == 0 && !strings.Contains(first, ".") {
|
||||
return nil, nil // the standard library
|
||||
}
|
||||
if vendored {
|
||||
dirs = append(dirs, path.Join(modRoot, "vendor", importPath))
|
||||
}
|
||||
return dirs, nil
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
queue := []string{rel}
|
||||
for len(queue) > 0 {
|
||||
dir := queue[0]
|
||||
queue = queue[1:]
|
||||
if seen[dir] {
|
||||
continue
|
||||
}
|
||||
seen[dir] = true
|
||||
entries[goPackageDirEntry(dir)] = true
|
||||
// A go.mod made between the module's root and a package moves that package out of the module.
|
||||
for up := dir; up != modRoot && up != "." && up != "/" && !strings.HasPrefix(up, "../"); up = path.Dir(up) {
|
||||
file(up, "go.mod")
|
||||
}
|
||||
listing, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(dir)))
|
||||
if err != nil {
|
||||
// A package that is not there fails the build that imports it; its directory is held, so
|
||||
// adding it is a change.
|
||||
continue
|
||||
}
|
||||
for _, f := range listing {
|
||||
name := f.Name()
|
||||
// **C and assembly beside Go** may include files from below the package's directory: the
|
||||
// directory is held whole, and an include reaching above it is refused.
|
||||
if !f.IsDir() && nativeSource(name) {
|
||||
entries[strings.TrimPrefix(dir+"/**", "./")] = true
|
||||
if dir == "." {
|
||||
entries["**"] = true
|
||||
}
|
||||
if err := includesStayWithin(filepath.Join(root, filepath.FromSlash(dir), name)); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path.Join(dir, name), err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if f.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(root, filepath.FromSlash(dir), name)
|
||||
imports, embeds, err := goFileReads(full)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path.Join(dir, name), err)
|
||||
}
|
||||
for _, ip := range imports {
|
||||
targets, err := resolve(ip)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, target := range targets {
|
||||
if !seen[target] {
|
||||
queue = append(queue, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, e := range embeds {
|
||||
entries[path.Join(dir, e)+"/**"] = true
|
||||
if !strings.ContainsAny(e, "*?[\\") {
|
||||
entries[path.Join(dir, e)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(entries))
|
||||
for e := range entries {
|
||||
out = append(out, e)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// goReplace is one local replacement in go.mod: an import path read from a directory.
|
||||
type goReplace struct{ from, to string }
|
||||
|
||||
// readGoMod is a go.mod's module path and its replacements by a local directory. A replacement by another
|
||||
// module version is resolved by go.sum, which the build source holds.
|
||||
func readGoMod(file string) (string, []goReplace, error) {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
var module string
|
||||
var replaces []goReplace
|
||||
inReplace := false
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if i := strings.Index(line, "//"); i >= 0 {
|
||||
line = line[:i]
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case line == "":
|
||||
continue
|
||||
case inReplace && line == ")":
|
||||
inReplace = false
|
||||
continue
|
||||
case strings.HasPrefix(line, "module "):
|
||||
module = unquoteGoMod(strings.TrimSpace(strings.TrimPrefix(line, "module")))
|
||||
continue
|
||||
case line == "replace (":
|
||||
inReplace = true
|
||||
continue
|
||||
case strings.HasPrefix(line, "replace "):
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, "replace"))
|
||||
case !inReplace:
|
||||
continue
|
||||
}
|
||||
left, right, found := strings.Cut(line, "=>")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
from := strings.Fields(left)
|
||||
to := strings.Fields(right)
|
||||
if len(from) == 0 || len(to) == 0 {
|
||||
continue
|
||||
}
|
||||
target := unquoteGoMod(to[0])
|
||||
// A local replacement is a path: ./, ../ or absolute. Anything else names a module version.
|
||||
if strings.HasPrefix(target, "./") || strings.HasPrefix(target, "../") || target == "." || target == ".." {
|
||||
replaces = append(replaces, goReplace{from: unquoteGoMod(from[0]), to: target})
|
||||
} else if strings.HasPrefix(target, "/") {
|
||||
return "", nil, fmt.Errorf("go.mod replaces %s with %s, outside the tree it is built from", from[0], target)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if module == "" {
|
||||
return "", nil, fmt.Errorf("%s names no module", file)
|
||||
}
|
||||
// The longest replacement first, so a replaced sub-path wins over its parent.
|
||||
sort.SliceStable(replaces, func(i, j int) bool { return len(replaces[i].from) > len(replaces[j].from) })
|
||||
return module, replaces, nil
|
||||
}
|
||||
|
||||
func unquoteGoMod(s string) string {
|
||||
if u, err := strconv.Unquote(s); err == nil {
|
||||
return u
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// goFileReads is what one Go file makes its build read: the packages it imports, and the directories its
|
||||
// //go:embed patterns start in, relative to its own directory ("." for the directory itself).
|
||||
//
|
||||
// **A cgo preamble reaching outside its directory is refused**: a header included by a relative path, or
|
||||
// a flag naming ${SRCDIR}/.., is a file of the build no import names. Inside the directory it is held
|
||||
// already.
|
||||
func goFileReads(file string) (imports, embeds []string, err error) {
|
||||
src, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
parsed, err := parser.ParseFile(fset, file, src, parser.ImportsOnly|parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, spec := range parsed.Imports {
|
||||
ip, err := strconv.Unquote(spec.Path.Value)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if ip == "C" {
|
||||
// The preamble is the comment before the import; only its #include and #cgo lines read files.
|
||||
for _, cg := range parsed.Comments {
|
||||
if cg.End() > spec.Pos() {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(cg.Text(), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if (strings.HasPrefix(line, "#include") || strings.HasPrefix(line, "#cgo")) && strings.Contains(line, "..") {
|
||||
return nil, nil, errors.New("its cgo preamble names a path outside its directory: " + line)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A cgo file may include from below its directory: the directory is held whole.
|
||||
embeds = append(embeds, ".")
|
||||
continue
|
||||
}
|
||||
imports = append(imports, ip)
|
||||
}
|
||||
// //go:embed directives may stand anywhere in the file, so the whole text is read for them.
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(src)))
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
rest, ok := strings.CutPrefix(line, "//go:embed")
|
||||
if !ok || (rest != "" && rest[0] != ' ' && rest[0] != '\t') {
|
||||
continue
|
||||
}
|
||||
patterns, err := embedPatterns(rest)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, p := range patterns {
|
||||
embeds = append(embeds, embedRoot(p))
|
||||
}
|
||||
}
|
||||
return imports, embeds, scanner.Err()
|
||||
}
|
||||
|
||||
// nativeSource is a file the Go command compiles or links beside Go: C, C++, Objective-C, Fortran,
|
||||
// assembly, their headers and a system object.
|
||||
func nativeSource(name string) bool {
|
||||
switch strings.ToLower(path.Ext(name)) {
|
||||
case ".c", ".h", ".cc", ".cpp", ".cxx", ".hh", ".hpp", ".hxx", ".m", ".s", ".sx", ".f", ".f90", ".for", ".syso":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// includesStayWithin refuses a native source whose #include names a path above its directory.
|
||||
func includesStayWithin(file string) error {
|
||||
body, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, line := range strings.Split(string(body), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "#") && strings.Contains(line, "include") && strings.Contains(line, "..") {
|
||||
return errors.New("it includes a path outside its directory: " + line)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// embedPatterns splits a //go:embed line's patterns: separated by spaces, each possibly quoted.
|
||||
func embedPatterns(s string) ([]string, error) {
|
||||
var out []string
|
||||
s = strings.TrimSpace(s)
|
||||
for s != "" {
|
||||
var p string
|
||||
switch s[0] {
|
||||
case '"', '`':
|
||||
q, err := strconv.QuotedPrefix(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("an embed pattern does not parse: %w", err)
|
||||
}
|
||||
if p, err = strconv.Unquote(q); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s = s[len(q):]
|
||||
default:
|
||||
end := strings.IndexAny(s, " \t")
|
||||
if end < 0 {
|
||||
end = len(s)
|
||||
}
|
||||
p, s = s[:end], s[end:]
|
||||
}
|
||||
out = append(out, p)
|
||||
s = strings.TrimSpace(s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// embedRoot is the directory an embed pattern starts in, relative to the package: its leading elements
|
||||
// without a wildcard. A pattern naming a file or a directory outright is held as itself.
|
||||
func embedRoot(pattern string) string {
|
||||
pattern = strings.TrimPrefix(pattern, "all:")
|
||||
var kept []string
|
||||
for _, el := range strings.Split(pattern, "/") {
|
||||
if strings.ContainsAny(el, "*?[\\") {
|
||||
break
|
||||
}
|
||||
kept = append(kept, el)
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
return "."
|
||||
}
|
||||
return path.Clean(strings.Join(kept, "/"))
|
||||
}
|
||||
|
||||
// narrowTree copies into dst the files of src a build source holds, and nothing else — never `.git` — and
|
||||
// returns a fingerprint of what it copied: each file's path, mode and content, hashed in path order. **A
|
||||
// build handed only its build source cannot read past it** (novox/hq ADR 0267 rule 3): a recipe that
|
||||
// copies a file outside it fails, naming the file, where it would have built and been missed.
|
||||
func narrowTree(src, dst string, entries []string) (string, error) {
|
||||
if err := os.RemoveAll(dst); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var lines []string
|
||||
err := filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(src, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if d.IsDir() {
|
||||
if d.Name() == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !SourceHolds(entries, rel) {
|
||||
return nil
|
||||
}
|
||||
out := filepath.Join(dst, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
// A link in the repository is copied as the link it is, and what it names said in the
|
||||
// fingerprint.
|
||||
target, err := os.Readlink(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%s link %s", rel, target))
|
||||
return os.Symlink(target, out)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
in, err := os.Open(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
w, err := os.OpenFile(out, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sum := sha256.New()
|
||||
if _, err := io.Copy(io.MultiWriter(w, sum), in); err != nil {
|
||||
w.Close()
|
||||
return err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%s %o %s", rel, info.Mode().Perm()&0o111, hex.EncodeToString(sum.Sum(nil))))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sort.Strings(lines)
|
||||
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
|
||||
return "narrow:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user