Compare commits
93
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 {
|
for _, r := range built.Read {
|
||||||
result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref})
|
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))
|
say("built", built.Manifest.Module+" from "+short(built.Commit))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -179,7 +180,21 @@ func (a *actor) release() {
|
|||||||
// holderOf is this process as the lease's holder.
|
// holderOf is this process as the lease's holder.
|
||||||
func holderOf(instance string) lease.Holder {
|
func holderOf(instance string) lease.Holder {
|
||||||
host, _ := os.Hostname()
|
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
|
// 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
|
// The one judgement: `node show`, the self-check, and ADR 0259's router honouring a verified sender read
|
||||||
// it here.
|
// 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)
|
n, err := inv.NodeByName(ctx, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, false, "", err
|
return false, false, "", err
|
||||||
@@ -61,7 +62,7 @@ func agentConfined(ctx context.Context, inv *inventory.Inventory, node string) (
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return true, false, "", err
|
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
|
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
|
// 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
|
// search before that is itself the urgent condition: the longest a search is expected to take (link.RootSearchQuiet,
|
||||||
// engine's own value), counted from when this controller first saw it waiting, never from the engine's start.
|
// the engine's own value; novox/hq issue 361 — the search runs to completion, with no bound of its own), counted
|
||||||
const searchQuietFor = link.RootSearchBound
|
// 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.
|
// The kinds of an agent account's verdict, for the quiet a search earns.
|
||||||
const (
|
const (
|
||||||
@@ -206,10 +209,10 @@ func probeAgentAccounts(ctx context.Context, d *doctor) ([]conditions.Observatio
|
|||||||
if confined {
|
if confined {
|
||||||
continue
|
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
|
// 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,
|
// 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 {
|
if quiet {
|
||||||
continue
|
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",
|
return []string{fmt.Sprintf(" agents run as the operator account (%s); no agent account is named",
|
||||||
orNoneKnown(n.Account))}
|
orNoneKnown(n.Account))}
|
||||||
}
|
}
|
||||||
_, confined, why, err := agentConfined(ctx, inv, n.Name)
|
_, confined, why, err := agentConfined(ctx, inv, n.Name, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return []string{fmt.Sprintf(" agents run as %s (home %s); whether it can become root could NOT be read: %v",
|
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)}
|
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 {
|
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)
|
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)
|
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") {
|
!strings.Contains(why, "operator account") {
|
||||||
t.Fatalf("agentConfined on a machine naming none: %v %q %v", named, why, err)
|
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
|
// Until a complete search for setuid programs judges — none since the node-engine's state was kept, or the last
|
||||||
// is not judged until it ends. DA does not raise that as urgent while the search is within its bound, counted from
|
// older than the engine lets one judge — the agent account is not judged, and the search runs to its end with no
|
||||||
// when this controller first saw it waiting — never from the engine's own "since", which a restart resets, so an
|
// bound (novox/hq issue 361). DA does not raise that as urgent within searchQuietFor, counted from when this
|
||||||
// engine restarted in a loop does not keep it quiet. The account is still not confined, and `node show` still
|
// controller first saw it waiting — never from the engine's own "since", which a restart resets, so an engine
|
||||||
// says not judged; a search that failed, or a way to root found, is urgent at once.
|
// 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) {
|
func TestASearchStillRunningAfterARestartIsNotUrgent(t *testing.T) {
|
||||||
if searchQuietFor != rootsearch.Bound {
|
if searchQuietFor != rootsearch.Quiet {
|
||||||
t.Fatalf("the quiet is %s and the node-engine's bound %s: they are one value", searchQuietFor, rootsearch.Bound)
|
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)
|
open := aMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
@@ -246,7 +252,7 @@ func TestASearchStillRunningAfterARestartIsNotUrgent(t *testing.T) {
|
|||||||
if found := say(link.StateUnknown, running); len(found) != 0 {
|
if found := say(link.StateUnknown, running); len(found) != 0 {
|
||||||
t.Fatalf("a search first seen now was raised: %+v", found)
|
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)
|
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.
|
// 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 {
|
for _, r := range result.Read {
|
||||||
kept.Read = append(kept.Read, inventory.ReadRepository{Repository: r.Repository, Ref: r.Ref})
|
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
|
var announced []inventory.Artifact
|
||||||
for _, made := range result.Made {
|
for _, made := range result.Made {
|
||||||
announced = append(announced, inventory.Artifact{
|
announced = append(announced, inventory.Artifact{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"hash/fnv"
|
"hash/fnv"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
"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) {
|
if _, _, err := takeIn(ctx, inv, fork); !errors.Is(err, errNotItsSource) {
|
||||||
t.Fatalf("the fork's build was taken in: %v", err)
|
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"})
|
map[string]any{"module": "sudo", "version": "2"})
|
||||||
failed.Commit = "badbadbad0123456"
|
failed.Commit = "badbadbad0123456"
|
||||||
if _, _, err := takeIn(ctx, inv, failed); err != nil {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// 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).
|
// the modules after it in the list hear nothing (novox/hq issue 208, where this runs on each send).
|
||||||
var failed []error
|
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 {
|
for _, c := range consumers {
|
||||||
if err := r.EnsureConsumer(c.Consumer); err != nil {
|
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))
|
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
|
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.
|
// 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) {
|
func moduleConsumerCount(ctx context.Context, inv *inventory.Inventory) (int, error) {
|
||||||
consumers, err := moduleConsumers(ctx, inv)
|
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
|
// 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
|
// 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.
|
// 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 {
|
func moduleCheck(paths []string, out io.Writer) error {
|
||||||
return moduleCheckFor(paths, catalogue.DefaultLongestMachine, out)
|
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
|
// 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
|
// 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.
|
// 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)
|
problems := catalogue.CatalogueProblems(shelf)
|
||||||
sort.Strings(problems)
|
sort.Strings(problems)
|
||||||
for _, p := range 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)
|
fmt.Fprintln(out, p)
|
||||||
|
failed++
|
||||||
}
|
}
|
||||||
failed += len(problems)
|
|
||||||
|
|
||||||
// Between the manifests too: an identity against the bounds of the provisions it wants, which
|
// Between the manifests too: an identity against the bounds of the provisions it wants, which
|
||||||
// only the provider's manifest states.
|
// 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 {
|
if _, err := git("fetch", "--quiet", "origin", *base); err != nil {
|
||||||
return fmt.Errorf("cannot fetch %s to say what the change touches: %w", *base, err)
|
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 {
|
if err != nil {
|
||||||
return err
|
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())
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
read, err := inv.ReadRepositories(ctx)
|
read, err := readForPlanning(ctx, inv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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...) },
|
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
|
// What status leads with changed: composed again soon (a nudge outside the serving controller
|
||||||
// does nothing).
|
// does nothing).
|
||||||
Changed: statusFrom.nudge,
|
Changed: func() { statusFrom.nudge(); askerFrom.nudge() },
|
||||||
// Written under the lease, carrying its epoch (novox/hq to-be 45 §6).
|
// Written under the lease, carrying its epoch (novox/hq to-be 45 §6).
|
||||||
Epoch: func() (uint64, error) { return theLease.epoch(context.WithoutCancel(ctx)) }}), nil
|
Epoch: func() (uint64, error) { return theLease.epoch(context.WithoutCancel(ctx)) }}), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
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"))
|
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()
|
now := time.Now().UTC()
|
||||||
p.Delivery.Go, p.Delivery.By, p.Delivery.Why = &now, by, why
|
p.Delivery.Go, p.Delivery.By, p.Delivery.Why = &now, by, why
|
||||||
p.Note = "let go by " + by + "; its first tier is asked next"
|
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 {
|
if walks, err = inv.OpenPlans(ctx); err != nil {
|
||||||
return err
|
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)
|
recent, err := inv.RecentPlans(ctx, *limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -604,7 +612,7 @@ func theGraph(ctx context.Context, inv *inventory.Inventory) ([]inventory.Entry,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
read, err := inv.ReadRepositories(ctx)
|
read, err := readForPlanning(ctx, inv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,12 +136,13 @@ func TestTheDeliveryOwnerIsAskedOverTheBus(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)) {
|
if !slices.Contains(granted.Publish, link.SeatToolSubject(catalogue.DeliverySeat, verb)) {
|
||||||
t.Errorf("the controller may not ask %s.%s", 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)
|
t.Fatalf("a verb the grant does not name was asked: %v", err)
|
||||||
}
|
}
|
||||||
conn, err := nats.Connect(testbus.URL(t))
|
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)
|
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 {
|
if _, err := inv.Assign(ctx, "anchor", "mesh-delivery"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -259,10 +261,11 @@ func TestAMergeWaitsForItsDeliverysWordAndThePersonsWordWorksWithoutIt(t *testin
|
|||||||
t.Fatalf("the word was not kept: %+v %v", got.Delivery, err)
|
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
|
// The delivery's owner's own merge never waits for it. A walk that started is never taken over (novox/hq
|
||||||
// built (app, folded in: ADR 0218), which goes with it on the controller's own path.
|
// ADR 0276): the merge's batch is cut once it ended.
|
||||||
|
finish(p.ID)
|
||||||
p = merge("c3cccccccc", "modules/mesh-delivery/main.go")
|
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)
|
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)
|
advanceHeld(ctx, open)
|
||||||
got, _ = inv.PlanByID(ctx, p.ID)
|
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)
|
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 " +
|
{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",
|
"newest statement, unable to become root without a person", From: "ADR 0266, ADR 0259 §8",
|
||||||
Kind: kindAgentCanBecomeRoot, Phase: 1, run: probeAgentAccounts},
|
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",
|
{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},
|
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
|
// 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 {
|
if err != nil {
|
||||||
return snapshot.Facts{}, err
|
return snapshot.Facts{}, err
|
||||||
}
|
}
|
||||||
read, err := inv.ReadRepositories(ctx)
|
read, err := readForPlanning(ctx, inv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return snapshot.Facts{}, err
|
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,
|
Commit: e.Source.BuiltFrom, Provided: e.Provided, RollOut: current[e.Manifest.Module].RollOut,
|
||||||
Manifest: raw}
|
Manifest: raw}
|
||||||
for _, r := range read[e.Manifest.Module] {
|
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))
|
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)
|
f.Modules = append(f.Modules, mod)
|
||||||
if e.Provided || e.Source.Repository == "" {
|
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,
|
return inventory.ResourceHealth{Module: module, Resource: module + ".data", Kind: link.KindDirectory,
|
||||||
Target: "/srv/" + module, State: link.StateUnhealthy, Since: since,
|
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 " +
|
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) {
|
func TestADirectoryFoundBeforeTheSendIsAWaitForAPerson(t *testing.T) {
|
||||||
|
|||||||
+158
-4
@@ -87,6 +87,9 @@ const (
|
|||||||
healthWaiting
|
healthWaiting
|
||||||
healthNotYet
|
healthNotYet
|
||||||
healthBroken
|
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.
|
// 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
|
// 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.
|
// not (issue 318 review): the only move whose wait for a new login is excused.
|
||||||
groupsAdded map[string]bool
|
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
|
// 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,
|
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)
|
short(r.From), r.Outcome, r.Why)
|
||||||
}
|
}
|
||||||
|
if why, superseded := f.supersededOn(module, machine); superseded {
|
||||||
|
return healthSuperseded, why
|
||||||
|
}
|
||||||
r, said := f.reports[machine]
|
r, said := f.reports[machine]
|
||||||
switch {
|
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)
|
return healthNotYet, fmt.Sprintf("%s has not reported on what it was sent", machine)
|
||||||
case r.Outcome == inventory.OutcomeFailed || r.Outcome == inventory.OutcomeRefused:
|
case r.Outcome == inventory.OutcomeFailed || r.Outcome == inventory.OutcomeRefused:
|
||||||
return healthBroken, fmt.Sprintf("%s %s what it was sent", machine, r.Outcome)
|
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)
|
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,
|
// **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.judged {
|
||||||
if f.openErr != nil {
|
if f.openErr != nil {
|
||||||
return healthNotYet, "what is wrong cannot be read, so whether the build made anything wrong is not known: " +
|
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
|
// 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,
|
// reading, not a fault raised since the send: the gate reads it from the statement below (ADR 0254,
|
||||||
// novox/hq issue 339).
|
// 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
|
continue
|
||||||
}
|
}
|
||||||
onIt := c.Subject.Machine == machine || slices.Contains(c.Subject.Also, machine) ||
|
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 ||
|
aboutIt := c.Subject.Scope == conditions.ScopeMachine && (c.Subject.ID == machine || c.Subject.Machine == machine ||
|
||||||
slices.Contains(c.Subject.Also, machine))
|
slices.Contains(c.Subject.Also, machine))
|
||||||
// A directory used as found waits for a person, whatever the send did (novox/hq issue 339).
|
// 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)
|
kept = append(kept, c)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -436,6 +477,21 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
facts.groupsAdded = movesAddingGroups(ctx, open.inventory, g, pairs, shelf)
|
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
|
// **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
|
// machine judged, apart from what is wrong with a module there, and never pinned on the module the
|
||||||
// gate happens to be kept on.
|
// 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 {
|
if _, seen := reading[j.module]; !seen {
|
||||||
modules = append(modules, j.module)
|
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) {
|
if h == healthBroken && !slices.Contains(g.Broken, j.module) {
|
||||||
g.Broken = append(g.Broken, j.module)
|
g.Broken = append(g.Broken, j.module)
|
||||||
if g.BrokenWhy == "" {
|
if g.BrokenWhy == "" {
|
||||||
@@ -575,6 +638,40 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
|
|||||||
return g.Verdict, nil
|
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
|
// 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).
|
// for a person, never another's (issue 318 review).
|
||||||
func whyFor(g *inventory.PlanGate, module string) string {
|
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))
|
"back", module, short(state.Previous), inventory.KeptBuilds))
|
||||||
return
|
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 {
|
if err := inv.RestoreModule(ctx, previous); err != nil {
|
||||||
notBack(err.Error())
|
notBack(err.Error())
|
||||||
return
|
return
|
||||||
@@ -833,6 +940,53 @@ func gateFailed(ctx context.Context, open *stores, p *inventory.Plan, module str
|
|||||||
sayRollback(ctx, open, module, g, "")
|
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
|
// 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
|
// 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.
|
// 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 {
|
for node, h := range g.health {
|
||||||
if h == healthNotYet {
|
if h == healthNotYet {
|
||||||
|
// Not reported on the send: neither the send made last, nor the gate's own (issue 352).
|
||||||
f.rolledBack[node] = nil
|
f.rolledBack[node] = nil
|
||||||
r := f.reports[node]
|
r := f.reports[node]
|
||||||
r.Current = false
|
r.Current, r.Declared, r.ReportedSequence = false, "", 0
|
||||||
f.reports[node] = r
|
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).
|
// 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 " +
|
{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},
|
"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"},
|
{Verb: "plans close"},
|
||||||
// A walk started by a person instead of its delivery's owner (novox/hq ADR 0239): the owner down, or
|
// 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.
|
// not trusted with it — either is a repair the owner should have made. Unless the operator chose it on
|
||||||
{Verb: "plans go"},
|
// 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"},
|
{Verb: "broker consumer-reset"},
|
||||||
// Silencing the same condition twice says the condition, or what it watches, wants mending — unless
|
// 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).
|
// 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
|
// 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
|
// 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.
|
// 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",
|
{Verb: "secret rotate", Decision: "a value a person judged disclosed is replaced on their word",
|
||||||
DecidedFor: []string{causeLeakedInLogs}},
|
DecidedFor: []string{causeLeakedInLogs}},
|
||||||
}
|
}
|
||||||
@@ -241,6 +251,9 @@ func handActCommand(ctx context.Context, args []string) error {
|
|||||||
if len(args) > 0 && args[0] == "drill" {
|
if len(args) > 0 && args[0] == "drill" {
|
||||||
return handActDrill(ctx, args[1:])
|
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], "-") {
|
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> " +
|
return errors.New("hand-act record <what> --why <text> --cause <word> | hand-act drill <what> --why <text> " +
|
||||||
"| hand-acts [--days N] [--json]")
|
"| 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 {
|
for _, newer := range recent {
|
||||||
if newer.ID == p.ID || !newer.Created.After(p.Created) || !repositoryMatches(newer.Repository, p.Repository) ||
|
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
|
continue
|
||||||
}
|
}
|
||||||
return inventory.PlanSuperseded, fmt.Sprintf("superseded by %s (%s %s), a newer merge of the same repository "+
|
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())
|
problems = append(problems, err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
undecided := undecidedParts(f)
|
||||||
for _, c := range open {
|
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
|
continue
|
||||||
}
|
}
|
||||||
why := "no machine says it any more"
|
why := "no machine says it any more"
|
||||||
@@ -116,6 +117,59 @@ func judgeNetworks(ctx context.Context, inv *inventory.Inventory, k *conditions.
|
|||||||
return nil
|
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.
|
// pointed is one machine's failing part that points at another machine.
|
||||||
type pointed struct {
|
type pointed struct {
|
||||||
from string
|
from string
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ func run() error {
|
|||||||
return rotateCommand(ctx, args[1:])
|
return rotateCommand(ctx, args[1:])
|
||||||
case "ask":
|
case "ask":
|
||||||
return askCommand(ctx, args[1:])
|
return askCommand(ctx, args[1:])
|
||||||
|
case "rehearse":
|
||||||
|
return rehearseCommand(ctx, args[1:])
|
||||||
case "builds":
|
case "builds":
|
||||||
return buildsCommand(ctx, args[1:])
|
return buildsCommand(ctx, args[1:])
|
||||||
// The build queue, controlled by hand (novox/hq ADR 0219).
|
// 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,
|
entries = append(entries, inventory.Entry{Manifest: manifest, Provided: mod.Provided,
|
||||||
Source: inventory.Source{Repository: mod.Repository, Path: mod.Path, BuiltFrom: mod.Commit}})
|
Source: inventory.Source{Repository: mod.Repository, Path: mod.Path, BuiltFrom: mod.Commit}})
|
||||||
for _, r := range mod.Reads {
|
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
|
var edges []inventory.Edge
|
||||||
|
|||||||
@@ -141,6 +141,12 @@ func runForMeshCLI(ctx context.Context, node string, asked link.CLIAsked, v cliV
|
|||||||
if v.refused != "" {
|
if v.refused != "" {
|
||||||
return link.CLIRefusal(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]] {
|
if cliServers[asked.Line[0]] {
|
||||||
return link.CLIAnswer{Exit: 1, Why: v.why, Refused: fmt.Sprintf("%s serves until stopped, and is not a "+
|
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])}
|
"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 := selfCommand(ctx, line)
|
||||||
cmd.Env = commandEnvironment(fmt.Sprintf("%s through mesh-cli on %s", asked.Account, node), verb, v.terminal)
|
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
|
cmd.Stdin = nil
|
||||||
|
if len(asked.Stdin) > 0 {
|
||||||
|
cmd.Stdin = bytes.NewReader(asked.Stdin)
|
||||||
|
}
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||||
err := cmd.Run()
|
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"},
|
{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"}
|
return link.CLIAsked{Line: line, Account: account, UID: uid, Session: "session-1.scope"}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,13 +39,13 @@ func TestMeshCLIIsTheTerminalOnlyForTheControlNodesOperator(t *testing.T) {
|
|||||||
refused string
|
refused string
|
||||||
why string
|
why string
|
||||||
}{
|
}{
|
||||||
{"the control-node's operator", "control", asked("operator", 1000, "status"), control, true, "", "the controller's terminal"},
|
{"the control-node's operator", "control", cliAsked("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 node's operator", "laptop", cliAsked("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", ""},
|
{"another account", "control", cliAsked("agent", 1001, "status"), control, false, "operator account (operator) only", ""},
|
||||||
{"root", "control", asked("root", 0, "status"), control, false, "never root", ""},
|
{"root", "control", cliAsked("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 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", asked("operator", 1000, "status"), control, false, "not a node this mesh knows", ""},
|
{"a node the mesh does not know", "elsewhere", cliAsked("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"},
|
{"two control-nodes", "control", cliAsked("operator", 1000, "status"), []string{"control", "laptop"}, false, "", "2 control-nodes"},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
v := judgeCLI(c.node, c.asked, cliNodes, c.control)
|
v := judgeCLI(c.node, c.asked, cliNodes, c.control)
|
||||||
@@ -70,7 +70,7 @@ func TestTheTerminalRunsWithoutAVerbAndAnOrdinaryCallNamesMeshCLI(t *testing.T)
|
|||||||
t.Setenv(servedVar, "1")
|
t.Setenv(servedVar, "1")
|
||||||
ctx := context.Background()
|
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 {
|
if a.Exit != 0 || a.Refused != "" || !a.Terminal {
|
||||||
t.Fatalf("the terminal's line did not run: %+v", a)
|
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)
|
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" {
|
if a.Exit != 0 || a.Terminal || a.Why != "not the terminal" {
|
||||||
t.Fatalf("an ordinary line did not run as one: %+v", a)
|
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")
|
t.Setenv(echoEnvironment, "1")
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
ordinary := cliVerdict{why: "not the terminal"}
|
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" {
|
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)
|
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"`) {
|
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)
|
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"} {
|
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 {
|
if a.Refused == "" || len(a.Stdout) != 0 {
|
||||||
t.Fatalf("%s was run for mesh-cli: %+v", server, a)
|
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 {
|
if a.Refused != "agent is not answered" || len(a.Stdout) != 0 {
|
||||||
t.Fatalf("a refused line ran: %+v", a)
|
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) }
|
cliJournal = func(line string) { said = append(said, line) }
|
||||||
t.Cleanup(func() { cliJournal = was })
|
t.Cleanup(func() { cliJournal = was })
|
||||||
ctx := link.WithCallID(context.Background(), "call-1")
|
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"})
|
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")
|
all := strings.Join(said, "\n")
|
||||||
if len(said) != 2 || !strings.Contains(all, "call-1") || !strings.Contains(all, "operator on control") ||
|
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") {
|
!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 {
|
if _, err := ordinaryLine(line); err == nil {
|
||||||
t.Errorf("%q composed as an ordinary line", line)
|
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 {
|
if a.Refused == "" || len(a.Stdout) != 0 {
|
||||||
t.Errorf("%q ran as an ordinary line: %+v", line, a)
|
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"`) {
|
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)
|
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") {
|
if got := string(a.Stdout); !strings.Contains(got, "terminal=true") {
|
||||||
t.Fatalf("the terminal's line ran as %s", got)
|
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)
|
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.
|
// catchingUpOnMerges reads back the forge's announcements on a timer, until the context ends.
|
||||||
func catchingUpOnMerges(ctx context.Context, open *stores, announced merges) {
|
func catchingUpOnMerges(ctx context.Context, open *stores, announced merges) {
|
||||||
f := following{open}
|
f := following{open}
|
||||||
@@ -48,7 +92,7 @@ func catchingUpOnMerges(ctx context.Context, open *stores, announced merges) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
read, err := open.inventory.ReadRepositories(ctx)
|
read, err := readForPlanning(ctx, open.inventory)
|
||||||
return entries, read, err
|
return entries, read, err
|
||||||
}
|
}
|
||||||
failing := ""
|
failing := ""
|
||||||
@@ -61,7 +105,7 @@ func catchingUpOnMerges(ctx context.Context, open *stores, announced merges) {
|
|||||||
case <-tick.C:
|
case <-tick.C:
|
||||||
}
|
}
|
||||||
watchedMerges.begin()
|
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...)
|
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
|
// 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
|
return err
|
||||||
}
|
}
|
||||||
for _, a := range all {
|
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 {
|
if now.Sub(a.At) < mergeGrace {
|
||||||
continue
|
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
|
continue
|
||||||
}
|
}
|
||||||
if entries, read, err = catalogued(ctx); err != nil {
|
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)
|
moves := wouldMove(a.SourceMoved, entries, read)
|
||||||
if len(moves) == 0 {
|
if len(moves) == 0 {
|
||||||
|
if !owedToTheRecord(ctx, announced, a.SourceMoved, entries, read) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
raw := a.SourceMoved
|
||||||
|
raw.MergedAt = ""
|
||||||
|
moves = wouldMove(raw, entries, read)
|
||||||
|
}
|
||||||
var names []string
|
var names []string
|
||||||
for _, e := range moves {
|
for _, e := range moves {
|
||||||
names = append(names, e.Manifest.Module)
|
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,
|
Explanation: fmt.Sprintf("%s on %s is not healthy: %s. It clears as soon as it runs again.", module, node,
|
||||||
namesWords(plain, 3)),
|
namesWords(plain, 3)),
|
||||||
Needs: needs,
|
Needs: needs,
|
||||||
|
Actions: moduleActions(node, rs),
|
||||||
Resolved: fmt.Sprintf("%s works again on %s", module, node)}
|
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)
|
said = append(said, wait)
|
||||||
}
|
}
|
||||||
if len(found) > 0 {
|
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 "+
|
said = append(said, fmt.Sprintf("on %s, %s uses %s as found and waits for the operator to hand it over "+
|
||||||
"(`mesh-host hand-over <directory>` at the machine)", machine, module, strings.Join(found, ", ")))
|
"(`nox node hand-over %s <directory>` on the control-node)", machine, module, strings.Join(found, ", "), machine))
|
||||||
}
|
}
|
||||||
return healthPerson, strings.Join(said, "; ")
|
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. "+
|
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.",
|
"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)
|
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.Resolved = fmt.Sprintf("%s's directory on %s is the mesh's", module, node)
|
||||||
o.Actions = nil
|
o.Actions = nil
|
||||||
return o
|
return o
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
"github.com/novox/mesh-controller/internal/overlay"
|
"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 {
|
func settingsCommand(ctx context.Context, args []string) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return errors.New("settings show <module> [--node <node>] [--history], settings set <module> <file> " +
|
return errors.New("settings show <module> [--node <node>] [--history], settings set <module> <file> " +
|
||||||
"[--node <node>] [--replace], settings clear <module> [--node <node>], or settings preferences " +
|
"[--node <node>] [--replace], settings clear <module> [--node <node>], settings preferences " +
|
||||||
"[<module>] [--node <node>]")
|
"[<module>] [--node <node>], settings propose <module> <file | --clear> [--node <node>] [--replace], " +
|
||||||
|
"or settings proposals [<id>]")
|
||||||
}
|
}
|
||||||
open, err := openStores(ctx)
|
open, err := openStores(ctx)
|
||||||
if err != nil {
|
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,
|
// **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
|
// 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.
|
// 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")
|
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:])
|
positionals, err := parseAround(set, args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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"
|
where := "the whole mesh"
|
||||||
if *node != "" {
|
if *node != "" {
|
||||||
@@ -455,7 +484,7 @@ func settingsCommand(ctx context.Context, args []string) error {
|
|||||||
"removal is meant (novox/hq ADR 0217). Nothing was changed",
|
"removal is meant (novox/hq ADR 0217). Nothing was changed",
|
||||||
positionals[0], where, strings.Join(removed, ", "), positionals[0], nodeFlag(*node))
|
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
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("%s on %s:\n", positionals[0], where)
|
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 {
|
for _, p := range past {
|
||||||
shown, _ := json.MarshalIndent(p.Values, " ", " ")
|
shown, _ := json.MarshalIndent(p.Values, " ", " ")
|
||||||
fmt.Printf("%s on %s, until %s (%s):\n %s\n", positionals[0], where,
|
by := ""
|
||||||
p.ReplacedAt.Local().Format("2006-01-02 15:04:05"), p.ReplacedBy, shown)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -513,6 +546,14 @@ func settingsCommand(ctx context.Context, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Println(string(shown))
|
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
|
// 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
|
// 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 {
|
if err := refuseTerminalSettingsThroughAVerb(ctx, inv, before, nil, positionals[0], where); err != nil {
|
||||||
return err
|
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
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("%s on %s is back to what the module says\n", positionals[0], where)
|
fmt.Printf("%s on %s is back to what the module says\n", positionals[0], where)
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
default:
|
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
|
// 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.
|
// the module's default when a layer overrides it.
|
||||||
func describeEffective(module, where string, values []catalogue.SettingSource) string {
|
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).
|
// 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) {
|
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 "+
|
return fmt.Errorf("the settings of %s on %s are set at the controller's terminal (`mesh-cli` on the control-node) or on "+
|
||||||
"line came through %q): %s merges whatever key a layer sets into a file root or a consumer trusts, so "+
|
"the operator's warrant, never through a verb alone (this line came through %q): %s merges whatever key a "+
|
||||||
"any key could point the module at a listener of the caller's, and whoever may call a verb includes "+
|
"layer sets into a file root or a consumer trusts, so any key could point the module at a listener of the "+
|
||||||
"agents (novox/hq issue 340; a file nothing trusts says \"trusted\": false). Nothing was changed",
|
"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, ", "))
|
module, where, verb, strings.Join(files, ", "))
|
||||||
}
|
}
|
||||||
for _, key := range catalogue.TerminalKeys(shelf[module]) {
|
for _, key := range catalogue.TerminalKeys(shelf[module]) {
|
||||||
@@ -1119,11 +1172,13 @@ func refuseTerminalSettingsThroughAVerb(ctx context.Context, inv *inventory.Inve
|
|||||||
if string(was) == string(now) {
|
if string(was) == string(now) {
|
||||||
continue
|
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 "+
|
return fmt.Errorf("%s of %s on %s is set at the controller's terminal (`mesh-cli` on the control-node) or on the "+
|
||||||
"line came through %q): it says where root creates and owns a module's directories, which of "+
|
"operator's warrant, never through a verb alone (this line came through %q): it says where root creates and "+
|
||||||
"the machine's paths are mounted into its container, what the mesh's consumers trust, or what a file "+
|
"owns a module's directories, which of the machine's paths are mounted into its container, what the mesh's "+
|
||||||
"root or a person's session obeys takes, and whoever may call a verb includes agents (novox/hq issue 339; "+
|
"consumers trust, or what a file root or a person's session obeys takes, and whoever may call a verb "+
|
||||||
"issue 340 for a mergeable file's own keys). Nothing was changed", key, module, where, 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -426,10 +426,10 @@ func overlayShow(ctx context.Context, open *stores) error {
|
|||||||
tunnel.Interface, tunnel.Range, tunnel.Port)
|
tunnel.Interface, tunnel.Range, tunnel.Port)
|
||||||
case n.Hub && hubName == n.Name && tunnel.Interface != "":
|
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; "+
|
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:
|
case n.Hub:
|
||||||
fmt.Print(" hub — found no tunnel; if the machine runs the predecessor's, " +
|
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():
|
case !n.Reachable():
|
||||||
fmt.Print(" not dialable")
|
fmt.Print(" not dialable")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
"github.com/novox/mesh-controller/internal/conditions"
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
"github.com/novox/mesh-controller/internal/token"
|
"github.com/novox/mesh-controller/internal/token"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,7 +30,7 @@ import (
|
|||||||
|
|
||||||
func nodeCommand(ctx context.Context, args []string) error {
|
func nodeCommand(ctx context.Context, args []string) error {
|
||||||
if len(args) == 0 {
|
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)
|
open, err := openStores(ctx)
|
||||||
if err != nil {
|
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>.
|
// an optional second argument is the home when it is not /home/<account>.
|
||||||
return nodeAccount(ctx, inv, args[1:])
|
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:
|
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).
|
// 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 {
|
func addNode(ctx context.Context, inv *inventory.Inventory, args []string) error {
|
||||||
set := flag.NewFlagSet("node add", flag.ContinueOnError)
|
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 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"},
|
{"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)
|
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), ""},
|
{"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), ""},
|
{"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)
|
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.",
|
Explanation: "Its walk across the machines has not moved for longer than usual. Nothing is lost.",
|
||||||
Resolved: "Resolved: the delivery moves again"}
|
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 {
|
kindWalkWaiting: worded(func(o conditions.Observation) words {
|
||||||
return words{Headline: "A delivery is waiting to start",
|
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 " +
|
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.
|
// 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 {
|
func waitingNeeds(severity conditions.Severity) string {
|
||||||
if severity == conditions.Urgent {
|
if severity == conditions.Urgent {
|
||||||
return "start it, or stop it, " + FromMeshMCPServer
|
return "start it, or stop it, " + FromMeshMCPServer
|
||||||
@@ -687,6 +700,20 @@ func waitingNeeds(severity conditions.Severity) string {
|
|||||||
return ""
|
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
|
// 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.
|
// 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).
|
// 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 != "" {
|
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 fmt.Sprintf("restart its service %s on %s %s", unit, node, FromMeshMCPServer)
|
||||||
}
|
}
|
||||||
return ""
|
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
|
// 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
|
// 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
|
// 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)
|
long = "for " + humanDuration(d)
|
||||||
}
|
}
|
||||||
if o.Resolver == conditions.ResolverOperator {
|
if o.Resolver == conditions.ResolverOperator {
|
||||||
// Words only: releasing or stopping a delivery is not an acknowledgement, so no desk click
|
// Asked of the operator, approved on a channel that proves who answered (novox/hq ADR 0259); the
|
||||||
// performs it (ADR 0258).
|
// 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 {
|
switch held {
|
||||||
case "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":
|
case "ready", "checked":
|
||||||
needs = "merge its pull request, or close it."
|
needs = "merge its pull request, or close it."
|
||||||
default:
|
default:
|
||||||
needs = "stop it " + FromMeshMCPServer
|
needs, actions = "stop it "+FromMeshMCPServer, []conditions.Action{stop}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("Delivery of %s %s %s", name, held, long),
|
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)
|
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)
|
f.waits[0].since = now.Add(-5 * time.Hour)
|
||||||
got = watchWaits(f)
|
got = watchWaits(f)
|
||||||
plainExample(t, got[0], "openrazer delivery waiting to start",
|
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 "+
|
"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 "+
|
"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.
|
// Many modules are counted, not listed in the headline.
|
||||||
f.waits[0].modules = []string{"a", "b", "c", "d"}
|
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
|
// **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
|
// account's own service manager (exit-code)". Restarting is not an acknowledgement: it is asked of the
|
||||||
// and offered as no answer (ADR 0258).
|
// operator at the approve level (novox/hq ADR 0259), so a desk click never performs it (ADR 0258).
|
||||||
func TestAModuleUnhealthyAsksForARestartInWords(t *testing.T) {
|
func TestAModuleUnhealthyAsksForARestartInWords(t *testing.T) {
|
||||||
o := moduleUnhealthyObservation("openrazer", "g14", []inventory.ResourceHealth{{Kind: link.KindUnit,
|
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()}})
|
Reason: "failed in the account's own service manager (exit-code)", Since: time.Now()}})
|
||||||
plainExample(t, o, "openrazer not working on g14",
|
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. "+
|
"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 "+
|
"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.
|
// 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",
|
o = moduleUnhealthyObservation("openrazer", "g14", []inventory.ResourceHealth{{Kind: "account",
|
||||||
Resource: "operator-in-group", Target: "jochen", Reason: "relogin needed: the account is in the group"}})
|
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"}})
|
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",
|
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.",
|
"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
|
// **Every kind the controller raises has plain words**, and its words are plain for a subject of every
|
||||||
|
|||||||
@@ -512,15 +512,6 @@ func sortedKeysOf(m map[string]string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// sayPlanDiff is `plan --diff`: what the declaration leaves out, then the diff. A module left out is not in
|
|
||||||
// the body, so the diff alone would say "nothing would change" for a module just assigned whose settings
|
|
||||||
// cannot compose — success-shaped silence. Said first, with why, as push and the plain plan say it
|
|
||||||
// (novox/hq ADR 0163, rule 6).
|
|
||||||
func sayPlanDiff(node string, declared sendable, diff func() error) error {
|
|
||||||
reportLeftOut(node, declared)
|
|
||||||
return diff()
|
|
||||||
}
|
|
||||||
|
|
||||||
// reportLeftOut says which of a machine's modules its declaration leaves out and why (novox/hq ADR
|
// reportLeftOut says which of a machine's modules its declaration leaves out and why (novox/hq ADR
|
||||||
// 0163, rule 6), one line each: the machine is told everything else, and is told it was left out.
|
// 0163, rule 6), one line each: the machine is told everything else, and is told it was left out.
|
||||||
func reportLeftOut(node string, declared sendable) {
|
func reportLeftOut(node string, declared sendable) {
|
||||||
@@ -1248,9 +1239,11 @@ func planCommand(ctx context.Context, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return sayPlanDiff(args[0], declared, func() error {
|
// A module left out is not in the body, so the diff alone would say "nothing would change" for
|
||||||
|
// a module just assigned whose settings cannot compose — success-shaped silence. Said first, with
|
||||||
|
// why, as push and the plain plan say it (novox/hq ADR 0163, rule 6).
|
||||||
|
reportLeftOut(args[0], declared)
|
||||||
return writePlanDiff(ctx, open.inventory, args[0], body)
|
return writePlanDiff(ctx, open.inventory, args[0], body)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if *asJSON {
|
if *asJSON {
|
||||||
declared, err := declarationFor(ctx, open, args[0], plan, settings)
|
declared, err := declarationFor(ctx, open, args[0], plan, settings)
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 2026-10-09: nfs-server was assigned to the home server, its file asked for a setting nothing set, and
|
|
||||||
// `plan --diff` said "nothing would change". The diff now says what is left out, and why, before the diff.
|
|
||||||
func TestPlanDiffSaysAModuleLeftOutBeforeTheDiff(t *testing.T) {
|
|
||||||
declared := sendable{LeftOut: []string{"nfs-server"},
|
|
||||||
leftOutWhy: map[string]string{"nfs-server": `nothing sets "shares" for it`}}
|
|
||||||
said := printed(t, func() error {
|
|
||||||
return sayPlanDiff("home", declared, func() error {
|
|
||||||
writeDiff(os.Stdout, "home", sentDiff{}, nil)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
})
|
|
||||||
left := strings.Index(said, "home: nfs-server left out")
|
|
||||||
nothing := strings.Index(said, "home: nothing would change")
|
|
||||||
if left < 0 || !strings.Contains(said, `nothing sets "shares" for it`) || nothing < left {
|
|
||||||
t.Errorf("the left-out module and why, then the diff:\n%s", said)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -164,12 +164,13 @@ func retryRefusal(p inventory.Plan, plans []inventory.Plan) error {
|
|||||||
case p.Open():
|
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)
|
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 len(failedIn(p)) > 0 {
|
||||||
if q, found := newerOpenPlan(p, plans); found {
|
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 "+
|
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)
|
"what that one replaced", q.ID, q.Repository, q.ID, short(q.Commit), p.ID)
|
||||||
}
|
}
|
||||||
return nil
|
return oneWalkAtATime(p, plans)
|
||||||
}
|
}
|
||||||
stopped := stoppedRollouts(p)
|
stopped := stoppedRollouts(p)
|
||||||
if len(stopped) == 0 {
|
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)
|
"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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +276,18 @@ func retryPlan(ctx context.Context, open *stores, id string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
plans = append(plans, recent...)
|
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 {
|
if err := retryRefusal(p, plans); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,16 +15,16 @@ import (
|
|||||||
// inventory.Dependencies (dependenciesOf over the records), and the merge planned by reachOfMerge — the
|
// inventory.Dependencies (dependenciesOf over the records), and the merge planned by reachOfMerge — the
|
||||||
// path a real merge takes, short of the bus.
|
// path a real merge takes, short of the bus.
|
||||||
//
|
//
|
||||||
// **The repository rows are CURRENT BEHAVIOUR, documented — not the rule the operator states**
|
// **A shared repository moves only what a change's files are in the build source of** (novox/hq ADR 0267,
|
||||||
// (novox/hq issue 338, and the decision pending on it): a build that read a repository gives its module a
|
// issues 338 and 363): each build records the build source it said, and a merge is mapped onto those of the
|
||||||
// packages edge to every module built from that repository, and mergeCandidates moves it on any merge to
|
// newest builds. A build that said none (P below, as every build before ADR 0267) is read as before: P moves
|
||||||
// that repository, whatever the files. So a change to C alone, or to a README, moves the module that
|
// on any merge to the repository it packages, though through no edge. The rows tagged 338 held the opposite
|
||||||
// packages C's repository. ADR 0238 §3 records exactly that today ("a repository a recipe names"); the
|
// until ADR 0267 was built.
|
||||||
// expectations marked 338 change with that decision.
|
func TestASharedRepositoryIsPlannedFromTheRecordedBuildSources(t *testing.T) {
|
||||||
func TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday(t *testing.T) {
|
|
||||||
inv := inventory.ForTest(t)
|
inv := inventory.ForTest(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
asked := time.Now().Add(-time.Hour)
|
asked := time.Now().Add(-time.Hour)
|
||||||
|
sourcesOf := map[string][]inventory.BuildSource{}
|
||||||
register := func(m catalogue.Manifest, repository, path string, against []string, read []inventory.ReadRepository) {
|
register := func(m catalogue.Manifest, repository, path string, against []string, read []inventory.ReadRepository) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := inv.RegisterModule(ctx, m, inventory.Source{Repository: repository, Seat: "git", Path: path,
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-" + m.Module, Repository: repository, Ref: "main",
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,7 +41,16 @@ func TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday(t *testing.T) {
|
|||||||
agent := catalogue.Manifest{Module: "build-agent", Version: "1",
|
agent := catalogue.Manifest{Module: "build-agent", Version: "1",
|
||||||
Claims: []catalogue.Claim{{Name: "node-build-agent", Scope: catalogue.ScopeNode}}}
|
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(catalogue.Manifest{Module: "mesh-controller", Version: "1"}, "novox/mesh-controller", "", nil, nil)
|
||||||
register(agent, "novox/mesh-catalog", "modules/build-agent", nil, controllerRead)
|
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)
|
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) {
|
if !reflect.DeepEqual(shared, sharedRepositoryEdges) {
|
||||||
t.Errorf("derived %v\nthe hand-written rows use %v", 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{
|
for _, want := range []inventory.Edge{
|
||||||
dep("d", inventory.EdgeStandsOn, "a"),
|
dep("d", inventory.EdgeStandsOn, "a"),
|
||||||
dep("e", inventory.EdgeDeclared, "b"),
|
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"),
|
dep("d", inventory.EdgeBuiltBy, "build-agent"),
|
||||||
} {
|
} {
|
||||||
found := false
|
found := false
|
||||||
@@ -105,15 +117,23 @@ func TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday(t *testing.T) {
|
|||||||
want string
|
want string
|
||||||
issue338 bool
|
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},
|
[]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",
|
{"C alone: C, and P, read whole as before; P after nothing", "one",
|
||||||
[]string{"modules/c/x.go"}, "c,p", true},
|
[]string{"modules/c/x.go"}, "c,p", false},
|
||||||
{"a README of the repository P packages: P moves, nothing built from it does", "one",
|
{"a README of the repository P packages: P, read whole as before", "one",
|
||||||
[]string{"README.md"}, "p", true},
|
[]string{"README.md"}, "p", false},
|
||||||
{"the dependent's repository: D alone", "two", []string{"d/main.go"}, "d", 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",
|
{"a README of the controller's repository: no module", "mesh-controller",
|
||||||
[]string{"README.md"}, "mesh-controller | build-agent | route-proxy", true},
|
[]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",
|
{"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
|
||||||
[]string{"modules/route-proxy/module.json"}, "route-proxy", false},
|
[]string{"modules/route-proxy/module.json"}, "route-proxy", false},
|
||||||
{"the build agent's directory: it alone, nothing it builds", "mesh-catalog",
|
{"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 {
|
if got != c.want {
|
||||||
tag := ""
|
tag := ""
|
||||||
if c.issue338 {
|
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)
|
t.Errorf("%s: planned %q, wanted %q%s", c.what, got, c.want, tag)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
snapshot "github.com/novox/mesh-controller/internal/facts"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
)
|
)
|
||||||
@@ -21,7 +24,7 @@ import (
|
|||||||
// kind widens the plan orders the tiers
|
// kind widens the plan orders the tiers
|
||||||
// stands-on yes yes, after its base is built
|
// stands-on yes yes, after its base is built
|
||||||
// declared 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
|
// built-by no yes, after the build machine — except for what the build machine stands
|
||||||
// on, and for the controller whose worker it binds
|
// on, and for the controller whose worker it binds
|
||||||
// worker-of no yes, the build seat's holder after the controller (hq issue 206)
|
// 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",
|
{what: "transitive: F on D on A, A changed",
|
||||||
edges: []inventory.Edge{dep("f", standsOn, "d"), dep("d", standsOn, "a")},
|
edges: []inventory.Edge{dep("f", standsOn, "d"), dep("d", standsOn, "a")},
|
||||||
repo: "one", paths: []string{"modules/a/x"}, want: "a | d | f"},
|
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")},
|
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).
|
// 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")},
|
{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"},
|
repo: "one", paths: []string{"modules/a/x"}, want: "a | x"},
|
||||||
{what: "declared orders", edges: []inventory.Edge{dep("x", declared, "a")},
|
{what: "declared orders", edges: []inventory.Edge{dep("x", declared, "a")},
|
||||||
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
|
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")},
|
{what: "packages, recorded before ADR 0267, widens nothing", edges: []inventory.Edge{dep("x", packages, "a")},
|
||||||
repo: "one", paths: []string{"modules/a/x"}, want: "a,x"},
|
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
|
||||||
{what: "packages does not order", edges: []inventory.Edge{dep("x", packages, "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"},
|
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")},
|
{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"},
|
repo: "one", paths: []string{"modules/z/x"}, want: "z | a,b | d"},
|
||||||
{what: "a diamond of mixed kinds orders on the ordering side only",
|
{what: "a diamond of mixed kinds orders on the ordering side only",
|
||||||
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", packages, "b")},
|
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.
|
// 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"),
|
{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
|
// **A shared repository moves only what a change's files are in the build source of** (novox/hq ADR 0267,
|
||||||
// built from a shared repository moves on every merge to it) and the decision pending on it would change
|
// issue 338, issue 363). The controller is built from its repository's root as a Go bundle; the route proxy
|
||||||
// every row here. Today:
|
// 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;
|
// The build sources are this repository's own programs as GoBuildSource reads them (held to that by
|
||||||
// - route-proxy and build-agent package the whole of that repository (a build context), so the build
|
// TestThisRepositorysProgramsHaveBuildSourcesOfTheirOwn in internal/builder), cut to what the rows need.
|
||||||
// record's `read` makes them move on any merge to it, whatever the files, and dependenciesOf gives
|
func TestASharedRepositoryMovesOnlyWhatItsBuildSourceHolds(t *testing.T) {
|
||||||
// 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) {
|
|
||||||
const catalogueRepo = "http://forge.internal:20000/novox/mesh-catalog.git"
|
const catalogueRepo = "http://forge.internal:20000/novox/mesh-catalog.git"
|
||||||
const controllerRepo = "http://forge.internal:20000/novox/mesh-controller.git"
|
const controllerRepo = "http://forge.internal:20000/novox/mesh-controller.git"
|
||||||
entries := []inventory.Entry{
|
entries := []inventory.Entry{
|
||||||
@@ -249,7 +246,26 @@ func TestASharedRepositoryMovesWhatPackagesItAsItDoesToday(t *testing.T) {
|
|||||||
fromRepo("route-proxy", catalogueRepo, "modules/route-proxy"),
|
fromRepo("route-proxy", catalogueRepo, "modules/route-proxy"),
|
||||||
fromRepo("gitea", catalogueRepo, "modules/gitea"),
|
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{
|
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"}},
|
"build-agent": {{Repository: "novox/mesh-controller", Ref: "main"}},
|
||||||
"route-proxy": {{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 {
|
for _, c := range []struct {
|
||||||
what, repo string
|
what, repo string
|
||||||
paths []string
|
paths []string
|
||||||
|
read map[string][]inventory.ReadRepository
|
||||||
want string
|
want string
|
||||||
|
unread string
|
||||||
}{
|
}{
|
||||||
// The live three-tier plan of 2026-10-08 (issue 338), in the worker-of order (issue 206) that
|
// The operator's acceptance: a merge of the controller's own code plans the controller alone.
|
||||||
// TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency's controller case holds too.
|
{"the controller's own command: the controller alone (338)", "mesh-controller",
|
||||||
{"a README of the controller's repository moves all three, in three tiers", "mesh-controller",
|
[]string{"cmd/mesh-controller/main.go"}, read, "mesh-controller", ""},
|
||||||
[]string{"README.md"}, "mesh-controller | build-agent | route-proxy"},
|
{"a package only the controller builds from: the controller alone (338)", "mesh-controller",
|
||||||
{"the controller's own code: the same", "mesh-controller",
|
[]string{"internal/conditions/condition.go", "internal/conditions/bus.go"}, read, "mesh-controller", ""},
|
||||||
[]string{"cmd/mesh-controller/main.go"}, "mesh-controller | build-agent | route-proxy"},
|
{"a test beside the controller's command: nothing is built from it", "mesh-controller",
|
||||||
{"the route proxy's program alone: the same, the controller with it", "mesh-controller",
|
[]string{"cmd/mesh-controller/main_test.go"}, read, "", "cmd/mesh-controller/main_test.go"},
|
||||||
[]string{"examples/route-proxy/main.go"}, "mesh-controller | build-agent | route-proxy"},
|
{"a README of the controller's repository: no module (338)", "mesh-controller",
|
||||||
// In the catalogue, where they live, the rule is path-precise.
|
[]string{"README.md"}, read, "", "README.md"},
|
||||||
{"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
|
{"the route proxy's program alone: the route proxy alone (338)", "mesh-controller",
|
||||||
[]string{"modules/route-proxy/module.json"}, "route-proxy"},
|
[]string{"examples/route-proxy/main.go"}, read, "route-proxy", ""},
|
||||||
{"the build agent's directory: it alone, nothing it builds", "mesh-catalog",
|
{"the build seat's program alone: its holder alone", "mesh-controller",
|
||||||
[]string{"modules/build-agent/module.json"}, "build-agent"},
|
[]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",
|
{"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 {
|
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, ",") != "" {
|
if u := strings.Join(r.Unread, ","); u != c.unread {
|
||||||
t.Errorf("%s: a root-built module reads every file, and %v were said unread", c.what, r.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
|
// 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{
|
var sharedRepositoryEdges = []inventory.Edge{
|
||||||
dep("build-agent", inventory.EdgePackages, "mesh-controller"),
|
|
||||||
dep("build-agent", inventory.EdgeWorkerOf, "mesh-controller"),
|
dep("build-agent", inventory.EdgeWorkerOf, "mesh-controller"),
|
||||||
dep("gitea", inventory.EdgeBuiltBy, "build-agent"),
|
dep("gitea", inventory.EdgeBuiltBy, "build-agent"),
|
||||||
dep("mesh-controller", inventory.EdgeBuiltBy, "build-agent"),
|
dep("mesh-controller", inventory.EdgeBuiltBy, "build-agent"),
|
||||||
dep("route-proxy", 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
|
// **The planner's invariant, over random catalogues.** For any catalogue whose dependencies form no cycle
|
||||||
// and any set of changed files in one repository:
|
// 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,
|
// - 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
|
// for a module built from the root), and everything reachable from them along stands-on and declared —
|
||||||
// packages — never along built-by or worker-of;
|
// 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
|
// - 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;
|
// depended on in an earlier tier;
|
||||||
// - no cycle is said.
|
// - no cycle is said.
|
||||||
@@ -310,7 +451,7 @@ var sharedRepositoryEdges = []inventory.Edge{
|
|||||||
func TestAPlanIsTheTouchedModulesAndWhatIsReachableAlongTheWideningEdges(t *testing.T) {
|
func TestAPlanIsTheTouchedModulesAndWhatIsReachableAlongTheWideningEdges(t *testing.T) {
|
||||||
kinds := []string{inventory.EdgeStandsOn, inventory.EdgeDeclared, inventory.EdgePackages,
|
kinds := []string{inventory.EdgeStandsOn, inventory.EdgeDeclared, inventory.EdgePackages,
|
||||||
inventory.EdgeBuiltBy, inventory.EdgeWorkerOf}
|
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,
|
orders := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true,
|
||||||
inventory.EdgeBuiltBy: true, inventory.EdgeWorkerOf: true}
|
inventory.EdgeBuiltBy: true, inventory.EdgeWorkerOf: true}
|
||||||
// Directory names drawn from one pool, so two repositories hold directories of the same name, and one
|
// 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
|
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))
|
v.wrong[0], andMore(len(v.wrong)-1))
|
||||||
} else {
|
} else {
|
||||||
// Nothing but silence: held for the next run, which raises it if the resolver is still silent.
|
// 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.Confirm = true
|
||||||
o.Summary = fmt.Sprintf("the mesh's resolver on %s does not answer: %d of the %d question(s) about the "+
|
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,
|
"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
|
// discoverHolders asks the bus's discovery who serves what, and answers seat → machine for every
|
||||||
// endpoint a seat's verb is served on.
|
// endpoint a seat's verb is served on.
|
||||||
func discoverHolders(ctx context.Context, conn *nats.Conn) (map[string]map[string]bool, error) {
|
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{}
|
out := map[string]map[string]bool{}
|
||||||
deadline := time.Now().Add(discoveryPatience)
|
err := discoverServices(ctx, conn, func(info micro.Info) {
|
||||||
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
|
|
||||||
}
|
|
||||||
for _, e := range info.Endpoints {
|
for _, e := range info.Endpoints {
|
||||||
seat, node := e.Metadata["seat"], e.Metadata["node"]
|
seat, node := e.Metadata["seat"], e.Metadata["node"]
|
||||||
if seat == "" {
|
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] = map[string]bool{}
|
||||||
}
|
}
|
||||||
out[info.Name][info.ID] = true
|
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.
|
// 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
|
return nil, err
|
||||||
}
|
}
|
||||||
hostVersions := deliveredVersions(shelf[hostModule])
|
hostVersions := deliveredVersions(shelf[hostModule])
|
||||||
rolling := map[string]bool{}
|
rolling := rollingModules(plans)
|
||||||
for _, p := range plans {
|
|
||||||
for m := range p.Modules {
|
|
||||||
rolling[m] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nodes, err := inv.Nodes(ctx)
|
nodes, err := inv.Nodes(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1103,6 +1140,26 @@ func probeCoreBuilds(ctx context.Context, d *doctor) ([]conditions.Observation,
|
|||||||
return out, nil
|
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
|
// 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
|
// 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.
|
// 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()
|
defer open.Close()
|
||||||
inv := open.inventory
|
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)
|
ident, err := openIdentity(ctx)
|
||||||
if err != nil {
|
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
|
// 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.
|
// machines to report moves when they have, and a plan left by a replaced controller resumes.
|
||||||
go planTicker(ctx, open)
|
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
|
// 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.
|
// is registered, ended with why when its build will not register it, raised and cleared as conditions.
|
||||||
// Never by a read.
|
// Never by a read.
|
||||||
@@ -1250,11 +1268,14 @@ func issueMemberships(ctx context.Context, open *stores, server *link.Server, se
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
where := broker.PlacementsOf(records, records.Interchangeable)
|
|
||||||
bus, ok := server.Bus().(link.OverNATS)
|
bus, ok := server.Bus().(link.OverNATS)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
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
|
// **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
|
// 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
|
// 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))
|
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") {
|
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)
|
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 := plan("plan-3", inventory.PlanBuilding, at.Add(time.Hour))
|
||||||
other.Branch = "release"
|
other.Branch = "release"
|
||||||
if err := retryRefusal(failed, []inventory.Plan{other, plan("plan-0", inventory.PlanBuilding, at.Add(-time.Hour)),
|
for _, open := range []inventory.Plan{other, plan("plan-0", inventory.PlanBuilding, at.Add(-time.Hour))} {
|
||||||
plan("plan-4", inventory.PlanFailed, at.Add(time.Hour))}); err != nil {
|
if err := retryRefusal(failed, []inventory.Plan{open}); err == nil || !strings.Contains(err.Error(), "one walk at a time") {
|
||||||
t.Errorf("refused for a plan that does not supersede it: %v", err)
|
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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// walkedBy is the open plan that has started walking a module's build — sent it to a first machine,
|
// walkedBy is the open plan that has started walking a module's build — sent it to a first machine, not
|
||||||
// not yet passed — other than to this machine; empty when none does.
|
// yet passed — and whose walk this move would cross; empty when none does. A move of the same build to a
|
||||||
func (f moveFacts) walkedBy(module, node string) string {
|
// 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 {
|
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]
|
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
|
continue
|
||||||
}
|
}
|
||||||
if s.Gate != nil && s.Gate.Verdict == inventory.GatePassed {
|
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) {
|
if own(mv.Module) {
|
||||||
continue
|
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,
|
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)
|
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 {
|
for _, o := range owns {
|
||||||
i := slices.IndexFunc(moves, func(mv inventory.CarriedMove) bool { return mv.Module == o.Module })
|
i := slices.IndexFunc(moves, func(mv inventory.CarriedMove) bool { return mv.Module == o.Module })
|
||||||
switch {
|
switch {
|
||||||
@@ -526,7 +557,7 @@ func waitingMoves(ctx context.Context, open *stores, all bool) (map[string][]inv
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, mv := range moves {
|
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)
|
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++
|
r.Next++
|
||||||
continue
|
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))
|
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 != "" {
|
if said := recreationsSaid(moves); said != "" {
|
||||||
p.Note += "; " + said
|
p.Note += "; " + said
|
||||||
@@ -693,6 +724,15 @@ func advanceRelease(ctx context.Context, open *stores, p *inventory.Plan) (bool,
|
|||||||
r.Next++
|
r.Next++
|
||||||
r.Gate = nil
|
r.Gate = nil
|
||||||
return true, 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 = ""
|
p.Note = ""
|
||||||
batched, back := batchingRollbacks(ctx)
|
batched, back := batchingRollbacks(ctx)
|
||||||
|
|||||||
@@ -138,9 +138,11 @@ func reachableFrom(moved []string, edges []inventory.Edge) []string {
|
|||||||
grew = false
|
grew = false
|
||||||
for _, e := range edges {
|
for _, e := range edges {
|
||||||
// Built-by and worker-of order a plan; neither widens it. A new build machine changes
|
// 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 —
|
// nothing it builds, and a new controller changes nothing about the holder it orders. A
|
||||||
// what packages the controller's source is already a code edge.
|
// packages edge, read from a record made before novox/hq ADR 0267, widens nothing either:
|
||||||
if e.Kind == inventory.EdgeBuiltBy || e.Kind == inventory.EdgeWorkerOf {
|
// 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
|
continue
|
||||||
}
|
}
|
||||||
if in[e.To] && !in[e.From] {
|
if in[e.To] && !in[e.From] {
|
||||||
@@ -179,9 +181,18 @@ func hasCycle(tiers [][]string, edges []inventory.Edge) bool {
|
|||||||
return false
|
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.
|
// tiered, with the merge it answers.
|
||||||
func planOfMerge(m link.SourceMoved, moved []string, edges []inventory.Edge) inventory.Plan {
|
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)
|
set := reachableFrom(moved, edges)
|
||||||
tiers := tiersOf(set, edges)
|
tiers := tiersOf(set, edges)
|
||||||
modules := map[string]*inventory.PlanModule{}
|
modules := map[string]*inventory.PlanModule{}
|
||||||
@@ -190,9 +201,6 @@ func planOfMerge(m link.SourceMoved, moved []string, edges []inventory.Edge) inv
|
|||||||
}
|
}
|
||||||
return inventory.Plan{
|
return inventory.Plan{
|
||||||
ID: fmt.Sprintf("plan-%d", time.Now().UnixNano()),
|
ID: fmt.Sprintf("plan-%d", time.Now().UnixNano()),
|
||||||
Repository: m.Owner + "/" + m.Repo,
|
|
||||||
Branch: m.Base,
|
|
||||||
Commit: m.Commit,
|
|
||||||
Created: time.Now().UTC(),
|
Created: time.Now().UTC(),
|
||||||
State: inventory.PlanBuilding,
|
State: inventory.PlanBuilding,
|
||||||
Tiers: tiers,
|
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
|
// 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
|
// 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
|
// 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{}
|
seen := map[string]bool{}
|
||||||
var out []string
|
var out []string
|
||||||
for _, e := range edges {
|
for _, e := range edges {
|
||||||
if later[e.From] && inTier[e.To] && e.Kind == inventory.EdgeBuiltBy && !seen[e.To] && rollsOut(e.To) &&
|
// A delivery group's order inside a walk (novox/hq ADR 0276 decision 5) gates as built-by does: the
|
||||||
!isBaseOf(e.From, e.To, edges, all) {
|
// 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
|
seen[e.To] = true
|
||||||
out = append(out, e.To)
|
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}
|
source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat}
|
||||||
fmt.Printf(" tier %d: ", p.Tier)
|
fmt.Printf(" tier %d: ", p.Tier)
|
||||||
// The branch it follows, never a commit a build once named (novox/hq 04-ISSUES/215).
|
// The branch it follows, never a commit a build once named (novox/hq 04-ISSUES/215): the branch contains every
|
||||||
id, err := askABuild(ctx, source, e.Source.Path, followedBranch(e.Source.Ref))
|
// 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 {
|
if err != nil {
|
||||||
state.State = "failed"
|
state.State = "failed"
|
||||||
state.Why = err.Error()
|
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)
|
p.Note = fmt.Sprintf("%s could not be asked for: %v", name, err)
|
||||||
return
|
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,
|
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.State = "asked"
|
||||||
state.AskedAt = &now
|
state.AskedAt = &now
|
||||||
state.Build = id
|
state.Build = id
|
||||||
@@ -476,6 +446,47 @@ func planBuilt(ctx context.Context, open *stores, module, commit, failed string,
|
|||||||
advanceHeld(ctx, open)
|
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
|
// 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
|
// 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.
|
// 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.
|
// advanceHeld is advancePlans for a caller already holding the plans.
|
||||||
func advanceHeld(ctx context.Context, open *stores) {
|
func advanceHeld(ctx context.Context, open *stores) {
|
||||||
inv := open.inventory
|
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)
|
plans, err := inv.OpenPlans(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("plans: cannot read them: %v\n", err)
|
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) {
|
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 {
|
if err := askTier(ctx, inv, p); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -759,6 +788,15 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
|||||||
case inventory.GateFailed:
|
case inventory.GateFailed:
|
||||||
failFirstSend(ctx, open, p, m, state, state.Gate.Machines, state.Gate.Why, step.rest)
|
failFirstSend(ctx, open, p, m, state, state.Gate.Machines, state.Gate.Why, step.rest)
|
||||||
return true, nil
|
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:
|
case inventory.GatePassed:
|
||||||
if !state.Gate.Kept {
|
if !state.Gate.Kept {
|
||||||
gatePassed(ctx, open, p, m, state)
|
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()
|
now := time.Now().UTC()
|
||||||
lead := modules[0]
|
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 {
|
for _, m := range modules {
|
||||||
s := p.Modules[m]
|
s := p.Modules[m]
|
||||||
// What the first machine ran before: what a failed gate puts back (ADR 0236).
|
// 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.First, s.FirstAt = sent, &now
|
||||||
s.Gate = &inventory.PlanGate{Component: coreComponent(m), Machines: firstRunning(sent, runningOf[m]),
|
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 = ""
|
s.GatedBy = ""
|
||||||
if m == lead {
|
if m == lead {
|
||||||
s.Gate.Carried = carried
|
s.Gate.Carried = carried
|
||||||
@@ -1097,8 +1138,15 @@ func nextRollout(s inventory.PlanModule, running []string, together bool, report
|
|||||||
var waiting, failed []string
|
var waiting, failed []string
|
||||||
for _, n := range s.First {
|
for _, n := range s.First {
|
||||||
r, said := byNode[n]
|
r, said := byNode[n]
|
||||||
// Only a report about what it was last sent says anything about this build.
|
// Only a report about what this plan sent it — or what it was sent after that — says anything about
|
||||||
if !said || r.At == nil || !r.Current {
|
// 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)
|
waiting = append(waiting, n)
|
||||||
continue
|
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
|
// 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
|
// 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
|
// 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 {
|
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))
|
where := fmt.Sprintf("tier %d of %d", min(p.Tier+1, len(p.Tiers)), len(p.Tiers))
|
||||||
switch p.State {
|
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:
|
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:
|
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:
|
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)
|
since := now.Sub(inTierSince(p)).Round(time.Second)
|
||||||
if p.Waiting() {
|
if p.Waiting() {
|
||||||
// Waiting for its delivery's word is no lateness of the walk's (novox/hq ADR 0239).
|
// 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 {
|
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 := ""
|
late := ""
|
||||||
if since > bound {
|
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 {
|
if p.State == inventory.PlanRolling {
|
||||||
what = p.Note
|
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
|
// 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
|
return err
|
||||||
}
|
}
|
||||||
bounds := readTierBounds(ctx, inv, now)
|
bounds := readTierBounds(ctx, inv, now)
|
||||||
fmt.Printf("%s — %s\n", p.ID, planLineWith(p, now, buildSeatPause(ctx, inv, []inventory.Plan{p}),
|
fmt.Printf("%s — %s\n", p.ID, planLineOn(p, now, buildSeatPause(ctx, inv, []inventory.Plan{p}),
|
||||||
bounds.of(p.Repository)))
|
bounds.of(p.Repository), func(module string) []string {
|
||||||
|
on, _ := inv.Running(ctx, module)
|
||||||
|
return on
|
||||||
|
}))
|
||||||
if r := p.Release; r != nil {
|
if r := p.Release; r != nil {
|
||||||
// A release plan's walk (ADR 0236): machines done, the one judged, those to come.
|
// 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, ", "),
|
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 {
|
if err != nil {
|
||||||
return err
|
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")
|
fmt.Println("no merge has produced a plan yet")
|
||||||
return nil
|
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)
|
pause := buildSeatPause(ctx, inv, plans)
|
||||||
bounds := readTierBounds(ctx, inv, now)
|
bounds := readTierBounds(ctx, inv, now)
|
||||||
|
running := func(module string) []string {
|
||||||
|
on, _ := inv.Running(ctx, module)
|
||||||
|
return on
|
||||||
|
}
|
||||||
for _, p := range plans {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1508,11 +1639,12 @@ func planWhatIf(ctx context.Context, inv *inventory.Inventory, repository string
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
read, err := inv.ReadRepositories(ctx)
|
read, err := readForPlanning(ctx, inv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var from, packaging []inventory.Entry
|
var from, packaging []inventory.Entry
|
||||||
|
deleted := map[string]bool{}
|
||||||
named := map[string]bool{}
|
named := map[string]bool{}
|
||||||
for _, name := range modules {
|
for _, name := range modules {
|
||||||
named[name] = true
|
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).
|
// request's check ask it (novox/hq ADR 0238).
|
||||||
r := reachOfMerge(m, entries, read, nil)
|
r := reachOfMerge(m, entries, read, nil)
|
||||||
from, packaging = append(append([]inventory.Entry{}, r.Touched...), r.Deleted...), r.Packaging
|
from, packaging = append(append([]inventory.Entry{}, r.Touched...), r.Deleted...), r.Packaging
|
||||||
|
for _, e := range r.Deleted {
|
||||||
|
deleted[e.Manifest.Module] = true
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
switch {
|
switch {
|
||||||
@@ -1548,6 +1683,18 @@ func planWhatIf(ctx context.Context, inv *inventory.Inventory, repository string
|
|||||||
}
|
}
|
||||||
p := planOfMerge(m, names, edges)
|
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))
|
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{}
|
rolls := map[string]string{}
|
||||||
for i, tier := range p.Tiers {
|
for i, tier := range p.Tiers {
|
||||||
fmt.Printf(" tier %d\n", i)
|
fmt.Printf(" tier %d\n", i)
|
||||||
@@ -1565,19 +1712,19 @@ func planWhatIf(ctx context.Context, inv *inventory.Inventory, repository string
|
|||||||
rolls[name] = how
|
rolls[name] = how
|
||||||
}
|
}
|
||||||
fmt.Printf(" %-22s %s\n", 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) {
|
if hasCycle(p.Tiers, edges) {
|
||||||
fmt.Println(" the last tier depends on itself and would be built together, in no order")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,17 +59,18 @@ func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) {
|
|||||||
t.Fatalf("no cycle here: %v", tiers)
|
t.Fatalf("no cycle here: %v", tiers)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The controller alone moved: the proxy with it, nothing else.
|
// The controller alone moved: the controller alone — what packages its repository moves only when the
|
||||||
small := reachableFrom([]string{"mesh-controller"}, edges)
|
// change is in its own build source (novox/hq ADR 0267), so a packages edge widens nothing.
|
||||||
if len(small) != 3 {
|
if alone := reachableFrom([]string{"mesh-controller"}, edges); len(alone) != 1 {
|
||||||
t.Fatalf("a controller merge rebuilds the controller and what packages it: %v", small)
|
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,
|
// 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
|
// 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,
|
// 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
|
// 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.)
|
// 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)
|
smallTiers := tiersOf(small, edges)
|
||||||
if got := tiered(smallTiers); got != "mesh-controller | builder | route-proxy" {
|
if got := tiered(smallTiers); got != "mesh-controller | builder | route-proxy" {
|
||||||
t.Fatalf("the controller, then the builder, then the proxy: %v", smallTiers)
|
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,
|
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "abc", Paths: paths,
|
||||||
ModuleDirs: []string{"modules/build-agent"}, ModuleDirsSaid: true}
|
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" {
|
if len(touched) != 1 || touched[0].Manifest.Module != "build-agent" {
|
||||||
t.Fatalf("%v touched %v", paths, touched)
|
t.Fatalf("%v touched %v", paths, touched)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ import (
|
|||||||
func TestMain(m *testing.M) {
|
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
|
// 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).
|
// 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) != "" {
|
if os.Getenv(echoEnvironment) != "" {
|
||||||
fmt.Printf("verb=%q caller=%q terminal=%v\n", os.Getenv("MESH_VERB"), os.Getenv("MESH_CALLER"), startedAtTheTerminal())
|
fmt.Printf("verb=%q caller=%q terminal=%v\n", os.Getenv("MESH_VERB"), os.Getenv("MESH_CALLER"), startedAtTheTerminal())
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
|
|||||||
@@ -135,6 +135,12 @@ func handOver(ctx context.Context, seatName, to string, adding bool) error {
|
|||||||
if !ok || nodeName == "" || module == "" {
|
if !ok || nodeName == "" || module == "" {
|
||||||
return fmt.Errorf("the new holder is named <node>/<module>, not %q", to)
|
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)
|
open, err := openStores(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"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)
|
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
|
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.
|
// readArguments refuses what the verb does not take, before anything is composed.
|
||||||
func readArguments(verb string, args map[string]any) (*verbArguments, error) {
|
func readArguments(verb string, args map[string]any) (*verbArguments, error) {
|
||||||
v, known := controllerVerb(verb)
|
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)
|
return nil, fmt.Errorf("%s: %q is text, not true or false", verb, k)
|
||||||
}
|
}
|
||||||
value = fmt.Sprint(x)
|
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:
|
default:
|
||||||
return nil, fmt.Errorf("%s: %q is text, and was given %T", verb, k, x)
|
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
|
// 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.
|
// 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.
|
// 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" }) {
|
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 and cleared through the settings verb, not the " +
|
return &heldAtTheTerminal{msg: "settings are set, cleared and proposed through the settings verb, not the " +
|
||||||
"generic command; and places and accesses only at the controller's terminal (novox/hq issue 339). " +
|
"generic command; and places and accesses are set at the controller's terminal or on the operator's " +
|
||||||
"Nothing was done"}
|
"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
|
// 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.
|
// 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")
|
return nil, errors.New("tools is answered from the records, not by a command")
|
||||||
case "dead-letters":
|
case "dead-letters":
|
||||||
return nil, errors.New("dead-letters is answered by the serving controller, on its own connection, not by a command")
|
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":
|
case "status":
|
||||||
return []string{"status", "--json"}, nil
|
return []string{"status", "--json"}, nil
|
||||||
case "nodes":
|
case "nodes":
|
||||||
@@ -513,6 +536,11 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
|||||||
argv = append(argv, "--condition", c)
|
argv = append(argv, "--condition", c)
|
||||||
}
|
}
|
||||||
return argv, nil
|
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":
|
case "hand-acts":
|
||||||
argv := []string{"hand-acts", "--json"}
|
argv := []string{"hand-acts", "--json"}
|
||||||
if d := str("days"); d != "" {
|
if d := str("days"); d != "" {
|
||||||
@@ -739,6 +767,23 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
|||||||
argv = append(argv, "--probe", p)
|
argv = append(argv, "--probe", p)
|
||||||
}
|
}
|
||||||
return append(argv, "--json"), nil
|
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":
|
case "rotate":
|
||||||
if p := str("provision"); p != "" {
|
if p := str("provision"); p != "" {
|
||||||
argv := []string{"rotate", p}
|
argv := []string{"rotate", p}
|
||||||
@@ -802,6 +847,21 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
|||||||
if str("module") == "" && on("clear") {
|
if str("module") == "" && on("clear") {
|
||||||
return nil, errors.New("settings: a module is needed to clear a layer; name it with module")
|
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 := str("list"); list != "" || str("module") == "" {
|
||||||
if list != "" && list != "preferences" {
|
if list != "" && list != "preferences" {
|
||||||
return nil, fmt.Errorf("settings lists %q only; %q is not a listing", "preferences", list)
|
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
|
var argv []string
|
||||||
switch {
|
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"):
|
case on("clear"):
|
||||||
argv = []string{"settings", "clear", str("module")}
|
argv = []string{"settings", "clear", str("module")}
|
||||||
case str("values") != "":
|
case str("values") != "":
|
||||||
@@ -907,6 +983,8 @@ func repairingCommand(argv []string) string {
|
|||||||
return "plans " + argv[1]
|
return "plans " + argv[1]
|
||||||
case argv[0] == "broker" && len(argv) > 1 && argv[1] == "consumer-reset":
|
case argv[0] == "broker" && len(argv) > 1 && argv[1] == "consumer-reset":
|
||||||
return "broker 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":
|
case argv[0] == "hand-act" && len(argv) > 1 && argv[1] == "drill":
|
||||||
return "hand-act drill"
|
return "hand-act drill"
|
||||||
case argv[0] == "hand-act":
|
case argv[0] == "hand-act":
|
||||||
@@ -1092,6 +1170,9 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) {
|
|||||||
if verb == "dead-letters" {
|
if verb == "dead-letters" {
|
||||||
return deadLettersAnswer(ctx, a)
|
return deadLettersAnswer(ctx, a)
|
||||||
}
|
}
|
||||||
|
if verb == "root-free" {
|
||||||
|
return rootFreeAnswer(ctx, a.given["machines"], rootClock())
|
||||||
|
}
|
||||||
if verb == "doctor" {
|
if verb == "doctor" {
|
||||||
// From the serving controller, which runs the self-check and hears the signals
|
// 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.
|
// (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,
|
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
|
// What a consumer gave up on, read and changed on the serving controller's own connection (novox/hq
|
||||||
// issue 330).
|
// 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
|
// 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
|
// through `command`. A push sends the machine holding the bus first when its user list changed, the
|
||||||
@@ -1406,7 +1490,7 @@ var commandReadForms = map[string]func(rest []string) bool{
|
|||||||
"conditions": func(r []string) bool { return flagsOnly(r) || subIn(r, "list", "show", "history") },
|
"conditions": func(r []string) bool { return flagsOnly(r) || subIn(r, "list", "show", "history") },
|
||||||
"node": func(r []string) bool { return subIn(r, "list", "show") },
|
"node": func(r []string) bool { return subIn(r, "list", "show") },
|
||||||
"module": func(r []string) bool { return subIn(r, "list") },
|
"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") },
|
"retire": func(r []string) bool { return subIn(r, "list") },
|
||||||
"cleanup": 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") },
|
"delivery": func(r []string) bool { return subIn(r, "plan", "walks") },
|
||||||
@@ -1466,6 +1550,24 @@ var terminalOnlyCommands = map[string]string{
|
|||||||
"licence": "the licences' secrets",
|
"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
|
// 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
|
// 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
|
// `node agent-account` above all. Whoever may call a verb includes agents, and an agent that named itself
|
||||||
@@ -1479,8 +1581,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 "+
|
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)
|
"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.
|
// Of a secret's commands only rotation, which seals the new value to the machine that uses it, and the
|
||||||
if argv[0] == "secret" && (len(argv) < 2 || argv[1] != "rotate") {
|
// `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, "+
|
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 "+
|
"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:], " "))
|
"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",
|
"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",
|
"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"},
|
"hand-acts": {"json": "set by the verb: the answer is data"},
|
||||||
"conditions": {"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"},
|
"retire": {"json": "set by the verb: the answer is data"},
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ func secretCommand(ctx context.Context, args []string) error {
|
|||||||
}
|
}
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "accept":
|
case "accept":
|
||||||
|
case "ask":
|
||||||
|
return secretAsk(ctx, args[1:])
|
||||||
case "rotate":
|
case "rotate":
|
||||||
return secretRotate(ctx, args[1:])
|
return secretRotate(ctx, args[1:])
|
||||||
case "recover":
|
case "recover":
|
||||||
@@ -55,6 +57,9 @@ func secretCommand(ctx context.Context, args []string) error {
|
|||||||
provider := set.String("provider", "",
|
provider := set.String("provider", "",
|
||||||
"the node providing <name>: the value becomes the PAIR credential between <module> on <node> "+
|
"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)")
|
"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", "",
|
local := set.String("local", "",
|
||||||
"with --provider: the name the credential goes by inside <module>, where its manifest keeps "+
|
"with --provider: the name the credential goes by inside <module>, where its manifest keeps "+
|
||||||
"several for <name> (ADR 0094)")
|
"several for <name> (ADR 0094)")
|
||||||
@@ -65,6 +70,18 @@ func secretCommand(ctx context.Context, args []string) error {
|
|||||||
return errors.New(secretUsage)
|
return errors.New(secretUsage)
|
||||||
}
|
}
|
||||||
node, module, name := rest[0], rest[1], rest[2]
|
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)
|
value, err := valueFor(node, module, name, *from)
|
||||||
if err != nil {
|
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)
|
fmt.Printf(" run `push %s` and `push %s` to send it\n", *provider, node)
|
||||||
return nil
|
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 {
|
if err != nil {
|
||||||
return err
|
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
|
// 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.
|
// machine and the mesh cannot read it again.
|
||||||
fmt.Printf("%s on %s now holds %q, sealed to that machine.\n", module, node, name)
|
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
|
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" +
|
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 recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
|
||||||
"secret export [--out <file>]"
|
"secret export [--out <file>]"
|
||||||
|
|
||||||
@@ -369,6 +419,8 @@ func valueFor(node, module, name, from string) (string, error) {
|
|||||||
fmt.Fprintf(os.Stderr,
|
fmt.Fprintf(os.Stderr,
|
||||||
"reading %s's %q for %s from standard input; it is not echoed anywhere\n",
|
"reading %s's %q for %s from standard input; it is not echoed anywhere\n",
|
||||||
module, name, node)
|
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')
|
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||||
if err != nil && line == "" {
|
if err != nil && line == "" {
|
||||||
return "", fmt.Errorf("nothing was given on standard input: %w", err)
|
return "", fmt.Errorf("nothing was given on standard input: %w", err)
|
||||||
@@ -452,3 +504,23 @@ func whoAsked() string {
|
|||||||
}
|
}
|
||||||
return "the mesh"
|
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).
|
// callBounds are the verbs that may run longer than callDefault, and how long (S7).
|
||||||
var callBounds = map[string]time.Duration{
|
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,
|
"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 {
|
newest: func(f *signalFacts) time.Time {
|
||||||
return newestOf(f.waits, func(w waitFacts) time.Time { return w.since })
|
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",
|
{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)",
|
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, " +
|
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,
|
out = append(out, conditions.Observation{Scope: conditions.ScopePlan, ID: w.id, Kind: kindWalkWaiting,
|
||||||
Severity: severity,
|
Severity: severity,
|
||||||
Summary: fmt.Sprintf("the walk of %s %s has waited %s for %s's word to start: `mesh-delivery.show` for the "+
|
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,
|
"deliveries 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),
|
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),
|
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",
|
Headline: deliveryName(w.modules, w.repository) + " waiting to start",
|
||||||
Explanation: walkWaitingWords(w, in, severity),
|
Explanation: walkWaitingWords(w, in, severity),
|
||||||
Needs: waitingNeeds(severity),
|
Needs: waitingNeeds(severity),
|
||||||
|
Actions: waitingActions(w.id, severity),
|
||||||
Resolved: deliveryName(w.modules, w.repository) + " no longer waiting"})
|
Resolved: deliveryName(w.modules, w.repository) + " no longer waiting"})
|
||||||
}
|
}
|
||||||
return out
|
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 {
|
func watchLoop(f *signalFacts) []conditions.Observation {
|
||||||
if f.loop.pending == 0 {
|
if f.loop.pending == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -142,6 +142,28 @@ var suppressions = map[string]suppression{
|
|||||||
since: f.now.Add(-31 * time.Minute)}}
|
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,
|
// 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.
|
// whatever the bound (novox/hq issue 336). Inside: a new bus build waits, and no send was refused for it.
|
||||||
"S17": {
|
"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"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/builder"
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
@@ -297,27 +298,20 @@ func notNow(err error) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// SourceMoved is the forge announcing a merge: every module recorded as built from that
|
// SourceMoved is the forge announcing a merge. It is put into the open batch (novox/hq ADR 0276), which
|
||||||
// repository and branch is marked as moved to the merge commit, and built — bases first, so a
|
// becomes one walk when its merge window closes: hearMerge, and cutBatchesHeld in batches.go.
|
||||||
// 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.
|
|
||||||
func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
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
|
return f.hearMerge(ctx, m, time.Now().UTC())
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 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)
|
from, packaging, already := mergeCandidates(m, entries, read)
|
||||||
if len(from) == 0 && len(packaging) == 0 {
|
if len(from) == 0 && len(packaging) == 0 {
|
||||||
// "Already built from it" and "nothing reads it" are different facts, and reading the first
|
// "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 {
|
if already > 0 {
|
||||||
fmt.Printf("%s/%s merged into %s (%.8s); %d module(s) the mesh holds are already built "+
|
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)
|
"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",
|
fmt.Printf("%s/%s merged into %s (%.8s); nothing the mesh holds reads it\n",
|
||||||
m.Owner, m.Repo, m.Base, m.Commit)
|
m.Owner, m.Repo, m.Base, m.Commit)
|
||||||
return nil
|
return nil, 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
|
|
||||||
}
|
}
|
||||||
// Said, never silent (novox/hq 04-ISSUES/215): a module built from this repository that follows
|
// 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.
|
// 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)
|
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
|
// **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
|
// 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.
|
// 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 {
|
for _, e := range touched {
|
||||||
if err := inv.SourceMoved(ctx, e.Manifest.Module, m.Commit); err != nil {
|
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
|
// **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: "+
|
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,
|
"built, registered when the build lands, and sent nowhere\n", m.Owner, m.Repo, m.Base, m.Commit,
|
||||||
strings.Join(built, ", "))
|
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 "+
|
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)
|
"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
|
// Why each is in it (issue 363): the files of its build source the merge changed, or why it is read whole.
|
||||||
// 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
|
|
||||||
for _, e := range moved {
|
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 {
|
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 "+
|
var names []string
|
||||||
"is left where it is\n", strings.Join(also, ", "))
|
for _, e := range moved {
|
||||||
|
names = append(names, e.Manifest.Module)
|
||||||
}
|
}
|
||||||
if plan.Waiting() {
|
return names, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// askNewModules asks the build seat for every module a merge adds to the repository — a directory holding a
|
// 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)
|
from = append(from, e)
|
||||||
case readsFrom(read[e.Manifest.Module], m):
|
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)
|
packaging = append(packaging, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return from, packaging, already
|
return from, packaging, already
|
||||||
}
|
}
|
||||||
|
|
||||||
// wouldMove is the modules built from the merged repository that acting on this merge would mark as
|
// lookedAtCommit is whether a plan that built a module, or is building it, answered this merge commit.
|
||||||
// moved and rebuild — SourceMoved's judgement, made without acting (novox/hq issue 266). Empty for a
|
func lookedAtCommit(read []inventory.ReadRepository, commit string) bool {
|
||||||
// merge already acted on: acting marks each of them as looked at, so the merge then reads as history.
|
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
|
// **The ones packaging source from it too** (novox/hq ADR 0267): with a module moved only by the files of
|
||||||
// records nothing about those, so a merge acted on would go on reading as unacted for them, and be
|
// its build source, a merge can move a packaging module and nothing built from the repository, and a missed
|
||||||
// acted on again on every look. A merge that moves both is caught by the first kind, and acting on it
|
// one of those was never acted on. A packaging module's look is its newest build or plan (lookedAt), so a
|
||||||
// rebuilds the second as well.
|
// merge acted on for it reads as history once its plan is made.
|
||||||
func wouldMove(m link.SourceMoved, entries []inventory.Entry,
|
func wouldMove(m link.SourceMoved, entries []inventory.Entry,
|
||||||
read map[string][]inventory.ReadRepository) []inventory.Entry {
|
read map[string][]inventory.ReadRepository) []inventory.Entry {
|
||||||
from, _, _ := mergeCandidates(m, entries, read)
|
from, packaging, _ := mergeCandidates(m, entries, read)
|
||||||
touched, _ := splitDeleted(whatTheMergeTouched(from, entries, m), m)
|
touched, _ := splitDeleted(whatTheMergeTouched(from, entries, m, read), m)
|
||||||
return touched
|
return append(touched, packaging...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// splitDeleted parts the modules a merge touched into those it changed and those whose manifest it
|
// 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")))
|
(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
|
// readsFrom is whether a merge changed what a module's build read in another repository: the second
|
||||||
// recipe packages source from. Its ref must be the branch that moved, or unset — the same rule a
|
// repository its recipe packages source from. Its ref must be the branch that moved, or unset — the same
|
||||||
// module's own source follows.
|
// 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 {
|
func readsFrom(read []inventory.ReadRepository, m link.SourceMoved) bool {
|
||||||
for _, r := range read {
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
for _, p := range m.Paths {
|
||||||
|
if builder.SourceHolds(r.Paths, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// lastLookAt is the most recent look at this repository by anything built from it.
|
// ownSource is the build source a module's newest trunk build said it read in its own repository; nil
|
||||||
func lastLookAt(entries []inventory.Entry, m link.SourceMoved) time.Time {
|
// when it said none, and the module's own directory — or, built from the root, its whole repository — is
|
||||||
var newest time.Time
|
// its build source, as before (novox/hq ADR 0267).
|
||||||
for _, e := range entries {
|
func ownSource(read []inventory.ReadRepository) []string {
|
||||||
if sameRepository(e.Source.Repository, m) && e.Source.Seen.After(newest) {
|
for _, r := range read {
|
||||||
newest = e.Source.Seen
|
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
|
// 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
|
// 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.
|
// 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 {
|
func whatTheMergeTouched(candidates, known []inventory.Entry, m link.SourceMoved,
|
||||||
touched, _, _ := touchedBy(candidates, known, m)
|
read map[string][]inventory.ReadRepository) []inventory.Entry {
|
||||||
|
touched, _, _ := touchedBy(candidates, known, m, read)
|
||||||
return touched
|
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
|
// 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.
|
// 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
|
// **A changed file touches exactly the modules whose build reads it.** What a build reads is its build
|
||||||
// own directory — the builder clones the repository and builds within that directory alone: the manifest,
|
// 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
|
// 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
|
// 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
|
// 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
|
// Nothing said about the files, or not all of them said, is still everything: what is not known cannot
|
||||||
// be narrowed.
|
// 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{}
|
knownDirs := map[string]bool{}
|
||||||
for _, e := range known {
|
for _, e := range known {
|
||||||
if !e.Provided && sameRepository(e.Source.Repository, m) {
|
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
|
return candidates, added, nil
|
||||||
}
|
}
|
||||||
for _, e := range candidates {
|
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)
|
touched = append(touched, e)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, p := range m.Paths {
|
for _, p := range m.Paths {
|
||||||
read := newDir["."]
|
isRead := newDir["."]
|
||||||
for _, e := range candidates {
|
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 {
|
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)
|
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,
|
func reachOfMerge(m link.SourceMoved, entries []inventory.Entry, read map[string][]inventory.ReadRepository,
|
||||||
edges []inventory.Edge) mergeReach {
|
edges []inventory.Edge) mergeReach {
|
||||||
from, packaging, already := mergeCandidates(m, entries, read)
|
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)
|
kept, deleted := splitDeleted(touched, m)
|
||||||
r := mergeReach{Touched: kept, Deleted: deleted, Packaging: packaging, Already: already, Added: added, Unread: unread}
|
r := mergeReach{Touched: kept, Deleted: deleted, Packaging: packaging, Already: already, Added: added, Unread: unread}
|
||||||
var building []string
|
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
|
plansErr error
|
||||||
// waits are the walks waiting for their delivery's word (S16, novox/hq ADR 0239).
|
// waits are the walks waiting for their delivery's word (S16, novox/hq ADR 0239).
|
||||||
waits []waitFacts
|
waits []waitFacts
|
||||||
|
// batches are the batches not yet cut (S18, S19, novox/hq ADR 0276).
|
||||||
|
batches []batchFacts
|
||||||
|
|
||||||
loop loopFacts
|
loop loopFacts
|
||||||
loopErr error
|
loopErr error
|
||||||
@@ -159,6 +161,8 @@ type waitFacts struct {
|
|||||||
since time.Time
|
since time.Time
|
||||||
// modules are the modules the walk moves, as a person names the delivery.
|
// modules are the modules the walk moves, as a person names the delivery.
|
||||||
modules []string
|
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 {
|
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.machines, f.machinesErr = w.gatherMachines(ctx, inv, now)
|
||||||
f.bus, f.busErr = gatherBus(ctx, inv)
|
f.bus, f.busErr = gatherBus(ctx, inv)
|
||||||
f.plans, f.waits, f.plansErr = gatherPlans(ctx, inv, now, f.bus.heldByTheBus())
|
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.loop, f.loopErr = w.gatherLoop()
|
||||||
f.mergesPassed, f.merges, f.mergesErr = watchedMerges.last()
|
f.mergesPassed, f.merges, f.mergesErr = watchedMerges.last()
|
||||||
if f.mergesErr == nil && !f.mergesPassed.IsZero() && now.Sub(f.mergesPassed) > 3*mergeCatchUpEvery {
|
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.
|
// S16's, whatever mesh-delivery says or does not say.
|
||||||
if p.Waiting() {
|
if p.Waiting() {
|
||||||
waits = append(waits, waitFacts{id: p.ID, repository: p.Repository, commit: p.Commit,
|
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
|
continue
|
||||||
}
|
}
|
||||||
_, paused := pausedWaiting(p, pause, now)
|
_, 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.
|
// under the lease and the brake, every act said.
|
||||||
healers := newHealing(open, keeper, bus, server.JetStream())
|
healers := newHealing(open, keeper, bus, server.JetStream())
|
||||||
go healers.keep(watching)
|
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)
|
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 "+
|
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,
|
"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: "mesh-controller", Kind: inventory.EdgePackages},
|
||||||
{From: "route-proxy", To: "build-agent", Kind: inventory.EdgeBuiltBy},
|
{From: "route-proxy", To: "build-agent", Kind: inventory.EdgeBuiltBy},
|
||||||
}
|
}
|
||||||
set := reachableFrom([]string{"mesh-controller"}, edges)
|
// A packages edge recorded before novox/hq ADR 0267 widens nothing: the controller moved alone moves
|
||||||
if len(set) != 3 {
|
// alone, and a change to a package all three build from moves all three, each by its own build source.
|
||||||
t.Fatalf("the controller, what packages it, and nothing more: %v", set)
|
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)
|
tiers := tiersOf(set, edges)
|
||||||
pos := map[string]int{}
|
pos := map[string]int{}
|
||||||
for i, tier := range tiers {
|
for i, tier := range tiers {
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ module github.com/novox/mesh-controller
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
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/nats-io/nats.go v1.54.0
|
||||||
github.com/novox/mesh-host v0.0.0
|
github.com/novox/mesh-host v0.0.0
|
||||||
golang.org/x/crypto v0.57.0
|
golang.org/x/crypto v0.57.0
|
||||||
golang.org/x/net v0.58.0
|
golang.org/x/net v0.58.0
|
||||||
|
golang.org/x/sys v0.48.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -19,12 +22,9 @@ require (
|
|||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
github.com/minio/highwayhash v1.0.4 // indirect
|
github.com/minio/highwayhash v1.0.4 // indirect
|
||||||
github.com/nats-io/jwt/v2 v2.8.1 // 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/nkeys v0.4.16 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // 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/sync v0.23.0 // indirect
|
||||||
golang.org/x/sys v0.48.0 // indirect
|
|
||||||
golang.org/x/text v0.42.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
golang.org/x/time v0.15.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
|
// 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
|
// 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.
|
// `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-20261009231844-b8c854611812 h1:pzVzwF5VMWaTECxu8+Pd1dNoOHNEm7upC5wPadQTkBw=
|
||||||
git.novox.be/novox/mesh-host v0.0.0-20261006095519-3e80b7ae325e/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
git.novox.be/novox/mesh-host v0.0.0-20261009231844-b8c854611812/go.mod h1:K3/xEzVgmrNKLMV2vv4M80MwmPnQNXqvQ4C5Jj0fJT4=
|
||||||
git.novox.be/novox/mesh-host v0.0.0-20261007120832-bdd44154ccac h1:KvnKtJ2rWeIE/t4GweK+JL0OjKSNxsrVP3/nMdpii8o=
|
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970 h1:9tFDQsgmI+4X7/BpZGXIr+HemPKE7YddYGqWV0lINAI=
|
||||||
git.novox.be/novox/mesh-host v0.0.0-20261007120832-bdd44154ccac/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
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=
|
|
||||||
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 h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
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=
|
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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
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
|
// 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.
|
// allowed to act writes by compare-and-set and renews; its revision when taken is the epoch.
|
||||||
LeaseBucket = BucketName(ControllerSeat, "lease")
|
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
|
// 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.
|
// every five. The bucket's age, so the bus forgets a holder that stopped renewing.
|
||||||
const LeaseTTL = 15 * time.Second
|
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.
|
// IsControllerBucket says a bucket is the controller's own, not a module's state nothing declares.
|
||||||
func IsControllerBucket(bucket string) bool {
|
func IsControllerBucket(bucket string) bool {
|
||||||
return bucket == CallsBucket || bucket == HandActsBucket || bucket == ConditionsBucket ||
|
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.
|
// ControllerBuckets are the controller's own buckets, in the order they are asserted.
|
||||||
func ControllerBuckets() []string {
|
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.
|
// ControllerBucketsAsserter is what raising the controller's buckets needs of a connection.
|
||||||
@@ -149,6 +156,18 @@ func (j *JetStream) EnsureControllerBuckets() error {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("asserting bucket %s: %w", ConditionHistoryBucket, err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
package broker
|
package broker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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
|
// **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")
|
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 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,
|
// 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.
|
// 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
|
return Consumer{}, false
|
||||||
}
|
}
|
||||||
perms, err := PermissionsFor(p)
|
perms, err := PermissionsFor(p)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package broker
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"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
|
// and refuses, with the reason, what is not on it — the bus enforces only the union over every
|
||||||
// module on the machine.
|
// module on the machine.
|
||||||
State []StateIssued `json:"state,omitempty"`
|
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.
|
// 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,
|
// 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.
|
// so the module's plain subject is issued to all of them in one queue.
|
||||||
Interchangeable map[string]bool
|
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
|
// 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})
|
m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}", Queue: "serve." + d.Module})
|
||||||
}
|
}
|
||||||
for _, s := range d.Holds {
|
for _, s := range d.Holds {
|
||||||
|
if servedOnlyByTheController(s) {
|
||||||
|
continue // answered by the serving controller alone, never through a membership
|
||||||
|
}
|
||||||
for _, verb := range s.Serves {
|
for _, verb := range s.Serves {
|
||||||
m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)})
|
m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.State = stateIssuedFor(d, 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 {
|
if len(d.Invokes) > 0 {
|
||||||
m.Reaches = map[string][]string{}
|
m.Reaches = map[string][]string{}
|
||||||
for _, t := range d.Invokes {
|
for _, t := range d.Invokes {
|
||||||
@@ -145,5 +171,67 @@ func PlacementsOf(r Records, interchangeable map[string]bool) Placements {
|
|||||||
for _, nodes := range p.Nodes {
|
for _, nodes := range p.Nodes {
|
||||||
sort.Strings(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
|
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
|
Emits []string
|
||||||
Serves []string
|
Serves []string
|
||||||
Versions []string // protocol versions served beside the current one; empty for v1 only
|
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
|
// 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.
|
// the controller's grant that acts, and only through the step a person starts.
|
||||||
var VerbsTheBusStepAsks = []SeatVerb{{Seat: "node-backup", Verb: "now"}}
|
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
|
// 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
|
// 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.
|
// 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"},
|
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
|
// 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.
|
// 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.
|
// (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" }
|
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
|
// 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
|
// §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.
|
// 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.
|
// Permissions is what a principal may publish and subscribe, and whether it may answer.
|
||||||
type Permissions struct {
|
type Permissions struct {
|
||||||
Publish []string
|
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
|
Subscribe []string
|
||||||
// AllowResponses lets a principal reply to a request it received, on the reply subject that
|
// AllowResponses lets a principal reply to a request it received, on the reply subject that
|
||||||
// request carried, once.
|
// request carried, once.
|
||||||
@@ -366,10 +451,28 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
for _, v := range VerbsTheBusStepAsks {
|
for _, v := range VerbsTheBusStepAsks {
|
||||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
|
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).
|
// And the delivery's owner, a mesh seat, asked on its flat subjects (ADR 0239).
|
||||||
for _, v := range VerbsTheControllerAsksTheDeliveryOwner {
|
for _, v := range VerbsTheControllerAsksTheDeliveryOwner {
|
||||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb)
|
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
|
// 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.
|
// the same discovery the console reads. The question only; the answers come to its own inbox.
|
||||||
pub = append(pub, "$SRV.INFO")
|
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
|
// 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
|
// `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.
|
// 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
|
// 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
|
// 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.
|
// 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
|
// 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.
|
// role is hearing what it announced, not taking part in it.
|
||||||
for _, w := range p.Watches {
|
for _, w := range p.Watches {
|
||||||
|
if w.Kinded {
|
||||||
|
continue // composed by SeatTrafficOf below
|
||||||
|
}
|
||||||
for _, e := range w.Emits {
|
for _, e := range w.Emits {
|
||||||
sub = append(sub, seatSubject(w, "event", e))
|
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.INFO."+consumerStream(p)+"."+consumerDurable(p),
|
||||||
"$JS.API.CONSUMER.MSG.NEXT."+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 {
|
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
|
// 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
|
// 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 —
|
// 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
|
// seat's inbound subject and watch other modules' traffic, nor publish its outbound
|
||||||
// events and lie about outcomes (design 29 §2).
|
// events and lie about outcomes (design 29 §2).
|
||||||
for _, s := range p.Uses {
|
for _, s := range p.Uses {
|
||||||
for _, a := range s.Accepts {
|
for _, a := range plainVerbs(s, s.Accepts) {
|
||||||
pub = append(pub, seatSubject(s, "accept", a))
|
pub = append(pub, seatSubject(s, "accept", a))
|
||||||
}
|
}
|
||||||
for _, t := range s.Serves {
|
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,
|
pub = append(pub, stateGrants(stateAccess{Module: p.Module, Node: p.Node, Keeps: p.State,
|
||||||
PerMachine: p.PerMachine, Reads: p.Reads, KeyedReads: p.KeyedReads})...)
|
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:
|
case KindNodeTools:
|
||||||
// **One process serves what every module on the machine would have served for itself**
|
// **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
|
// (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)
|
pub = append(pub, own+".event."+e)
|
||||||
}
|
}
|
||||||
for _, s := range d.Holds {
|
for _, s := range d.Holds {
|
||||||
|
if servedOnlyByTheController(s) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
for _, t := range s.Serves {
|
for _, t := range s.Serves {
|
||||||
sub = append(sub, seatToolSubject(s, t, p.Node))
|
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),
|
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})...)
|
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)
|
sub = unique(sub)
|
||||||
pub = unique(pub)
|
pub = unique(pub)
|
||||||
}
|
}
|
||||||
@@ -714,13 +864,29 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
if err := CheckWriters(p, pub); err != nil {
|
if err := CheckWriters(p, pub); err != nil {
|
||||||
return Permissions{}, err
|
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{
|
return Permissions{
|
||||||
Publish: pub,
|
Publish: pub,
|
||||||
|
PublishDeny: deny,
|
||||||
Subscribe: sub,
|
Subscribe: sub,
|
||||||
// A module answers what it was asked — a tool call reaches it on its own namespace, so the
|
// 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
|
// authority is bounded by having been asked — and so does the controller. A node is asked one
|
||||||
// person are never asked anything, and are granted nothing here.
|
// thing, a hand-over on its own subject (novox/hq issue 356), and answers that: the node is its
|
||||||
AllowResponses: p.Kind == KindModule || p.Kind == KindController || p.Kind == KindNodeTools,
|
// 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
|
}, 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
|
// 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
|
// 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.
|
// 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 {
|
func seatToolSubject(s Seat, verb, node string) string {
|
||||||
base := seatSubject(s, "tool", verb)
|
base := seatSubject(s, "tool", verb)
|
||||||
if s.Scope == "node" && node != "" {
|
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())
|
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)
|
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, " publish: { allow: [%s] }\n", quoted(perms.Publish))
|
||||||
|
}
|
||||||
fmt.Fprintf(&b, " subscribe: { allow: [%s] }\n", quoted(perms.Subscribe))
|
fmt.Fprintf(&b, " subscribe: { allow: [%s] }\n", quoted(perms.Subscribe))
|
||||||
if perms.AllowResponses {
|
if perms.AllowResponses {
|
||||||
fmt.Fprintf(&b, " allow_responses: { max: 1, ttl: \"%dm\" }\n", int(ResponseTTL/time.Minute))
|
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
|
// 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
|
// 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) {
|
func TestOnlyWhatCanBeAskedMayAnswer(t *testing.T) {
|
||||||
module, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "audit",
|
module, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "audit",
|
||||||
Consumes: []string{"shop.order.placed"}, PasswordHash: "x"})
|
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")
|
t.Fatal("a module cannot answer a tool call on its own namespace")
|
||||||
}
|
}
|
||||||
node, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", PasswordHash: "x"})
|
node, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", PasswordHash: "x"})
|
||||||
if node.AllowResponses {
|
if !node.AllowResponses || !slices.Contains(node.Subscribe, AskHandOverSubject("one")) ||
|
||||||
t.Fatal("a node was granted the right to answer, and nothing asks a node anything")
|
!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.
|
// seat to check before it merges — every machine of the facts snapshot composed with the change.
|
||||||
// Appended, because the index is a name.
|
// Appended, because the index is a name.
|
||||||
moduleEventSubject("gitea", "pull.updated"),
|
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
|
// 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.
|
// (catalogue.ProvisionerEvents), which this package cannot import; a test keeps them agreeing.
|
||||||
const (
|
const (
|
||||||
|
|||||||
+4
-3
@@ -24,8 +24,8 @@ accounts {
|
|||||||
jetstream: enabled
|
jetstream: enabled
|
||||||
users = [
|
users = [
|
||||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
{ 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.*"] }
|
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"] }
|
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" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
|
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
|
||||||
@@ -34,7 +34,8 @@ accounts {
|
|||||||
} }
|
} }
|
||||||
{ user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: {
|
{ 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.>"] }
|
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: {
|
{ 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.>"] }
|
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
|
// 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.
|
// 48 §3): the machine's node-engine asks them of its own node tools, and is granted that and no more.
|
||||||
Checks []string
|
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.
|
// 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
|
// 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.
|
// (ADR 0160), which decides whether the module's plain subject is issued to every instance.
|
||||||
Interchangeable map[string]bool
|
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.
|
// 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
|
// is a mesh that cannot be told anything, and there is no state of the records in which that is
|
||||||
// correct.
|
// correct.
|
||||||
func Users(r Records) ([]Principal, error) {
|
func Users(r Records) ([]Principal, error) {
|
||||||
out := []Principal{{Kind: KindController}}
|
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r)}}
|
||||||
|
|
||||||
for _, node := range sortedCopy(r.Nodes) {
|
for _, node := range sortedCopy(r.Nodes) {
|
||||||
witness := false
|
witness := false
|
||||||
@@ -120,10 +127,13 @@ func Users(r Records) ([]Principal, error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if runtimeHere {
|
if runtimeHere {
|
||||||
out = append(out, Principal{
|
var carried []Declared
|
||||||
Kind: KindNodeTools, Node: node, Module: RuntimeModule,
|
for _, d := range r.Assigned[node] {
|
||||||
Carries: append([]Declared(nil), 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) {
|
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.
|
// controllerModule is the controller's module: the machine assigned it witnesses its upgrades.
|
||||||
const controllerModule = "mesh-controller"
|
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{
|
var WritersTable = []WriterRow{
|
||||||
{State: "a machine's declaration", Writer: "controller (lease holder)", KeptIn: "the bus, last per subject",
|
{State: "a machine's declaration", Writer: "controller (lease holder)", KeptIn: "the bus, last per subject",
|
||||||
Others: "read", Subjects: []string{"mesh.node.*.declare"}, Writes: isController},
|
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",
|
{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",
|
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
|
// 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.
|
// to the table; one dropped from either fails.
|
||||||
var designRows = []string{
|
var designRows = []string{
|
||||||
"a machine's declaration",
|
"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",
|
"a machine's applied state and its report",
|
||||||
"the controller lease",
|
"the controller lease",
|
||||||
"plans and their tiers",
|
"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"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -87,6 +88,23 @@ type Result struct {
|
|||||||
// pin the build. Two builds with one fingerprint are one build, whatever digests they made
|
// pin the build. Two builds with one fingerprint are one build, whatever digests they made
|
||||||
// (novox/hq issue 280).
|
// (novox/hq issue 280).
|
||||||
Source string
|
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.
|
// 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.
|
// What it is made from, for its source fingerprint: the module's own tree first.
|
||||||
src := newSourceInputs(manifest.Module)
|
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 {
|
if src.tree, err = gitTree(ctx, run, tree, path); err != nil {
|
||||||
src.notPinned("its tree could not be named: " + err.Error())
|
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 == "" {
|
if fingerprint == "" {
|
||||||
say("source", "no source fingerprint: %s", orNoTree(src.unpinned))
|
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,
|
return Result{Manifest: resolved, Commit: commit, Built: built,
|
||||||
Against: against(within, manifest, stoodOn), Read: readBy(manifest), Source: fingerprint,
|
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/`.
|
// 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
|
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.
|
// orNoTree is why a build has no source fingerprint, for its log.
|
||||||
func orNoTree(why string) string {
|
func orNoTree(why string) string {
|
||||||
if why == "" {
|
if why == "" {
|
||||||
@@ -648,15 +706,51 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
} else if src != nil {
|
} else if src != nil {
|
||||||
src.contexts[a.Name] = t
|
src.contexts[a.Name] = t
|
||||||
}
|
}
|
||||||
// docker build accepts -f outside the context it is given; the recipe stays exactly
|
buildDir = cloned
|
||||||
// where it was read from and validated against, absolute so the working directory
|
if t, _ := trunkOf(ctx, run, cloned, "HEAD"); t == "" || !atTrunkHead(ctx, run, cloned, "HEAD", t) {
|
||||||
// switching to the cloned context does not change which file that is.
|
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))
|
absRecipe, err := filepath.Abs(filepath.Join(tree, a.From))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Built{}, fmt.Errorf("%s: %s's recipe: %w", module, a.Name, err)
|
return catalogue.Built{}, fmt.Errorf("%s: %s's recipe: %w", module, a.Name, err)
|
||||||
}
|
}
|
||||||
recipePath = absRecipe
|
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...)
|
invocation := append([]string{"build", "-f", recipePath, "-t", local}, args...)
|
||||||
if a.Target != "" {
|
if a.Target != "" {
|
||||||
@@ -708,6 +802,18 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
if src != nil {
|
if src != nil {
|
||||||
src.toolchains[a.Name] = toolchainOf(chain, base)
|
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 chain.Language == "typescript" {
|
||||||
if own, _ := ownDependencies(tree); len(own) > 0 {
|
if own, _ := ownDependencies(tree); len(own) > 0 {
|
||||||
src.notPinned(a.Name + " resolves packages of its own at build time")
|
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
|
// there is no Publisher call — the container itself publishes, with the credential the
|
||||||
// build was handed.
|
// build was handed.
|
||||||
say("package", "building and publishing %s (%s)", a.Name, a.Language)
|
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")
|
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)
|
reference, err := publishPackage(ctx, run, module, tree, a, npmrc, say)
|
||||||
if err != nil {
|
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
|
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil
|
||||||
|
|
||||||
case catalogue.ArtifactArchive:
|
case catalogue.ArtifactArchive:
|
||||||
|
src.ownHas(cleanEntry(a.From) + "/**")
|
||||||
body, err := pack(filepath.Join(tree, a.From))
|
body, err := pack(filepath.Join(tree, a.From))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Built{}, fmt.Errorf("%s: packing %s failed: %w", module, a.Name, err)
|
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; "
|
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
|
// 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.
|
// 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,
|
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) {
|
checked := func(dir string) (string, error) {
|
||||||
var own tail
|
var own tail
|
||||||
cmd := exec.CommandContext(ctx, "docker", LabelledArgs("docker",
|
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)
|
inItsOwnGroup(cmd)
|
||||||
w := io.MultiWriter(out, &own)
|
w := io.MultiWriter(out, &own)
|
||||||
cmd.Stdout, cmd.Stderr = w, w
|
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