Compare commits

..
Author SHA1 Message Date
jochen 468d509462 Read how a module says each resource is ready, and send it to engines that read it (hq ADR 0240, to-be 48 Phase B)
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check fail: its merge-check.sh failed: FAIL github.com/novox/mesh-controller/cmd/mesh-controller 1800.059s
mesh/delivery-group group feat/health-the-field rejected: its order contradicts itself: novox/mesh-controller@468d509462fe, novox/mesh-host@96bf9415aae4
mesh/delivery superseded: a newer head of the same pull request
A module could say nothing about what ready means for what it runs, so a web
application with its port open and its requests hanging passed everything for
eleven hours (issue 145). A long-running resource now carries `health` — the
image's own check adopted by name, http, tcp, exec, unit or a module's own tool,
with its timing — refused near its author when it names a port or an address,
an endpoint the module does not declare, a tool it does not serve, a tool check
alone, or a timing outside the record's bounds. It is composed with the endpoint
as the port this machine published it on, and sent only to a node-engine whose
statement says it reads it: an older one would refuse the whole declaration.
The engine is granted its own machine's instance of each health tool. `module
check` warns of every long-running resource without `health`, counts them for
the catalogue, and refuses them from 2026-11-18. A check's findings stay out of
a condition's summary. The node-engine's validator is vendored at its Phase B
commit, so what is composed is judged by the words the engine takes.
2026-10-07 14:39:13 +02:00
350 changed files with 1408 additions and 47032 deletions
-16
View File
@@ -274,22 +274,6 @@ func (h *holder) removeContainers(id string) (int, error) {
return builder.RemoveContainersOf(cleanup, h.remove, id)
}
// removeLeftBehind removes, once, what earlier holders on this machine left — called before anything is
// taken, so none of it is this holder's — and says what it did. A runtime that cannot be asked is said
// and the holder takes work all the same: what is left is waste, not a reason to stop building.
func (h *holder) removeLeftBehind() int {
cleanup, stop := context.WithTimeout(context.Background(), killRemoves)
defer stop()
n, err := builder.RemoveLeftBehind(cleanup, h.remove)
switch {
case err != nil:
fmt.Fprintf(os.Stderr, "could not look for containers earlier builds left on %s: %v\n", h.on, err)
case n > 0:
fmt.Fprintf(os.Stderr, "removed %d container(s) earlier builds left on %s\n", n, h.on)
}
return n
}
// returned records that the build's work ended, and says whether a kill came first — only then is
// the build killed; an error it ended with on its own is its own outcome.
func (h *holder) returned(r *running) bool {
-24
View File
@@ -3,7 +3,6 @@ package main
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
@@ -147,26 +146,3 @@ func TestAKillArrivingAfterTheBuildEndedIsRefusedAndAnUnannouncedKillSaysSo(t *t
t.Fatalf("kill said %q (%v)", said, err)
}
}
// **Issue 306**: a holder starting removes the containers earlier builds left on its machine, through
// the runner a kill uses, and a runtime that cannot be asked does not stop it.
func TestAHolderStartingRemovesWhatEarlierBuildsLeftHere(t *testing.T) {
h := newHolder("novox", link.TheBuildMachine, t.TempDir(), nil)
var removed []string
h.remove = func(_ context.Context, _ string, name string, args ...string) (string, error) {
removed = append(removed, name+" "+strings.Join(args, " "))
if args[0] == "ps" {
return "c1 build-1\nc2 build-1\nc3 check-here-2\n", nil
}
return "", nil
}
if n := h.removeLeftBehind(); n != 2 || len(removed) != 2 || removed[1] != "docker rm -f c1 c2" {
t.Fatalf("removed %d: %v", n, removed)
}
h.remove = func(context.Context, string, string, ...string) (string, error) {
return "", errors.New("no runtime")
}
if n := h.removeLeftBehind(); n != 0 {
t.Errorf("a runtime that cannot be asked removed %d", n)
}
}
+1 -13
View File
@@ -27,8 +27,6 @@ import (
"syscall"
"time"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/builder"
"github.com/novox/mesh-controller/internal/link"
@@ -131,9 +129,6 @@ func run() error {
if h.Paused() {
fmt.Fprintf(os.Stderr, "paused (kept in %s): taking no build until resumed\n", pausedFile(workspace))
}
// **What an earlier holder here left goes before anything is taken** (novox/hq issue 306): a holder
// recreated mid-check left the check's store and bus running, and the ask went to another machine.
h.removeLeftBehind()
machine := link.MachineOverNATSWith(js, on, seat, link.MachineOptions{Paused: h.Paused})
defer machine.Close()
@@ -177,9 +172,7 @@ func dialFor(credential Credential) (*broker.JetStream, string, error) {
if !credential.onTheNewBus() {
return nil, "", fmt.Errorf("the credential at hand names %q, which is not the mesh's bus", credential.URL)
}
// Named for what it is, not the controller whose code dials it (novox/hq issue 327).
host, _ := os.Hostname()
js, err := broker.DialPinned(credential.natsURL(), credential.Fingerprint, nats.Name("build agent on "+host))
js, err := broker.DialPinned(credential.natsURL(), credential.Fingerprint)
if err != nil {
return nil, "", err
}
@@ -268,8 +261,6 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc,
forgeFrom(), say, request.Seats)
}
// What it copied into the store, whatever became of the build (novox/hq ADR 0257).
result.Mirrored = built.Mirrored
// Only a build the kill ended: the kill came before its work did. One that finished — built, or
// failed on its own — in the moment the kill arrived says what it did, and the kill is refused.
killed := false
@@ -306,9 +297,6 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
for _, r := range built.Read {
result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref})
}
for _, s := range built.Sources {
result.Sources = append(result.Sources, link.BuildSource{Repository: s.Repository, Ref: s.Ref, Paths: s.Paths})
}
say("built", built.Manifest.Module+" from "+short(built.Commit))
}
}
+2 -19
View File
@@ -5,11 +5,9 @@ import (
"errors"
"fmt"
"os"
"strings"
"sync"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/novox/mesh-controller/internal/broker"
@@ -180,21 +178,7 @@ func (a *actor) release() {
// holderOf is this process as the lease's holder.
func holderOf(instance string) lease.Holder {
host, _ := os.Hostname()
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))
return lease.Holder{Instance: instance, Host: host, Build: version}
}
// serveUnderTheLease takes the lease for the serving controller, waiting while another holds it, and
@@ -203,8 +187,7 @@ func (a *actor) serveUnderTheLease(ctx context.Context, inv *inventory.Inventory
a.mu.Lock()
a.serving = true
a.mu.Unlock()
// Its own connection, held as long as the lease, and named so (novox/hq issue 327).
js, err := broker.Dial(address, nats.Name(broker.ConnectionName+" lease"))
js, err := broker.Dial(address)
if err != nil {
return nil, fmt.Errorf("the mesh is on the bus at %s and this control plane cannot reach it to take the "+
"lease: %w", broker.BareAddress(address), err)
-61
View File
@@ -7,10 +7,8 @@ import (
"slices"
"sort"
"strings"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
)
// The things the mesh can be asked to do, separated from how it was asked.
@@ -43,17 +41,9 @@ import (
// It costs a resolution per machine. Assignment is a person typing a command, and being told which
// machines this just blocked is worth more than the milliseconds.
func assign(ctx context.Context, open *stores, node string, modules ...string) (string, error) {
return assignWith(ctx, open, node, assignOptions{}, modules...)
}
// assignWith is assign asked with its options: build, for a module known and not built (novox/hq issue 325).
func assignWith(ctx context.Context, open *stores, node string, opts assignOptions, modules ...string) (string, error) {
if len(modules) == 0 {
return "", fmt.Errorf("assign %s names no module", node)
}
if err := refusedMovingTheController(modules); err != nil {
return "", err
}
// Held while it is recorded, so it cannot land between a converge's preview and its flip and
// be taken without ever having been previewed (novox/hq ADR 0100).
ctx, release, err := holdNodes(ctx, open, []string{node})
@@ -61,29 +51,6 @@ func assignWith(ctx context.Context, open *stores, node string, opts assignOptio
return "", err
}
defer release()
// **A module the catalogue does not hold is looked for further before it is refused** (novox/hq issue
// 325): a build in flight keeps the assignment pending, a module known and not built is said with how to
// build it, and only a name the mesh never heard of is refused as unknown. Several modules in one act
// are judged together (ADR 0207), so an act naming one not registered is not made in part.
if missing, err := notInCatalogue(ctx, open, modules); err != nil {
return "", err
} else if len(missing) > 0 {
if len(modules) == 1 {
return notRegistered(ctx, open, node, modules[0], opts)
}
var lines []string
for _, m := range missing {
where, err := whereIs(ctx, open.inventory, m, time.Now())
if err != nil {
return "", err
}
lines = append(lines, where.said)
}
return "", fmt.Errorf("%w: nothing was assigned, since modules assigned together are judged together "+
"(ADR 0207) and %s not registered:\n %s\n assign them together once each is registered, or each "+
"alone to keep it pending on its build", inventory.ErrNoSuchModule, strings.Join(missing, ", "),
strings.Join(lines, "\n "))
}
// **The one assignment refused for what the node lacks** (novox/hq ADR 0207). Everything else
// an assignment leaves unresolved is kept, because assignment is not an ordering; a module whose
// resources are applied through a seat nothing on the node holds is refused, because that order
@@ -212,9 +179,6 @@ func seatDependenciesOnAssign(ctx context.Context, open *stores, node string, mo
// (novox/hq ADR 0207) — the other side of refusing that module's assignment without one. Several
// modules in one act are judged together, so a holder and its dependents come off in one command.
func unassign(ctx context.Context, open *stores, node string, modules ...string) (string, error) {
if err := refusedMovingTheController(modules); err != nil {
return "", err
}
if len(modules) == 0 {
return "", fmt.Errorf("unassign %s names no module", node)
}
@@ -236,17 +200,6 @@ func unassign(ctx context.Context, open *stores, node string, modules ...string)
for _, a := range assigned {
runs[a] = true
}
// A module only pending on the node (novox/hq issue 325) is withdrawn, when it is the act's one module:
// that is the undo of an assignment kept while its build runs.
if len(modules) == 1 && !runs[modules[0]] {
said, withdrawn, err := withdrawPending(ctx, open.inventory, node, modules[0])
if err != nil {
return "", err
}
if withdrawn {
return said, nil
}
}
for _, module := range modules {
if !runs[module] {
return "", fmt.Errorf("%s is not assigned to %s", module, node)
@@ -366,17 +319,3 @@ func issueOnAssign(ctx context.Context, open *stores, node, module string) strin
}
return fmt.Sprintf("its bus credential is issued and sealed to %s, and arrives with the push", node)
}
// refusedMovingTheController refuses assigning or unassigning the controller's module through a verb (review of
// novox/hq ADR 0272): the node it is assigned to is the control-node, and the control-node's operator account is
// the controller's terminal, so whoever moves the module chooses the terminal. At the terminal alone, as every
// change that says who may change what.
func refusedMovingTheController(modules []string) error {
verb, through := throughAVerb()
if !through || !slices.Contains(modules, controllerModule) {
return nil
}
return fmt.Errorf("%s is assigned and unassigned at the controller's terminal only (mesh-cli on the control-node), "+
"never through a verb (this came through %q): the node it runs on is the control-node, whose operator is the "+
"terminal (novox/hq ADR 0272). Nothing was assigned", controllerModule, verb)
}
-253
View File
@@ -1,253 +0,0 @@
package main
// The account agents run as (novox/hq ADR 0266).
//
// On the control node every agent session ran as the operator's account, which may become root without a
// password — so any agent there could become root without a person, and ADR 0259 §8 (an answer from the
// operator's phone authorises an act) rests on that being false where the router and its channels run. The
// decision: a node may name an account its agents run as, of their own and without sudo; the operator's
// account keeps its sudo.
//
// - **Named at the controller's terminal only** (`node agent-account`): not a verb, not a setting, so no
// agent can name itself another account. Empty is a real state: agents run as the operator there.
// - **Composed** as `${machine:agent-account}`, `${machine:agent-home}` and `${machine:agent-root}` for the
// agent's module, which declares the account with `root: never`, and as MESH_AGENT_ACCOUNT and
// MESH_AGENT_HOME for its tools (catalogue/machine_into_files.go, runtime.go).
// - **Judged by the machine itself.** The node-engine reads, on every look, whether an account declared
// `root: never` can become root without a person — uid 0, a group that grants root, a sudo rule, a
// secret of the mesh it may read — and says it as the declaring module's account verdict, marked
// Root "never". agentConfined reads that verdict; the self-check (probe DA) raises
// `agent-can-become-root` while it does not hold, and `node show` says it.
//
// A verdict not given is never a pass: an engine older than the judging, an account not yet declared, a
// statement that says nothing of it — each is "not judged", and fails.
import (
"context"
"fmt"
"sort"
"strings"
"time"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// kindAgentCanBecomeRoot is the condition raised while a machine's agent account can become root without
// a person, or is not judged (ADR 0266).
const kindAgentCanBecomeRoot = "agent-can-become-root"
// agentAccountProbe is the self-check's probe of it.
const agentAccountProbe = "DA"
// agentConfined says whether the agents of a machine that names an agent account are confined: the
// machine's newest statement holds a healthy account verdict, judged for root, on that account. named is
// false for a machine that names none — agents run as the operator account there, which this does not
// judge. why is said either way, in the mesh's words; err is a store that could not be read.
//
// The one judgement: `node show`, the self-check, and ADR 0259's router honouring a verified sender read
// it here.
// now is the judging clock, threaded so a caller judging several things at one instant judges them all at it.
func agentConfined(ctx context.Context, inv *inventory.Inventory, node string, now time.Time) (named, confined bool, why string, err error) {
n, err := inv.NodeByName(ctx, node)
if err != nil {
return false, false, "", err
}
if n.AgentAccount == "" {
return false, false, fmt.Sprintf("%s names no agent account: agents run as the operator account (%s)",
node, orNoneKnown(n.Account)), nil
}
h, had, err := inv.HealthOf(ctx, node)
if err != nil {
return true, false, "", err
}
confined, why = judgedConfined(n.AgentAccount, h, had, now)
return true, confined, why, nil
}
// verdictFreshFor is how old the statement holding the verdict may be, by this controller's clock. A
// node-engine states its health on every change and at least every five minutes (mesh-host's sayAnyway), so
// three statements missed is a node-engine stopped, or a machine away. **A stale verdict is not a pass**: an
// agent that stopped the node-engine must not leave "cannot become root" standing from before.
const verdictFreshFor = 15 * time.Minute
// judgedConfined is the judgement over one statement, without the store, at now.
func judgedConfined(agent string, h inventory.NodeHealth, had bool, now time.Time) (bool, string) {
if !had {
return false, fmt.Sprintf("the agent account %s is not judged: the machine's node-engine has stated "+
"nothing of what it runs", agent)
}
if age := now.Sub(h.HeardAt); age > verdictFreshFor {
return false, fmt.Sprintf("the agent account %s is not judged: the machine's newest statement was heard at "+
"%s, more than %d minutes ago, and a verdict that old is not a verdict on now", agent,
h.HeardAt.Local().Format("2006-01-02 15:04"), int(verdictFreshFor.Minutes()))
}
if h.Contract < link.RootContract {
return false, fmt.Sprintf("the agent account %s is not judged: the machine's node-engine is older than "+
"the judging of an account's root (its statement's contract is %d, the judging is %d)",
agent, h.Contract, link.RootContract)
}
var verdicts []inventory.ResourceHealth
for _, r := range h.Resources {
if r.Kind == link.KindAccount && r.Target == agent && r.Root == link.RootNever {
verdicts = append(verdicts, r)
}
}
if len(verdicts) == 0 {
return false, fmt.Sprintf("the agent account %s is not judged: the machine's newest statement holds no "+
"verdict on it — no module there declares it never to become root, or the declaration naming it "+
"has not been applied", agent)
}
sort.Slice(verdicts, func(i, j int) bool {
return verdicts[i].Module+verdicts[i].Resource < verdicts[j].Module+verdicts[j].Resource
})
for _, v := range verdicts {
switch v.State {
case link.StateHealthy:
case link.StateUnhealthy:
// The engine's own words, which start with link.ReasonRoot when it found a way to root.
return false, fmt.Sprintf("the agent account %s %s (said by %s's %s)", agent,
orNoneKnown(v.Reason), v.Module, v.Resource)
default:
return false, fmt.Sprintf("the agent account %s is not judged: %s (%s's %s, %s)", agent,
orNoneKnown(v.Reason), v.Module, v.Resource, v.State)
}
}
return true, fmt.Sprintf("the agent account %s cannot become root without a person (judged %s)", agent,
h.SaidAt.Local().Format("2006-01-02 15:04"))
}
// 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 longest a search is expected to take (link.RootSearchQuiet,
// the engine's own value; novox/hq issue 361 — the search runs to completion, with no bound of its own), counted
// from when this controller first saw it waiting, never from the engine's start. Quiet raises nothing; it never
// makes the agent account confined, which only a healthy verdict from a complete, fresh search does.
const searchQuietFor = link.RootSearchQuiet
// The kinds of an agent account's verdict, for the quiet a search earns.
const (
verdictOther = iota // anything else: a stale statement, an older engine, no verdict, another unknown
verdictPending // waiting for the search, and otherwise healthy
verdictComplete // judged: healthy, or a way to root found
)
// rootVerdictKind reads a statement for the agent account (novox/hq ADR 0266): pending when every verdict on it
// is healthy or not judged yet because the engine's setuid search runs, at least one of them that; complete when
// every verdict is healthy or one found a way to root. The engine's own "since" is not read: it starts again at
// every restart of the engine.
func rootVerdictKind(agent string, h inventory.NodeHealth, had bool, now time.Time) int {
if !had || now.Sub(h.HeardAt) > verdictFreshFor || h.Contract < link.RootContract {
return verdictOther
}
pending, any := false, false
for _, r := range h.Resources {
if r.Kind != link.KindAccount || r.Target != agent || r.Root != link.RootNever {
continue
}
any = true
switch {
case r.State == link.StateHealthy:
case r.State == link.StateUnhealthy:
return verdictComplete
case r.State == link.StateUnknown && strings.HasPrefix(r.Reason, link.ReasonRootPending):
pending = true
default:
return verdictOther
}
}
switch {
case !any:
return verdictOther
case pending:
return verdictPending
}
return verdictComplete
}
// searchStillRunning says the one thing keeping an agent account from being judged is the node-engine's setuid
// search, and that this controller first saw it waiting less than searchQuietFor ago — kept in the store, so a
// node-engine restarted in a loop does not keep it quiet. A complete verdict forgets when it began.
func searchStillRunning(ctx context.Context, inv *inventory.Inventory, node, agent string, h inventory.NodeHealth,
had bool, now time.Time) (bool, error) {
switch rootVerdictKind(agent, h, had, now) {
case verdictComplete:
return false, inv.RootSearchJudged(ctx, node)
case verdictPending:
since, err := inv.RootSearchPending(ctx, node, now)
if err != nil {
return false, err
}
return now.Sub(since) <= searchQuietFor, nil
}
return false, nil
}
// probeAgentAccounts is DA: every machine that names an agent account has it judged, on its node-engine's
// newest statement, unable to become root without a person (ADR 0266).
func probeAgentAccounts(ctx context.Context, d *doctor) ([]conditions.Observation, error) {
inv := d.open.inventory
nodes, err := inv.Nodes(ctx)
if err != nil {
return nil, err
}
var out []conditions.Observation
for _, n := range nodes {
if n.AgentAccount == "" {
continue
}
h, had, err := inv.HealthOf(ctx, n.Name)
if err != nil {
return nil, err
}
now := time.Now()
quiet, err := searchStillRunning(ctx, inv, n.Name, n.AgentAccount, h, had, now)
if err != nil {
return nil, err
}
confined, why := judgedConfined(n.AgentAccount, h, had, now)
if confined {
continue
}
// Not judged yet only because the node-engine's search runs and no complete, fresh one judges: not the
// urgent condition after every restart. The agent is still not confined — ADR 0259's router reads
// agentConfined, not this — and `node show` still says not judged. Loud again once the search fails,
// waits past searchQuietFor, or the statement goes stale.
if quiet {
continue
}
out = append(out, conditions.Observation{Scope: conditions.ScopeMachine, ID: n.Name, Token: "agent-root",
Machine: n.Name, Severity: conditions.Urgent,
Summary: fmt.Sprintf("on %s, %s (ADR 0266): an agent there may become root without a person, and "+
"no answer from a channel authorises an act there (ADR 0259 §8)", n.Name, why),
Said: why})
}
return sortedFound(out), nil
}
// agentAccountLines is what `node show` says of the account agents run as.
func agentAccountLines(ctx context.Context, inv *inventory.Inventory, n inventory.Node) []string {
if n.AgentAccount == "" {
return []string{fmt.Sprintf(" agents run as the operator account (%s); no agent account is named",
orNoneKnown(n.Account))}
}
_, confined, why, err := agentConfined(ctx, inv, n.Name, time.Now())
if err != nil {
return []string{fmt.Sprintf(" agents run as %s (home %s); whether it can become root could NOT be read: %v",
n.AgentAccount, n.AgentHome(), err)}
}
verdict := "CAN become root, or is not judged: " + why
if confined {
verdict = why
}
return []string{fmt.Sprintf(" agents run as %s (home %s)", n.AgentAccount, n.AgentHome()),
" " + verdict}
}
// orNoneKnown is a value, or that none is known.
func orNoneKnown(s string) string {
if strings.TrimSpace(s) == "" {
return "none known"
}
return s
}
-279
View File
@@ -1,279 +0,0 @@
package main
import (
"github.com/novox/mesh-host/rootsearch"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/conditions"
snapshot "github.com/novox/mesh-controller/internal/facts"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// The agent account's judgement (novox/hq ADR 0266): confined only on a healthy account verdict judged for
// root, on the very account; every verdict not given — no statement, an older engine, no verdict on it, a
// verdict of unknown — is "not judged" and fails, never a pass.
func TestAnAgentAccountIsConfinedOnlyOnAHealthyVerdictJudgedForRoot(t *testing.T) {
at := time.Date(2026, 10, 8, 19, 21, 0, 0, time.UTC)
verdict := func(target, root, state, reason string) inventory.ResourceHealth {
return inventory.ResourceHealth{Module: "claude-code", Resource: "claude-code.agent-account",
Kind: link.KindAccount, Target: target, State: state, Reason: reason, Root: root, Account: target}
}
statement := func(contract int, rs ...inventory.ResourceHealth) inventory.NodeHealth {
return inventory.NodeHealth{Node: "anchor", Contract: contract, SaidAt: at, HeardAt: at, Resources: rs}
}
for _, c := range []struct {
name string
h inventory.NodeHealth
had bool
confined bool
says string
}{
{"judged and unable", statement(link.RootContract, verdict("agent", link.RootNever, link.StateHealthy, "")),
true, true, "cannot become root without a person"},
{"judged and able", statement(link.RootContract, verdict("agent", link.RootNever, link.StateUnhealthy,
link.ReasonRoot+": in the group docker, which grants root")), true, false, "in the group docker"},
{"a verdict of unknown", statement(link.RootContract, verdict("agent", link.RootNever, link.StateUnknown,
"sudo could not be read")), true, false, "not judged"},
{"no statement", inventory.NodeHealth{}, false, false, "not judged"},
{"an older engine", statement(link.ReadinessContract, verdict("agent", "", link.StateHealthy, "")),
true, false, "older than the judging"},
{"a verdict on groups only", statement(link.RootContract, verdict("agent", "", link.StateHealthy, "")),
true, false, "no verdict on it"},
{"a verdict on another account", statement(link.RootContract, verdict("ops", link.RootNever, link.StateHealthy, "")),
true, false, "no verdict on it"},
} {
confined, why := judgedConfined("agent", c.h, c.had, at.Add(time.Minute))
if confined != c.confined || !strings.Contains(why, c.says) {
t.Errorf("%s: confined %v, %q; want %v saying %q", c.name, confined, why, c.confined, c.says)
}
}
// A verdict heard longer ago than the bound is no verdict: an agent that stopped the node-engine must not
// leave "healthy" standing.
fresh := statement(link.RootContract, verdict("agent", link.RootNever, link.StateHealthy, ""))
if ok, _ := judgedConfined("agent", fresh, true, at.Add(verdictFreshFor)); !ok {
t.Error("a verdict exactly at the bound is still one")
}
if ok, why := judgedConfined("agent", fresh, true, at.Add(verdictFreshFor+time.Second)); ok ||
!strings.Contains(why, "not judged") {
t.Errorf("a stale healthy verdict passed: %q", why)
}
}
// DA raises an urgent condition, with plain words, on a machine whose agent account is not judged unable to
// become root; a machine that names none is not its to judge.
func TestTheSelfCheckSaysAnAgentAccountThatCanBecomeRoot(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
for _, n := range []string{"anchor", "laptop"} {
if _, err := inv.NodeByName(ctx, n); err != nil {
if _, err := inv.AddNode(ctx, n); err != nil {
t.Fatal(err)
}
}
if err := inv.SetAccount(ctx, n, "ops", ""); err != nil {
t.Fatal(err)
}
}
if err := inv.SetAgentAccount(ctx, "anchor", "agent", ""); err != nil {
t.Fatal(err)
}
d := &doctor{open: open}
found, err := probeAgentAccounts(ctx, d)
if err != nil {
t.Fatal(err)
}
found = onlyMachine(found, "anchor")
if len(found) != 1 || found[0].Machine != "anchor" || found[0].Severity != conditions.Urgent ||
!strings.Contains(found[0].Said, "not judged") {
t.Fatalf("a named agent account with no verdict: %+v", found)
}
w := plainWordings[kindAgentCanBecomeRoot](conditions.Observation{Kind: kindAgentCanBecomeRoot, Machine: "anchor"})
if w.Headline == "" || w.Needs == "" || w.Resolved == "" {
t.Errorf("the condition has no plain words: %+v", w)
}
healthy := inventory.ResourceHealth{Module: "claude-code", Resource: "claude-code.agent-account",
Kind: link.KindAccount, Target: "agent", State: link.StateHealthy, Root: link.RootNever, Account: "agent"}
if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.RootContract,
SaidAt: time.Now(), HeardAt: time.Now(), Resources: []inventory.ResourceHealth{healthy}}); err != nil {
t.Fatal(err)
}
if found, err = probeAgentAccounts(ctx, d); err != nil || len(onlyMachine(found, "anchor")) != 0 {
t.Fatalf("a judged agent account still fails: %+v %v", found, err)
}
if named, confined, why, err := agentConfined(ctx, inv, "anchor", time.Now()); err != nil || !named || !confined {
t.Fatalf("agentConfined on anchor: %v %v %q %v", named, confined, why, err)
}
if named, _, why, err := agentConfined(ctx, inv, "laptop", time.Now()); err != nil || named ||
!strings.Contains(why, "operator account") {
t.Fatalf("agentConfined on a machine naming none: %v %q %v", named, why, err)
}
}
func TestTheAgentRootWordsArePlain(t *testing.T) {
w := plainWordings[kindAgentCanBecomeRoot](conditions.Observation{Kind: kindAgentCanBecomeRoot, Machine: "anchor"})
if why, ok := conditions.PlainWords(w, "anchor"); !ok {
t.Fatalf("not plain: %s: %+v", why, w)
}
}
func onlyMachine(obs []conditions.Observation, machine string) []conditions.Observation {
var out []conditions.Observation
for _, o := range obs {
if o.Machine == machine {
out = append(out, o)
}
}
return out
}
// The snapshot a merge check composes from carries the agent account as a pseudonym (novox/hq ADR 0266),
// so a change is judged against machines that name one, and the name never leaves.
func TestTheFactsCarryTheAgentAccountAsAPseudonym(t *testing.T) {
open, _ := aMeshWithSecrets(t)
ctx := t.Context()
if err := open.inventory.SetAccount(ctx, "anchor", "keeper", ""); err != nil {
t.Fatal(err)
}
if err := open.inventory.SetAgentAccount(ctx, "anchor", "warden", ""); err != nil {
t.Fatal(err)
}
f, err := gatherFacts(ctx, open, "2.11.17")
if err != nil {
t.Fatal(err)
}
body, _ := f.Encode()
if strings.Contains(string(body), "warden") {
t.Error("the agent account's name is in the snapshot")
}
m, ok := f.Machine(snapshot.Pseudonym("machine", "anchor"))
if !ok || m.AgentAccount != snapshot.Pseudonym("account", "warden") || m.Account == m.AgentAccount {
t.Fatalf("the anchor's agent account reads as %q (operator %q)", m.AgentAccount, m.Account)
}
}
// No verb runs a `node` command that sets something: through the generic `command` verb, `node account`,
// `node agent-account` and every other `node` subcommand but list and show are refused, naming the terminal.
func TestNoVerbSetsANodesAccounts(t *testing.T) {
for _, line := range []string{
"node agent-account novox --clear",
"node agent-account novox ops",
"node account novox agent",
"node account novox",
"node add intruder",
"node public-domain novox --clear",
"node",
"node frobnicate",
} {
argv, err := argvFor("command", map[string]any{"command": line})
if err == nil || !strings.Contains(err.Error(), "controller's terminal") ||
!strings.Contains(err.Error(), "ADR 0266") {
t.Errorf("%q ran as %v (%v); want a refusal naming the terminal", line, argv, err)
}
}
for _, line := range []string{"node show novox", "node list --json", "status --json"} {
if _, err := argvFor("command", map[string]any{"command": line}); err != nil {
t.Errorf("%q, a read, was refused: %v", line, err)
}
}
if err := terminalOnly([]string{"node", "account", "a", "b"}); err == nil {
t.Error("the refusal is not only the command verb's")
}
}
// Naming the agent account is the controller's terminal's alone: the `node` verb only shows.
func TestTheNodeVerbOnlyShows(t *testing.T) {
argv, err := argvFor("node", map[string]any{"node": "anchor"})
if err != nil || strings.Join(argv, " ") != "node show anchor" {
t.Fatalf("the node verb runs %v (%v)", argv, err)
}
for _, v := range catalogue.ControllerVerbs {
if strings.Contains(v.Name, "agent") {
t.Errorf("a verb %q may name the agent account", v.Name)
}
}
}
// Until a complete search for setuid programs judges — none since the node-engine's state was kept, or the last
// older than the engine lets one judge — the agent account is not judged, and the search runs to its end with no
// bound (novox/hq issue 361). DA does not raise that as urgent within searchQuietFor, counted from when this
// controller first saw it waiting — never from the engine's own "since", which a restart resets, so an engine
// restarted in a loop does not keep it quiet. The account is still not confined, and `node show` still says not
// judged; a search that failed, or a way to root found, is urgent at once.
func TestASearchStillRunningAfterARestartIsNotUrgent(t *testing.T) {
if searchQuietFor != rootsearch.Quiet {
t.Fatalf("the quiet is %s and the node-engine's %s: they are one value", searchQuietFor, rootsearch.Quiet)
}
// A daily search that finishes within the quiet never leaves the account unjudged between two of them.
if rootsearch.FreshFor < rootsearch.Every+rootsearch.Quiet {
t.Fatalf("a complete search judges for %s, less than a day's search (%s) and the quiet (%s)",
rootsearch.FreshFor, rootsearch.Every, rootsearch.Quiet)
}
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
if _, err := inv.NodeByName(ctx, "anchor"); err != nil {
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
t.Fatal(err)
}
}
if err := inv.SetAgentAccount(ctx, "anchor", "agent", ""); err != nil {
t.Fatal(err)
}
d := &doctor{open: open}
say := func(state, reason string) []conditions.Observation {
t.Helper()
// The engine's since is always now: it was just restarted.
v := inventory.ResourceHealth{Module: "claude-code", Resource: "claude-code.agent-account",
Kind: link.KindAccount, Target: "agent", State: state, Reason: reason, Root: link.RootNever,
Account: "agent", Since: time.Now()}
if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.RootContract,
SaidAt: time.Now(), HeardAt: time.Now(), Resources: []inventory.ResourceHealth{v}}); err != nil {
t.Fatal(err)
}
found, err := probeAgentAccounts(ctx, d)
if err != nil {
t.Fatal(err)
}
return onlyMachine(found, "anchor")
}
running := link.ReasonRootPending + ": the search for setuid programs, started at 19:21, has not finished yet"
healthy := func() {
t.Helper()
if found := say(link.StateHealthy, ""); len(found) != 0 {
t.Fatalf("a healthy verdict raised: %+v", found)
}
}
if found := say(link.StateUnknown, running); len(found) != 0 {
t.Fatalf("a search first seen now was raised: %+v", found)
}
if _, confined, why, _ := agentConfined(ctx, inv, "anchor", time.Now()); confined || !strings.Contains(why, "not judged") {
t.Fatalf("an account whose search runs was read as confined: %q", why)
}
// The engine restarted again and again, each statement's own since fresh: the controller's clock runs on.
if _, err := inv.RootSearchPending(ctx, "anchor", time.Now().Add(-searchQuietFor-time.Minute)); err != nil {
t.Fatal(err)
}
healthy() // a complete verdict forgets when the waiting began …
if _, err := inv.RootSearchPending(ctx, "anchor", time.Now().Add(-searchQuietFor-time.Minute)); err != nil {
t.Fatal(err) // … and this restart loop began past the bound
}
if found := say(link.StateUnknown, running); len(found) != 1 || found[0].Severity != conditions.Urgent {
t.Fatalf("a search pending past the bound, by the controller's clock, was not urgent: %+v", found)
}
healthy()
incomplete := rootsearch.ReasonIncomplete + ": the search for setuid programs did not finish (last tried at " +
"19:23: timeout); it is tried again later"
if found := say(link.StateUnknown, incomplete); len(found) != 1 || found[0].Severity != conditions.Urgent {
t.Fatalf("a search that did not finish was not urgent: %+v", found)
}
if found := say(link.StateUnhealthy, link.ReasonRoot+": in the group docker; "+running); len(found) != 1 ||
found[0].Severity != conditions.Urgent {
t.Fatalf("a way to root found while the search runs was not urgent: %+v", found)
}
}
-937
View File
@@ -1,937 +0,0 @@
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")
-151
View File
@@ -1,151 +0,0 @@
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)
}
}
-810
View File
@@ -1,810 +0,0 @@
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)
}
}
-339
View File
@@ -1,339 +0,0 @@
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
-902
View File
@@ -1,902 +0,0 @@
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)
}
}
-103
View File
@@ -1,103 +0,0 @@
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
}
+7 -104
View File
@@ -160,16 +160,9 @@ func buildFrom(result link.BuildResult) inventory.Build {
for _, ref := range result.Against {
kept.Against = append(kept.Against, catalogue.Recorded(ref))
}
for _, ref := range result.Mirrored {
kept.Mirrored = append(kept.Mirrored, catalogue.Recorded(ref))
}
for _, r := range result.Read {
kept.Read = append(kept.Read, inventory.ReadRepository{Repository: r.Repository, Ref: r.Ref})
}
// What it was made from, as files (novox/hq ADR 0267): the planner maps the next merge onto it.
for _, s := range result.Sources {
kept.Sources = append(kept.Sources, inventory.BuildSource{Repository: s.Repository, Ref: s.Ref, Paths: s.Paths})
}
var announced []inventory.Artifact
for _, made := range result.Made {
announced = append(announced, inventory.Artifact{
@@ -403,49 +396,15 @@ func buildBehind(ctx context.Context, wait time.Duration) error {
//
// Separated from the command so `--behind` can walk a list without a second path to the same act.
func buildOne(ctx context.Context, source buildSource, path, ref string, wait time.Duration) error {
_, err := buildOneAsked(ctx, source, path, ref, wait, false, "build")
_, err := buildOneAsked(ctx, source, path, ref, wait, false)
return err
}
// recordAsked keeps a build request once it is asked (novox/hq issue 325), with who asked it, in a store
// opened for the purpose: the asks reach here from processes that hold none.
func recordAsked(ctx context.Context, r inventory.BuildRequest) {
open, err := openStores(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "build %s is asked and not kept as a build request: %v\n", r.ID, err)
return
}
defer open.Close()
recordBuildRequest(ctx, open.inventory, r)
}
// markWaitFailed says what became of a kept build request whose waited ask failed: never handed over, so
// nothing runs; or handed over and no longer waited for, so asked with its outcome unknown — still read as in
// flight until its outcome or its bound (novox/hq issue 325).
func markWaitFailed(ctx context.Context, id string, why error) {
open, err := openStores(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "build %s could not be marked: %v\n", id, err)
return
}
defer open.Close()
mark := open.inventory.MarkOutcomeUnknown
if errors.Is(why, link.ErrNotHandedOver) {
mark = open.inventory.MarkNotAsked
}
if err := mark(ctx, id, why.Error()); err != nil {
fmt.Fprintf(os.Stderr, "build %s could not be marked: %v\n", id, err)
}
}
// buildOneAsked is buildOne answering the id it asked with — what a plan keeps to match the outcome
// by (novox/hq ADR 0219) — and, for an ask not waited for, optionally a dry run: built and looked
// at, never taken in (issue 240), which is what `replay` asks unless told to register.
//
// asker is who asked, for the build request kept once the ask is made (novox/hq issue 325); empty for an
// asker that keeps the request itself, with its own name, once it knows the ask was made.
func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wait time.Duration,
dryRun bool, asker string) (string, error) {
dryRun bool) (string, error) {
if dryRun && wait != 0 {
return "", errors.New("a dry run waited for is `build --dry-run`")
}
@@ -455,10 +414,6 @@ func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wa
if err != nil {
return "", err
}
// Through a verb, only a repository the catalogue builds from (novox/hq ADR 0266).
if err := verbMayAsk(ctx, source, repository); err != nil {
return "", err
}
ident, err := openIdentity(ctx)
if err != nil {
@@ -504,14 +459,6 @@ func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wa
}
defer ask.Close()
fmt.Printf(" of %s\n", seat)
// Kept once it is asked, so `assign` knows a module is coming while its build runs (novox/hq issue 325);
// never before, so an ask that failed never reads as a build in flight. A dry run registers nothing, and
// is not kept.
keep := !dryRun && asker != ""
// Asked at the terminal is what lets its outcome register a module from a repository the catalogue does
// not build it from (novox/hq ADR 0266); never through a verb.
asked := inventory.BuildRequest{ID: request.ID, Repository: source.Repository, Seat: source.Seat, Path: path,
Ref: ref, For: asker, AtTerminal: startedAtTheTerminal()}
if wait == 0 {
// Asked and not waited for (novox/hq issue 176): the outcome is the role's event, and the
@@ -521,9 +468,6 @@ func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wa
if err := ask.Ask(ctx, request); err != nil {
return "", err
}
if keep {
recordAsked(ctx, asked)
}
if dryRun {
fmt.Printf("asked as a dry run, not waited for: `builds --log %s` follows it as it runs; "+
"its outcome is not taken in\n", request.ID)
@@ -534,16 +478,8 @@ func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wa
return request.ID, nil
}
// Waited for: kept while it is waited for, since that can take minutes, and marked when the hand-over
// failed (not asked) or the wait did (asked, outcome unknown) — an outcome heard later is the last word.
if keep {
recordAsked(ctx, asked)
}
result, err := ask.Submit(ctx, request, wait)
if err != nil {
if keep {
markWaitFailed(ctx, request.ID, err)
}
return request.ID, err
}
@@ -553,10 +489,6 @@ func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wa
}
defer open.Close()
manifest, kept, err := takeIn(ctx, open.inventory, result)
// The assignments pending on this build, made or ended (novox/hq issue 325).
for _, line := range settlePendingOnBuild(ctx, open, result, manifest.Module, err) {
fmt.Printf(" %s\n", line)
}
if err != nil {
return request.ID, err
}
@@ -641,21 +573,6 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
return manifest, kept, fmt.Errorf("%s built %s (%s), and the mesh does not register it: %w",
result.On, result.Repository, short(result.Commit), err)
}
// **Only from the repository the catalogue builds the module from** (novox/hq ADR 0266): else the trunk
// below is the trunk of whatever repository was built, which may be one an agent made — and a module named
// `sudo` from it would be what the next push sends. Another repository is the operator's, at the terminal.
trunk := result.Trunk
if was, err := inv.SourceOf(ctx, manifest.Module); err == nil && followedBranch(was.Ref) != "" {
trunk = followedBranch(was.Ref)
}
if trunk == "" {
trunk = "main"
}
repoID, err := mayRegisterFrom(ctx, inv, manifest.Module, recorded, result.ID, result.Path, trunk)
if err != nil {
return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", result.On,
manifest.Module, short(result.Commit), err)
}
// **Only a commit on the trunk is published** (novox/hq ADR 0238): a commit off its repository's
// default branch — a pull request's head, a feature branch built by hand, a `rebuild` or `replay
// --register` of one — is for checking, and is never a module's version; nothing could then send it.
@@ -703,10 +620,6 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
}
return manifest, kept, err
}
// Which repository it is registered from, by the forge's own id (novox/hq ADR 0266).
if err := inv.SetSourceIdentity(ctx, manifest.Module, repoID); err != nil {
return manifest, kept, err
}
// The keep set just moved, and new bytes just landed (novox/hq ADR 0189). Asked here rather
// than on a timer of its own: this is the only moment either is true. Never fatal — the build
// worked and the module is registered.
@@ -748,9 +661,6 @@ func buildAndShow(ctx context.Context, source buildSource, path, ref string, wai
if err != nil {
return err
}
if err := verbMayAsk(ctx, source, repository); err != nil {
return err
}
ident, err := openIdentity(ctx)
if err != nil {
return err
@@ -810,8 +720,6 @@ type answers struct {
plans []inventory.Plan
// paused is whether the build seat takes work, which a plan waiting on it says (ADR 0219).
paused pauseView
// tierBounds is how long each repository's plan tier may take before it is late (novox/hq issue 296).
tierBounds tierBounds
// refused is why a machine cannot be worked out at all, by name. A different thing from every
// other answer here: those are about a machine that was told something, and this is about one
// that cannot be told anything — it never reaches waiting, because nothing was computed for it
@@ -858,11 +766,6 @@ type answers struct {
// handed to the operator; healsUnread why it could not be read.
heals *healsCount
healsUnread string
// pending is every assignment waiting for its module's build, and every one ended in the last day
// (novox/hq issue 325); pendingUnread why they could not be read. One waiting, or one ended other than
// applied, is not well: an assignment somebody made is not made yet, or will not be.
pending []inventory.PendingAssignment
pendingUnread string
}
// heldBy is every artifact this mesh has built, for a build that may need one as its base.
@@ -911,10 +814,6 @@ func heldBy(ctx context.Context) map[string]string {
// the mesh runs on today this needs the controller's own connection, so it is handed one; on the bus
// being built it dials, because a build request is a one-shot and holds nothing else.
func askOverOn(seat string) (link.Builders, error) {
// The serving controller asks on its own connection (novox/hq issue 327).
if serving := servingBus.Load(); serving != nil {
return link.BuildsOn(serving, seat), nil
}
address, err := broker.BusAddress()
if err != nil {
return nil, err
@@ -973,7 +872,11 @@ func buildSeatAmong(entries []inventory.Entry) string {
// with a consumer of its own that is gone when this returns, so nothing accumulates in the server
// for the reading, and filtered by subject, so one build's lines are all that travel.
func buildLog(ctx context.Context, id string) error {
js, err := aBus()
address, err := broker.BusAddress()
if err != nil {
return err
}
js, err := broker.Dial(address)
if err != nil {
return fmt.Errorf("cannot reach the bus to read a build's log: %w", err)
}
-458
View File
@@ -1,458 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"regexp"
"strings"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// Where a build may register a module from (novox/hq ADR 0266).
//
// A build's outcome registers its module, and a registered module is what the next push sends. Before this,
// an outcome registered whatever its manifest named, from whichever repository it was built from: an agent
// that made a repository of its own, committed `modules/sudo/module.json` granting itself a rule without a
// password, and asked the `build` verb for it had its repository registered as the module `sudo` — whose next
// push made the agent root on every node. A fork of the node-engine did the same everywhere. The trunk rule
// (ADR 0238) did not stop it: the trunk it checked was the trunk of the repository built, which was the
// agent's own.
//
// So **an outcome registers a module only from the repository the catalogue already builds that module
// from**; and a module new to the catalogue only from a repository the catalogue already builds another
// module from — whose trunk takes a reviewed, approved merge, which is how a merge adds a module (novox/hq
// issue 300). Anything else — a module moved to another repository, a module from a repository the
// catalogue has never built — is the operator's, at the controller's terminal: allowed only when the build
// request was kept as asked there. Judged at the take-in, which every outcome reaches whoever hears it and
// whichever verb asked it (`build`, `rebuild`, `replay --register`, `assign` with build), and before the ask
// for a call through a verb, so an agent cannot have a build node run a repository the catalogue does not
// build from at all.
// errNotItsSource is an outcome refused for where it was built from.
var errNotItsSource = errors.New("not built from the repository the catalogue builds it from")
// servedVar marks every process the serving controller starts — each verb's command, each child — and the serving
// process itself, so none of them can read as the operator at the terminal (novox/hq ADR 0266). Set by serve
// before it answers anything, inherited by every child through os.Environ.
const servedVar = "MESH_SERVED_BY_THE_CONTROLLER"
// startedAtTheTerminal says this process was started at the controller's terminal: not the serving controller,
// not anything it started, not a verb's command, not a seat call's. The serving controller marks its own
// environment (servedVar), so a build asked in it, or by any process it starts, never reads as the terminal's;
// runVerb also names the verb and the caller.
//
// **And a line mesh-cli asked as the controller's terminal** (novox/hq ADR 0272 §4): the serving controller runs it
// without the served mark and without a verb, names its caller, and marks it with cliTerminalVar — a mark only the
// mesh-cli path sets and every other command line the controller runs is stripped of (commandEnvironment).
func startedAtTheTerminal() bool {
if os.Getenv(servedVar) != "" || os.Getenv(verbVar) != "" {
return false
}
return os.Getenv(link.CallerVar) == "" || os.Getenv(cliTerminalVar) != ""
}
// cliTerminalVar marks a command line the serving controller runs as the controller's terminal for mesh-cli.
const cliTerminalVar = "MESH_CLI_TERMINAL"
// markServed marks this process, and so everything it starts, as the serving controller's.
func markServed() {
if err := os.Setenv(servedVar, "1"); err != nil {
panic("the serving controller could not mark its environment: " + err.Error())
}
}
// sourceForms compares sources however each is spelled: a path on a seat's holder (ADR 0111) or a URL — a
// build asked of a seat's path is registered with the URL composed from it when its outcome does not echo
// the seat. A seat's path is composed into its URL where the seat's holder is known, so both spellings of
// one repository are one; where it is not, a seat's path matches only the same seat's same path.
type sourceForms struct {
bases map[string]string // the seat's clone base, `scheme://host:port`, by seat; "" where it is not known
base func(seat string) string
}
// newSourceForms reads the seats' bases from the mesh once, when first needed.
func newSourceForms(ctx context.Context, inv *inventory.Inventory) *sourceForms {
f := &sourceForms{bases: map[string]string{}}
var world *catalogue.World
f.base = func(seat string) string {
if b, known := f.bases[seat]; known {
return b
}
if world == nil {
w := catalogue.World{}
if shelf, err := inv.Catalogue(ctx); err == nil {
if read, err := theRestOfTheMesh(ctx, inv, shelf, ""); err == nil {
w = read
}
}
world = &w
}
b, err := seatBase(*world, seat)
if err != nil {
b = ""
}
f.bases[seat] = b
return b
}
return f
}
// canonical is one spelling of a repository: lower case, no `.git`, no trailing slash, and a seat's path as
// the URL its holder serves it at where that is known.
func (f *sourceForms) canonical(repository, seat string) string {
trim := func(s string) string {
s = strings.TrimSpace(strings.ToLower(s))
s = strings.TrimRight(s, "/")
return strings.TrimRight(strings.TrimSuffix(s, ".git"), "/")
}
if seat == "" {
return trim(repository)
}
if b := f.base(seat); b != "" {
return trim(b + "/" + strings.Trim(repository, "/"))
}
return "seat:" + seat + ":" + trim(strings.Trim(repository, "/"))
}
// same says two sources are one repository.
func (f *sourceForms) same(aRepository, aSeat, bRepository, bSeat string) bool {
if aRepository == "" || bRepository == "" {
return false
}
return f.canonical(aRepository, aSeat) == f.canonical(bRepository, bSeat)
}
// buildsFrom says the catalogue builds some module from this repository.
func (f *sourceForms) buildsFrom(entries []inventory.Entry, repository, seat string) bool {
for _, e := range entries {
if e.Provided || e.Source.Repository == "" {
continue
}
if f.same(e.Source.Repository, e.Source.Seat, repository, seat) {
return true
}
}
return false
}
// forgeFacts is what the mesh's forge says of a repository a module is registered from (novox/hq ADR 0266).
type forgeFacts struct {
// ID is the forge's own id of the repository: what tells it from one deleted and made again by its name.
ID int64
// Guarded is whether the branch is protected as a module's trunk must be: no direct push, at least one
// required status, and no administrator merging past one. Why says what is missing when it is not.
Guarded bool
Why string
}
// askTheForge asks the forge, through its module's tools on the bus, for a repository's id and its branch's
// protection. A variable so a test needs no forge.
var askTheForge = func(ctx context.Context, owner, repo, branch string) (forgeFacts, error) {
js, err := aBus()
if err != nil {
return forgeFacts{}, err
}
defer js.Close()
bus := link.OverNATS{Conn: js.Conn()}
ask := func(tool string, args map[string]any, into any) error {
raw, _ := json.Marshal(args)
answer, err := link.Ask(ctx, bus, "gitea", tool, raw, 30*time.Second)
if err != nil {
return err
}
if answer.Error != "" {
return fmt.Errorf("gitea.%s: %s", tool, answer.Error)
}
return json.Unmarshal(answer.Result, into)
}
var found struct {
Result struct {
ID int64 `json:"id"`
FullName string `json:"full_name"`
} `json:"result"`
}
if err := ask("gitea_api", map[string]any{"path": "/repos/" + owner + "/" + repo}, &found); err != nil {
return forgeFacts{}, err
}
if found.Result.ID == 0 {
return forgeFacts{}, fmt.Errorf("the forge named no id for %s/%s", owner, repo)
}
var rules struct {
Rules []protectionRule `json:"rules"`
}
if err := ask("gitea_branch_protection_get", map[string]any{"owner": owner, "repo": repo}, &rules); err != nil {
return forgeFacts{}, err
}
facts := judgedRule(rules.Rules, branch)
facts.ID = found.Result.ID
return facts, nil
}
// protectionRule is a branch protection rule as the forge's tool summarises it.
type protectionRule struct {
Rule string `json:"rule"`
Push bool `json:"push"`
RequiredStatuses []string `json:"required_statuses"`
AdminMayOverride bool `json:"admin_may_override"`
}
// judgedRule judges the one rule the forge applies to a branch, as the forge picks it: the rule named for the
// branch, else the first glob rule, in the forge's order, that covers it (gitea's first matching rule). Never a
// later rule that happens to be stronger: the forge does not read it.
func judgedRule(rules []protectionRule, branch string) forgeFacts {
var applied *protectionRule
for i := range rules {
if rules[i].Rule == branch {
applied = &rules[i]
break
}
}
if applied == nil {
for i := range rules {
if ruleCovers(rules[i].Rule, branch) {
applied = &rules[i]
break
}
}
}
switch r := applied; {
case r == nil:
return forgeFacts{Why: fmt.Sprintf("no protection rule covers %s", branch)}
case r.Push:
return forgeFacts{Why: fmt.Sprintf("the rule %s lets a person push to %s directly", r.Rule, branch)}
case len(r.RequiredStatuses) == 0:
return forgeFacts{Why: fmt.Sprintf("the rule %s requires no status before a merge into %s", r.Rule, branch)}
case r.AdminMayOverride:
return forgeFacts{Why: fmt.Sprintf("the rule %s lets an administrator merge into %s past a status", r.Rule, branch)}
}
return forgeFacts{Guarded: true}
}
// ruleCovers says a protection rule's name — a branch, or a glob of them — covers a branch, as the forge reads it.
func ruleCovers(rule, branch string) bool {
if rule == branch {
return true
}
if !strings.ContainsAny(rule, "*?[") {
return false
}
var re strings.Builder
re.WriteString("^")
for i := 0; i < len(rule); i++ {
switch c := rule[i]; {
case c == '*' && i+1 < len(rule) && rule[i+1] == '*':
re.WriteString(".*")
i++
case c == '*':
re.WriteString("[^/]*")
case c == '?':
re.WriteString("[^/]")
default:
re.WriteString(regexp.QuoteMeta(string(c)))
}
}
re.WriteString("$")
ok, _ := regexp.MatchString(re.String(), branch)
return ok
}
// onTheForge is a source's owner and name on the mesh's own forge (the git seat's holder), and whether it is
// there at all.
func (f *sourceForms) onTheForge(repository, seat string) (owner, name string, ok bool) {
var rest string
switch {
case seat == gitSeat:
rest = strings.Trim(repository, "/")
case seat == "":
base := f.base(gitSeat)
if base == "" {
return "", "", false
}
url, prefix := f.canonical(repository, ""), f.canonical(base, "")+"/"
if !strings.HasPrefix(url, prefix) {
return "", "", false
}
// The case the forge spells it with: the URL as given, past the base.
rest = strings.TrimSuffix(strings.Trim(repository[len(prefix):], "/"), ".git")
default:
return "", "", false
}
parts := strings.Split(rest, "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", false
}
return parts[0], parts[1], true
}
// mayRegisterFrom says whether a build's outcome may register module from the source it was built from, and the
// forge's id of that repository to record (novox/hq ADR 0266). Through any verb, only:
//
// - from the module's registered repository — the same repository by the forge's own id, not only its name; or,
// for a module new to the catalogue, from a repository the catalogue builds another module from;
// - and from a repository on the mesh's forge whose trunk is protected as a trunk must be: no direct push, at
// least one required status, no administrator merging past one.
//
// Anything else only when the build request was kept as asked at the controller's terminal, for this very
// repository and path. path is the module's directory as built; branch the trunk it is registered from.
func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module string, built inventory.Source,
buildID, path, branch string) (int64, error) {
forms := newSourceForms(ctx, inv)
was, err := inv.SourceOf(ctx, module)
isNew := errors.Is(err, inventory.ErrNoSuchModule)
if err != nil && !isNew {
return 0, err
}
terminal, err := askedHereFor(ctx, inv, forms, buildID, built, path)
if err != nil {
return 0, err
}
refuse := func(why string) (int64, error) {
return 0, fmt.Errorf("%w: %s. A module is registered from such a source only by a build asked at the "+
"controller's terminal, never through a verb: a registered module is what the next push sends, and whoever "+
"may call a verb includes agents (novox/hq ADR 0266)", errNotItsSource, why)
}
var why string
var alongside []inventory.Entry // the modules a new one's repository already builds
switch {
case !isNew && forms.same(was.Repository, was.Seat, built.Repository, built.Seat):
case !isNew:
registered := was.Repository
if registered == "" {
registered = "no repository (it was handed over by hand)"
}
why = fmt.Sprintf("%s is built from %s, and this build is of %s", module, sourceWords(registered, was.Seat),
sourceWords(built.Repository, built.Seat))
default:
entries, err := inv.Catalogued(ctx)
if err != nil {
return 0, err
}
for _, e := range entries {
if !e.Provided && e.Source.Repository != "" &&
forms.same(e.Source.Repository, e.Source.Seat, built.Repository, built.Seat) {
alongside = append(alongside, e)
}
}
if len(alongside) == 0 {
why = fmt.Sprintf("%s is new to the catalogue, and %s is no repository the catalogue builds a module from",
module, sourceWords(built.Repository, built.Seat))
}
}
if why != "" && !terminal {
return refuse(why)
}
owner, name, onForge := forms.onTheForge(built.Repository, built.Seat)
if !onForge {
if terminal {
fmt.Printf("%s: %s is not on the mesh's forge — registered, as asked at the controller's terminal\n",
buildID, sourceWords(built.Repository, built.Seat))
return 0, nil
}
return refuse(fmt.Sprintf("%s is not on the mesh's forge, so whether its trunk is protected cannot be read",
sourceWords(built.Repository, built.Seat)))
}
facts, err := askTheForge(ctx, owner, name, branch)
if err != nil {
if terminal {
fmt.Printf("%s: the forge could not be asked about %s/%s (%v) — registered, as asked at the controller's "+
"terminal\n", buildID, owner, name, err)
return 0, nil
}
return refuse(fmt.Sprintf("the forge could not say whether %s/%s's %s is protected: %v", owner, name, branch, err))
}
if terminal {
if why != "" || !facts.Guarded {
fmt.Printf("%s: %s — registered, as asked at the controller's terminal\n", buildID,
strings.Trim(why+"; "+facts.Why, "; "))
}
return facts.ID, nil
}
if !facts.Guarded {
return refuse(fmt.Sprintf("%s/%s's %s is not protected as a module's trunk must be: %s", owner, name, branch,
facts.Why))
}
// The same repository by the forge's id, not only its name: one deleted and made again is another.
recorded := map[string]int64{}
if !isNew {
id, err := inv.SourceIdentity(ctx, module)
if err != nil {
return 0, err
}
recorded[module] = id
}
for _, e := range alongside {
id, err := inv.SourceIdentity(ctx, e.Manifest.Module)
if err != nil {
return 0, err
}
recorded[e.Manifest.Module] = id
}
for m, id := range recorded {
if id != 0 && id != facts.ID {
return refuse(fmt.Sprintf("%s/%s is not the repository %s was registered from: the forge knows it as "+
"repository %d, and %s was registered from repository %d — one of that name deleted and made again",
owner, name, m, facts.ID, m, id))
}
}
return facts.ID, nil
}
// askedHereFor says the build was asked at the controller's terminal, for this repository and this path: a kept
// request marked so, whose source is the outcome's (novox/hq ADR 0266).
func askedHereFor(ctx context.Context, inv *inventory.Inventory, forms *sourceForms, buildID string,
built inventory.Source, path string) (bool, error) {
r, found, err := inv.BuildRequestByID(ctx, buildID)
if err != nil || !found || !r.AtTerminal {
return false, err
}
if !forms.same(r.Repository, r.Seat, built.Repository, built.Seat) ||
strings.Trim(r.Path, "/") != strings.Trim(path, "/") {
fmt.Printf("%s was asked at the terminal of %s at %q, and its outcome is of %s at %q: not the terminal's\n",
buildID, sourceWords(r.Repository, r.Seat), r.Path, sourceWords(built.Repository, built.Seat), path)
return false, nil
}
return true, nil
}
// sourceWords is a source as a person reads it.
func sourceWords(repository, seat string) string {
if seat == "" {
return repository
}
return buildSource{Repository: repository, Seat: seat}.String()
}
// verbMayAsk refuses, for a call through a verb, a build of a repository the catalogue builds no module from
// (novox/hq ADR 0266): the build node would run what an agent wrote, and its outcome could never be
// registered anyway. url is the repository as it is cloned. At the terminal anything may be asked.
func verbMayAsk(ctx context.Context, source buildSource, url string) error {
if startedAtTheTerminal() {
return nil
}
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
entries, err := open.inventory.Catalogued(ctx)
if err != nil {
return err
}
forms := newSourceForms(ctx, open.inventory)
if forms.buildsFrom(entries, source.Repository, source.Seat) || forms.buildsFrom(entries, url, "") {
return nil
}
return terminalRefusal("%s is no repository the catalogue builds a module from, and a build of any other is "+
"asked at the controller's terminal only, never through a verb: a build node runs what the repository "+
"says, and its outcome would register a module the next push sends — whoever may call a verb includes "+
"agents (novox/hq ADR 0266). Nothing was asked", source)
}
-384
View File
@@ -1,384 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"os"
"os/exec"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// The routes to root a review of ADR 0266 found (novox/hq ADR 0266 §7): an agent makes a repository of its
// own — or forks one the mesh builds from — commits a module.json naming a module the mesh runs everywhere
// (`sudo`, granting itself a rule without a password; `mesh-host`, the node-engine), and asks the `build` verb
// for it. Its outcome was registered under that name from the agent's repository, and the next push sent it.
// onTrunk is an outcome of a commit on its repository's trunk, as the build seat says it.
func onTrunk(id, repository, seat, path string, manifest map[string]any) link.BuildResult {
raw, _ := json.Marshal(manifest)
r := link.BuildResult{ID: id, Repository: "http://forge.internal:20000/" + repository + ".git", Path: path,
Ref: "main", On: "anchor", Commit: "c0ffee0123456789", Manifest: raw,
Trunk: "main", OnTrunk: true, Branches: []string{"main"}}
if seat != "" {
r.Source = &link.SourceOnSeat{Seat: seat, Repository: repository}
}
return r
}
// keptAsked keeps a build request as the asker would: through a verb, or at the terminal.
func keptAsked(t *testing.T, inv *inventory.Inventory, id, repository, path string, atTerminal bool) {
t.Helper()
if err := inv.RecordBuildRequest(t.Context(), inventory.BuildRequest{ID: id, Repository: repository, Seat: "git",
Path: path, For: "build", AtTerminal: atTerminal}); err != nil {
t.Fatal(err)
}
}
// theCatalogue is a mesh whose sudo is built from the catalogue repository and whose node-engine from its own.
func theCatalogue(t *testing.T) *stores {
t.Helper()
open := aMesh(t)
ctx := t.Context()
for _, b := range []link.BuildResult{
onTrunk("build-sudo", "novox/mesh-catalog", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "1"}),
onTrunk("build-host", "novox/mesh-host", "git", "", map[string]any{"module": "mesh-host", "version": "1"}),
} {
keptAsked(t, open.inventory, b.ID, b.Source.Repository, b.Path, true)
if _, _, err := takeIn(ctx, open.inventory, b); err != nil {
t.Fatal(err)
}
}
return open
}
func TestABuildFromAnAgentsRepositoryIsNotRegisteredAsAModuleTheMeshHolds(t *testing.T) {
open := theCatalogue(t)
ctx := t.Context()
for _, c := range []struct {
name, repository, path, module string
}{
{"its own repository naming sudo", "agent/sudo", "modules/sudo", "sudo"},
{"a fork of the catalogue", "agent/mesh-catalog", "modules/sudo", "sudo"},
{"a fork of the node-engine", "agent/mesh-host", "", "mesh-host"},
} {
t.Run(c.name, func(t *testing.T) {
id := "build-" + strings.ReplaceAll(c.repository, "/", "-")
keptAsked(t, open.inventory, id, c.repository, c.path, false) // through the build verb
evil := onTrunk(id, c.repository, "git", c.path, map[string]any{"module": c.module, "version": "evil"})
_, _, err := takeIn(ctx, open.inventory, evil)
if !errors.Is(err, errNotItsSource) {
t.Fatalf("a build of %s was taken in as %s: %v", c.repository, c.module, err)
}
shelf, err := open.inventory.Catalogue(ctx)
if err != nil {
t.Fatal(err)
}
if got := shelf[c.module].Version; got != "1" {
t.Fatalf("%s is now %q, from %s", c.module, got, c.repository)
}
if _, found, _ := open.inventory.BuildByID(ctx, id); !found {
t.Errorf("the refused build %s is not recorded", id)
}
})
}
}
func TestANewModuleFromARepositoryTheCatalogueDoesNotBuildFromIsNotRegistered(t *testing.T) {
open := theCatalogue(t)
ctx := t.Context()
keptAsked(t, open.inventory, "build-new", "agent/tools", "", false)
_, _, err := takeIn(ctx, open.inventory, onTrunk("build-new", "agent/tools", "git", "",
map[string]any{"module": "agent-tools", "version": "1"}))
if !errors.Is(err, errNotItsSource) {
t.Fatalf("a new module from an agent's repository was taken in: %v", err)
}
// And one asked of nobody here — an outcome on the bus no request was kept for — the same.
_, _, err = takeIn(ctx, open.inventory, onTrunk("build-unasked", "agent/tools", "git", "",
map[string]any{"module": "agent-tools", "version": "1"}))
if !errors.Is(err, errNotItsSource) {
t.Fatalf("an outcome nobody asked for was taken in: %v", err)
}
if shelf, _ := open.inventory.Catalogue(ctx); shelf["agent-tools"].Module != "" {
t.Fatal("the refused module is in the catalogue")
}
}
// The operator at the terminal may still move a module, or add one from a new repository.
func TestAtTheTerminalAnotherRepositoryIsRegistered(t *testing.T) {
open := theCatalogue(t)
ctx := t.Context()
keptAsked(t, open.inventory, "build-moved", "novox/sudo", "", true)
if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-moved", "novox/sudo", "git", "",
map[string]any{"module": "sudo", "version": "2"})); err != nil {
t.Fatalf("a move the operator asked for at the terminal was refused: %v", err)
}
if src, _ := open.inventory.SourceOf(ctx, "sudo"); src.Repository != "novox/sudo" {
t.Fatalf("sudo is built from %q", src.Repository)
}
if err := open.inventory.RecordBuildRequest(ctx, inventory.BuildRequest{ID: "build-external",
Repository: "http://forge.internal:20000/someone/app.git", For: "build", AtTerminal: true}); err != nil {
t.Fatal(err)
}
if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-external", "someone/app", "", "",
map[string]any{"module": "app", "version": "1"})); err != nil {
t.Fatalf("a new module the operator asked for at the terminal was refused: %v", err)
}
}
// The delivery's flow is untouched: a merge's rebuild of a module from its own repository, and a merge adding
// a module to a repository the catalogue builds from (novox/hq issue 300), are registered with no terminal.
func TestADeliveryFromTheRegisteredRepositoryIsRegistered(t *testing.T) {
open := theCatalogue(t)
ctx := t.Context()
rebuilt := onTrunk("build-plan", "novox/mesh-catalog", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "2"})
if err := open.inventory.RecordBuildRequest(ctx, inventory.BuildRequest{ID: rebuilt.ID,
Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/sudo", For: "plan"}); err != nil {
t.Fatal(err)
}
if _, _, err := takeIn(ctx, open.inventory, rebuilt); err != nil {
t.Fatalf("a plan's build of the module's own repository was refused: %v", err)
}
added := onTrunk("build-merge", "novox/mesh-catalog", "git", "modules/zram", map[string]any{"module": "zram", "version": "1"})
if _, _, err := takeIn(ctx, open.inventory, added); err != nil {
t.Fatalf("a module a merge added to the catalogue repository was refused: %v", err)
}
shelf, _ := open.inventory.Catalogue(ctx)
if shelf["sudo"].Version != "2" || shelf["zram"].Module == "" {
t.Fatalf("not registered: sudo %q, zram %q", shelf["sudo"].Version, shelf["zram"].Module)
}
}
// Through a verb, a build of a repository the catalogue builds nothing from is not even asked: the build node
// would run what the agent wrote.
func TestAVerbAsksNoBuildOfARepositoryTheCatalogueDoesNotBuildFrom(t *testing.T) {
theCatalogue(t)
ctx := t.Context()
t.Setenv(verbVar, "build")
t.Setenv(link.CallerVar, "node-tools.anchor, through the mesh-controller seat")
err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, "http://forge.internal:20000/agent/sudo.git")
var policy *heldAtTheTerminal
if !errors.As(err, &policy) {
t.Fatalf("a verb's build of an agent's repository was asked: %v", err)
}
if err := verbMayAsk(ctx, buildSource{Repository: "novox/mesh-catalog", Seat: "git"},
"http://forge.internal:20000/novox/mesh-catalog.git"); err != nil {
t.Fatalf("a verb's build of the catalogue repository was refused: %v", err)
}
t.Setenv(verbVar, "")
t.Setenv(link.CallerVar, "")
if err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, ""); err != nil {
t.Fatalf("the terminal was refused: %v", err)
}
}
// asTheOperator keeps a build as asked at the controller's terminal, as the operator's first build of a module
// from a repository the catalogue does not yet build from is (novox/hq ADR 0266), and hands it back.
func asTheOperator(t *testing.T, inv *inventory.Inventory, b link.BuildResult) link.BuildResult {
t.Helper()
repository, seat := b.Repository, ""
if b.Source != nil {
repository, seat = b.Source.Repository, b.Source.Seat
}
if err := inv.RecordBuildRequest(t.Context(), inventory.BuildRequest{ID: b.ID, Repository: repository, Seat: seat,
Path: b.Path, For: "build", AtTerminal: true}); err != nil {
t.Fatal(err)
}
return b
}
// A rollback puts back only a build of the module's own repository: an agent's build of the module's name,
// recorded and refused, at the very commit the machine ran before (a fork carries it), is never registered by
// the back door of a failed gate.
func TestARollbackNeverPutsBackABuildFromAnotherRepository(t *testing.T) {
open := theCatalogue(t)
ctx := t.Context()
inv := open.inventory
fork := onTrunk("build-1791500000000000000", "agent/mesh-catalog", "git", "modules/sudo",
map[string]any{"module": "sudo", "version": "evil"})
fork.Commit = "c0ffee0123456789" // the commit sudo was registered at
keptAsked(t, inv, fork.ID, "agent/mesh-catalog", "modules/sudo", false)
if _, _, err := takeIn(ctx, inv, fork); !errors.Is(err, errNotItsSource) {
t.Fatalf("the fork's build was taken in: %v", err)
}
// Asked after the registered build, whenever the test runs: an id naming a fixed moment read as older
// than the registered build once the clock passed it (2026-10-10 02:40 UTC), and the test failed on main.
failed := onTrunk(fmt.Sprintf("build-%d", time.Now().Add(time.Hour).UnixNano()), "novox/mesh-catalog", "git", "modules/sudo",
map[string]any{"module": "sudo", "version": "2"})
failed.Commit = "badbadbad0123456"
if _, _, err := takeIn(ctx, inv, failed); err != nil {
t.Fatal(err)
}
record, _, err := inv.BuildByID(ctx, failed.ID)
if err != nil {
t.Fatal(err)
}
previous, found, err := inv.PreviousBuild(ctx, "sudo", "c0ffee0123456789", record)
if err != nil {
t.Fatal(err)
}
if found && previous.ID == fork.ID {
t.Fatalf("a rollback would put back the fork's build %s", previous.ID)
}
if !found || previous.ID != "build-sudo" {
t.Fatalf("a rollback puts back %q (found %v), want the registered build-sudo", previous.ID, found)
}
}
// theForge is what the forge says in a test, by owner/name: a repository not named here is protected as a
// trunk must be, with an id of its own.
var theForge sync.Map
func init() {
askTheForge = func(_ context.Context, owner, repo, _ string) (forgeFacts, error) {
if said, ok := theForge.Load(owner + "/" + repo); ok {
switch f := said.(type) {
case error:
return forgeFacts{}, f
case forgeFacts:
return f, nil
}
}
h := fnv.New32a()
_, _ = h.Write([]byte(owner + "/" + repo))
return forgeFacts{ID: int64(h.Sum32()), Guarded: true}, nil
}
}
// A module's trunk the forge does not protect — direct pushes, no required status — or a forge that cannot say,
// registers nothing through a verb: the trunk rule means nothing on a branch anyone pushes to.
func TestATrunkTheForgeDoesNotProtectRegistersNothing(t *testing.T) {
open := theCatalogue(t)
ctx := t.Context()
theForge.Store("novox/unguarded", forgeFacts{ID: 7, Why: "the rule main lets a person push to main directly"})
t.Cleanup(func() { theForge.Delete("novox/unguarded") })
first := onTrunk("build-unguarded-1", "novox/unguarded", "git", "", map[string]any{"module": "unguarded", "version": "1"})
keptAsked(t, open.inventory, first.ID, "novox/unguarded", "", true)
if _, _, err := takeIn(ctx, open.inventory, first); err != nil {
t.Fatalf("at the terminal: %v", err)
}
again := onTrunk("build-unguarded-2", "novox/unguarded", "git", "", map[string]any{"module": "unguarded", "version": "2"})
if _, _, err := takeIn(ctx, open.inventory, again); !errors.Is(err, errNotItsSource) ||
!strings.Contains(err.Error(), "push to main directly") {
t.Fatalf("a rebuild from an unprotected trunk was taken in: %v", err)
}
theForge.Store("novox/unguarded", errors.New("nothing serves gitea.gitea_api"))
if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-unguarded-3", "novox/unguarded", "git", "",
map[string]any{"module": "unguarded", "version": "3"})); !errors.Is(err, errNotItsSource) {
t.Fatalf("a forge that could not say was read as a protected trunk: %v", err)
}
if shelf, _ := open.inventory.Catalogue(ctx); shelf["unguarded"].Version != "1" {
t.Fatalf("unguarded is %q", shelf["unguarded"].Version)
}
}
// A repository deleted and made again under the module's repository's name is another repository: the forge's
// id, recorded at registration, tells them apart.
func TestARepositoryMadeAgainUnderItsNameIsNotTheModulesSource(t *testing.T) {
open := theCatalogue(t)
ctx := t.Context()
theForge.Store("novox/remade", forgeFacts{ID: 100, Guarded: true})
t.Cleanup(func() { theForge.Delete("novox/remade") })
first := onTrunk("build-remade-1", "novox/remade", "git", "", map[string]any{"module": "remade", "version": "1"})
keptAsked(t, open.inventory, first.ID, "novox/remade", "", true)
if _, _, err := takeIn(ctx, open.inventory, first); err != nil {
t.Fatal(err)
}
if id, _ := open.inventory.SourceIdentity(ctx, "remade"); id != 100 {
t.Fatalf("the forge's id was not recorded: %d", id)
}
theForge.Store("novox/remade", forgeFacts{ID: 101, Guarded: true}) // deleted, and made again by an agent
if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-remade-2", "novox/remade", "git", "",
map[string]any{"module": "remade", "version": "evil"})); !errors.Is(err, errNotItsSource) ||
!strings.Contains(err.Error(), "made again") {
t.Fatalf("a repository made again under the name was taken in: %v", err)
}
// And a new module from it, beside the one registered from the first, the same.
if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-remade-3", "novox/remade", "git", "modules/other",
map[string]any{"module": "other", "version": "1"})); !errors.Is(err, errNotItsSource) {
t.Fatalf("a new module from a repository made again was taken in: %v", err)
}
}
// The terminal's mark is the operator's for the repository and path they asked: an outcome of another, under that
// build's id, is not theirs.
func TestATerminalRequestCoversOnlyWhatItAsked(t *testing.T) {
open := theCatalogue(t)
ctx := t.Context()
if err := open.inventory.RecordBuildRequest(ctx, inventory.BuildRequest{ID: "build-asked", Repository: "novox/app",
Seat: "git", Path: "modules/app", For: "build", AtTerminal: true}); err != nil {
t.Fatal(err)
}
other := onTrunk("build-asked", "agent/sudo", "git", "modules/app", map[string]any{"module": "sudo", "version": "evil"})
if _, _, err := takeIn(ctx, open.inventory, other); !errors.Is(err, errNotItsSource) {
t.Fatalf("an outcome of another repository under a terminal request's id was taken in: %v", err)
}
elsewhere := onTrunk("build-asked", "novox/app", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "evil"})
if _, _, err := takeIn(ctx, open.inventory, elsewhere); !errors.Is(err, errNotItsSource) {
t.Fatalf("an outcome of another path under a terminal request's id was taken in: %v", err)
}
}
// The serving controller, and everything it starts, is never the terminal: a build asked in it reads as asked
// through the mesh even when no verb and no caller is named.
func TestTheServingControllerIsNeverTheTerminal(t *testing.T) {
t.Setenv(verbVar, "")
t.Setenv(link.CallerVar, "")
t.Setenv(servedVar, "")
if !startedAtTheTerminal() {
t.Fatal("a process started by hand is not the terminal")
}
markServed()
if startedAtTheTerminal() {
t.Fatal("the serving controller reads as the terminal")
}
child := exec.Command(os.Args[0], "-test.run=^$")
child.Env = os.Environ()
if !slices.Contains(child.Env, servedVar+"=1") {
t.Fatal("what the serving controller starts does not carry its mark")
}
}
// The forge applies one rule to a branch: the rule named for it, else the first glob that covers it. A stronger
// rule later in the list is not what guards the branch, and is not read as if it were (the confirmation review).
func TestTheRuleJudgedIsTheOneTheForgeApplies(t *testing.T) {
guarded := protectionRule{Rule: "*", RequiredStatuses: []string{"mesh/merge-gate"}}
open := protectionRule{Rule: "main", Push: true, RequiredStatuses: []string{"mesh/merge-gate"}}
if f := judgedRule([]protectionRule{guarded, open}, "main"); f.Guarded {
t.Error("an exact rule letting pushes was passed over for a glob that guards")
}
if f := judgedRule([]protectionRule{{Rule: "ma*", RequiredStatuses: nil}, {Rule: "*", RequiredStatuses: []string{"x"}}},
"main"); f.Guarded || !strings.Contains(f.Why, "ma*") {
t.Errorf("the first glob covering the branch was not the one judged: %+v", f)
}
if f := judgedRule([]protectionRule{{Rule: "release/*"}, {Rule: "main", RequiredStatuses: []string{"x"}}}, "main"); !f.Guarded {
t.Errorf("the rule named for the branch was not judged: %+v", f)
}
if f := judgedRule(nil, "main"); f.Guarded {
t.Error("no rule is no protection")
}
}
// An id the forge could not give keeps the id already recorded.
func TestAnUnknownIdentityKeepsTheRecordedOne(t *testing.T) {
open := theCatalogue(t)
ctx := t.Context()
if err := open.inventory.SetSourceIdentity(ctx, "sudo", 100); err != nil {
t.Fatal(err)
}
if err := open.inventory.SetSourceIdentity(ctx, "sudo", 0); err != nil {
t.Fatal(err)
}
if id, _ := open.inventory.SourceIdentity(ctx, "sudo"); id != 100 {
t.Fatalf("the recorded id became %d", id)
}
}
+4 -4
View File
@@ -19,11 +19,11 @@ func TestABuildHeardIsRecordedAndRegistered(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
manifest, _ := json.Marshal(map[string]any{"module": "shop", "version": "3"})
m, _, err := takeIn(ctx, open.inventory, asTheOperator(t, open.inventory, link.BuildResult{
m, _, err := takeIn(ctx, open.inventory, link.BuildResult{
ID: "b-1", Repository: "http://forge.internal:20000/novox/shop.git", Path: "modules/shop",
Ref: "main", On: "anchor", Commit: "abcdef0123", Manifest: manifest,
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/shop"},
}))
})
if err != nil {
t.Fatal(err)
}
@@ -78,7 +78,7 @@ func TestABuildAtACommitKeepsTheBranchTheModuleFollows(t *testing.T) {
Path: "modules/unifi", Ref: ref, On: "anchor", Commit: commit, Manifest: manifest,
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}}
}
if _, _, err := takeIn(ctx, open.inventory, asTheOperator(t, open.inventory, result("b-1", "main", "1111111aaaa"))); err != nil {
if _, _, err := takeIn(ctx, open.inventory, result("b-1", "main", "1111111aaaa")); err != nil {
t.Fatal(err)
}
if _, _, err := takeIn(ctx, open.inventory, result("b-2", "9c97a8a", "9c97a8a1d2c3")); err != nil {
@@ -117,7 +117,7 @@ func TestAnOlderBuildHeardLaterDoesNotReplaceTheNewer(t *testing.T) {
Path: "modules/postgres", Ref: "main", On: "anchor", Commit: "efff5415", Manifest: manifest,
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}}
}
if _, _, err := takeIn(ctx, open.inventory, asTheOperator(t, open.inventory, result(newer, "4bcd5f73"))); err != nil {
if _, _, err := takeIn(ctx, open.inventory, result(newer, "4bcd5f73")); err != nil {
t.Fatal(err)
}
_, _, err := takeIn(ctx, open.inventory, result(older, "0ab07fa9"))
+2 -9
View File
@@ -151,13 +151,6 @@ func busCommand(ctx context.Context, args []string) error {
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
sub, args = args[0], args[1:]
}
// The view's credential, a terminal line like a person's (bus_view.go).
switch sub {
case "view-credential":
return busViewCredential(ctx, args)
case "view-revoke":
return busViewRevoke(ctx, args)
}
set := flag.NewFlagSet("bus", flag.ContinueOnError)
snapshot := set.String("snapshot-taken", "", "where the streams' snapshot a person took is, while the mesh takes none itself")
reversible := set.Bool("reversible", false, "the new version can be undone by putting the old one back")
@@ -167,14 +160,14 @@ func busCommand(ctx context.Context, args []string) error {
if rest, err := parseAround(set, args); err != nil {
return err
} else if len(rest) > 0 {
return errors.New(busUsage)
return errors.New("bus [upgrade --why … --reversible|--irreversible [--snapshot-taken <where>]]")
}
switch sub {
case "":
return busStatus(ctx)
case "upgrade":
default:
return fmt.Errorf("bus says what a bus upgrade would do, or `bus upgrade`, `bus view-credential`, `bus view-revoke` — not %q", sub)
return fmt.Errorf("bus says what a bus upgrade would do, or `bus upgrade` — not %q", sub)
}
// Everything refused before anything is done.
if err := why.require("bus upgrade"); err != nil {
-117
View File
@@ -1,117 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"strconv"
"strings"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/inventory"
)
// The view's credential: the one read-only user a page in a browser connects to the bus as, over the
// bus module's WebSocket listener (novox/hq research 036, gap G1; broker.KindView).
//
// **A terminal line, like a person's credential** (operator.go): printed once, never stored — the mesh
// keeps a hash — and revoked by forgetting the row, which the next composition of the user list makes
// real. There is one view; issuing it again rotates its password.
const busUsage = "bus [upgrade --why … --reversible|--irreversible [--snapshot-taken <where>] | view-credential | view-revoke]"
// busWebSocketPort is the port the bus module's WebSocket listener is published on, mirrored from the
// nats module's manifest (its `bus-websocket` opening), because the credential names where to connect
// and the controller does not read the module's configuration. Reached across the overlay only: the
// opening is from the mesh, and the mesh's filter admits nothing else.
const busWebSocketPort = 4223
func busViewCredential(ctx context.Context, args []string) error {
if len(args) != 0 {
return errors.New("bus view-credential takes nothing: there is one view, and this prints its credential once")
}
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
inv := open.inventory
// Refused here rather than at the next composition, where it would stop the whole file.
if _, err := broker.PermissionsFor(broker.Principal{Kind: broker.KindView, PasswordHash: "x"}); err != nil {
return err
}
password, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: broker.ViewUser, Kind: inventory.BusView})
if err != nil {
return err
}
where, err := broker.FromEnvironment()
if err != nil && !errors.Is(err, broker.ErrNotConfigured) {
return err
}
host := where.Address
if h, _, err := net.SplitHostPort(where.Address); err == nil {
host = h
}
websocket := ""
if host != "" {
websocket = "ws://" + net.JoinHostPort(host, strconv.Itoa(busWebSocketPort))
}
held, err := json.Marshal(struct {
WebSocket string `json:"websocket,omitempty"`
URL string `json:"url,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
User string `json:"user"`
Password string `json:"password"`
InboxPrefix string `json:"inbox_prefix"`
Hears []string `json:"hears"`
Reads string `json:"reads"`
HowToRead string `json:"how_to_read"`
}{
WebSocket: websocket, URL: "nats://" + where.Address, Fingerprint: where.Fingerprint,
User: broker.ViewUser, Password: password,
// The client must make its inboxes under the view's own prefix: its subscribe grant is
// `_INBOX.view.>` and no wider (design 25 §4), and a client's default inbox is not under it.
InboxPrefix: "_INBOX." + broker.ViewUser,
Hears: broker.ViewHears, Reads: broker.ViewBucket,
HowToRead: "direct reads only, no watch (a consumer is refused): list with a request to $JS.API.DIRECT.GET.KV_" +
broker.ViewBucket + ` carrying {"multi_last":["$KV.` + broker.ViewBucket + `.>"]}, answered until a 204 status; ` +
"read one key with $JS.API.DIRECT.GET.KV_" + broker.ViewBucket + ".$KV." + broker.ViewBucket + ".<number> " +
"(nats.js: kvm.open(bucket, {allow_direct: true}), never create); re-read the key an event's number names",
})
if err != nil {
return err
}
fmt.Printf("issued the view, which hears %s and reads the bucket %s, and nothing else\n",
strings.Join(broker.ViewHears, ", "), broker.ViewBucket)
fmt.Println(" this is the only time the credential is printed; the mesh keeps a hash")
fmt.Println(" it works once the bus has been told, which is the next push to the machine holding mesh-broker —")
fmt.Println(" and while a new build of the bus module waits for that machine, the next `bus upgrade` a person starts,")
fmt.Println(" which is also what brings the WebSocket listener it connects through")
fmt.Println()
fmt.Println(string(held))
return nil
}
func busViewRevoke(ctx context.Context, args []string) error {
if len(args) != 0 {
return errors.New("bus view-revoke takes nothing: there is one view")
}
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
if err := open.inventory.ForgetBusUser(ctx, broker.ViewUser); err != nil {
return err
}
// **Revoked at the next composition, not now** — as a person is (operator revoke): the bus's users
// are a file, and the credential stops working when the file no longer names it.
fmt.Println("the view is forgotten, and its credential stops working at the next composition — " +
"push the machine holding mesh-broker to make it so")
return nil
}
-195
View File
@@ -1,195 +0,0 @@
package main
import (
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
)
// A send held for the bus's planned step (novox/hq issue 336).
//
// **A wait only a person can end is said to that person at once.** No send replaces the bus but its planned
// step (sendToEach, ADR 0236), and the step is a person's `bus upgrade`. So while a new bus build waits, every
// walk whose tier sends to the bus's machine is refused, and tries again each tick. The refusal was kept only
// as the walk's note; nothing was raised, and the operator found the hold by asking why a walk did not move.
// Row S17 raises it on the first tick after the first refusal, in the bus's scope, for the operator: what
// waits, behind which bus build, since when, and the verb. A walk held only by it is not late, so S3 leaves it
// out, as it leaves out a walk under a paused build seat. It clears once the bus's machine has been sent the
// build the mesh holds — the step was taken, and no send is refused for the bus any more — whatever the walks'
// notes still say. Whether the new bus came up healthy is the step's own condition (probe DB).
// kindBusStepWaiting is S17's kind: bus.<module>.step-waiting.
const kindBusStepWaiting = "bus-step-waiting"
// busFacts is a new bus build waiting for its planned step, and the sends held for it.
type busFacts struct {
module, to string
// from is the build each machine of the bus runs; machines those whose bus the step would replace.
from map[string]string
machines []string
waits []busWaitFacts
}
// busWaitFacts is one walk whose send was refused because it would replace the bus.
type busWaitFacts struct {
plan, repository, commit string
// modules are what its tier sends: what waits.
modules []string
// since is the first refusal: as this controller saw it, or, read back, the save that kept the refusal.
since time.Time
}
// busRefusedFirst is when this controller first saw each walk refused for the bus's step. The walk's note
// keeps the refusal across a restart; this keeps its moment more exactly than the walk's last save.
var busRefusedFirst = &firstSeen{at: map[string]time.Time{}}
type firstSeen struct {
mu sync.Mutex
at map[string]time.Time
}
// mark keeps the first moment an id was seen.
func (s *firstSeen) mark(id string, at time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
if _, seen := s.at[id]; !seen {
s.at[id] = at
}
}
// of is when an id was first seen; zero when it was not.
func (s *firstSeen) of(id string) time.Time {
s.mu.Lock()
defer s.mu.Unlock()
return s.at[id]
}
// keepOnly forgets every id not given: a walk no longer held is not held since then.
func (s *firstSeen) keepOnly(ids map[string]bool) {
s.mu.Lock()
defer s.mu.Unlock()
for id := range s.at {
if !ids[id] {
delete(s.at, id)
}
}
}
// refusedForTheBus is whether an error is the refusal of a send for the bus's planned step.
func refusedForTheBus(err error) bool {
return err != nil && strings.Contains(err.Error(), errBusWaits.Error())
}
// busWaitsOf is the sends held for the bus's step, from the open walks: each walk not waiting for its
// delivery's word whose note keeps a refusal for this very bus build, while the build would still replace
// the bus on a machine. first is when this controller first saw a walk refused, zero when it did not.
func busWaitsOf(plans []inventory.Plan, b busPending, first func(string) time.Time) busFacts {
f := busFacts{module: b.module, to: b.to, from: b.from}
for _, n := range b.machines {
if b.moves(n) {
f.machines = append(f.machines, n)
}
}
if len(f.machines) == 0 {
return f
}
for _, p := range plans {
if !p.Open() || p.Waiting() || !strings.Contains(p.Note, errBusWaits.Error()) || !strings.Contains(p.Note, short(b.to)) {
continue
}
since := p.Updated
if seen := first(p.ID); !seen.IsZero() && (since.IsZero() || seen.Before(since)) {
since = seen
}
var modules []string
if p.Tier >= 0 && p.Tier < len(p.Tiers) {
modules = append(modules, p.Tiers[p.Tier]...)
} else {
modules = planModules(p)
}
sort.Strings(modules)
f.waits = append(f.waits, busWaitFacts{plan: p.ID, repository: p.Repository, commit: p.Commit, modules: modules,
since: since})
}
sort.Slice(f.waits, func(i, j int) bool { return f.waits[i].since.Before(f.waits[j].since) })
return f
}
// heldByTheBus is the walks of a bus's facts, by id: what S3 leaves out.
func (b busFacts) heldByTheBus() map[string]bool {
out := map[string]bool{}
for _, w := range b.waits {
out[w.plan] = true
}
return out
}
// busUpgradeVerb is the call that ends the wait, as the summary names it: through the mesh MCP server, with
// why, saying whether the new version can be undone ("reversible": "true") or not ("irreversible": "true").
const busUpgradeVerb = "`mesh_call mesh-controller.bus {\"upgrade\": \"true\", \"why\": \"…\", \"reversible\" or \"irreversible\": \"true\"}`"
// watchBusWaits is S17: a send held for the bus's planned step, said at once to the operator.
func watchBusWaits(f *signalFacts) []conditions.Observation {
b := f.bus
if len(b.waits) == 0 || len(b.machines) == 0 {
return nil
}
since := b.waits[0].since
var walks, what []string
for _, w := range b.waits {
walks = append(walks, fmt.Sprintf("%s (%s %s, tier: %s)", w.plan, w.repository, short(w.commit),
strings.Join(w.modules, ", ")))
what = append(what, deliveryWhat(w.modules, w.repository))
}
var from []string
for _, n := range b.machines {
from = append(from, short(orNotKnown(b.from[n])))
}
machines := namesWords(b.machines, 3)
in := f.now.Sub(since)
return []conditions.Observation{{Scope: conditions.ScopeBus, ID: b.module, Token: "step-waiting",
Kind: kindBusStepWaiting, Severity: conditions.Warning, Resolver: conditions.ResolverOperator,
Machine: b.machines[0], Also: b.machines[1:],
Summary: fmt.Sprintf("sends to %s wait for the bus's planned step: a new bus build (%s %s → %s) would replace "+
"the bus there, which only a person's %s does; waiting since %s: %s", strings.Join(b.machines, ", "),
b.module, strings.Join(sortedUnique(from), ", "), short(b.to), busUpgradeVerb,
since.UTC().Format(time.RFC3339), strings.Join(walks, "; ")),
Said: fmt.Sprintf("%d walk(s) refused since %s", len(b.waits), since.UTC().Format(time.RFC3339)),
Headline: clipWords("Sends to "+machines+" wait for a bus upgrade", conditions.HeadlineMax),
Explanation: clipWords(fmt.Sprintf("A new version of the mesh's message system is built, and only a person "+
"installs it. Until then nothing else is sent to %s: %s waits, for %s so far.", machines,
namesWords(sortedUnique(what), 3), humanDuration(in)), conditions.ExplanationMax),
Needs: "start the bus upgrade " + FromMeshMCPServer,
Resolved: "Resolved: the bus upgrade started, and sends go on"}}
}
// sortedUnique is a list sorted, each once.
func sortedUnique(xs []string) []string {
seen := map[string]bool{}
var out []string
for _, x := range xs {
if !seen[x] {
seen[x] = true
out = append(out, x)
}
}
sort.Strings(out)
return out
}
// clipWords keeps a plain sentence within a bound, at a word.
func clipWords(s string, n int) string {
if len(s) <= n {
return s
}
cut := strings.LastIndex(s[:n-1], " ")
if cut <= 0 {
cut = n - 1
}
return s[:cut] + "…"
}
-159
View File
@@ -1,159 +0,0 @@
package main
import (
"context"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
)
// novox/hq issue 336: a send held because it would replace the bus outside its planned step waits for a
// person, so a person is told at once — what waits, behind which bus build, since when, and the verb that
// ends it — and the wait is not read as a walk running late.
// aBusOnAnchor is a new bus build waiting for its step on anchor: nats 88135ad0 there, 32307bd1 held.
func aBusOnAnchor() busPending {
return busPending{module: "nats", machines: []string{"anchor"}, from: map[string]string{"anchor": "88135ad0aaaa"},
to: "32307bd1bbbb", same: map[string]bool{}}
}
// refusedNote is the note a walk keeps when its send was refused for the bus, as advanceHeld writes it.
func refusedNote(b busPending, tier int) string {
held := "sending anchor would replace the bus (nats " + short(b.from["anchor"]) + " → " + short(b.to) +
"), which is a planned step: `bus upgrade --why …` snapshots its streams first and checks them after (novox/hq ADR 0236)"
return "tier " + string(rune('0'+tier)) + ": " + errBusWaits.Error() + ": " + held + " — tried again"
}
func TestASendHeldForTheBusStepIsFoundFromTheWalksItHolds(t *testing.T) {
now := time.Date(2026, 10, 8, 18, 30, 0, 0, time.UTC)
b := aBusOnAnchor()
walk := func(id, repo, note string, updated time.Time) inventory.Plan {
return inventory.Plan{ID: id, Repository: repo, Commit: "c0ffee001122", State: inventory.PlanRolling, Tier: 0,
Tiers: [][]string{{"mesh-host"}}, Modules: map[string]*inventory.PlanModule{"mesh-host": {}}, Note: note,
Updated: updated}
}
for _, c := range []struct {
name string
bus busPending
plans []inventory.Plan
first map[string]time.Time
want []string
since time.Time
}{
{"a walk refused for the bus", b,
[]inventory.Plan{walk("plan-1", "novox/mesh-host", refusedNote(b, 0), now.Add(-27*time.Minute))}, nil,
[]string{"plan-1"}, now.Add(-27 * time.Minute)},
{"refused earlier than its last save, as this controller saw it", b,
[]inventory.Plan{walk("plan-1", "novox/mesh-host", refusedNote(b, 0), now.Add(-5*time.Minute))},
map[string]time.Time{"plan-1": now.Add(-28 * time.Minute)}, []string{"plan-1"}, now.Add(-28 * time.Minute)},
{"a walk refused for another reason", b,
[]inventory.Plan{walk("plan-1", "novox/mesh-host", "tier 0: the build seat is paused — tried again", now)}, nil,
nil, time.Time{}},
{"refused for an older bus build than the one held now", func() busPending { o := b; o.to = "99999999cccc"; return o }(),
[]inventory.Plan{walk("plan-1", "novox/mesh-host", refusedNote(b, 0), now)}, nil, nil, time.Time{}},
{"the bus already runs the build held: its step started", func() busPending {
o := aBusOnAnchor()
o.from = map[string]string{"anchor": o.to}
return o
}(), []inventory.Plan{walk("plan-1", "novox/mesh-host", refusedNote(b, 0), now)}, nil, nil, time.Time{}},
{"a walk waiting for its delivery's word asks no send", b, func() []inventory.Plan {
p := walk("plan-1", "novox/mesh-host", refusedNote(b, 0), now)
p.Delivery = &inventory.PlanDelivery{Awaits: "mesh-delivery"}
return []inventory.Plan{p}
}(), nil, nil, time.Time{}},
} {
t.Run(c.name, func(t *testing.T) {
got := busWaitsOf(c.plans, c.bus, func(id string) time.Time { return c.first[id] })
var ids []string
for _, w := range got.waits {
ids = append(ids, w.plan)
}
if strings.Join(ids, ",") != strings.Join(c.want, ",") {
t.Fatalf("held for the bus: %v, want %v", ids, c.want)
}
if len(c.want) > 0 {
if !got.waits[0].since.Equal(c.since) {
t.Errorf("waiting since %s, want %s", got.waits[0].since, c.since)
}
if strings.Join(got.machines, ",") != "anchor" || got.to != b.to || got.module != "nats" {
t.Errorf("behind %+v", got)
}
}
})
}
}
// **Said at once, not as lateness, and cleared when the step starts**: the walk held only for the bus raises
// the bus's condition on the first tick, before any tier bound, naming what waits, the bus build from and to,
// since when and `bus upgrade`, for the operator; S3 says nothing of it even past its bound; and the
// condition clears once the bus's machine runs the new build.
func TestASendHeldForTheBusStepIsSaidAtOnceAndNotAsLateness(t *testing.T) {
now := time.Date(2026, 10, 8, 18, 30, 0, 0, time.UTC)
b := aBusOnAnchor()
held := func(entered time.Duration) *signalFacts {
f := calm(now)
f.plans = []planFacts{{id: "plan-1", repository: "novox/mesh-host", commit: "c0ffee00", tier: 0, tiers: 1,
entered: now.Add(-entered), bound: 30 * time.Minute, waiting: refusedNote(b, 0), bus: true}}
f.bus = busFacts{module: "nats", to: b.to, from: b.from, machines: []string{"anchor"},
waits: []busWaitFacts{{plan: "plan-1", repository: "novox/mesh-host", commit: "c0ffee001122",
modules: []string{"mesh-host"}, since: now.Add(-time.Minute)}}}
return f
}
f := held(time.Minute)
got := watchBusWaits(f)
if len(got) != 1 {
t.Fatalf("a send held for the bus a minute ago raised %+v", got)
}
o := got[0]
if o.Key() != "bus.nats.step-waiting" || o.Kind != kindBusStepWaiting || o.Resolver != conditions.ResolverOperator {
t.Fatalf("raised %s (%s), resolver %q", o.Key(), o.Kind, o.Resolver)
}
for _, want := range []string{"anchor", "mesh-host", "88135ad0", "32307bd1", "mesh_call mesh-controller.bus",
`"upgrade": "true"`, `"reversible"`, `"irreversible"`, "2026-10-08T18:29:00Z", "plan-1"} {
if !strings.Contains(o.Summary, want) {
t.Errorf("its summary does not say %q: %s", want, o.Summary)
}
}
if !strings.Contains(o.Explanation, "mesh-host") || !strings.Contains(o.Headline, "bus upgrade") || o.Needs == "" {
t.Errorf("its words do not say what waits and what the operator does: %+v", o)
}
if why, ok := conditions.PlainWords(conditions.Words{Headline: o.Headline, Explanation: o.Explanation,
Resolved: o.Resolved, Needs: o.Needs}, "anchor"); !ok {
t.Errorf("its words are not plain: %s", why)
}
// Past S3's bound: still the bus's wait, never a stalled walk.
late := held(45 * time.Minute)
if s3 := watchPlans(late); len(s3) != 0 {
t.Fatalf("a walk held only by the bus step was said stalled: %+v", s3)
}
// A walk held for something else past its bound is still stalled.
other := held(45 * time.Minute)
other.plans[0].bus = false
if s3 := watchPlans(other); len(s3) != 1 {
t.Fatalf("a walk held for something else past its bound raised %+v", s3)
}
// Through the keeper: open at the first tick, cleared when the step started.
store := conditions.NewInMemory()
k := conditions.NewKeeper(t.Context(), conditions.Options{Store: store, History: store, Teller: &conditions.Told{},
Now: func() time.Time { return now }})
defer k.Close(context.Background())
w := &watchdogs{keeper: k, started: now.Add(-time.Hour)}
w.see(t.Context(), held(time.Minute))
open, err := k.Open(t.Context())
if err != nil || len(open) != 1 || open[0].Key != "bus.nats.step-waiting" {
t.Fatalf("after the first tick, open: %+v (%v)", open, err)
}
started := held(time.Minute)
started.bus = busFacts{module: "nats", to: b.to}
started.plans[0].bus = false
w.see(t.Context(), started)
if open, _ := k.Open(t.Context()); len(open) != 0 {
t.Fatalf("the step started, and open: %+v", open)
}
}
-48
View File
@@ -53,26 +53,9 @@ func assertBusObjects(ctx context.Context, inv *inventory.Inventory, r broker.Ra
if err != nil {
return nil, err
}
// And the work queues of seats that name their caller or their kind, with each holder's worker
// (novox/hq ADR 0259 §3): an ask queues until the router takes it, a channel's work until that kind
// takes it.
trafficStreams, trafficWorkers, err := seatTrafficObjects(ctx, inv)
if err != nil {
return nil, err
}
// Every one tried, and every failure named: one module's consumer the bus refuses is no reason
// the modules after it in the list hear nothing (novox/hq issue 208, where this runs on each send).
var failed []error
for _, s := range trafficStreams {
if err := r.EnsureStream(s); err != nil {
failed = append(failed, fmt.Errorf("the work queue %s: %w", s.Name, err))
}
}
for _, c := range trafficWorkers {
if err := r.EnsureConsumer(c); err != nil {
failed = append(failed, fmt.Errorf("the worker %s on %s: %w", c.Name, c.Stream, err))
}
}
for _, c := range consumers {
if err := r.EnsureConsumer(c.Consumer); err != nil {
failed = append(failed, fmt.Errorf("how %s on %s hears what it consumes: %w", c.Module, c.Node, err))
@@ -147,37 +130,6 @@ func moduleConsumers(ctx context.Context, inv *inventory.Inventory) ([]broker.Mo
return broker.ConsumersOf(users), nil
}
// seatTrafficObjects is the work queues and workers of seats that name their caller or their kind, from
// the records the user list is composed from.
func seatTrafficObjects(ctx context.Context, inv *inventory.Inventory) ([]broker.Stream, []broker.Consumer, error) {
records, err := inv.BusRecords(ctx)
if err != nil {
return nil, nil, err
}
users, err := broker.Users(records)
if err != nil {
return nil, nil, err
}
streams, workers := broker.SeatTrafficObjects(users)
// And the queue of every such seat the catalogue declares, held or not: work queues from registration,
// so what is submitted before a holder is assigned waits for it (the correctness review of 2026-10-08).
declared, err := inv.DeclaredTrafficSeats(ctx)
if err != nil {
return nil, nil, err
}
have := map[string]bool{}
for _, s := range streams {
have[s.Name] = true
}
for _, s := range broker.TrafficQueues(declared) {
if !have[s.Name] {
streams = append(streams, s)
have[s.Name] = true
}
}
return streams, workers, nil
}
// moduleConsumerCount is how many modules hear what they consume, for the raise's one line.
func moduleConsumerCount(ctx context.Context, inv *inventory.Inventory) (int, error) {
consumers, err := moduleConsumers(ctx, inv)
+2
View File
@@ -37,6 +37,8 @@ func TestAPushAnswersBeforeItSends(t *testing.T) {
}{
{"push", map[string]any{"node": "anchor", "why": "w"}, true},
{"push", map[string]any{"why": "w"}, true},
{"command", map[string]any{"command": "push anchor --why w"}, true},
{"command", map[string]any{"command": "push --behind --why=w"}, true},
{"command", map[string]any{"command": "builds"}, false},
{"status", map[string]any{}, false},
{"assign", map[string]any{"node": "anchor", "module": "m"}, false},
+2 -15
View File
@@ -5,7 +5,6 @@ import (
"sort"
"strings"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
@@ -51,13 +50,7 @@ func changePlanOf(repository, base, head string, r mergeReach, entries []invento
}
}
switch {
case (name == "nats" || catalogue.ProvidesBus(e.Manifest)) && len(e.On) > 0:
// Said before the merge (novox/hq issue 336): a new bus build holds every send to the bus's machine
// until a person takes the step, so merging it is a promise to take it.
p.Steps = append(p.Steps, fmt.Sprintf("%s: %s is never sent by an ordinary send, and until %s runs, "+
"nothing else is sent to %s either — unless the new build turns out the same as the one running there "+
"(issue 280)", busUpgradeNeeded, name, busUpgradeVerb, strings.Join(e.On, ", ")))
case name == "nats" || catalogue.ProvidesBus(e.Manifest):
case name == "nats":
p.Steps = append(p.Steps, "a planned bus step: the bus is upgraded by `bus upgrade`, never by an ordinary send")
case waits && len(e.On) > 0:
p.Steps = append(p.Steps, fmt.Sprintf("%s waits for a person: its policy records (%s)", name,
@@ -88,9 +81,6 @@ func changePlanOf(repository, base, head string, r mergeReach, entries []invento
return p
}
// busUpgradeNeeded is how a change plan says that merging it holds the bus's machine for a person's step.
const busUpgradeNeeded = "merging this needs a person's bus upgrade"
// summaryOf is a change plan in one line: what it builds, where it goes, and whether the bus moves.
func summaryOf(p link.ChangePlan) string {
if len(p.Moved) == 0 && len(p.New) == 0 {
@@ -120,10 +110,7 @@ func summaryOf(p link.ChangePlan) string {
}
bus := "no bus step"
for _, s := range p.Steps {
switch {
case strings.HasPrefix(s, busUpgradeNeeded):
bus = busUpgradeNeeded
case strings.HasPrefix(s, "a planned bus step") && bus == "no bus step":
if strings.HasPrefix(s, "a planned bus step") {
bus = "a bus step"
}
}
+2 -6
View File
@@ -45,7 +45,7 @@ func TestAChangePlanSaysWhatEachMachineReceives(t *testing.T) {
}
p = plan("modules/nats/Dockerfile", "modules/photos/x.js")
if !strings.Contains(p.Summary, "needs a person's bus upgrade") || !strings.Contains(p.Summary, "2 wait(s) for a person") ||
if !strings.Contains(p.Summary, "a bus step") || !strings.Contains(p.Summary, "2 wait(s) for a person") ||
!strings.Contains(p.Summary, "(+1 dependent(s))") {
t.Errorf("the bus and a held module read %q", p.Summary)
}
@@ -58,11 +58,7 @@ func TestAChangePlanSaysWhatEachMachineReceives(t *testing.T) {
t.Errorf("the deploy plan reads %v", got)
}
text := strings.Join(p.Steps, "\n")
// Said before the merge (novox/hq issue 336): merging it holds every send to the bus's machine until a
// person runs the bus's step, and the verb that does.
for _, want := range []string{"merging this needs a person's bus upgrade", "nothing else is sent to anchor",
"mesh_call mesh-controller.bus", "the same as the one running there", "photos waits for a person",
"nats provides mesh-bus"} {
for _, want := range []string{"a planned bus step", "photos waits for a person", "nats provides mesh-bus"} {
if !strings.Contains(text, want) {
t.Errorf("the steps do not say %q:\n%s", want, text)
}
+1 -40
View File
@@ -32,9 +32,6 @@ import (
// provision it wants that a manifest given here offers. An overflow is refused in the pull request
// that introduces it — a new requirement, a lowered bound, a longer slug — instead of on the
// provider's machine when a real machine's name first meets the module's.
// SomeManifestsVar, set by the merge gate, says the manifests given are only some of their repository's.
const SomeManifestsVar = "MESH_MODULE_CHECK_SOME"
func moduleCheck(paths []string, out io.Writer) error {
return moduleCheckFor(paths, catalogue.DefaultLongestMachine, out)
}
@@ -68,13 +65,6 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
}
// A definition names no installation (novox/hq ADR 0112, ADR 0155): judged here, in the
// catalogue-wide test, and at registration, which refuses in the same words.
if wrong := catalogue.TrustProblems(m); len(wrong) > 0 {
for _, p := range wrong {
fmt.Fprintf(out, "%s: %s\n", path, p)
}
failed += len(wrong)
faulted[m.Module] = true
}
if named := catalogue.InstallationProblems(m); len(named) > 0 {
for _, p := range named {
fmt.Fprintf(out, "%s: %s\n", path, p)
@@ -88,24 +78,12 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
// Between the manifests: a seat declared twice, a use of a seat nothing declares, a claim on
// a seat that does not exist. Run only over what parsed, because a problem inside one manifest
// has already been said and would be said again here in a worse form.
//
// **Over some of a repository's manifests, a seat none of them declares is a note** (novox/hq issue 364), as
// this command's own word says above: the merge gate passes only the manifests a change touches, and says so
// with SomeManifestsVar, so a module that uses or claims a seat another module declares (the operator
// channel's, a channel bench) was refused there for a manifest it was not given. Given every manifest — the
// catalogue's own check, and registration — it stays a refusal.
some := os.Getenv(SomeManifestsVar) != ""
problems := catalogue.CatalogueProblems(shelf)
sort.Strings(problems)
for _, p := range problems {
if some && catalogue.IsUndeclaredSeat(p) {
fmt.Fprintf(out, "note: %s among the manifests given; registration judges it against the whole catalogue, "+
"and passing the declaring module's manifest too judges it here\n", p)
continue
}
fmt.Fprintln(out, p)
failed++
}
failed += len(problems)
// Between the manifests too: an identity against the bounds of the provisions it wants, which
// only the provider's manifest states.
@@ -152,13 +130,6 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
}
}
// **A file that asks for a setting and does not say whether it is trusted counts as trusted** (novox/hq issue
// 339): listed and counted, never refused, so an author can opt out a file nothing trusts.
unsaid := 0
for _, name := range names {
unsaid += len(catalogue.UnsaidTrust(shelf[name]))
}
for _, name := range names {
m := shelf[name]
if faulted[name] {
@@ -200,11 +171,6 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
if len(checks) > 0 {
fmt.Fprintf(out, ", ready: %s", strings.Join(checks, "; "))
}
if missing := catalogue.UnsaidTrust(m); len(missing) > 0 {
fmt.Fprintf(out, "; WARNING: %s ask(s) for a setting and do(es) not say whether it is trusted, so it counts as "+
"trusted: set at the terminal alone; say %q false where nothing trusts it (novox/hq issue 339)",
strings.Join(missing, ", "), catalogue.TrustedField)
}
if missing := catalogue.Undeclared(m); len(missing) > 0 {
fmt.Fprintf(out, "; WARNING: %s stay(s) up and say(s) not how it is ready — judged by liveness alone, "+
"refused from %s (ADR 0240 rule 8)", strings.Join(missing, ", "), catalogue.HealthRequiredFrom.Format("2006-01-02"))
@@ -213,7 +179,6 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
}
// The count the catalogue keeps (ADR 0240 rule 8), in a line its merge check reads.
fmt.Fprintf(out, "%s %d\n", UndeclaredHealthLine, undeclared)
fmt.Fprintf(out, "%s %d\n", UnsaidTrustLine, unsaid)
if failed > 0 {
return fmt.Errorf("%d problem(s) in %d manifest(s)", failed, len(paths))
}
@@ -228,10 +193,6 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
// `health` in, over the manifests given: the catalogue's merge check compares it with the number it keeps.
const UndeclaredHealthLine = "long-running resources without health:"
// UnsaidTrustLine starts the line `module check` says the count of files that ask for a setting and do not say
// whether it is trusted, and so count as trusted (novox/hq issue 339).
const UnsaidTrustLine = "files asking for a setting without saying whether it is trusted:"
// checkNow is the clock `module check` judges the date by; a test sets it.
var checkNow = time.Now
+3 -5
View File
@@ -17,7 +17,7 @@ import (
)
// `check-here` is a pull request's merge check run on the machine at hand **exactly as the build seat
// runs it** (novox/hq issue 286): the same code (builder.Check), the same ask the controller would make of
// runs it** (novox/hq issue 283): the same code (builder.Check), the same ask the controller would make of
// the seat — the gate's modules and judge by the planner's own answer over the facts snapshot, the
// repositories beside it at the refs the snapshot says the seat clones them at — in the toolchain image
// the mesh holds, as the user the build seat runs as, against a throwaway store and bus of the versions
@@ -82,9 +82,7 @@ func checkHereCommand(ctx context.Context, args []string) error {
if _, err := git("fetch", "--quiet", "origin", *base); err != nil {
return fmt.Errorf("cannot fetch %s to say what the change touches: %w", *base, err)
}
// 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")
changedText, err := git("diff", "--name-only", "origin/"+*base+"...HEAD")
if err != nil {
return err
}
@@ -128,7 +126,7 @@ func checkHereCommand(ctx context.Context, args []string) error {
}
if len(toolchains) == 0 {
return errors.New("the facts snapshot names no toolchain: it was taken by a controller from before " +
"issue 286, and the seat's toolchain cannot be known here")
"issue 283, and the seat's toolchain cannot be known here")
}
beside := map[string]builder.Beside{}
for d, ref := range f.Beside {
-20
View File
@@ -103,23 +103,3 @@ func TestTheControllersManifestServesEveryVerbOfItsSeat(t *testing.T) {
t.Fatalf("the controller's own module.json fails module check: %v\n%s", err, out.String())
}
}
// A module that uses a seat another module declares (novox/hq issue 364): refused over the whole catalogue when the
// declarer is missing, a note when the gate says it gives only the manifests a change touches.
func TestASeatAnotherModuleDeclaresIsANoteOverSomeManifests(t *testing.T) {
dir := t.TempDir()
user := filepath.Join(dir, "user.json")
os.WriteFile(user, []byte(`{"module":"asker","version":"1","uses":["operator-channel"]}`), 0o600)
var out bytes.Buffer
if err := moduleCheck([]string{user}, &out); err == nil {
t.Fatalf("a use of a seat nothing given declares passed the whole-catalogue check:\n%s", out.String())
}
t.Setenv(SomeManifestsVar, "1")
out.Reset()
if err := moduleCheck([]string{user}, &out); err != nil {
t.Fatalf("over some manifests the use was refused:\n%s", out.String())
}
if !strings.Contains(out.String(), "note: asker uses the seat \"operator-channel\"") {
t.Fatalf("the note was not said:\n%s", out.String())
}
}
+1 -1
View File
@@ -143,7 +143,7 @@ func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error {
if err != nil {
return err
}
read, err := readForPlanning(ctx, inv)
read, err := inv.ReadRepositories(ctx)
if err != nil {
return err
}
+75 -176
View File
@@ -17,173 +17,14 @@ import (
// moment the keep set moved, so it is the moment to say what may go — and it needs no timer of
// its own. Reclaiming the bytes is the store's own nightly step; this only decides.
//
// **And run when a person asks** (novox/hq ADR 0251): the `collect` verb is the same sweep, with
// bounds of its own. One implementation, so what a person runs is exactly what a build runs.
//
// Never fatal to a build. The build succeeded, the module is registered, and a store that could
// not be reached is a thing to say rather than a reason to undo any of that. The next build asks
// again, and the references it could not collect are still uncollected, so nothing is lost by
// having failed.
// sweepBounds is how much one sweep may ask of the store.
type sweepBounds struct {
// most is how many artifacts it lets go of at most.
most int
// budget is the longest it keeps its caller waiting.
budget time.Duration
// platforms is whether an index is let go of with its own platform manifests (novox/hq ADR 0257).
// Only a sweep a person confirmed, after its dry run listed what stays and names a platform: the
// records cannot see an unrecorded index, or a copy in progress, naming the same platform, and the
// store's tools can.
platforms bool
}
// afterBuild are the bounds of the sweep inside somebody's build.
//
// **Bounded, because this runs inside somebody's build.** The first sweep of a mesh that has never
// collected has the whole history to get through, and a person waiting on `build` should not pay for
// it. What is left over is simply offered again next time — builds are frequent, and the point is
// that the store stops growing, not that it empties tonight.
var afterBuild = sweepBounds{most: mostPerSweep, budget: sweepBudget}
// sweepResult is what one sweep did.
type sweepResult struct {
// HoldersWritten is how many kept archives the store now holds by a manifest it did not before.
HoldersWritten int
// Missing is how many kept archives the store does not have at all.
Missing int
// Held is whether every kept archive could be asked to be held; false stops the sweep before it
// lets anything go.
Held bool
// LetGo is what the store let go of, and the record now says collected.
LetGo []string
// Skipped is how many references the sweep will not address (novox/hq issue 226).
Skipped int
// Indexes is how many eligible indexes a sweep a person did not confirm left for one that is: an
// index goes only with its platform manifests (novox/hq ADR 0257 §4).
Indexes int
// Left is how many eligible references were not asked about this time.
Left int
// Bounded is whether it stopped at its bounds rather than at a refusal.
Bounded bool
// Stopped says why the sweep stopped before the end; empty when it did not.
Stopped string
}
// sweep holds every kept archive, then asks the store to let go of each reference in order, records
// what it let go of, and says what it did.
func sweep(ctx context.Context, inv *inventory.Inventory, store artifacts.Store, references, kept []string,
bounds sweepBounds) sweepResult {
within, stop := context.WithTimeout(ctx, bounds.budget)
defer stop()
var r sweepResult
// **Hold before letting go** (novox/hq issue 253). The store's collector keeps only what a
// manifest names, and archives were published as bare blobs, so every kept archive is first
// held by its manifest — which backfills the ones published before holders, a few at a time
// as builds come, and is two HEADs each once done. A kept archive that could not be held stops
// the sweep before it deletes anything: "everything kept is held" is the precondition the
// collector's safety rests on, and a store refusing a hold would refuse the deletes too.
// **An index goes with its platform manifests only when a person confirmed it** (novox/hq ADR
// 0257), and a kept index keeps its own: which platform manifests the kept indexes name is read
// from the store for every repository the sweep will touch, before the first delete. A single
// read that fails stops the sweep before it deletes anything, as a kept archive that cannot be
// held does. The sweep after a build leaves an eligible index for such a collect, below.
store.Spare = nil
if bounds.platforms {
if inv == nil {
r.Stopped = "no records to read which platform manifests a kept index names, so nothing was let go"
r.Left = len(references)
return r
}
images, err := inv.KeptImages(ctx)
if err == nil {
store.Spare, err = store.SpareKeptIndexes(within, images, references)
}
if err != nil {
r.Stopped = fmt.Sprintf("which platform manifests a kept index names could not be read, so nothing was let go: %v", err)
r.Left = len(references)
return r
}
}
wrote, missing, err := holdKept(within, store, kept)
r.HoldersWritten, r.Missing = wrote, missing
if err != nil {
r.Stopped = fmt.Sprintf("not every kept archive could be held, so nothing was let go: %v", err)
r.Left = len(references)
return r
}
r.Held = true
for i, reference := range references {
if i >= bounds.most || within.Err() != nil {
r.Left = len(references) - i
r.Bounded = true
if i >= bounds.most {
r.Stopped = fmt.Sprintf("asked about %d, the most one sweep asks", bounds.most)
} else {
r.Stopped = fmt.Sprintf("ran out of its %s", bounds.budget)
}
break
}
if !bounds.platforms {
// **An index waits for a confirmed collect** (novox/hq ADR 0257 §4). Let go of alone,
// its platform manifests would stay in the store and the record would say collected, so
// no later sweep would offer it again and they would stay for ever. Left eligible, the
// next collect a person confirms takes it with them.
index, err := store.IsIndex(within, reference)
if err != nil && !errors.Is(err, artifacts.Gone) && !errors.Is(err, artifacts.ErrNotOurs) {
r.Stopped = fmt.Sprintf("the artifact store could not say whether %s is an index, so nothing more was asked of it: %v", reference, err)
r.Left = len(references) - i
break
}
if index {
r.Indexes++
continue
}
}
err := store.LetGo(within, reference)
if err == nil || errors.Is(err, artifacts.Gone) {
// Gone is the outcome wanted, already true. Recorded so the next sweep does not ask
// again for ever.
r.LetGo = append(r.LetGo, reference)
continue
}
if errors.Is(err, artifacts.ErrNotOurs) {
// **A fact about this record, so this record is skipped** (novox/hq issue 226). Not
// marked collected — the mesh did not remove it and should not claim to — and not a
// reason to stop, because the store was never asked. One of these at the front of
// the oldest-first order ended every sweep until this.
r.Skipped++
if r.Skipped == 1 {
fmt.Fprintf(os.Stderr, "the sweep will not address %s and went on: %v\n", reference, err)
}
continue
}
// **Stopped at the first refusal by the STORE, not pushed through.** A store that refuses
// one refuses all of them — deletion disabled, the store down, the network gone — so
// going on would be a hundred identical failures and a hundred identical log lines in
// front of whoever was building something.
r.Stopped = fmt.Sprintf("the artifact store kept %s, so nothing more was asked of it: %v", reference, err)
r.Left = len(references) - i
break
}
if len(r.LetGo) > 0 {
// Recorded outside `within`: the deletions happened, and losing the record of them because
// the sweep ran out of budget would mean asking about them again for ever.
if err := inv.MarkCollected(ctx, r.LetGo); err != nil {
r.Stopped = fmt.Sprintf("the store let go of %d artifact(s) and the record of it did not keep: %v",
len(r.LetGo), err)
}
}
return r
}
// collect asks the store to let go of everything the mesh made and no longer keeps, and records
// what it let go of — after a build. Says what it did and what it could not; returns nothing,
// because nothing upstream should branch on it.
// what it let go of. Says what it did and what it could not; returns nothing, because nothing
// upstream should branch on it.
func collect(ctx context.Context, inv *inventory.Inventory) {
references, err := inv.ToCollect(ctx)
if err != nil {
@@ -214,28 +55,86 @@ func collect(ctx context.Context, inv *inventory.Inventory) {
return
}
r := sweep(ctx, inv, artifacts.Store{Address: address}, references, kept, afterBuild)
if r.HoldersWritten > 0 {
fmt.Fprintf(os.Stderr, "the artifact store now holds %d more kept archive(s) by a manifest\n", r.HoldersWritten)
// **Bounded, because this runs inside somebody's build.** The first sweep of a mesh that has
// never collected has the whole history to get through, and a person waiting on `build` should
// not pay for it. Two bounds, and what is left over is simply offered again next time —
// builds are frequent, and the point is that the store stops growing, not that it empties
// tonight.
within, stop := context.WithTimeout(ctx, sweepBudget)
defer stop()
store := artifacts.Store{Address: address}
// **Hold before letting go** (novox/hq issue 253). The store's collector keeps only what a
// manifest names, and archives were published as bare blobs, so every kept archive is first
// held by its manifest — which backfills the ones published before holders, a few at a time
// as builds come, and is two HEADs each once done. A kept archive that could not be held stops
// the sweep before it deletes anything: "everything kept is held" is the precondition the
// collector's safety rests on, and a store refusing a hold would refuse the deletes too.
wrote, missing, err := holdKept(within, store, kept)
if wrote > 0 {
fmt.Fprintf(os.Stderr, "the artifact store now holds %d more kept archive(s) by a manifest\n", wrote)
}
if r.Missing > 0 {
if missing > 0 {
fmt.Fprintf(os.Stderr, "%d archive(s) the mesh keeps are not in the artifact store at all; "+
"`collection` lists them\n", r.Missing)
"`collection` lists them\n", missing)
}
if len(r.LetGo) > 0 {
fmt.Fprintf(os.Stderr, "the artifact store let go of %d artifact(s) the mesh no longer keeps\n", len(r.LetGo))
if err != nil {
fmt.Fprintf(os.Stderr, "not every kept archive could be held, so nothing was let go: %v\n", err)
return
}
if r.Stopped != "" && !r.Bounded {
fmt.Fprintln(os.Stderr, r.Stopped)
var done []string
var left, skipped int
for i, reference := range references {
if i >= mostPerSweep || within.Err() != nil {
left = len(references) - i
break
}
err := store.LetGo(within, reference)
if err == nil || errors.Is(err, artifacts.Gone) {
// Gone is the outcome wanted, already true. Recorded so the next sweep does not ask
// again for ever.
done = append(done, reference)
continue
}
if errors.Is(err, artifacts.ErrNotOurs) {
// **A fact about this record, so this record is skipped** (novox/hq issue 226). Not
// marked collected — the mesh did not remove it and should not claim to — and not a
// reason to stop, because the store was never asked. One of these at the front of
// the oldest-first order ended every sweep until this.
skipped++
if skipped == 1 {
fmt.Fprintf(os.Stderr,
"the sweep will not address %s and went on: %v\n", reference, err)
}
continue
}
// **Stopped at the first refusal by the STORE, not pushed through.** A store that refuses
// one refuses all of them — deletion disabled, the store down, the network gone — so
// going on would be a hundred identical failures and a hundred identical log lines in
// front of whoever was building something.
fmt.Fprintf(os.Stderr, "the artifact store kept %s, so nothing more was asked of it: %v\n",
reference, err)
left = len(references) - i
break
}
if r.Held && r.Left > 0 {
fmt.Fprintf(os.Stderr, "%d more to collect; the next build asks again\n", r.Left)
if len(done) > 0 {
// Recorded outside `within`: the deletions happened, and losing the record of them because
// the sweep ran out of budget would mean asking about them again for ever.
if err := inv.MarkCollected(ctx, done); err != nil {
fmt.Fprintf(os.Stderr, "the store let go of %d artifact(s) and the record of it did not keep: %v\n",
len(done), err)
return
}
fmt.Fprintf(os.Stderr, "the artifact store let go of %d artifact(s) the mesh no longer keeps\n",
len(done))
}
if r.Skipped > 0 {
fmt.Fprintf(os.Stderr, "%d artifact(s) the sweep will not address were skipped\n", r.Skipped)
if left > 0 {
fmt.Fprintf(os.Stderr, "%d more to collect; the next build asks again\n", left)
}
if r.Indexes > 0 {
fmt.Fprintf(os.Stderr, "%d eligible index(es) wait for a collect a person confirms, which takes their platforms too\n", r.Indexes)
if skipped > 0 {
fmt.Fprintf(os.Stderr, "%d artifact(s) the sweep will not address were skipped\n", skipped)
}
}
+30 -72
View File
@@ -5,7 +5,6 @@ import (
"errors"
"flag"
"fmt"
"io"
"os"
"slices"
"strconv"
@@ -47,7 +46,7 @@ func keeperOn(ctx context.Context, conn *nats.Conn) (*conditions.Keeper, error)
Say: func(format string, args ...any) { fmt.Fprintf(os.Stderr, format+"\n", args...) },
// What status leads with changed: composed again soon (a nudge outside the serving controller
// does nothing).
Changed: func() { statusFrom.nudge(); askerFrom.nudge() },
Changed: statusFrom.nudge,
// Written under the lease, carrying its epoch (novox/hq to-be 45 §6).
Epoch: func() (uint64, error) { return theLease.epoch(context.WithoutCancel(ctx)) }}), nil
}
@@ -108,20 +107,19 @@ func conditionsCommand(ctx context.Context, args []string) error {
}
switch sub {
case "list":
return listConditions(ctx, args, os.Stdout)
return listConditions(ctx, args)
case "show":
return showCondition(ctx, args, os.Stdout)
return showCondition(ctx, args)
case "silence":
return silenceCondition(ctx, args)
case "history":
return conditionHistory(ctx, args, os.Stdout)
return conditionHistory(ctx, args)
}
return errors.New(conditionsUsage)
}
func listConditions(ctx context.Context, args []string, w io.Writer) error {
func listConditions(ctx context.Context, args []string) error {
set := flag.NewFlagSet("conditions", flag.ContinueOnError)
usageTo(set, w)
scope := set.String("scope", "", "only this scope: "+strings.Join(conditions.Scopes, ", "))
severity := set.String("severity", "", "only urgent, or only warning")
machine := set.String("machine", "", "only those about this machine")
@@ -146,64 +144,26 @@ func listConditions(ctx context.Context, args []string, w io.Writer) error {
}
}
if *asJSON {
return printJSONTo(w, map[string]any{"conditions": inBrief(out), "open": len(open), "counted": counted(out),
"note": "urgent first, then oldest first; a condition clears when observation says so, never by hand; " +
"each with its newest evidence — `conditions key=<key>` gives one whole"})
if out == nil {
out = []conditions.Condition{}
}
return printJSON(map[string]any{"conditions": out, "open": len(open),
"note": "urgent first, then oldest first; a condition clears when observation says so, never by hand"})
}
if len(out) == 0 {
if len(open) == 0 {
fmt.Fprintln(w, "no open conditions")
fmt.Println("no open conditions")
} else {
fmt.Fprintf(w, "none of the %d open condition(s) is about that\n", len(open))
fmt.Printf("none of the %d open condition(s) is about that\n", len(open))
}
return nil
}
for _, line := range conditionLines(out, time.Now()) {
fmt.Fprintln(w, line)
fmt.Println(line)
}
return nil
}
// briefEvidence is how many observations a condition carries in a list of them: its newest. The store
// keeps ten, and a list of hundreds of conditions with ten each was the larger half of an answer more
// than the bus carries in one message (novox/hq issue 314). One condition whole is `conditions key=`.
const briefEvidence = 1
// conditionInBrief is a condition as a list carries it: its newest evidence, with how much more there is
// and where to read it. Everything else of it, so a reader of the list — the operator's channel among
// them — reads the same condition it always did.
type conditionInBrief struct {
conditions.Condition
// EvidenceKept is how many observations the condition keeps, when the list shows fewer.
EvidenceKept int `json:"evidence-kept,omitempty"`
More string `json:"more,omitempty"`
}
// inBrief is a list of conditions in brief; never null.
func inBrief(list []conditions.Condition) []conditionInBrief {
out := make([]conditionInBrief, 0, len(list))
for _, c := range list {
b := conditionInBrief{Condition: c}
if len(c.Evidence) > briefEvidence {
b.EvidenceKept = len(c.Evidence)
b.Evidence = c.Evidence[:briefEvidence:briefEvidence]
b.More = c.Show()
}
out = append(out, b)
}
return out
}
// counted is how many of a list each source raised of each kind — `D14 stalled: 689` — so a list of
// hundreds says its shape before its lines.
func counted(list []conditions.Condition) map[string]int {
out := map[string]int{}
for _, c := range list {
out[c.Source+" "+c.Kind]++
}
return out
}
// concerns says whether a condition is about a machine: it names it, or its key does.
func concerns(c conditions.Condition, machine string) bool {
if c.Subject.Machine == machine || slices.Contains(c.Subject.Also, machine) {
@@ -235,9 +195,8 @@ func conditionLines(list []conditions.Condition, now time.Time) []string {
return out
}
func showCondition(ctx context.Context, args []string, w io.Writer) error {
func showCondition(ctx context.Context, args []string) error {
set := flag.NewFlagSet("conditions show", flag.ContinueOnError)
usageTo(set, w)
asJSON := set.Bool("json", false, "as data")
rest, err := parseAround(set, args)
if err != nil {
@@ -269,34 +228,34 @@ func showCondition(ctx context.Context, args []string, w io.Writer) error {
"history --key %s` what became of it", key, key)
}
if *asJSON {
return printJSONTo(w, c)
return printJSON(c)
}
now := time.Now()
fmt.Fprintf(w, "%s %s\n %s\n\n", strings.ToUpper(string(c.Severity)), c.Key, c.Summary)
fmt.Fprintf(w, " kind %s\n about %s %s", c.Kind, c.Subject.Scope, c.Subject.ID)
fmt.Printf("%s %s\n %s\n\n", strings.ToUpper(string(c.Severity)), c.Key, c.Summary)
fmt.Printf(" kind %s\n about %s %s", c.Kind, c.Subject.Scope, c.Subject.ID)
if c.Subject.Machine != "" {
fmt.Fprintf(w, ", on %s", c.Subject.Machine)
fmt.Printf(", on %s", c.Subject.Machine)
}
fmt.Fprintf(w, "\n raised by %s\n since %s (%s ago), observed %d time(s), last %s ago\n",
fmt.Printf("\n raised by %s\n since %s (%s ago), observed %d time(s), last %s ago\n",
c.Source, c.Raised.Local().Format("2006-01-02 15:04:05"), roughly(now.Sub(c.Raised)), c.Observations,
now.Sub(c.LastObserved).Round(time.Second))
if c.Count > 1 {
fmt.Fprintf(w, " raised %d times, each within ten minutes of clearing\n", c.Count)
fmt.Printf(" raised %d times, each within ten minutes of clearing\n", c.Count)
}
fmt.Fprintf(w, " resolved by %s\n", resolverWords(c.Resolver))
fmt.Printf(" resolved by %s\n", resolverWords(c.Resolver))
if c.Silenced != nil {
fmt.Fprintf(w, " silenced until %s by %s: %s\n", c.Silenced.Until.Local().Format("2006-01-02 15:04"),
fmt.Printf(" silenced until %s by %s: %s\n", c.Silenced.Until.Local().Format("2006-01-02 15:04"),
c.Silenced.By, c.Silenced.Why)
}
if len(c.Tried) > 0 {
fmt.Fprintln(w, "\n tried:")
fmt.Println("\n tried:")
for _, t := range c.Tried {
fmt.Fprintf(w, " %s %s — %s: %s\n", t.At.Local().Format("2006-01-02 15:04"), orHealer(t.By), t.What, t.Outcome)
fmt.Printf(" %s %s — %s: %s\n", t.At.Local().Format("2006-01-02 15:04"), orHealer(t.By), t.What, t.Outcome)
}
}
fmt.Fprintln(w, "\n evidence, newest first:")
fmt.Println("\n evidence, newest first:")
for _, e := range c.Evidence {
fmt.Fprintf(w, " %s %s\n", e.At.Local().Format("2006-01-02 15:04:05"), e.Said)
fmt.Printf(" %s %s\n", e.At.Local().Format("2006-01-02 15:04:05"), e.Said)
}
return nil
}
@@ -381,9 +340,8 @@ func parseFor(s string) (time.Duration, error) {
return d, nil
}
func conditionHistory(ctx context.Context, args []string, w io.Writer) error {
func conditionHistory(ctx context.Context, args []string) error {
set := flag.NewFlagSet("conditions history", flag.ContinueOnError)
usageTo(set, w)
days := set.Int("days", 7, "how many days back, at most 90")
key := set.String("key", "", "only this condition")
asJSON := set.Bool("json", false, "as data")
@@ -424,10 +382,10 @@ func conditionHistory(ctx context.Context, args []string, w io.Writer) error {
if out == nil {
out = []conditions.Event{}
}
return printJSONTo(w, map[string]any{"history": out, "days": *days})
return printJSON(map[string]any{"history": out, "days": *days})
}
if len(out) == 0 {
fmt.Fprintf(w, "nothing was raised, changed or cleared in the last %d day(s)\n", *days)
fmt.Printf("nothing was raised, changed or cleared in the last %d day(s)\n", *days)
return nil
}
for _, e := range out {
@@ -444,7 +402,7 @@ func conditionHistory(ctx context.Context, args []string, w io.Writer) error {
line += " — " + e.Why
}
}
fmt.Fprintln(w, line)
fmt.Println(line)
}
return nil
}
@@ -1,108 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/conditions"
)
// stalledConditions are n open conditions like those of 2026-10-08: one per delivery held past its bound,
// each keeping all its evidence.
func stalledConditions(n int) []conditions.Condition {
at := time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC)
var out []conditions.Condition
for i := 0; i < n; i++ {
id := fmt.Sprintf("d-%012x", 0x51a11ed+i)
lines := stalledObservations([]stalledLine{{ID: id, State: "checking", For: "13h2m", Bound: "45m",
H2: "close: recheck", Says: "the check was asked and nothing has answered"}})
o := lines[0]
c := conditions.Condition{Key: o.Key(), Kind: o.Kind, Subject: conditions.Subject{Scope: o.Scope, ID: o.ID},
Severity: o.Severity, Summary: o.Summary, Source: probeDeliveriesID, Raised: at, LastObserved: at,
Observations: 160, Count: 1, Resolver: conditions.ResolverSelf}
for e := 0; e < conditions.KeptEvidence; e++ {
c.Evidence = append(c.Evidence, conditions.Evidence{At: at.Add(time.Duration(e) * 5 * time.Minute),
Said: "checking for " + (13*time.Hour + time.Duration(e)*time.Minute).String()})
}
out = append(out, c)
}
return out
}
// novox/hq issue 314: a list of hundreds of open conditions carries each one's newest evidence, with
// how much more it keeps and where to read it, and the verb's answer says it once — as data, not again
// as the text the command printed. On 2026-10-08, 689 stalled deliveries made that answer more than the
// bus carries in one message; now it is less than half of one.
func TestAListOfConditionsCarriesEachInBrief(t *testing.T) {
list := stalledConditions(689)
printed, _ := json.MarshalIndent(map[string]any{"conditions": inBrief(list), "open": len(list),
"counted": counted(list)}, "", " ")
var parsed any
if err := json.Unmarshal(printed, &parsed); err != nil {
t.Fatal(err)
}
wire, _ := json.Marshal(map[string]any{"result": verbAnswer{OK: true, Answer: parsed,
Output: "its answer, as data, is `answer`\n"}})
before, _ := json.MarshalIndent(map[string]any{"conditions": list}, "", " ")
t.Logf("689 stalled deliveries: %d bytes on the wire, where the whole list printed was %d and was sent twice",
len(wire), len(before))
if len(wire) >= 512<<10 {
t.Fatalf("the answer of 689 conditions is %d bytes, not under half of one message of the bus", len(wire))
}
var doc struct {
Conditions []conditionInBrief `json:"conditions"`
Counted map[string]int `json:"counted"`
}
if err := json.Unmarshal(printed, &doc); err != nil {
t.Fatal(err)
}
c := doc.Conditions[0]
if len(doc.Conditions) != 689 || len(c.Evidence) != 1 || c.EvidenceKept != conditions.KeptEvidence ||
c.Key != list[0].Key || c.Summary != list[0].Summary || !strings.Contains(c.More, "key="+c.Key) {
t.Fatalf("a condition in brief reads %+v", c)
}
if doc.Counted["D14 stalled"] != 689 {
t.Fatalf("counted %v", doc.Counted)
}
if one := inBrief(list[:1])[0]; len(list[0].Evidence) != conditions.KeptEvidence || one.Evidence[0] != list[0].Evidence[0] {
t.Fatal("the brief changed the condition it was made from, or kept other than the newest")
}
}
// The self-check's verdict carries at most overviewFindings of one probe's findings, with their count and
// how to read them all; `doctor probe=` gives that probe whole (novox/hq issue 314).
func TestAVerdictCarriesAProbesFindingsInBrief(t *testing.T) {
var found []string
for i := 0; i < 689; i++ {
found = append(found, fmt.Sprintf("delivery d-%012x has been checking for 13h, past its bound of 45m", i))
}
run := doctorRun{Run: "run-1", Probes: []probeVerdict{{ID: "D14", Verdict: "failed", Found: found},
{ID: "D1", Verdict: "passed"}}}
answer, err := verdictAnswer(run, time.Now(), "")
if err != nil {
t.Fatal(err)
}
brief := answer["run"].(doctorRun).Probes[0]
if len(brief.Found) != overviewFindings || brief.FoundCount != 689 || !strings.Contains(brief.More, "probe=D14") {
t.Fatalf("the verdict carries %d findings of D14, count %d, more %q", len(brief.Found), brief.FoundCount, brief.More)
}
if len(run.Probes[0].Found) != 689 {
t.Fatal("the overview cut the run it was made from")
}
whole, err := verdictAnswer(run, time.Now(), "d14")
if err != nil {
t.Fatal(err)
}
if p := whole["run"].(doctorRun).Probes; len(p) != 1 || len(p[0].Found) != 689 || p[0].More != "" {
t.Fatalf("doctor probe=D14 answered %+v", p)
}
if _, err := verdictAnswer(run, time.Now(), "D99"); err == nil {
t.Fatal("a probe the run does not have was answered")
}
if !strings.Contains(doctorText(answer), "probe=D14` gives them all") {
t.Fatalf("the text does not say how to read them all:\n%s", doctorText(answer))
}
}
+2 -2
View File
@@ -86,7 +86,7 @@ func TestASilenceThroughTheVerbHoldsAndTheConditionStaysOpen(t *testing.T) {
func TestUnreadableConditionsAreNotAWellMesh(t *testing.T) {
open := aMesh(t)
_, store := withConditionsInMemory(t)
store.SetFail(errors.New("the bus is away"))
store.Fail = errors.New("the bus is away")
asked, err := theThreeQuestions(t.Context(), open)
if err != nil {
t.Fatal(err)
@@ -98,7 +98,7 @@ func TestUnreadableConditionsAreNotAWellMesh(t *testing.T) {
if !strings.HasPrefix(said, "the open conditions could NOT be read") || strings.Contains(said, "no open conditions") {
t.Fatalf("%s", said)
}
store.SetFail(nil)
store.Fail = nil
asked, _ = theThreeQuestions(t.Context(), open)
said = printed(t, func() error { return printStatus(asked) })
if asked.well() && !strings.Contains(said, "no open conditions;") {
-162
View File
@@ -1,162 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"github.com/nats-io/nats.go"
"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/link"
)
// What a consumer gave up on, answered by the serving controller (novox/hq issue 330).
//
// **In this process, on its own connection**: DEAD_LETTERS is read and changed on the bus, and the
// serving controller is already on it. A verb run as a fresh process would open a connection of its own
// for each call (novox/hq issue 327).
// busHandles are the serving controller's connection and JetStream handle.
type busHandles struct {
conn *nats.Conn
js nats.JetStreamContext
}
// defaultDeadLetters is how many the list says when not asked for more.
const defaultDeadLetters = 50
// causeDeadLetter is the cause a delivery or drop gives when the caller gives none.
const causeDeadLetter = "dead-letter"
// deadLettersAnswer is what `dead-letters` answers: the list, one whole, or what came of delivering one
// again or dropping it.
func deadLettersAnswer(ctx context.Context, a *verbArguments) (any, error) {
serving := servingBus.Load()
if serving == nil {
return nil, errors.New("this controller is not serving, so it does not read DEAD_LETTERS: ask again, " +
"and the serving controller answers")
}
on := &busHandles{conn: serving.Conn(), js: serving.Context()}
// The shape first, and every argument it reads; one given beside it is refused before anything is
// done, as every verb refuses what it would pass over (novox/hq issue 244).
var deliver, drop, why, cause, idText, consumer, limit string
switch {
case a.given["deliver"] != "" || a.given["drop"] != "":
deliver, drop, why, cause = a.str("deliver"), a.str("drop"), a.str("why"), a.str("cause")
case a.given["id"] != "":
idText = a.str("id")
default:
consumer, limit = a.str("consumer"), a.str("limit")
}
if unused := a.unused(); len(unused) > 0 {
return nil, fmt.Errorf("dead-letters did not use %s together with %s, and an argument a verb would pass "+
"over is refused: nothing was done", quoteAll(unused), quoteAll(a.usedGiven()))
}
switch {
case deliver != "" && drop != "":
return nil, errors.New("dead-letters delivers one again or drops one, not both. Nothing was done")
case deliver != "" || drop != "":
act, text := "deliver", deliver
if drop != "" {
act, text = "drop", drop
}
if strings.TrimSpace(why) == "" {
return nil, fmt.Errorf("dead-letters %s is a hand act, and says why: why is required and recorded in "+
"the hand-act log (novox/hq to-be 45 §7). Nothing was done", act)
}
id, err := deadLetterID(text)
if err != nil {
return nil, err
}
return actOnDeadLetter(ctx, on, act, id, why, cause)
case idText != "":
id, err := deadLetterID(idText)
if err != nil {
return nil, err
}
return link.DeadLetterNamed(on.js, id)
}
most := defaultDeadLetters
if limit != "" {
n, err := strconv.Atoi(limit)
if err != nil || n <= 0 {
return nil, fmt.Errorf("limit is a number of dead letters, not %q", limit)
}
most = n
}
held, total, err := link.DeadLetters(on.js, consumer, most)
if err != nil {
return nil, err
}
answer := map[string]any{"dead_letters": held, "held": total,
"note": "newest first; with id, one whole; deliver or drop one with why"}
if total == 0 {
answer["note"] = "no consumer gave up on a message that is still kept"
}
return answer, nil
}
// deadLetterID is a dead letter's id as a caller wrote it.
func deadLetterID(text string) (uint64, error) {
id, err := strconv.ParseUint(strings.TrimSpace(text), 10, 64)
if err != nil || id == 0 {
return 0, fmt.Errorf("a dead letter's id is its number in %s, as dead-letters lists it, not %q",
broker.DeadLettersStream, text)
}
return id, nil
}
// actOnDeadLetter delivers one again or drops it, recorded in the hand-act log before it is done. A log
// that cannot be written is said, and the act still happens: the log is never the reason a person's act
// is refused (handacts.go).
func actOnDeadLetter(ctx context.Context, on *busHandles, act string, id uint64, why, cause string) (any, error) {
d, err := link.DeadLetterNamed(on.js, id)
if err != nil {
return nil, err
}
if act == "deliver" {
// Refused before it is recorded: an act that cannot be done is not an act.
if _, err := link.AgainTo(on.js, d); err != nil {
return nil, err
}
}
if cause == "" {
cause = causeDeadLetter
}
by := link.CallerIn(ctx)
if by == "" {
by = "a seat call whose caller the bus did not name"
}
answer := map[string]any{"dead_letter": d.ID, "consumer": d.Who, "subject": d.Subject}
recorded, logErr := link.RecordHandAct(ctx, on.conn, link.HandAct{Verb: "dead-letters " + act,
Args: []string{strconv.FormatUint(id, 10), d.Stream + "." + d.Consumer}, Why: why, Cause: cause,
Condition: conditions.Key(conditions.ScopeBus, d.Stream+"."+d.Consumer, link.AdvisoryMaxDeliveries),
By: by + ", through the " + catalogue.ControllerSeatName + " seat"})
if logErr != nil {
answer["unrecorded"] = "the hand-act log could not be written, and the act was done all the same: " + logErr.Error()
} else {
answer["recorded"] = recorded.ID
}
switch act {
case "deliver":
_, to, err := link.DeliverAgain(on.js, id)
if err != nil {
return nil, err
}
answer["delivered_on"] = to
answer["done"] = fmt.Sprintf("dead letter %d was delivered again to %s, and nobody else; it is no longer kept",
id, consumerWho(d.Stream, d.Consumer))
case "drop":
if _, err := link.DropDeadLetter(on.js, id); err != nil {
return nil, err
}
answer["done"] = fmt.Sprintf("dead letter %d, which %s gave up on, was dropped for good", id,
consumerWho(d.Stream, d.Consumer))
}
return answer, nil
}
-132
View File
@@ -1,132 +0,0 @@
package main
import (
"context"
"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/testbus"
)
// The serving controller's bus, with the mesh's streams, for the verb to read and act on.
func servingDeadLetters(t *testing.T) *broker.JetStream {
t.Helper()
js, err := broker.Dial(testbus.URL(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(js.Close)
if err := broker.AssertMeshStreams(js); err != nil {
t.Fatal(err)
}
if err := js.EnsureControllerBuckets(); err != nil {
t.Fatal(err)
}
before := servingBus.Load()
servingBus.Store(js)
t.Cleanup(func() { servingBus.Store(before) })
return js
}
func askDeadLetters(t *testing.T, args map[string]any) (any, error) {
t.Helper()
a, err := readArguments("dead-letters", args)
if err != nil {
return nil, err
}
return deadLettersAnswer(context.Background(), a)
}
func TestDeadLettersListsDropsAndRecordsWhy(t *testing.T) {
js := servingDeadLetters(t)
if _, err := js.Context().Publish("mesh.mod.gitea.event.pull.merged", []byte(`{"n":1}`)); err != nil {
t.Fatal(err)
}
d, err := link.KeepDeadLetter(js.Context(), []byte(`{"stream":"EVENTS","consumer":"media_sonarr","stream_seq":1,"deliveries":5}`))
if err != nil {
t.Fatal(err)
}
answer, err := askDeadLetters(t, map[string]any{})
if err != nil {
t.Fatal(err)
}
listed := answer.(map[string]any)
if listed["held"] != 1 || len(listed["dead_letters"].([]link.DeadLetter)) != 1 {
t.Fatalf("listed %v", listed)
}
// Refused before anything is done: an act without why, an argument the shape passes over, both acts.
for _, args := range []map[string]any{
{"drop": "1"},
{"drop": "1", "why": "x", "limit": "3"},
{"drop": "1", "deliver": "1", "why": "x"},
{"why": "x"},
{"id": "nought"},
} {
if _, err := askDeadLetters(t, args); err == nil {
t.Errorf("%v was done", args)
}
}
if _, total, _ := link.DeadLetters(js.Context(), "", 0); total != 1 {
t.Fatalf("a refused call changed what is kept: %d left", total)
}
done, err := askDeadLetters(t, map[string]any{"drop": "1", "why": "the media server took the download in by hand"})
if err != nil {
t.Fatal(err)
}
if said := done.(map[string]any); said["recorded"] == nil || !strings.Contains(said["done"].(string), "dropped") {
t.Fatalf("answered %v", said)
}
acts, err := link.HandActs(context.Background(), js.Conn(), time.Now().Add(-time.Minute))
if err != nil || len(acts) != 1 || acts[0].Verb != "dead-letters drop" || acts[0].Cause != causeDeadLetter ||
!strings.Contains(acts[0].Condition, "media_sonarr") {
t.Fatalf("recorded %+v (%v)", acts, err)
}
if !personsDecision(acts[0]) {
t.Error("dropping a dead letter is counted as a repair, so S15 would want a healer for it")
}
if _, err := askDeadLetters(t, map[string]any{"id": "1"}); err == nil {
t.Errorf("dead letter %d is still answered after it was dropped", d.ID)
}
}
// Open while DEAD_LETTERS holds a message for the consumer, in words the operator reads in one pass:
// what is held, and where to act.
func TestAConsumersDeadLettersAreSaidUntilActedOn(t *testing.T) {
f := &signalFacts{now: time.Now(), deadLetters: map[string]int{"EVENTS.media_sonarr": 4, "EVENTS.controller": 1}}
found := watchDeadLetters(f)
if len(found) != 2 {
t.Fatalf("said %d conditions", len(found))
}
for _, o := range found {
if o.Kind != "max-deliveries" || o.Severity != conditions.Warning || o.Needs == "" {
t.Errorf("%+v", o)
}
if why, ok := conditions.PlainWords(conditions.Words{Headline: o.Headline, Needs: o.Needs,
Explanation: o.Explanation, Resolved: o.Resolved}, "media"); !ok {
t.Errorf("%q is not plain: %s", o.Headline, why)
}
if !strings.Contains(o.Needs, "mesh MCP server") {
t.Errorf("does not say where to act: %q", o.Needs)
}
}
sonarr := found[1]
if sonarr.ID != "EVENTS.media_sonarr" || sonarr.Machine != "media" ||
sonarr.Headline != "Sonarr on media could not handle 4 messages" ||
!strings.Contains(sonarr.Summary, "DEAD_LETTERS") {
t.Errorf("%+v", sonarr)
}
if found[0].Headline != "The controller could not handle a message" {
t.Errorf("%q", found[0].Headline)
}
// None held, none said: it clears when they are delivered again or dropped.
if left := watchDeadLetters(&signalFacts{now: time.Now()}); len(left) != 0 {
t.Fatalf("%v", left)
}
}
+13 -138
View File
@@ -103,8 +103,6 @@ func letGo(ctx context.Context, inv *inventory.Inventory, id, by, why string) (i
return p, fmt.Errorf("%s was let go by %s at %s already", p.ID, p.Delivery.By,
p.Delivery.Go.Local().Format("15:04:05"))
}
// **One walk at a time** (novox/hq ADR 0276): the word is taken, and the walk starts once no other walk is
// started (advanceOnce), so the delivery's owner says it once and is not refused.
now := time.Now().UTC()
p.Delivery.Go, p.Delivery.By, p.Delivery.Why = &now, by, why
p.Note = "let go by " + by + "; its first tier is asked next"
@@ -170,22 +168,11 @@ func (m orderMember) pull() link.PullUpdated {
ModuleDirs: m.ModuleDirs, ModuleDirsSaid: m.ModuleDirsSaid}
}
// orderPair is one "before" among a group's members, why, and the rules it won over.
// orderPair is one "before" among a group's members, and why.
type orderPair struct {
Before string `json:"before"`
After string `json:"after"`
Why string `json:"why"`
// Over is the rules that ordered the pair the other way and lost to Why, by precedence (novox/hq ADR
// 0249): said, so a person reading the order sees which rule won.
Over []string `json:"over,omitempty"`
}
// orderContradiction is two members that rules of one rank order both ways: no precedence resolves it, so
// the group is refused, the rules named, and Said says how a person declares the order.
type orderContradiction struct {
Members []string `json:"members"`
Rules []orderPair `json:"rules"`
Said string `json:"said"`
}
// orderReach is what a member moves, as the planner says.
@@ -196,14 +183,12 @@ type orderReach struct {
Manifests []string `json:"manifests,omitempty"`
}
// groupOrder is a group's order: the members in it, every pair and why, and the cycle when there is one —
// with each contradiction no precedence resolves.
// groupOrder is a group's order: the members in it, every pair and why, and the cycle when there is one.
type groupOrder struct {
Order []string `json:"order"`
Pairs []orderPair `json:"pairs,omitempty"`
Cycle []string `json:"cycle,omitempty"`
Contradictions []orderContradiction `json:"contradictions,omitempty"`
Reach map[string]orderReach `json:"reach,omitempty"`
Order []string `json:"order"`
Pairs []orderPair `json:"pairs,omitempty"`
Cycle []string `json:"cycle,omitempty"`
Reach map[string]orderReach `json:"reach,omitempty"`
}
// The reasons a pair is ordered, in the words the delivery plan shows.
@@ -214,23 +199,7 @@ const (
orderEngineFirst = "engine before controller"
)
// orderRank is a rule's precedence when two rules order one pair both ways (novox/hq ADR 0249), the lower
// winning: what a person declared; then what the graph and the change say (built by, version skew); then
// the rollout default, which infers a direction from the modules' names alone.
func orderRank(why string) int {
switch why {
case orderDeclared:
return 0
case orderBuiltBy, orderVersionSkew:
return 1
case orderEngineFirst:
return 2
}
return 3
}
// orderOf is a group's order (novox/hq ADR 0239 decision 4, ADR 0249), pure. A member goes before another
// when:
// orderOf is a group's order (novox/hq ADR 0239 decision 4), pure. A member goes before another when:
//
// - its pull request is named in the other's `after:` lines (declared);
// - it moves a module the other's moved modules are built by, stand on, package or declare (built by);
@@ -240,28 +209,24 @@ func orderRank(why string) int {
// does not yet grant, and a controller sends nothing an older engine would refuse — ADR 0236's rollout
// order).
//
// When rules order one pair both ways, the one of higher precedence wins (orderRank) and the pair says
// what it won over. Rules of one rank both ways are a contradiction: both are kept as pairs, the members
// are the cycle, and the contradiction says how to declare the order. Otherwise, by repository then id, so
// every reading gives one order. A cycle through three members or more is named by its members, and none
// of them is ordered.
// Otherwise, by repository then id, so every reading gives one order. A pair both ways is a cycle: the
// members in it are named, and none of them is ordered.
func orderOf(members []orderMember, reach map[string]orderReach, edges []inventory.Edge) groupOrder {
out := groupOrder{Reach: reach}
byID := map[string]orderMember{}
for _, m := range members {
byID[m.ID] = m
}
var claims []orderPair
add := func(before, after, why string) {
if before == after {
return
}
for _, p := range claims {
if p.Before == before && p.After == after && p.Why == why {
for _, p := range out.Pairs {
if p.Before == before && p.After == after {
return
}
}
claims = append(claims, orderPair{Before: before, After: after, Why: why})
out.Pairs = append(out.Pairs, orderPair{Before: before, After: after, Why: why})
}
named := func(said, repository string) bool {
said = strings.TrimSuffix(strings.TrimSpace(said), ".git")
@@ -298,7 +263,6 @@ func orderOf(members []orderMember, reach map[string]orderReach, edges []invento
}
}
}
out.Pairs, out.Contradictions = resolveClaims(claims, byID)
sort.Slice(out.Pairs, func(i, j int) bool {
if out.Pairs[i].Before != out.Pairs[j].Before {
return out.Pairs[i].Before < out.Pairs[j].Before
@@ -348,89 +312,6 @@ func orderOf(members []orderMember, reach map[string]orderReach, edges []invento
return out
}
// orderRules is the rules in the order a pair names them when more than one says the same.
var orderRules = []string{orderDeclared, orderBuiltBy, orderVersionSkew, orderEngineFirst}
// resolveClaims is the pairs the rules' claims come to (novox/hq ADR 0249): for each two members, the claims
// of the highest precedence decide, and the claims they overrule are said on the pair. Claims of one rank
// both ways are a contradiction: both pairs are kept, so the walk finds the cycle, and the contradiction
// says how a person declares the order.
func resolveClaims(claims []orderPair, byID map[string]orderMember) ([]orderPair, []orderContradiction) {
byTwo := map[[2]string][]orderPair{}
var keys [][2]string
for _, c := range claims {
k := [2]string{min(c.Before, c.After), max(c.Before, c.After)}
if _, ok := byTwo[k]; !ok {
keys = append(keys, k)
}
byTwo[k] = append(byTwo[k], c)
}
slices.SortFunc(keys, func(a, b [2]string) int {
if c := strings.Compare(a[0], b[0]); c != 0 {
return c
}
return strings.Compare(a[1], b[1])
})
ruleIndex := func(why string) int { return slices.Index(orderRules, why) }
var pairs []orderPair
var contradictions []orderContradiction
for _, k := range keys {
cs := byTwo[k]
slices.SortStableFunc(cs, func(a, b orderPair) int { return ruleIndex(a.Why) - ruleIndex(b.Why) })
top := orderRank(cs[0].Why)
var won []orderPair
ways := map[string]bool{}
for _, c := range cs {
if orderRank(c.Why) == top {
won = append(won, c)
ways[c.Before] = true
}
}
if len(ways) == 1 {
p := orderPair{Before: won[0].Before, After: won[0].After, Why: won[0].Why}
for _, c := range cs {
if c.Before != p.Before && !slices.Contains(p.Over, c.Why) {
p.Over = append(p.Over, c.Why)
}
}
pairs = append(pairs, p)
continue
}
// One rank both ways: no precedence resolves it.
var first, second *orderPair
var said []string
for i := range won {
c := won[i]
if c.Before == k[0] && first == nil {
first = &won[i]
}
if c.Before == k[1] && second == nil {
second = &won[i]
}
said = append(said, fmt.Sprintf("%s puts %s first", c.Why, c.Before))
}
pairs = append(pairs, orderPair{Before: first.Before, After: first.After, Why: first.Why},
orderPair{Before: second.Before, After: second.After, Why: second.Why})
a, b := byID[k[0]], byID[k[1]]
how := fmt.Sprintf("neither outranks the other: declare the order with a line `after: %s` in %s's "+
"description, or `after: %s` in %s's", a.Repository, pullName(b), b.Repository, pullName(a))
if top == orderRank(orderDeclared) {
how = "each pull request declares it goes after the other: remove one of the `after:` lines"
}
contradictions = append(contradictions, orderContradiction{Members: []string{k[0], k[1]}, Rules: won,
Said: fmt.Sprintf("%s and %s: %s; %s", k[0], k[1], strings.Join(said, ", "), how)})
}
return pairs, contradictions
}
// pullName is a member as a person finds it: its pull request, or its id when no number was said.
func pullName(m orderMember) string {
if m.Number > 0 {
return fmt.Sprintf("%s#%d", m.Repository, m.Number)
}
return m.ID
}
// reachOfMembers is each member's reach, as the planner says it.
func reachOfMembers(members []orderMember, entries []inventory.Entry, read map[string][]inventory.ReadRepository,
edges []inventory.Edge) map[string]orderReach {
@@ -576,12 +457,6 @@ func deliveryCommand(ctx context.Context, args []string) error {
if walks, err = inv.OpenPlans(ctx); err != nil {
return err
}
// And the batch being assembled (novox/hq ADR 0276): never a walk to link or let go, shown.
batches, err := inv.Batches(ctx)
if err != nil {
return err
}
walks = append(walks, batches...)
recent, err := inv.RecentPlans(ctx, *limit)
if err != nil {
return err
@@ -612,7 +487,7 @@ func theGraph(ctx context.Context, inv *inventory.Inventory) ([]inventory.Entry,
if err != nil {
return nil, nil, nil, err
}
read, err := readForPlanning(ctx, inv)
read, err := inv.ReadRepositories(ctx)
if err != nil {
return nil, nil, nil, err
}
+6 -9
View File
@@ -33,14 +33,12 @@ var deliveryOwnerWithin = 10 * time.Second
// stalledLine is one delivery past its bound, as mesh-delivery's `stalled` says it.
type stalledLine struct {
ID string `json:"id"`
// Number is the pull request's, for a line of a head the forge never announced (novox/hq issue 347).
Number int `json:"number,omitempty"`
State string `json:"state"`
For string `json:"for"`
Bound string `json:"bound"`
H2 string `json:"h2"`
Says string `json:"says"`
ID string `json:"id"`
State string `json:"state"`
For string `json:"for"`
Bound string `json:"bound"`
H2 string `json:"h2"`
Says string `json:"says"`
}
// operatorsOnly is whether the table leaves H2 nothing to do for the line: the state is the operator's.
@@ -115,7 +113,6 @@ func stalledObservations(lines []stalledLine) []conditions.Observation {
if l.operatorsOnly() {
o.Resolver = conditions.ResolverOperator
}
o.Headline, o.Explanation, o.Resolved, o.Needs, o.Actions = stalledWords(l, o)
out = append(out, o)
}
return out
@@ -136,13 +136,12 @@ func TestTheDeliveryOwnerIsAskedOverTheBus(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// And release and stop, which the operator's warrant chooses (novox/hq ADR 0259).
for _, verb := range []string{"stalled", "close", "release", "stop"} {
for _, verb := range []string{"stalled", "close"} {
if !slices.Contains(granted.Publish, link.SeatToolSubject(catalogue.DeliverySeat, verb)) {
t.Errorf("the controller may not ask %s.%s", catalogue.DeliverySeat, verb)
}
}
if _, err := askDeliveryOwner(t.Context(), nil, "retire-history", nil); err == nil || !strings.Contains(err.Error(), "grant") {
if _, err := askDeliveryOwner(t.Context(), nil, "stop", nil); err == nil || !strings.Contains(err.Error(), "grant") {
t.Fatalf("a verb the grant does not name was asked: %v", err)
}
conn, err := nats.Connect(testbus.URL(t))
+8 -83
View File
@@ -3,7 +3,6 @@ package main
import (
"encoding/json"
"reflect"
"slices"
"strings"
"testing"
"time"
@@ -64,82 +63,11 @@ func TestAGroupIsOrderedByTheGraphAndWhatItsPullRequestsSay(t *testing.T) {
t.Fatalf("another reading ordered %v", again.Order)
}
// A declared order against an inferred one wins (novox/hq ADR 0249), and the pair says what it won over.
// A declared order against an inferred one is a cycle: named, and nothing ordered.
members[1].After = []string{"mesh-catalog"}
got = orderOf(members, reach, edges)
if len(got.Cycle) > 0 || len(got.Contradictions) > 0 {
t.Fatalf("a declared order did not win: cycle %v, %+v", got.Cycle, got.Contradictions)
}
if want := []string{"agent", "lab", "app", "cat", "host", "ctl"}; !reflect.DeepEqual(got.Order, want) {
t.Fatalf("ordered %v, wanted %v", got.Order, want)
}
if !slices.ContainsFunc(got.Pairs, func(p orderPair) bool {
return p.Before == "cat" && p.After == "ctl" && p.Why == orderDeclared && reflect.DeepEqual(p.Over, []string{orderVersionSkew})
}) || slices.ContainsFunc(got.Pairs, func(p orderPair) bool { return p.Before == "ctl" && p.After == "cat" }) {
t.Fatalf("the declared pair does not say it won over version skew: %+v", got.Pairs)
}
// Declared both ways is a contradiction no precedence resolves: a cycle, and how to end it.
members[0].After = []string{"novox/mesh-controller"}
got = orderOf(members, reach, edges)
if !reflect.DeepEqual(got.Cycle, []string{"cat", "ctl"}) || len(got.Contradictions) != 1 {
t.Fatalf("the cycle is %v, ordered %v, contradictions %+v", got.Cycle, got.Order, got.Contradictions)
}
if c := got.Contradictions[0]; !reflect.DeepEqual(c.Members, []string{"cat", "ctl"}) ||
!strings.Contains(c.Said, "remove one of the `after:` lines") {
t.Fatalf("the contradiction is said %+v", c)
}
}
// novox/hq ADR 0249: when two rules order one pair both ways, the rule of higher precedence wins and the
// pair says what it overruled; rules of one rank both ways are refused, naming both and how to declare.
func TestAnOrderRuleYieldsToAStrongerOne(t *testing.T) {
members := []orderMember{
{ID: "ctl", Repository: "novox/mesh-controller", Number: 7},
{ID: "host", Repository: "novox/mesh-host", Number: 9},
}
reach := map[string]orderReach{
"ctl": {Moved: []string{"mesh-controller", "build-agent"}},
"host": {Moved: []string{"mesh-host"}},
}
builtBy := []inventory.Edge{{From: "mesh-host", To: "build-agent", Kind: inventory.EdgeBuiltBy}}
// Built by outranks engine before controller.
got := orderOf(members, reach, builtBy)
if !reflect.DeepEqual(got.Order, []string{"ctl", "host"}) || len(got.Cycle) > 0 {
t.Fatalf("ordered %v, cycle %v", got.Order, got.Cycle)
}
if want := []orderPair{{Before: "ctl", After: "host", Why: orderBuiltBy, Over: []string{orderEngineFirst}}}; !reflect.DeepEqual(got.Pairs, want) {
t.Fatalf("pairs %+v, wanted %+v", got.Pairs, want)
}
// A declared line outranks both.
members[0].After = []string{"mesh-host"}
got = orderOf(members, reach, builtBy)
if !reflect.DeepEqual(got.Order, []string{"host", "ctl"}) || len(got.Cycle) > 0 ||
!reflect.DeepEqual(got.Pairs[0].Over, []string{orderBuiltBy}) || got.Pairs[0].Why != orderDeclared {
t.Fatalf("a declared order did not win: %v %+v", got.Order, got.Pairs)
}
// Without the build agent, engine before controller stands alone.
members[0].After = nil
got = orderOf(members, map[string]orderReach{"ctl": {Moved: []string{"mesh-controller"}}, "host": reach["host"]}, builtBy)
if !reflect.DeepEqual(got.Order, []string{"host", "ctl"}) || got.Pairs[0].Why != orderEngineFirst || len(got.Pairs[0].Over) > 0 {
t.Fatalf("engine before controller alone: %v %+v", got.Order, got.Pairs)
}
// Built by both ways is one rank against itself: refused, both rules named, and how to declare.
both := append(builtBy, inventory.Edge{From: "build-agent", To: "mesh-host", Kind: inventory.EdgeStandsOn})
got = orderOf(members, reach, both)
if !reflect.DeepEqual(got.Cycle, []string{"ctl", "host"}) || len(got.Order) > 0 || len(got.Contradictions) != 1 {
t.Fatalf("a contradiction was ordered: %v, cycle %v, %+v", got.Order, got.Cycle, got.Contradictions)
}
said := got.Contradictions[0].Said
for _, w := range []string{"built by puts ctl first", "built by puts host first",
"`after: novox/mesh-controller` in novox/mesh-host#9's description", "`after: novox/mesh-host` in novox/mesh-controller#7's"} {
if !strings.Contains(said, w) {
t.Errorf("the contradiction does not say %q: %s", w, said)
}
if !reflect.DeepEqual(got.Cycle, []string{"cat", "ctl"}) {
t.Fatalf("the cycle is %v, ordered %v", got.Cycle, got.Order)
}
}
@@ -227,9 +155,7 @@ func TestAMergeWaitsForItsDeliverysWordAndThePersonsWordWorksWithoutIt(t *testin
t.Fatalf("with no holder on record the walk waited (%v) or asked %v", p.Waiting(), *asked)
}
// The holder on record: the next merge waits, asking nothing, and an advance asks nothing either. 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)
// The holder on record: the next merge waits, asking nothing, and an advance asks nothing either.
if _, err := inv.Assign(ctx, "anchor", "mesh-delivery"); err != nil {
t.Fatal(err)
}
@@ -261,11 +187,10 @@ func TestAMergeWaitsForItsDeliverysWordAndThePersonsWordWorksWithoutIt(t *testin
t.Fatalf("the word was not kept: %+v %v", got.Delivery, err)
}
// The delivery's owner's own merge never waits for it. A walk that started is never taken over (novox/hq
// ADR 0276): the merge's batch is cut once it ended.
finish(p.ID)
// The delivery's owner's own merge never waits for it — and takes over what the older walk had not
// built (app, folded in: ADR 0218), which goes with it on the controller's own path.
p = merge("c3cccccccc", "modules/mesh-delivery/main.go")
if p.Waiting() || len(*asked) != 3 {
if p.Waiting() || len(*asked) != 4 {
t.Fatalf("mesh-delivery's own walk waited for mesh-delivery: %v %v", p.Waiting(), *asked)
}
@@ -283,7 +208,7 @@ func TestAMergeWaitsForItsDeliverysWordAndThePersonsWordWorksWithoutIt(t *testin
}
advanceHeld(ctx, open)
got, _ = inv.PlanByID(ctx, p.ID)
if got.Waiting() || !strings.HasPrefix(got.Delivery.By, "a person") || len(*asked) < 4 {
if got.Waiting() || !strings.HasPrefix(got.Delivery.By, "a person") || len(*asked) < 5 {
t.Fatalf("a person's word did not start the walk: %+v, asked %v", got.Delivery, *asked)
}
-333
View File
@@ -1,333 +0,0 @@
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
})
}
-512
View File
@@ -1,512 +0,0 @@
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)
}
}
}
+8 -86
View File
@@ -116,21 +116,6 @@ var probeRegistry = []probe{
{ID: probeDeliveriesID, Asserts: "no delivery is held past its state's bound unsaid: mesh-delivery's " +
"`stalled`, each with the transition its table lets healer H2 take", From: "ADR 0239",
Kind: kindDeliveryStalled, Phase: 3, run: probeDeliveries},
// A client of the bus reconnecting in a loop (novox/hq issue 327), from the server's record of closed
// connections, which the bus's own module reads.
{ID: probeReconnectsID, Asserts: "no user of the bus had its connection dropped more than twelve times in the " +
"last hour: the bus module's nats_closed_connections", From: "issue 327", Kind: kindBusReconnects,
Phase: 1, run: probeReconnects},
// The account agents run as (novox/hq ADR 0266): where a machine names one, its node-engine has judged it
// unable to become root without a person — what ADR 0259 §8 rests an authorised answer on.
{ID: agentAccountProbe, Asserts: "every machine that names an agent account has it judged, on its node-engine's " +
"newest statement, unable to become root without a person", From: "ADR 0266, ADR 0259 §8",
Kind: kindAgentCanBecomeRoot, Phase: 1, run: probeAgentAccounts},
// Root where the trusted parties run (novox/hq ADR 0259 §8): while an agent can become root there without a
// person, an answer proven there proves nothing.
{ID: "D-root", Asserts: "no agent can become root without a person on a machine where the router or a channel " +
"proving its sender runs: not by its own account, and not through a tool that runs its command as an account " +
"that can", From: "ADR 0259 §8", Kind: kindRootNotFree, Phase: 2, run: probeAgentRoot},
{ID: "DW", Asserts: "the watchdogs of the signals table ran within three of their intervals",
From: "ADR 0227 rule 6: the watchers are watched", Kind: "watchdogs-silent", Phase: 1, run: probeWatchdogs},
// The core's health definitions (novox/hq to-be 45 §8, ADR 0236): what a core component's new build is
@@ -166,50 +151,6 @@ type probeVerdict struct {
Unconfirmed []string `json:"unconfirmed,omitempty"`
Error string `json:"error,omitempty"`
Took string `json:"took,omitempty"`
// FoundCount and More are an overview's: how many findings the probe had when the overview shows
// fewer, and how to read them all (novox/hq issue 314). Never in a heartbeat.
FoundCount int `json:"found-count,omitempty"`
More string `json:"more,omitempty"`
}
// overviewFindings is how many findings of one probe an overview carries. On 2026-10-08 D14 found 689
// stalled deliveries, and an overview carrying every one of them, beside the conditions they raised,
// was more than the bus carries in one message (novox/hq issue 314); one probe's verdict whole is
// `doctor probe=<id>`.
const overviewFindings = 20
// inOverview is a run as an overview carries it: each probe's findings at most overviewFindings, with
// their count and how to read them all. The run itself is not changed.
func inOverview(run doctorRun) doctorRun {
probes := make([]probeVerdict, len(run.Probes))
for i, p := range run.Probes {
total := len(p.Found) + len(p.Unconfirmed)
if total > overviewFindings {
p.FoundCount = len(p.Found)
if len(p.Found) > overviewFindings {
p.Found = p.Found[:overviewFindings:overviewFindings]
}
if room := overviewFindings - len(p.Found); len(p.Unconfirmed) > room {
p.Unconfirmed = p.Unconfirmed[:room:room]
}
p.More = fmt.Sprintf("%d found and %d unconfirmed, %d of them shown — `doctor probe=%s` gives them all",
p.FoundCount, total-p.FoundCount, len(p.Found)+len(p.Unconfirmed), p.ID)
}
probes[i] = p
}
run.Probes = probes
return run
}
// oneProbe is a run with only one probe's verdict, whole.
func oneProbe(run doctorRun, id string) (doctorRun, error) {
for _, p := range run.Probes {
if strings.EqualFold(p.ID, id) {
run.Probes = []probeVerdict{p}
return run, nil
}
}
return doctorRun{}, fmt.Errorf("the run %s has no probe %q — `doctor probes=true` lists them", run.Run, id)
}
// doctorCounts are a run's verdicts, counted.
@@ -445,13 +386,12 @@ func doctorCommand(ctx context.Context, args []string) error {
}
set := flag.NewFlagSet("doctor", flag.ContinueOnError)
asJSON := set.Bool("json", false, "as data")
probe := set.String("probe", "", "one probe's verdict, with every finding")
if rest, err := parseAround(set, args); err != nil {
return err
} else if len(rest) > 0 {
return errors.New("doctor [run|probes|signals] [--probe <id>] [--json]")
return errors.New("doctor [run|probes|signals] [--json]")
}
answer, err := doctorAnswer(ctx, sub, *probe)
answer, err := doctorAnswer(ctx, sub)
if err != nil {
return err
}
@@ -463,18 +403,12 @@ func doctorCommand(ctx context.Context, args []string) error {
}
// doctorAnswer is what the verb answers, as data.
//
// A verdict is an overview — each probe's findings at most overviewFindings — unless probe names one,
// which is then answered alone and whole (novox/hq issue 314).
func doctorAnswer(ctx context.Context, sub, probe string) (any, error) {
if probe != "" && sub != "" && sub != "run" {
return nil, fmt.Errorf("probe names one probe of a verdict; %s has none", sub)
}
func doctorAnswer(ctx context.Context, sub string) (any, error) {
switch sub {
case "":
if doctorFrom != nil {
if run := doctorFrom.lastRun(); run != nil {
return verdictAnswer(*run, time.Now(), probe)
return verdictAnswer(*run, time.Now()), nil
}
return nil, fmt.Errorf("the self-check has not finished its first run yet: it runs %s after the "+
"controller starts, then every %s — `doctor run` runs it now", doctorFirstAfter, doctorEvery)
@@ -483,7 +417,7 @@ func doctorAnswer(ctx context.Context, sub, probe string) (any, error) {
if err != nil {
return nil, err
}
return verdictAnswer(run, time.Now(), probe)
return verdictAnswer(run, time.Now()), nil
case "run":
d := doctorFrom
if d == nil {
@@ -494,7 +428,7 @@ func doctorAnswer(ctx context.Context, sub, probe string) (any, error) {
defer closeIt()
d = local
}
return verdictAnswer(d.runOnce(ctx, "asked by "+link.Caller()), time.Now(), probe)
return verdictAnswer(d.runOnce(ctx, "asked by "+link.Caller()), time.Now()), nil
case "probes":
return probesAnswer(), nil
case "signals":
@@ -508,20 +442,11 @@ func doctorAnswer(ctx context.Context, sub, probe string) (any, error) {
}
// verdictAnswer is a run as the verb answers it, with its age.
func verdictAnswer(run doctorRun, now time.Time, probe string) (map[string]any, error) {
if probe != "" {
one, err := oneProbe(run, probe)
if err != nil {
return nil, err
}
run = one
} else {
run = inOverview(run)
}
func verdictAnswer(run doctorRun, now time.Time) map[string]any {
return map[string]any{"run": run, "age": now.Sub(run.At).Round(time.Second).String(),
"note": "a probe that could not run is never a pass; each failure is an open condition until a run passes it, " +
"and one a single look can be wrong about — an unanswered question, a slow answer — is raised when two " +
"runs in a row see it"}, nil
"runs in a row see it"}
}
// probesAnswer is the registry.
@@ -590,9 +515,6 @@ func doctorText(answer any) string {
for _, f := range p.Unconfirmed {
fmt.Fprintf(&b, " unconfirmed, raised if the next run sees it too: %s\n", f)
}
if p.More != "" {
fmt.Fprintf(&b, " … %s\n", p.More)
}
if p.Error != "" {
fmt.Fprintf(&b, " %s\n", p.Error)
}
+2 -2
View File
@@ -45,7 +45,7 @@ func TestAMachineIsSentTheEpochOnlyOnceItSaysItReadsOne(t *testing.T) {
t.Fatal(err)
}
d := &bodiesDelivery{recordedDelivery: recordedDelivery{inv: inv}, bodies: map[string][]byte{}}
if _, err := sendRound(ctx, open, []string{"anchor", "laptop"}, composeForPush(open, gens), d, "", nil); err != nil {
if _, err := sendRound(ctx, open, []string{"anchor", "laptop"}, composeForPush(open, gens), d, ""); err != nil {
t.Fatal(err)
}
carried := func(node string) (epoch float64, has bool) {
@@ -92,7 +92,7 @@ func TestNothingIsComposedOrSentWithoutTheLease(t *testing.T) {
t.Fatal(err)
}
d := &bodiesDelivery{recordedDelivery: recordedDelivery{inv: open.inventory}, bodies: map[string][]byte{}}
refused, err := sendRound(ctx, open, []string{"anchor"}, composeForPush(open, gens), d, "", nil)
refused, err := sendRound(ctx, open, []string{"anchor"}, composeForPush(open, gens), d, "")
if err != nil {
t.Fatal(err)
}
+2 -14
View File
@@ -205,7 +205,7 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
if err != nil {
return snapshot.Facts{}, err
}
read, err := readForPlanning(ctx, inv)
read, err := inv.ReadRepositories(ctx)
if err != nil {
return snapshot.Facts{}, err
}
@@ -269,8 +269,7 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
engines := map[string]bool{}
for _, n := range nodes {
m := snapshot.Machine{Name: scrub.Machine(n.Name), Length: len(n.Name), Adopted: n.Adopted,
AccountHome: scrub.Text(n.AccountHome), NodeEngine: n.HostVersion, PublicDomain: domains[n.Name],
AgentAccount: scrub.Account(n.AgentAccount), AgentAccountHome: scrub.Text(n.AgentAccountHome)}
AccountHome: scrub.Text(n.AccountHome), NodeEngine: n.HostVersion, PublicDomain: domains[n.Name]}
switch n.Account {
case "", "root":
m.Account = n.Account
@@ -411,18 +410,7 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
Commit: e.Source.BuiltFrom, Provided: e.Provided, RollOut: current[e.Manifest.Module].RollOut,
Manifest: raw}
for _, r := range read[e.Manifest.Module] {
// The module's own build source is said apart: a gate that predates it would read an own
// entry among Reads as a context of its own repository.
if r.Own {
if len(r.Paths) > 0 {
mod.Sources = append(mod.Sources, snapshot.BuildSource{Own: true, Paths: r.Paths})
}
continue
}
mod.Reads = append(mod.Reads, snapshot.RepositoryName(r.Repository))
if len(r.Paths) > 0 {
mod.Sources = append(mod.Sources, snapshot.BuildSource{Repository: snapshot.RepositoryName(r.Repository), Paths: r.Paths})
}
}
f.Modules = append(f.Modules, mod)
if e.Provided || e.Source.Repository == "" {
-156
View File
@@ -1,156 +0,0 @@
package main
import (
"context"
"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 directory the node-engine uses as found (novox/hq issue 339) waits for a person to hand it over at the
// machine. Found before this send, it is no fault of the build: the gate passes with the wait carried, so an
// urgent fix of that module still goes through. Found by this send, the send brought it, and the gate holds.
func foundDirectory(module string, since time.Time) inventory.ResourceHealth {
return inventory.ResourceHealth{Module: module, Resource: module + ".data", Kind: link.KindDirectory,
Target: "/srv/" + module, State: link.StateUnhealthy, Since: since,
Reason: link.ReasonUsedAsFound + " owned by 1000:1000, mode 700, as found; root, mode 755 was declared and " +
"not given it — `nox node hand-over laptop <directory>` on the control-node, with its path, hands it to the mesh"}
}
func TestADirectoryFoundBeforeTheSendIsAWaitForAPerson(t *testing.T) {
now := time.Now()
sent := now.Add(-time.Minute)
f := gateFacts{now: now, health: map[string]inventory.NodeHealth{"laptop": {Node: "laptop", HeardAt: now,
Resources: []inventory.ResourceHealth{foundDirectory("notes", sent.Add(-24*time.Hour))}}}}
h, why := moduleHealthWord("notes", "laptop", sent, f)
if h != healthPerson || !strings.Contains(why, "notes.data") || !strings.Contains(why, "hand-over") {
t.Fatalf("a directory found before the send reads %v %q; want a wait for a person", h, why)
}
// Found by this very send: the send brought it, and it is not passed.
f.health["laptop"] = inventory.NodeHealth{Node: "laptop", HeardAt: now,
Resources: []inventory.ResourceHealth{foundDirectory("notes", sent.Add(time.Second))}}
if h, why := moduleHealthWord("notes", "laptop", sent, f); h != healthNotYet {
t.Fatalf("a directory this send found reads %v %q; want not yet", h, why)
}
// A container down beside the old wait is a fault, as before.
f.health["laptop"] = inventory.NodeHealth{Node: "laptop", HeardAt: now, Resources: []inventory.ResourceHealth{
foundDirectory("notes", sent.Add(-time.Hour)),
{Module: "notes", Resource: "notes.web", Kind: "container", Target: "notes", State: link.StateUnhealthy, Reason: "down"}}}
if h, why := moduleHealthWord("notes", "laptop", sent, f); h != healthNotYet {
t.Fatalf("a container down beside the wait reads %v %q; want not yet", h, why)
}
}
// The whole walk: a module whose directory was used as found long before still gets its fix to every machine,
// its pass kept and the wait said; a directory this very send found holds it and puts it back.
func TestAFixGoesThroughPastADirectoryFoundBefore(t *testing.T) {
for _, c := range []struct {
name string
found time.Duration // when the directory was found, against now
passes bool
}{
{"found a day before the send", -24 * time.Hour, true},
{"found by this send", time.Hour, false},
} {
t.Run(c.name, func(t *testing.T) {
b := aBacklog(t)
ctx := t.Context()
inv := b.open.inventory
releaseHeard = func(context.Context, *stores) (map[string]bool, error) {
return map[string]bool{"anchor": true, "laptop": true}, nil
}
backlogFacts := gatherGateFacts
gatherGateFacts = func(ctx context.Context, open *stores, component string) (gateFacts, error) {
f, err := backlogFacts(ctx, open, component)
// The used-as-found condition, raised after the send (its second statement): its own kind, never a
// fault the gate reads as the build's.
f.judged, f.openErr = true, nil
f.open = append(f.open, conditions.Condition{Key: usedAsFoundKey("app", "anchor"), Kind: kindUsedAsFound,
Subject: conditions.Subject{Scope: conditions.ScopeModule, ID: "app.anchor", Machine: "anchor"},
Raised: time.Now()})
f.health = map[string]inventory.NodeHealth{}
for _, n := range []string{"anchor", "laptop"} {
f.health[n] = inventory.NodeHealth{Node: n, HeardAt: time.Now(), Resources: []inventory.ResourceHealth{
{Module: "app", Resource: "app.web", Kind: "container", Target: "app", State: link.StateHealthy},
foundDirectory("app", time.Now().Add(c.found)),
{Module: "late", Resource: "late.web", Kind: "container", Target: "late", State: link.StateHealthy}}}
}
return f, err
}
wasSettle, wasEvery, wasBound := gateSettle, gateEvery, gateBound
t.Cleanup(func() { gateSettle, gateEvery, gateBound = wasSettle, wasEvery, wasBound })
gateSettle, gateEvery, gateBound = 0, 0, 300*time.Millisecond
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
advancePlans(ctx, b.open)
if p := b.release(t); p.State != inventory.PlanRolling {
break
}
time.Sleep(20 * time.Millisecond)
}
p := b.release(t)
v, found, err := inv.GateOf(ctx, "build-app-c2")
if c.passes {
if p.State != inventory.PlanDone || err != nil || !found || v.Verdict != inventory.GatePassed {
t.Fatalf("the walk is %s (%s); app's verdict %+v: want the fix through", p.State, p.Note, v)
}
if !strings.Contains(v.Why+p.Note, "hand it over") {
t.Errorf("the wait is not carried: verdict %q, walk %q", v.Why, p.Note)
}
return
}
if p.State == inventory.PlanDone {
t.Fatalf("a directory this send found let the walk through: %s", p.Note)
}
})
}
}
// The condition says the wait in its own kind: the operator's, never urgent, and cleared once handed over.
func TestADirectoryUsedAsFoundIsItsOwnCondition(t *testing.T) {
k, _ := withConditionsInMemory(t)
ctx := t.Context()
rs := map[string][]inventory.ResourceHealth{"notes": {foundDirectory("notes", time.Now().Add(-time.Hour))}}
for i := 0; i < 2; i++ {
if err := judgeModuleHealth(ctx, nil, k, "laptop", rs, map[string]int{"notes": i + 1}, time.Now()); err != nil {
t.Fatal(err)
}
}
open, _ := k.Open(ctx)
var got *conditions.Condition
for i, c := range open {
if c.Key == usedAsFoundKey("notes", "laptop") {
got = &open[i]
}
if c.Kind == kindModuleUnhealthy {
t.Fatalf("raised as a fault: %+v", c)
}
}
if got == nil || got.Resolver != conditions.ResolverOperator || got.Severity == conditions.Urgent ||
!strings.Contains(got.Summary, "notes.data") {
t.Fatalf("the condition: %+v", got)
}
// Long open is still not urgent: only a person can hand it over, and nothing is broken by the wait.
if err := judgeModuleHealth(ctx, nil, k, "laptop", rs, map[string]int{"notes": 3}, time.Now().Add(48*time.Hour)); err != nil {
t.Fatal(err)
}
open, _ = k.Open(ctx)
for _, c := range open {
if c.Key == usedAsFoundKey("notes", "laptop") && c.Severity == conditions.Urgent {
t.Fatal("a directory used as found became urgent")
}
}
if err := judgeModuleHealth(ctx, nil, k, "laptop", map[string][]inventory.ResourceHealth{}, nil, time.Now()); err != nil {
t.Fatal(err)
}
open, _ = k.Open(ctx)
for _, c := range open {
if c.Key == usedAsFoundKey("notes", "laptop") {
t.Fatal("not cleared once handed over")
}
}
}
+20 -379
View File
@@ -77,19 +77,8 @@ type health int
const (
healthGood health = iota
// healthPerson is a wait for a person (novox/hq ADR 0254): everything unhealthy of the module waits for
// one person's new login (personWait). The build did what it should; no bound a machine can meet ends
// the wait. It counts as a pass, and the gate carries the reading along in its verdict.
healthPerson
// healthWaiting is not a pass and not a fault: what the module's checks find waits on an unhealthy
// provider (ADR 0240 rule 5), so the judging waits — past the bound too — rather than putting back a
// build for something it did not do.
healthWaiting
healthNotYet
healthBroken
// healthSuperseded is a judging that cannot go on: the machine was sent another build of the module
// after the gate's send (novox/hq issue 352). No verdict on the build judged, and nothing put back.
healthSuperseded
)
// served is what one machine's node tools answered the bus's discovery with.
@@ -120,44 +109,6 @@ type gateFacts struct {
// healthErr is why they could not be read.
health map[string]inventory.NodeHealth
healthErr error
// heldOn is, per "<module>@<machine>", the provider its findings are held under (ADR 0240 rule 5).
heldOn map[string]string
// groupsAdded is, per module, whether the move judged puts an account in a group its previous build did
// not (issue 318 review): the only move whose wait for a new login is excused.
groupsAdded map[string]bool
// sent is, per machine, the declaration the gate's own send carried there (novox/hq issue 352): a
// report is held against it, never against the send made last. sentBuilds is what each machine was
// last sent of every module, and judged the commit of each module this gate judges: a machine last
// sent another build of the module is not running the build judged.
sent map[string]inventory.SentDeclaration
sentBuilds map[string]map[string]string
commits map[string]string
}
// reportedOn says a machine's last report is on what the gate sent it (novox/hq issue 352): on that
// declaration, or one it was sent after it — or, for a gate kept before sends were kept on it, on the
// declaration last sent. On 2026-10-09 a release's gate read the control node's report against a newer
// send another plan had just made there, and failed three builds the machine had reported healthy as
// "has not reported on what it was sent".
func (f gateFacts) reportedOn(machine string, r inventory.Reported) bool {
if sent, kept := f.sent[machine]; kept {
return sent.ReportsOn(r)
}
return r.Current
}
// supersededOn says the machine was last sent another build of the module than the one this gate judges
// (novox/hq issue 352): the judging cannot go on, whatever the machine reports. On 2026-10-09 a controller
// put back by one gate judged another gate's newer controller build passed on the same machine, reading
// the put-back build's health as the newer one's.
func (f gateFacts) supersededOn(module, machine string) (string, bool) {
judged, known := f.commits[module]
sent, has := f.sentBuilds[machine][module]
if !known || !has || judged == "" || sent == "" || sameCommit(sent, judged) {
return "", false
}
return fmt.Sprintf("%s was sent %s %s after this gate's %s: the build judged no longer runs there, and "+
"this judging is superseded by that send's", machine, module, short(sent), short(judged)), true
}
// gatherGateFacts reads what a judging needs, from the store, the bus and this controller's memory. A
@@ -196,17 +147,6 @@ var gatherGateFacts = func(ctx context.Context, open *stores, component string)
} else {
f.servedErr = errors.New("this process does not serve the mesh, so it cannot ask the bus who serves what")
}
// Whose findings wait on an unhealthy provider (ADR 0240 rule 5): their gates wait, not fail.
if f.healthErr == nil && f.openErr == nil {
if hold, err := readHolding(ctx, inv, f.open); err == nil {
f.heldOn = map[string]string{}
for machine := range f.health {
for module, p := range hold.heldModules(machine) {
f.heldOn[module+"@"+machine] = p.Module + " on " + p.Node
}
}
}
}
if theLease != nil {
h, found, err := theLease.holder(ctx)
switch {
@@ -235,12 +175,9 @@ func judgeHealth(module, component string, m catalogue.Manifest, machine string,
return healthBroken, fmt.Sprintf("the witness on %s judged the %s %s and %s: %s", machine, r.Component,
short(r.From), r.Outcome, r.Why)
}
if why, superseded := f.supersededOn(module, machine); superseded {
return healthSuperseded, why
}
r, said := f.reports[machine]
switch {
case !said || r.At == nil || !f.reportedOn(machine, r):
case !said || r.At == nil || !r.Current:
return healthNotYet, fmt.Sprintf("%s has not reported on what it was sent", machine)
case r.Outcome == inventory.OutcomeFailed || r.Outcome == inventory.OutcomeRefused:
return healthBroken, fmt.Sprintf("%s %s what it was sent", machine, r.Outcome)
@@ -248,19 +185,14 @@ func judgeHealth(module, component string, m catalogue.Manifest, machine string,
return healthNotYet, fmt.Sprintf("%s reported %q", machine, r.Outcome)
}
// **No new condition about it**: about the machine itself, or naming the module on that machine,
// raised since the judging began. The gate's own are not evidence about the build. 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).
// raised since the judging began. The gate's own are not evidence about the build.
if f.judged {
if f.openErr != nil {
return healthNotYet, "what is wrong cannot be read, so whether the build made anything wrong is not known: " +
firstLine(f.openErr.Error())
}
for _, c := range f.open {
// A wait for a person's new login, or for a directory used as found to be handed over, is the module's
// reading, not a fault raised since the send: the gate reads it from the statement below (ADR 0254,
// novox/hq issue 339).
if c.Source == gateProbe || c.OpenAt(since) || c.Kind == kindReloginNeeded || c.Kind == kindUsedAsFound {
if c.Source == gateProbe || c.Raised.Before(since) {
continue
}
onIt := c.Subject.Machine == machine || slices.Contains(c.Subject.Also, machine) ||
@@ -334,8 +266,6 @@ type machineWord struct {
facts gateFacts
on map[string]string
whole string
// waiting is what holds the machine on something shown to be another's (ADR 0241): the judging waits.
waiting string
}
// kindCoreBehind is D10's kind: a machine runs core components older than the mesh holds, or has not
@@ -371,8 +301,7 @@ func aboutTheMachine(machine string, moved []string, since time.Time, f gateFact
for _, c := range f.open {
aboutIt := c.Subject.Scope == conditions.ScopeMachine && (c.Subject.ID == machine || c.Subject.Machine == machine ||
slices.Contains(c.Subject.Also, machine))
// A directory used as found waits for a person, whatever the send did (novox/hq issue 339).
if !aboutIt || c.Source == gateProbe || c.OpenAt(since) || c.Kind == kindUsedAsFound {
if !aboutIt || c.Source == gateProbe || c.Raised.Before(since) {
kept = append(kept, c)
continue
}
@@ -400,17 +329,6 @@ func aboutTheMachine(machine string, moved []string, since time.Time, f gateFact
}
case coreBehind:
// Not what the send moved: said nowhere against it.
case c.Kind == kindMachineUnits:
// **The machine's own failed units** (issue 315): no module places any of them, so nothing a
// send moved is among them — a module's failed unit is that module's own condition. Said
// nowhere against the send.
case c.Kind == kindNetworkRewritten || c.Kind == kindNetworkUnreachable && c.Subject.ID != machine:
// **Shown to be somebody else's** (ADR 0241): another program rewrote the resolver file the send
// did not move, or the machine cannot reach another that is down. Nothing the send did; the
// judging waits for it rather than putting back a build at the bound.
if w.waiting == "" {
w.waiting = machine + "'s network: " + said
}
default:
if w.whole == "" {
w.whole = machine + " as a whole: " + said
@@ -476,22 +394,6 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
if err != nil {
return "", err
}
facts.groupsAdded = movesAddingGroups(ctx, open.inventory, g, pairs, shelf)
facts.sent = g.Sent
facts.commits, facts.sentBuilds = judgedCommits(g, pairs), map[string]map[string]string{}
// A module this gate put back at once (putBackBroken) was sent its earlier build by the gate itself:
// not another send, and not a judging superseded.
for _, m := range g.Returned {
delete(facts.commits, m)
}
for _, j := range pairs {
if _, read := facts.sentBuilds[j.node]; read {
continue
}
if builds, known, err := open.inventory.SentBuilds(ctx, j.node); err == nil && known {
facts.sentBuilds[j.node] = builds
}
}
// **What is wrong with a machine itself is the machine's** (novox/hq issue 281): read once for each
// machine judged, apart from what is wrong with a module there, and never pinned on the module the
// gate happens to be kept on.
@@ -503,250 +405,52 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
for node, moved := range byMachine {
words[node] = aboutTheMachine(node, moved, *g.Since, facts)
}
// **Each module is judged on its own** (novox/hq ADR 0254, issue 318): its reading is the worst of its
// machines', its passes are counted apart, and the send's verdict is still one. **Every module of a send
// leaves it with a verdict** (issue 318 review): one healthy for the passes the gate asks, after the
// settle time, keeps a pass when another beside it fails; one found broken holds the send's verdict until
// the modules beside it have theirs, or the bound; and at the verdict, every module that did not pass is
// put back with what failed, so none is left on the machine unjudged for other walks to wait on.
worst, why := healthGood, ""
reading := map[string]health{}
waits := map[string]string{}
var modules []string
var failing []string
broken := map[string]bool{}
for _, j := range pairs {
w := words[j.node]
h, said := judgeHealth(j.module, coreComponent(j.module), shelf[j.module], j.node, *g.Since, w.facts)
if h == healthGood || h == healthPerson {
if h == healthGood {
if on, named := w.on[j.module]; named {
h, said = healthNotYet, on
} else if w.whole != "" {
h, said = healthNotYet, w.whole
} else if w.waiting != "" {
h, said = healthWaiting, w.waiting
}
}
if _, seen := reading[j.module]; !seen {
modules = append(modules, j.module)
if h != healthGood && !slices.Contains(failing, j.module) {
failing = append(failing, j.module)
}
if h == healthSuperseded {
// Decided at once (novox/hq issue 352): nothing of this gate's can be judged on a machine that
// was sent another build of it, and nothing is put back — the later send is what runs there.
g.Failing, g.Last = nil, ""
decide(g, inventory.GateSuperseded, said, now)
return g.Verdict, nil
}
if h == healthBroken && !slices.Contains(g.Broken, j.module) {
g.Broken = append(g.Broken, j.module)
if g.BrokenWhy == "" {
g.BrokenWhy = said
}
}
if slices.Contains(g.Broken, j.module) {
h = healthBroken // found broken once, broken for the rest of the judging
}
if h > reading[j.module] {
reading[j.module] = h
}
if h == healthPerson {
waits[j.module] = joinSaid(waits[j.module], said)
if h == healthBroken {
broken[j.module] = true
}
if h > worst {
worst, why = h, said
} else if h == worst && h > healthPerson && why == "" {
} else if h == worst && h != healthGood && why == "" {
why = said
}
}
if g.Healthy == nil {
g.Healthy = map[string]int{}
}
if g.HealthyAt == nil {
g.HealthyAt = map[string]time.Time{}
}
g.Waits = nil
var failing []string
for _, m := range modules {
if reading[m] > healthPerson {
failing = append(failing, m)
g.Healthy[m] = 0
delete(g.HealthyAt, m)
continue
}
// **A pass is counted only gateEvery after the one before** (issue 318 review): a send judged more
// often while another module beside it is not yet healthy counts no faster.
if at, counted := g.HealthyAt[m]; !counted || now.Sub(at) >= gateEvery {
g.Healthy[m]++
g.HealthyAt[m] = now
}
if w := waits[m]; w != "" {
if g.Waits == nil {
g.Waits = map[string]string{}
}
g.Waits[m] = w
}
}
settled := now.Sub(*g.Since) >= gateSettle
passedAlone := func(m string) bool {
return settled && reading[m] <= healthPerson && g.Healthy[m] >= gatePasses
}
// fail decides the send failed: what passed on its own keeps its pass, everything else is put back.
fail := func(why string) {
g.Passing, g.Failing = nil, nil
for _, m := range modules {
if passedAlone(m) {
g.Passing = append(g.Passing, m)
} else {
g.Failing = append(g.Failing, m)
}
}
decide(g, inventory.GateFailed, why, now)
}
pastBound := now.Sub(*g.Since) > gateBound
switch {
case worst == healthBroken:
var judging []string
for _, m := range modules {
if reading[m] != healthBroken && !passedAlone(m) {
judging = append(judging, m)
}
}
if len(judging) == 0 || pastBound {
fail(g.BrokenWhy)
break
}
// What broke fails the send, and is put back at once by the caller (putBackBroken); what is beside
// it is judged to its own verdict first, within the bound.
g.Passes, g.LastPass, g.Failing = 0, nil, failing
g.Last = fmt.Sprintf("%s; %s judged to its own verdict before the send's", g.BrokenWhy, strings.Join(judging, ", "))
case worst == healthWaiting:
// Waiting on a provider that is unhealthy: not a pass, and not a failure at the bound either —
// the provider's own condition says what is wrong (ADR 0240 rule 5).
g.Passes, g.LastPass, g.Last, g.Failing = 0, nil, why, failing
// What broke is put back; what was only not yet healthy beside it is too — they moved together.
g.Failing = failing
decide(g, inventory.GateFailed, why, now)
case worst == healthNotYet:
g.Passes, g.LastPass, g.Last, g.Failing = 0, nil, why, failing
if pastBound {
fail(fmt.Sprintf("not healthy within %s of its apply: %s", gateBound, why))
if now.Sub(*g.Since) > gateBound {
decide(g, inventory.GateFailed, fmt.Sprintf("not healthy within %s of its apply: %s", gateBound, why), now)
}
default:
// Healthy, or waiting for a person (ADR 0254): a pass, the wait carried along in the verdict.
g.Passes++
g.LastPass, g.Last, g.Failing = &now, "", nil
if g.Passes >= gatePasses && settled {
if g.Passes >= gatePasses && now.Sub(*g.Since) >= gateSettle {
decide(g, inventory.GatePassed, fmt.Sprintf("healthy %d times over %s", g.Passes,
now.Sub(*g.Since).Round(time.Second))+waitsSaid(g.Waits), now)
now.Sub(*g.Since).Round(time.Second)), now)
}
}
return g.Verdict, nil
}
// judgedCommits is the commit of each module a gate judges: the gate's own To for its module, and each
// carried move's. Pure.
func judgedCommits(g *inventory.PlanGate, pairs []judged) map[string]string {
out := map[string]string{}
for _, c := range g.Carried {
if c.To != "" {
out[c.Module] = c.To
}
}
if g.To != "" {
for _, j := range pairs {
if _, has := out[j.module]; !has && !slices.ContainsFunc(g.Carried, func(c inventory.CarriedMove) bool { return c.Module == j.module }) {
out[j.module] = g.To
}
}
}
return out
}
// sentNow is what each machine was just sent, read after a send for the gate to keep (novox/hq issue
// 352): a machine whose send is not on record is left out, and its report is read as before.
func sentNow(ctx context.Context, inv *inventory.Inventory, machines []string) map[string]inventory.SentDeclaration {
out := map[string]inventory.SentDeclaration{}
for _, n := range machines {
if s, found, err := inv.SentTo(ctx, n); err == nil && found {
out[n] = s
}
}
if len(out) == 0 {
return nil
}
return out
}
// whyFor is a passing gate's why as one module's verdict says it: the send's, and that module's own wait
// for a person, never another's (issue 318 review).
func whyFor(g *inventory.PlanGate, module string) string {
why, _, _ := strings.Cut(g.Why, waitsPrefix)
if w := g.Waits[module]; w != "" {
why += waitsPrefix + w
}
return why
}
const waitsPrefix = "; and it waits for a person: "
// waitsSaid is the waits for a person a passing gate carries, as its verdict says them.
func waitsSaid(waits map[string]string) string {
if len(waits) == 0 {
return ""
}
modules := make([]string, 0, len(waits))
for m := range waits {
modules = append(modules, m)
}
sort.Strings(modules)
var said []string
for _, m := range modules {
said = append(said, waits[m])
}
return waitsPrefix + strings.Join(said, "; ")
}
func joinSaid(a, b string) string {
switch {
case a == "":
return b
case b == "" || strings.Contains(a, b):
return a
}
return a + "; " + b
}
// movesAddingGroups is, per module a gate judges, whether its move puts an account in a group the build it
// moved from did not: read from the builds' manifests. A module whose move is not known adds none.
func movesAddingGroups(ctx context.Context, inv *inventory.Inventory, g *inventory.PlanGate, pairs []judged,
shelf map[string]catalogue.Manifest) map[string]bool {
out := map[string]bool{}
for _, j := range pairs {
if _, done := out[j.module]; done {
continue
}
from, to := g.From, g.To
for _, c := range g.Carried {
if c.Module == j.module {
from, to = c.From, c.To
break
}
}
target, found, err := inv.ManifestAt(ctx, j.module, to)
if err != nil || !found {
target = shelf[j.module]
}
// What it moved from is not known — no build named (a plan's own module whose previous build was not
// recorded), or no manifest kept for it: no wait is excused, rather than one the move did not bring.
if from == "" {
out[j.module] = false
continue
}
before, had, err := inv.ManifestAt(ctx, j.module, from)
if err != nil || !had {
out[j.module] = false
continue
}
out[j.module] = addsAccountGroups(before, had, target)
}
return out
}
// decide sets a gate's verdict.
func decide(g *inventory.PlanGate, verdict, why string, now time.Time) {
g.Verdict, g.Why, g.JudgedAt = verdict, why, &now
@@ -758,7 +462,7 @@ func gatePassed(ctx context.Context, open *stores, p *inventory.Plan, module str
g := state.Gate
err := open.inventory.RecordGate(ctx, inventory.GateVerdict{Build: state.Build, Module: module,
Commit: state.Commit, Previous: state.Previous, Plan: p.ID, Machines: g.Machines,
Verdict: inventory.GatePassed, Why: whyFor(g, module), Component: g.Component, JudgingFrom: g.Since})
Verdict: inventory.GatePassed, Why: g.Why, Component: g.Component, JudgingFrom: g.Since})
if err != nil && state.Build != "" {
fmt.Printf("%s: %s passed its gate, and the verdict could not be kept: %v\n", p.ID, module, err)
}
@@ -897,16 +601,6 @@ func gateFailed(ctx context.Context, open *stores, p *inventory.Plan, module str
"back", module, short(state.Previous), inventory.KeptBuilds))
return
}
if g.Component == lease.ComponentController {
// **The controller is never put back to a build older than the store's schema** (novox/hq issue
// 352): the build before it carries fewer migrations than the failed one applied, starts behind
// its own records, and judges the next gate with what it can read. The current build is kept and
// the condition says so; a person decides.
if why, ok := controllerSchemaAllows(ctx, inv, previous); !ok {
notBack(why)
return
}
}
if err := inv.RestoreModule(ctx, previous); err != nil {
notBack(err.Error())
return
@@ -940,53 +634,6 @@ func gateFailed(ctx context.Context, open *stores, p *inventory.Plan, module str
sayRollback(ctx, open, module, g, "")
}
// controllerSchemaAllows says the store's schema lets this build of the controller be put back: the
// build recorded, when it served, a reach at or past the highest migration the store has applied. One
// that never recorded a reach is not proved safe, and is refused as such (novox/hq issue 352). Why
// says what is kept and why when it is not.
func controllerSchemaAllows(ctx context.Context, inv *inventory.Inventory, previous inventory.Build) (string, bool) {
applied, err := inv.SchemaApplied(ctx)
if err != nil {
return "what the store's schema reaches cannot be read, so whether the build before it can read it is not " +
"known; the current build is kept: " + err.Error(), false
}
build := versionOfBuild(previous)
if build == "" {
return fmt.Sprintf("the build before it (%s) names no bundle to know it by, so whether it can read the store's "+
"schema (migration %04d) is not known; the current build is kept, and a person decides", short(previous.Commit), applied), false
}
reach, known, err := inv.SchemaReachOf(ctx, build)
if err != nil {
return "what the build before it knows of the store's schema cannot be read; the current build is kept: " + err.Error(), false
}
if !known {
return fmt.Sprintf("the build before it (%s, %s) never recorded how far it reads the store's schema — a "+
"controller records that when it serves — so it is not proved to read migration %04d, which the store "+
"has applied; a controller older than its store starts behind its own records and judges with what it "+
"can read, so the current build is kept, and a person decides", short(previous.Commit), build, applied), false
}
if reach < applied {
return fmt.Sprintf("the build before it (%s, %s) reads the store's schema up to migration %04d, and the store "+
"is at %04d: a controller older than its store starts behind its own records and judges with what it "+
"can read, so the current build is kept, and a person decides", short(previous.Commit), build, reach, applied), false
}
return "", true
}
// versionOfBuild is the version a build's bundle is delivered as — its archive's digest, short, as the
// catalogue names it (`${version}`) — read from the build's artifacts; empty when none is a bundle.
func versionOfBuild(b inventory.Build) string {
for _, a := range b.Made {
if a.Kind != catalogue.ArtifactBundle && a.Kind != catalogue.ArtifactArchive {
continue
}
if _, hex, found := strings.Cut(a.Reference, "sha256:"); found && len(hex) >= 12 {
return hex[:12]
}
}
return ""
}
// rollbacks is what a failed send puts back, sent together (novox/hq issue 281): a gate that judged one
// send judges what it moved as one, and what it found wanting goes back in one send per machine — not
// in a send for each module, which is the churn that failed the gate in the first place.
@@ -1276,12 +923,6 @@ func gateLine(g *inventory.PlanGate) string {
} else if g.Verdict == "" {
line += fmt.Sprintf(" (%d of %d passes)", g.Passes, gatePasses)
}
if g.Verdict == "" && len(g.Waits) > 0 {
line += waitsSaid(g.Waits)
}
if len(g.Passing) > 0 {
line += "; passed on their own and kept: " + strings.Join(g.Passing, ", ")
}
if g.Rollback != "" {
line += "; " + g.Rollback
}
-453
View File
@@ -1,453 +0,0 @@
package main
import (
"context"
"encoding/json"
"reflect"
"slices"
"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"
)
// What the review of issue 318's fix found (novox/hq ADR 0254): every module of a send leaves it with a
// verdict, a pass is counted no faster than the gate's spacing, a carried build's verdict carries only its own
// wait, and a provider waiting for a login says who waits on it.
// withGateBounds sets the gate's bounds for one test.
func withGateBounds(t *testing.T, settle, every, bound time.Duration) {
t.Helper()
wasSettle, wasEvery, wasBound := gateSettle, gateEvery, gateBound
gateSettle, gateEvery, gateBound = settle, every, bound
t.Cleanup(func() { gateSettle, gateEvery, gateBound = wasSettle, wasEvery, wasBound })
}
// factsOn is a judging's facts for one machine that applied its send: the node tools answer, and what it says
// of its modules' resources.
func factsOn(t *testing.T, machine string, since time.Time, rs ...inventory.ResourceHealth) func() gateFacts {
return func() gateFacts {
now := time.Now()
at := now
return gateFacts{now: now,
reports: map[string]inventory.Reported{machine: {Node: machine, Outcome: inventory.OutcomeApplied, At: &at, Current: true}},
engines: map[string]string{},
rolledBack: map[string][]lease.Rollback{},
served: map[string]served{machine: {runtime: true, tools: map[string]bool{}}},
health: map[string]inventory.NodeHealth{machine: {Node: machine, HeardAt: now, Resources: rs}},
}
}
}
func healthyContainer(module string) inventory.ResourceHealth {
return inventory.ResourceHealth{Module: module, Resource: module + ".web", Kind: "container", Target: module,
State: link.StateHealthy}
}
// **A fault decided before the settle time does not strand the module beside it** (review (a)): the node
// tools' witness put its build back at once, and the send waits for the healthy module's own verdict — a pass,
// counted over the gate's spacing — before it says its own. The broken one alone is put back.
func TestAFaultBeforeTheSettleTimeWaitsForTheModuleBesideIt(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
withGateBounds(t, 150*time.Millisecond, 20*time.Millisecond, 10*time.Second)
since := time.Now().Add(-time.Second)
base := factsOn(t, "laptop", since, healthyContainer("app"))
wasGather := gatherGateFacts
t.Cleanup(func() { gatherGateFacts = wasGather })
gatherGateFacts = func(context.Context, *stores, string) (gateFacts, error) {
f := base()
f.rolledBack["laptop"] = []lease.Rollback{{Component: lease.ComponentNodeTools, Outcome: lease.OutcomeRolledBack,
At: since.Add(100 * time.Millisecond), Why: "the node tools did not answer"}}
return f, nil
}
g := &inventory.PlanGate{Machines: []string{"laptop"}, Since: &since}
pairs := []judged{{module: broker_runtime, node: "laptop"}, {module: "app", node: "laptop"}}
judgings := 0
for deadline := time.Now().Add(5 * time.Second); g.Verdict == "" && time.Now().Before(deadline); {
if _, err := judgeMoves(ctx, open, g, pairs, time.Now()); err != nil {
t.Fatal(err)
}
judgings++
time.Sleep(30 * time.Millisecond)
}
if g.Verdict != inventory.GateFailed || judgings < gatePasses {
t.Fatalf("verdict %q after %d judging(s): %+v; want failed, after the module beside it was judged", g.Verdict,
judgings, g)
}
if !reflect.DeepEqual(g.Passing, []string{"app"}) || !reflect.DeepEqual(g.Failing, []string{broker_runtime}) {
t.Fatalf("passing %v, failing %v; want app kept and the node tools put back", g.Passing, g.Failing)
}
if !strings.Contains(g.Why, "witness") {
t.Errorf("the verdict does not say what broke: %q", g.Why)
}
}
// broker_runtime is the node tools' module name, a core component a witness judges.
const broker_runtime = "node-tools"
// **A pass is counted only the gate's spacing after the one before** (review (c)): a send judged every tick
// while a module beside it is not yet healthy counts the healthy one once.
func TestAPassIsCountedNoFasterThanTheGatesSpacing(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
withGateBounds(t, 0, time.Hour, time.Hour)
since := time.Now().Add(-time.Minute)
base := factsOn(t, "laptop", since, healthyContainer("app"), inventory.ResourceHealth{Module: "late",
Resource: "late.web", Kind: "container", Target: "late", State: link.StateUnhealthy, Reason: "down"})
wasGather := gatherGateFacts
t.Cleanup(func() { gatherGateFacts = wasGather })
gatherGateFacts = func(context.Context, *stores, string) (gateFacts, error) { return base(), nil }
g := &inventory.PlanGate{Machines: []string{"laptop"}, Since: &since}
pairs := []judged{{module: "app", node: "laptop"}, {module: "late", node: "laptop"}}
now := time.Now()
for i := 0; i < 3; i++ {
if _, err := judgeMoves(ctx, open, g, pairs, now.Add(time.Duration(i)*time.Second)); err != nil {
t.Fatal(err)
}
}
if g.Healthy["app"] != 1 || g.Healthy["late"] != 0 {
t.Fatalf("counted %v in three judgings within a second; want app once and late never", g.Healthy)
}
}
// **A carried build's pass carries its own wait, never another's** (review (e)).
func TestACarriedPassCarriesOnlyItsOwnWait(t *testing.T) {
b := aBacklog(t)
ctx := t.Context()
inv := b.open.inventory
since := time.Now().Add(-time.Minute)
g := &inventory.PlanGate{Machines: []string{"laptop"}, Since: &since, Verdict: inventory.GatePassed,
Why: "healthy 3 times over 2m0s" + waitsSaid(map[string]string{"late": "relogin needed on laptop: late waits"}),
Waits: map[string]string{"late": "relogin needed on laptop: late waits"},
Carried: []inventory.CarriedMove{{Module: "app", Node: "laptop", From: "c1", To: "c2", Build: "build-app-c2"},
{Module: "late", Node: "laptop", From: "c1", To: "c2", Build: "build-late-c2"}}}
passCarried(ctx, b.open, &inventory.Plan{ID: "release-test"}, g, "")
app, _, _ := inv.GateOf(ctx, "build-app-c2")
late, _, _ := inv.GateOf(ctx, "build-late-c2")
if strings.Contains(app.Why, "waits for a person") || app.Verdict != inventory.GatePassed {
t.Errorf("app's pass: %+v; want it without late's wait", app)
}
if !strings.Contains(late.Why, "relogin needed on laptop") {
t.Errorf("late's pass: %+v; want its own wait", late)
}
}
// **The tier path keeps the pass of the module the gate is kept on** (review (b)): a plan's first send
// carried its own module, healthy, and a build waiting on that machine that never became healthy. At the
// bound the waiting one is put back and marked; the plan's own module keeps its pass, so no walk waits on it.
func TestThePlansOwnModuleKeepsItsPassWhenItsSendFails(t *testing.T) {
g := aGateMesh(t)
ctx := t.Context()
inv := g.open.inventory
// `late` waits on anchor for a gate: c1 sent, c2 registered and built.
for _, b := range []inventory.Build{
{ID: "build-late-1", Module: "late", Commit: "l1", Repository: "novox/mesh-catalog", Path: "modules/late",
Asked: time.Now().Add(-2 * time.Hour), At: time.Now().Add(-2 * time.Hour)},
{ID: "build-late-2", Module: "late", Commit: "l2", Repository: "novox/mesh-catalog", Path: "modules/late",
Asked: time.Now().Add(-time.Minute), At: time.Now().Add(-time.Minute)},
} {
manifest, _ := json.Marshal(catalogue.Manifest{Module: "late", Version: b.Commit})
b.Manifest = manifest
b.Made = []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:" + b.Commit}}
if err := inv.RecordBuild(ctx, b); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "late", Version: b.Commit}, inventory.Source{
Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/late", BuiltFrom: b.Commit, Head: b.Commit,
Asked: b.Asked}); err != nil {
t.Fatal(err)
}
if b.Commit == "l1" {
if _, err := inv.Assign(ctx, "anchor", "late"); err != nil {
t.Fatal(err)
}
if err := inv.RecordSent(ctx, nodeID(t, g.open, "anchor"), "d-anchor-late", map[string]string{"app": "c1",
"late": "l1"}); err != nil {
t.Fatal(err)
}
}
}
wasMoves := machineMoves
t.Cleanup(func() { machineMoves = wasMoves })
machineMoves = func(ctx context.Context, open *stores, f moveFacts, node string, all bool) ([]inventory.CarriedMove, error) {
modules, err := open.inventory.Assigned(ctx, node)
if err != nil {
return nil, err
}
sent, known, err := open.inventory.SentBuilds(ctx, node)
if err != nil {
return nil, err
}
return f.moves(node, modules, sent, known, all), nil
}
gather := gatherGateFacts
gatherGateFacts = func(ctx context.Context, open *stores, component string) (gateFacts, error) {
f, err := gather(ctx, open, component)
f.health = map[string]inventory.NodeHealth{"anchor": {Node: "anchor", HeardAt: time.Now(), Resources: []inventory.ResourceHealth{
healthyContainer("app"), {Module: "late", Resource: "late.web", Kind: "container", Target: "late",
State: link.StateUnhealthy, Reason: "down"}}}}
return f, err
}
gateEvery, gateBound = 0, 400*time.Millisecond
for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); {
advancePlans(ctx, g.open)
if p := g.plan(t); p.State == inventory.PlanFailed || p.State == inventory.PlanDone {
break
}
time.Sleep(30 * time.Millisecond)
}
p := g.plan(t)
if p.State != inventory.PlanFailed {
t.Fatalf("the plan is %s: %s; want it failed on late", p.State, p.Note)
}
if v, found, err := inv.GateOf(ctx, "build-2"); err != nil || !found || v.Verdict != inventory.GatePassed ||
!strings.Contains(v.Why, "on its own") {
t.Fatalf("app's verdict: %+v (found %v, %v); want its own pass kept", v, found, err)
}
if failed, _ := inv.GateFailed(ctx, "build-late-2"); !failed {
t.Fatal("late's build is not marked failed at its gate")
}
if current, _ := inv.CurrentBuilds(ctx); current["app"].Commit != "c2" || current["late"].Commit != "l1" {
t.Fatalf("registered app %s, late %s; want app kept at c2 and late put back to l1", current["app"].Commit,
current["late"].Commit)
}
}
// **A provider waiting for a login says who waits on it** (review (g)): its consumer, failing a check that
// needs it, is held under it, and the provider's relogin-needed condition lists it and is urgent.
func TestAProviderWaitingForALoginSaysWhoWaitsOnIt(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
k := conditionsFrom
register(t, open, catalogue.Manifest{Module: "db", Version: "1",
Provides: []catalogue.Offer{{Name: "postgres-database", Scope: catalogue.ScopeMesh}}})
register(t, open, catalogue.Manifest{Module: "shop", Version: "1", Requires: []string{"postgres-database"}})
for _, a := range [][2]string{{"anchor", "db"}, {"laptop", "shop"}} {
if _, err := inv.Assign(ctx, a[0], a[1]); err != nil {
t.Fatal(err)
}
}
if err := inv.RecordBindings(ctx, "laptop", []inventory.Binding{{Machine: "laptop", Consumer: "shop",
Provision: "postgres-database", Provider: catalogue.Chosen{Node: "anchor", Module: "db"}}}); err != nil {
t.Fatal(err)
}
at := h0
say := func(machine string, rs ...link.ResourceHealth) {
t.Helper()
at = at.Add(time.Second)
for i := range rs {
rs[i].Since = at
}
if err := stateHealth(ctx, inv, k, machine, link.Health{Contract: link.ReadinessContract, At: at, Resources: rs}, at); err != nil {
t.Fatal(err)
}
}
account := link.ResourceHealth{Module: "db", Resource: "db.operator", Kind: link.KindAccount, Target: "operator",
Account: "operator", State: link.StateUnhealthy, Reason: link.ReasonRelogin + ": operator is in the group db"}
unit := link.ResourceHealth{Module: "db", Resource: "db.server", Kind: link.KindUnit, Target: "db.service",
Account: "operator", State: link.StateUnhealthy, Reason: "failed in the account's own service manager (exit-code)"}
shop := link.ResourceHealth{Module: "shop", Resource: "shop.web", Kind: "container", Target: "shop",
State: link.StateUnhealthy, Reason: "http /health on web: answered 500", Check: "http", Needs: "postgres-database"}
for look := 0; look < 2; look++ {
say("anchor", account, unit)
say("laptop", shop)
}
list, err := k.Open(ctx)
if err != nil {
t.Fatal(err)
}
var keys []string
var c conditions.Condition
for _, x := range list {
keys = append(keys, x.Key)
if x.Key == "module.db.anchor.relogin-needed" {
c = x
}
}
if !reflect.DeepEqual(keys, []string{"module.db.anchor.relogin-needed"}) {
t.Fatalf("open %v; want the provider's wait alone, its consumer held under it", keys)
}
if c.Severity != conditions.Urgent || !strings.Contains(c.Evidence[0].Said, "shop on laptop") {
t.Fatalf("the provider's wait: %s, %q; want it urgent and naming its consumer", c.Severity, c.Evidence[0].Said)
}
}
// **A broken module is put back at once** (issue 318 review): the laptop's witness put the node tools back
// within a minute of a send that also moved `app`, and `app` reads not yet healthy because of it. The node
// tools are marked and put back in the very judging that found them broken — their registered build is the
// one before, and the laptop is sent it — while `app` goes on being judged, recovers, and keeps its own pass.
func TestABrokenModuleIsPutBackAtOnceAndTheOneBesideItGetsItsOwnVerdict(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
withConditionsInMemory(t)
was := doctorFrom
doctorFrom = nil
t.Cleanup(func() { doctorFrom = was })
old, recent := time.Now().Add(-3*time.Hour), time.Now().Add(-time.Minute)
build := func(module, commit string, asked time.Time) {
manifest, _ := json.Marshal(catalogue.Manifest{Module: module, Version: "1"})
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-" + module + "-" + commit, Module: module, Commit: commit,
Repository: "novox/mesh-catalog", Path: "modules/" + module, Manifest: manifest, Asked: asked, At: asked,
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:" + module + commit}}}); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: module, Version: "1"}, inventory.Source{
Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/" + module, BuiltFrom: commit, Head: commit,
Asked: asked}); err != nil {
t.Fatal(err)
}
}
for _, m := range []string{"app", broker_runtime} {
build(m, "c1", old)
if _, err := inv.Assign(ctx, "laptop", m); err != nil {
t.Fatal(err)
}
}
if err := inv.RecordSent(ctx, nodeID(t, open, "laptop"), "d-laptop", map[string]string{"app": "c1", broker_runtime: "c1"}); err != nil {
t.Fatal(err)
}
build("app", "c2", recent)
build(broker_runtime, "c2", recent)
wasMoves, wasHeard, wasSend, wasGather := machineMoves, releaseHeard, sendRollout, gatherGateFacts
t.Cleanup(func() {
machineMoves, releaseHeard, sendRollout, gatherGateFacts = wasMoves, wasHeard, wasSend, wasGather
})
machineMoves = func(ctx context.Context, open *stores, f moveFacts, node string, all bool) ([]inventory.CarriedMove, error) {
modules, _ := open.inventory.Assigned(ctx, node)
sent, known, err := open.inventory.SentBuilds(ctx, node)
if err != nil {
return nil, err
}
return f.moves(node, modules, sent, known, all), nil
}
releaseHeard = func(context.Context, *stores) (map[string]bool, error) { return map[string]bool{"laptop": true}, nil }
var sends []string
n := 0
sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) {
current, err := open.inventory.CurrentBuilds(ctx)
if err != nil {
return nil, err
}
for _, node := range names {
n++
carried := map[string]string{"app": current["app"].Commit, broker_runtime: current[broker_runtime].Commit}
sends = append(sends, node+":"+carried[broker_runtime])
digest := "d-" + node + "-" + time.Now().Format("150405.000000") + string(rune('a'+n))
if err := open.inventory.RecordSent(ctx, nodeID(t, open, node), digest, carried); err != nil {
return nil, err
}
if _, err := open.inventory.RecordDoing(ctx, nodeID(t, open, node), inventory.Doing{Node: node,
Outcome: inventory.OutcomeApplied, Declared: digest, Applied: 1, At: time.Now()}); err != nil {
return nil, err
}
}
return names, nil
}
var seen []string // the node tools' registered build at each judging
gatherGateFacts = func(ctx context.Context, open *stores, component string) (gateFacts, error) {
now := time.Now()
f := gateFacts{now: now, reports: map[string]inventory.Reported{}, engines: map[string]string{},
rolledBack: map[string][]lease.Rollback{}, served: map[string]served{}}
reports, _ := open.inventory.LastReports(ctx)
for _, r := range reports {
f.reports[r.Node] = r
}
current, _ := open.inventory.CurrentBuilds(ctx)
seen = append(seen, current[broker_runtime].Commit)
app := healthyContainer("app")
if current[broker_runtime].Commit == "c2" {
// The witness reverted the node tools; app cannot reach them yet.
f.rolledBack["laptop"] = []lease.Rollback{{Component: lease.ComponentNodeTools, Outcome: lease.OutcomeRolledBack,
From: "c2", To: "c1", At: now, Why: "the node tools did not answer"}}
app.State, app.Reason = link.StateUnhealthy, "down"
}
f.served["laptop"] = served{runtime: current[broker_runtime].Commit == "c1", tools: map[string]bool{}}
f.health = map[string]inventory.NodeHealth{"laptop": {Node: "laptop", HeardAt: now, Resources: []inventory.ResourceHealth{app}}}
return f, nil
}
withGateBounds(t, 100*time.Millisecond, 0, 10*time.Second)
release := func() inventory.Plan {
t.Helper()
plans, _ := inv.RecentPlans(ctx, 10)
for _, p := range plans {
if p.Release != nil {
return p
}
}
t.Fatal("no walk")
return inventory.Plan{}
}
// Judged until the first judging has found the node tools broken, and one more.
for i := 0; i < 20 && len(seen) < 2; i++ {
advancePlans(ctx, open)
}
if len(seen) < 2 || seen[0] != "c2" || seen[1] != "c1" {
t.Fatalf("the node tools' registered build at each judging: %v; want c2, then c1 at the very next judging", seen)
}
if failed, _ := inv.GateFailed(ctx, "build-"+broker_runtime+"-c2"); !failed {
t.Fatal("the node tools' build is not marked failed at once")
}
if p := release(); p.State != inventory.PlanRolling || p.Release.Gate == nil || p.Release.Gate.Verdict != "" {
t.Fatalf("the walk is %s with gate %+v; want it still judging app", p.State, p.Release.Gate)
}
if !slices.Contains(sends, "laptop:c1") {
t.Fatalf("sends %v; want the laptop sent the node tools' earlier build at once", sends)
}
for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); {
advancePlans(ctx, open)
if release().State != inventory.PlanRolling {
break
}
time.Sleep(20 * time.Millisecond)
}
p := release()
if p.State != inventory.PlanFailed {
t.Fatalf("the walk is %s: %s; want failed, for the node tools", p.State, p.Note)
}
if v, found, _ := inv.GateOf(ctx, "build-app-c2"); !found || v.Verdict != inventory.GatePassed || !strings.Contains(v.Why, "on its own") {
t.Fatalf("app's verdict: %+v; want its own pass", v)
}
if current, _ := inv.CurrentBuilds(ctx); current["app"].Commit != "c2" {
t.Fatalf("app is registered at %s; want it kept at c2", current["app"].Commit)
}
}
// **A move from a build not known excuses no wait** (issue 318 review): a plan's own module whose previous
// build was not recorded, or whose previous manifest is not kept, cannot be shown to have added the group.
func TestAMoveFromAnUnknownBuildExcusesNoWait(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
record := func(commit string, m catalogue.Manifest) {
raw, _ := json.Marshal(m)
at := time.Now().Add(-time.Hour)
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-lights-" + commit, Module: "lights", Commit: commit,
Repository: "novox/mesh-catalog", Path: "modules/lights", Manifest: raw, Asked: at, At: at}); err != nil {
t.Fatal(err)
}
}
record("c1", catalogue.Manifest{Module: "lights"})
record("c2", catalogue.Manifest{Module: "lights", Resources: []map[string]any{{"id": "operator", "type": "user",
"name": "operator", "groups": []any{"lights"}}}})
pairs := []judged{{module: "lights", node: "laptop"}}
shelf := map[string]catalogue.Manifest{}
for _, c := range []struct {
from string
want bool
}{{"c1", true}, {"", false}, {"c0-never-built", false}} {
g := &inventory.PlanGate{From: c.from, To: "c2"}
if got := movesAddingGroups(ctx, inv, g, pairs, shelf)["lights"]; got != c.want {
t.Errorf("a move from %q adds a group: %v; want %v", c.from, got, c.want)
}
}
}
+2 -4
View File
@@ -113,10 +113,9 @@ func aGateMesh(t *testing.T) *gateMesh {
}
for node, h := range g.health {
if h == healthNotYet {
// Not reported on the send: neither the send made last, nor the gate's own (issue 352).
f.rolledBack[node] = nil
r := f.reports[node]
r.Current, r.Declared, r.ReportedSequence = false, "", 0
r.Current = false
f.reports[node] = r
}
}
@@ -222,8 +221,7 @@ func TestABuildThatFailsItsGateIsRolledBackOnItsFirstMachineAndGoesNoFurther(t *
t.Fatalf("sent again after the rollback: %v", g.sent)
}
_, _, err = takeIn(ctx, inv, link.BuildResult{ID: "build-2", Repository: "novox/mesh-catalog", Path: "modules/app",
Commit: "c2", Manifest: mustJSON(t, catalogue.Manifest{Module: "app", Version: "c2"}),
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}})
Commit: "c2", Manifest: mustJSON(t, catalogue.Manifest{Module: "app", Version: "c2"})})
if err == nil || !strings.Contains(err.Error(), "failed its gate") {
t.Fatalf("the failed build was registered again: %v", err)
}
+20 -83
View File
@@ -6,7 +6,6 @@ import (
"errors"
"flag"
"fmt"
"io"
"os"
"slices"
"sort"
@@ -15,7 +14,7 @@ import (
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/link"
)
@@ -37,9 +36,6 @@ type handActVerb struct {
Decision string
// DecidedFor limits Decision to these causes; empty, it holds for every act of the verb.
DecidedFor []string
// DecidedWhen limits Decision to the acts it answers true for: what the controller read the act to
// be from what it did (a push's kind), never a word the person gave.
DecidedWhen func(link.HandAct) bool
}
// causeLeakedInLogs is the cause a rotation after a value was printed into a log gives.
@@ -56,27 +52,16 @@ const causeDrill = "drill"
// listed without a decision, counts, so a new verb is a repair until its entry says otherwise.
var handActVerbs = []handActVerb{
// Repairs: each repeated is a healer the mesh lacks. A push by hand is exactly what roll-out by
// default (ADR 0236) exists to end — except a push that only moved builds a `record` policy held for
// a person's word (ADR 0242), which the push itself reads from what it carried (recorded_push.go).
{Verb: "push", Decision: "a recorded build moves only by a person's push: that push is the word its " +
"upgrade policy asks for (ADR 0242)", DecidedWhen: pushedRecorded},
// 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}},
// default (ADR 0236) exists to end.
{Verb: "push"},
{Verb: "plans stop"},
{Verb: "plans close"},
// A walk started by a person instead of its delivery's owner (novox/hq ADR 0239): the owner down, or
// not trusted with it — either is a repair the owner should have made. Unless the operator chose it on
// a warrant (ADR 0259).
{Verb: "plans go", Decision: "the operator's answer to an ask is their decision, not a repair (ADR 0259)",
DecidedFor: []string{conditions.CauseOperatorAnswer}},
// An act the operator chose on a warrant (novox/hq ADR 0259): asked by the controller, answered on a
// channel that proved who answered, performed by the controller as itself.
{Verb: handActWarrant, Decision: "the operator chose it, answering what the controller asked (ADR 0259)"},
// not trusted with it — either is a repair the owner should have made.
{Verb: "plans go"},
{Verb: "broker consumer-reset"},
// Silencing the same condition twice says the condition, or what it watches, wants mending — unless
// it is the operator's answer on a notification: a decision to live with it (novox/hq ADR 0258).
{Verb: "conditions silence", Decision: "the operator's answer on a notification is their decision, " +
"not a repair (ADR 0258)", DecidedFor: []string{conditions.CauseOperatorAnswer}},
// Silencing the same condition twice says the condition, or what it watches, wants mending.
{Verb: "conditions silence"},
// An act done outside the mesh: the mesh cannot tell a repair from a decision there, so it counts —
// except a drill recorded through it before `hand-act drill` existed (2026-10-07). It refuses the
// cause since, so no act recorded through it now carries it.
@@ -89,19 +74,6 @@ var handActVerbs = []handActVerb{
{Verb: "retire approve", Decision: "nothing is retired past its bound without a person (ADR 0230)"},
{Verb: "retire reject", Decision: "keeping a consumer active is a person's word (ADR 0230)"},
{Verb: "cleanup delete", Decision: "nothing retired is deleted without a person (ADR 0230)"},
// What becomes of a message a consumer gave up on (novox/hq issue 330): kept until a person says.
{Verb: "dead-letters deliver", Decision: "a message a consumer gave up on is delivered again only on a " +
"person's word (issue 330)"},
{Verb: "dead-letters drop", Decision: "a message a consumer gave up on is let go only on a person's word " +
"(issue 330)"},
// The sweep run on a person's word rather than after a build: the same decision the records make, at
// a moment the person chose (ADR 0251) — never a repair.
{Verb: "collect", Decision: "letting the store go of what the records keep for no reason, now rather " +
"than at the next build, is a person's word (ADR 0251)"},
// Recording a copy no record names as the mesh's: a person's reading of the store, never a repair
// (ADR 0257).
{Verb: "mirrors", Decision: "which copies in the store no record names are the mesh's is a person's " +
"word (ADR 0257)"},
{Verb: "bus upgrade", Decision: "the bus is never rolled by the mesh: replacing it is a planned step a " +
"person starts (ADR 0236)"},
{Verb: "upgrade release-backlog", Decision: "after a release plan failed, the next opens only when a " +
@@ -110,9 +82,6 @@ var handActVerbs = []handActVerb{
// to that judgement. Several values rotate for one leak, and a leak that recurs is a defect of the
// module that prints them, an issue against it, not a healer that rotates. A rotation for any other
// cause — a credential that stopped working — counts: a schedule or a healer could take it over.
// A value given at the desk (novox/hq ADR 0259 §10): an outside party's key, such as a bot token, which
// only a person can give. Their word, never a repair.
{Verb: "secret accept", Decision: "a value an outside party issued is given by a person, at their desk"},
{Verb: "secret rotate", Decision: "a value a person judged disclosed is replaced on their word",
DecidedFor: []string{causeLeakedInLogs}},
}
@@ -124,8 +93,7 @@ func personsDecision(a link.HandAct) bool {
if v.Verb != a.Verb {
continue
}
return v.Decision != "" && (len(v.DecidedFor) == 0 || slices.Contains(v.DecidedFor, a.Cause)) &&
(v.DecidedWhen == nil || v.DecidedWhen(a))
return v.Decision != "" && (len(v.DecidedFor) == 0 || slices.Contains(v.DecidedFor, a.Cause))
}
return false
}
@@ -164,10 +132,14 @@ func onTheBus(f func(*nats.Conn) error) error {
if handActConn != nil {
return f(handActConn)
}
js, err := aBus()
address, err := broker.BusAddress()
if err != nil {
return err
}
js, err := broker.Dial(address)
if err != nil {
return fmt.Errorf("cannot reach the bus: %w", err)
}
defer js.Close()
return f(js.Conn())
}
@@ -181,19 +153,6 @@ func (f handActFlags) record(ctx context.Context, verb string, args []string) {
}
act := link.HandAct{Verb: verb, Args: args, Why: strings.TrimSpace(*f.why),
Cause: strings.TrimSpace(*f.cause), Condition: strings.TrimSpace(*f.condition)}
// A push naming one machine says whether it only moves recorded builds (novox/hq issue 301):
// read from what it carries, before it is sent.
if verb == "push" && len(args) == 1 && !strings.HasPrefix(args[0], "-") {
switch carried, why, err := recordedPushOf(ctx, args[0]); {
case err != nil:
fmt.Fprintf(os.Stderr, "whether this push only moves recorded builds could not be read, so it is "+
"recorded as a push by hand: %v\n", err)
case len(carried) > 0:
act.Kind, act.Carried = link.KindRecordedBuilds, carried
default:
fmt.Printf("a push by hand, not of recorded builds only: %s\n", why)
}
}
err := onTheBus(func(conn *nats.Conn) error {
written, err := link.RecordHandAct(ctx, conn, act)
act = written
@@ -203,12 +162,6 @@ func (f handActFlags) record(ctx context.Context, verb string, args []string) {
fmt.Fprintf(os.Stderr, "this act by hand could NOT be recorded in the hand-act log, and is done anyway: %v\n", err)
return
}
if act.Kind == link.KindRecordedBuilds {
fmt.Printf("recorded as %s in the hand-act log: a push of recorded builds (%s) by %s, because %q "+
"— the person's word their upgrade policy asks for, which no healer is wanted for\n", act.ID,
strings.Join(act.Carried, "; "), act.By, act.Why)
return
}
fmt.Printf("recorded as %s in the hand-act log: %s, because %q (cause: %s)\n", act.ID, act.By, act.Why, act.Cause)
}
@@ -251,9 +204,6 @@ func handActCommand(ctx context.Context, args []string) error {
if len(args) > 0 && args[0] == "drill" {
return handActDrill(ctx, args[1:])
}
if len(args) > 0 && args[0] == "warrant" {
return handActWarrantCommand(ctx, args[1:])
}
if len(args) > 0 && args[0] != "list" && !strings.HasPrefix(args[0], "-") {
return errors.New("hand-act record <what> --why <text> --cause <word> | hand-act drill <what> --why <text> " +
"| hand-acts [--days N] [--json]")
@@ -261,13 +211,7 @@ func handActCommand(ctx context.Context, args []string) error {
if len(args) > 0 && args[0] == "list" {
args = args[1:]
}
return listHandActs(ctx, args, os.Stdout)
}
// listHandActs is `hand-acts`: what was done by hand lately, and the causes done more than once.
func listHandActs(ctx context.Context, args []string, w io.Writer) error {
set := flag.NewFlagSet("hand-acts", flag.ContinueOnError)
usageTo(set, w)
days := set.Int("days", 14, "how many days back")
asJSON := set.Bool("json", false, "as data")
if _, err := parseAround(set, args); err != nil {
@@ -285,28 +229,21 @@ func listHandActs(ctx context.Context, args []string, w io.Writer) error {
if err != nil {
return err
}
fmt.Fprintln(w, string(body))
fmt.Println(string(body))
return nil
}
if len(acts) == 0 {
fmt.Fprintf(w, "nothing was done by hand in the last %d day(s)\n", *days)
fmt.Printf("nothing was done by hand in the last %d day(s)\n", *days)
return nil
}
for i := len(acts) - 1; i >= 0; i-- {
a := acts[i]
fmt.Fprintf(w, "%s %s %s %s\n by %s — %s (cause: %s", a.At.Local().Format("2006-01-02 15:04"), a.ID,
fmt.Printf("%s %s %s %s\n by %s — %s (cause: %s", a.At.Local().Format("2006-01-02 15:04"), a.ID,
a.Verb, strings.Join(a.Args, " "), a.By, a.Why, a.Cause)
if a.Condition != "" {
fmt.Fprintf(w, ", condition %s", a.Condition)
}
fmt.Fprintln(w, ")")
if pushedRecorded(a) {
carried := strings.Join(a.Carried, "; ")
if carried == "" {
carried = recordedBefore[a.ID]
}
fmt.Fprintf(w, " a push of recorded builds, no repair: %s\n", carried)
fmt.Printf(", condition %s", a.Condition)
}
fmt.Println(")")
}
if len(repeated) > 0 {
causes := make([]string, 0, len(repeated))
@@ -314,7 +251,7 @@ func listHandActs(ctx context.Context, args []string, w io.Writer) error {
causes = append(causes, fmt.Sprintf("%s ×%d", c, n))
}
sort.Strings(causes)
fmt.Fprintf(w, "\ndone by hand more than once in a fortnight — a healer is wanted (to-be 45 S15): %s\n",
fmt.Printf("\ndone by hand more than once in a fortnight — a healer is wanted (to-be 45 S15): %s\n",
strings.Join(causes, ", "))
}
return nil
+5
View File
@@ -22,6 +22,10 @@ func TestARepairByHandWithoutAReasonIsRefused(t *testing.T) {
{"plans", map[string]any{"close": "plan-1"}},
{"plans", map[string]any{"stop": "plan-1"}},
{"hand-act", map[string]any{"what": "restarted the proxy", "cause": "proxy-stuck"}},
{"command", map[string]any{"command": "push anchor"}},
{"command", map[string]any{"command": "plans close plan-1"}},
{"command", map[string]any{"command": "broker consumer-reset EVENTS controller"}},
{"command", map[string]any{"command": "hand-act record restarted --cause x"}},
} {
argv, err := argvFor(c.verb, c.args)
if c.verb == "plans" && err == nil {
@@ -61,6 +65,7 @@ func TestARepairByHandCarriesItsReason(t *testing.T) {
{"plans", map[string]any{"retry": "plan-1"}, "plans retry plan-1"},
{"hand-act", map[string]any{"what": "restarted", "why": "hung", "cause": "proxy", "condition": "machine.a.silent"},
"hand-act record restarted --why hung --cause proxy --condition machine.a.silent"},
{"command", map[string]any{"command": "push anchor --why stuck"}, "push anchor --why stuck"},
{"command", map[string]any{"command": "plans plan-1"}, "plans plan-1"},
} {
argv, err := argvFor(c.verb, c.args)
-169
View File
@@ -1,169 +0,0 @@
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())
}
}
+1 -1
View File
@@ -625,7 +625,7 @@ func planStale(ctx context.Context, inv *inventory.Inventory, p inventory.Plan)
}
for _, newer := range recent {
if newer.ID == p.ID || !newer.Created.After(p.Created) || !repositoryMatches(newer.Repository, p.Repository) ||
newer.Branch != p.Branch || newer.State == inventory.PlanSuperseded || newer.Batch() {
newer.Branch != p.Branch || newer.State == inventory.PlanSuperseded {
continue
}
return inventory.PlanSuperseded, fmt.Sprintf("superseded by %s (%s %s), a newer merge of the same repository "+
-25
View File
@@ -45,28 +45,3 @@ func TestModuleCheckCountsTheUndeclaredAndRefusesThemFromTheDate(t *testing.T) {
t.Errorf("the refusal does not name the resource:\n%s", out.String())
}
}
// A file that asks for a setting without saying whether it is trusted counts as trusted (novox/hq issue 339):
// `module check` lists and counts it, and never refuses it — there is nothing unsafe to refuse.
func TestModuleCheckListsUnmarkedFilesAndNeverRefusesThem(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "module.json")
os.WriteFile(path, []byte(`{"module":"power","resources":[
{"id":"logind","type":"file","path":"/etc/systemd/logind.conf.d/power.conf","mode":"0644","trusted":true,
"content":"HandleLidSwitch=${setting:lid}\n"},
{"id":"note","type":"file","path":"/var/lib/power/note","mode":"0644","content":"${setting:greeting}\n"}]}`), 0o600)
defer func() { checkNow = time.Now }()
for _, at := range []time.Time{time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), time.Date(2036, 1, 1, 0, 0, 0, 0, time.UTC)} {
checkNow = func() time.Time { return at }
var out bytes.Buffer
if err := moduleCheck([]string{path}, &out); err != nil {
t.Fatalf("refused at %v: %v\n%s", at, err, out.String())
}
for _, want := range []string{"note ask(s) for a setting and do(es) not say whether it is trusted, so it counts as trusted",
UnsaidTrustLine + " 1"} {
if !strings.Contains(out.String(), want) {
t.Errorf("the check does not say %q:\n%s", want, out.String())
}
}
}
}
+2 -2
View File
@@ -179,7 +179,7 @@ func sayHeld(w io.Writer, node string, why []string) {
// machines that could not be composed, as refusals.
func flushBehind(ctx context.Context, open *stores, nodes []inventory.Node, handled map[string]bool,
compose func(held context.Context, node string) (sendable, error), d delivery, holder string,
keep keeping, w io.Writer) ([]string, error) {
w io.Writer) ([]string, error) {
inv := open.inventory
var refusals []string
// Bounded by the node count: a node is marked handled the round it is considered and is never
@@ -237,7 +237,7 @@ func flushBehind(ctx context.Context, open *stores, nodes []inventory.Node, hand
// a refusal and reported at the end, and the others are still sent (novox/hq ADR 0066).
// Held for this round only, and after the last round's were given back, so two pushes
// cascading into each other's machines never each wait on the other.
refused, err := sendRound(ctx, open, sending, compose, d, holder, keep)
refused, err := sendRound(ctx, open, sending, compose, d, holder)
refusals = append(refusals, refused...)
if err != nil {
return refusals, err
+11 -11
View File
@@ -203,7 +203,7 @@ func TestANamedPushLeavesAMachineAPolicyHoldsBack(t *testing.T) {
}
compose := composeForPush(open, gens)
d := &recordedDelivery{inv: inv}
if _, err := sendRound(ctx, open, []string{"anchor", "laptop"}, compose, d, "", nil); err != nil {
if _, err := sendRound(ctx, open, []string{"anchor", "laptop"}, compose, d, ""); err != nil {
t.Fatal(err)
}
if builds, known, err := inv.SentBuilds(ctx, "laptop"); err != nil || !known || builds["resolver"] != "c1c1c1c1c1" {
@@ -221,13 +221,13 @@ func TestANamedPushLeavesAMachineAPolicyHoldsBack(t *testing.T) {
// The change merges; the policy is record. `push anchor` sends the anchor...
aResolver(t, open, "c2c2c2c2c2", asked.Add(time.Minute))
d.declared = nil
if _, err := sendRound(ctx, open, []string{"anchor"}, compose, d, "", nil); err != nil {
if _, err := sendRound(ctx, open, []string{"anchor"}, compose, d, ""); err != nil {
t.Fatal(err)
}
// ...and its cascade leaves the laptop, saying so.
var said bytes.Buffer
d.declared = nil
refused, err := flushBehind(ctx, open, mustNodes(t, open), map[string]bool{"anchor": true}, compose, d, "", nil, &said)
refused, err := flushBehind(ctx, open, mustNodes(t, open), map[string]bool{"anchor": true}, compose, d, "", &said)
if err != nil || len(refused) != 0 {
t.Fatalf("the cascade failed: %v %v", refused, err)
}
@@ -248,13 +248,13 @@ func TestANamedPushLeavesAMachineAPolicyHoldsBack(t *testing.T) {
// is held, and that what else it is owed waits with it.
aThirdMachine(t, open)
d.declared = nil
if _, err := sendRound(ctx, open, []string{"spare"}, compose, d, "", nil); err != nil {
if _, err := sendRound(ctx, open, []string{"spare"}, compose, d, ""); err != nil {
t.Fatal(err)
}
d.declared = nil
said.Reset()
if _, err := flushBehind(ctx, open, mustNodes(t, open), map[string]bool{"anchor": true, "spare": true},
compose, d, "", nil, &said); err != nil {
compose, d, "", &said); err != nil {
t.Fatal(err)
}
if len(d.declared) != 0 || digestOfLaptop() != before {
@@ -271,7 +271,7 @@ func TestANamedPushLeavesAMachineAPolicyHoldsBack(t *testing.T) {
}
said.Reset()
if _, err := flushBehind(ctx, open, mustNodes(t, open), map[string]bool{"anchor": true, "spare": true},
compose, d, "", nil, &said); err != nil {
compose, d, "", &said); err != nil {
t.Fatal(err)
}
if len(d.declared) != 0 || !strings.Contains(said.String(), "has passed no gate yet") {
@@ -284,7 +284,7 @@ func TestANamedPushLeavesAMachineAPolicyHoldsBack(t *testing.T) {
}
said.Reset()
if _, err := flushBehind(ctx, open, mustNodes(t, open), map[string]bool{"anchor": true, "spare": true},
compose, d, "", nil, &said); err != nil {
compose, d, "", &said); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(d.declared, []string{"laptop"}) || digestOfLaptop() == before {
@@ -311,18 +311,18 @@ func TestANamedPushStillSendsAConsequenceNothingHolds(t *testing.T) {
}
compose := composeForPush(open, gens)
d := &recordedDelivery{inv: inv}
if _, err := sendRound(ctx, open, []string{"anchor", "laptop"}, compose, d, "", nil); err != nil {
if _, err := sendRound(ctx, open, []string{"anchor", "laptop"}, compose, d, ""); err != nil {
t.Fatal(err)
}
// `push spare`, the machine just placed: the others' peers change with it.
aThirdMachine(t, open)
if _, err := sendRound(ctx, open, []string{"spare"}, compose, d, "", nil); err != nil {
if _, err := sendRound(ctx, open, []string{"spare"}, compose, d, ""); err != nil {
t.Fatal(err)
}
d.declared = nil
var said bytes.Buffer
if _, err := flushBehind(ctx, open, mustNodes(t, open), map[string]bool{"spare": true}, compose, d, "", nil, &said); err != nil {
if _, err := flushBehind(ctx, open, mustNodes(t, open), map[string]bool{"spare": true}, compose, d, "", &said); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(d.declared, []string{"anchor", "laptop"}) {
@@ -342,7 +342,7 @@ func TestANamedPushStillSendsAConsequenceNothingHolds(t *testing.T) {
}
d.declared = nil
said.Reset()
if _, err := flushBehind(ctx, open, mustNodes(t, open), map[string]bool{"spare": true}, compose, d, "", nil, &said); err != nil {
if _, err := flushBehind(ctx, open, mustNodes(t, open), map[string]bool{"spare": true}, compose, d, "", &said); err != nil {
t.Fatal(err)
}
// The anchor, the hub, may still be settling from the machine placed above; the laptop is the
-26
View File
@@ -1,26 +0,0 @@
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")
}
}
+2 -2
View File
@@ -23,11 +23,11 @@ func TestASendRoundGivesItsHoldBackOnEveryWayOut(t *testing.T) {
for name, round := range map[string]func() error{
"a body that cannot be marshalled": func() error {
_, err := sendRound(ctx, open, []string{"anchor"}, unmarshallable, fine, "", nil)
_, err := sendRound(ctx, open, []string{"anchor"}, unmarshallable, fine, "")
return err
},
"a send that fails": func() error {
_, err := sendRound(ctx, open, []string{"anchor"}, plain, failing, "", nil)
_, err := sendRound(ctx, open, []string{"anchor"}, plain, failing, "")
return err
},
} {
-117
View File
@@ -1,117 +0,0 @@
package main
import (
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
)
// The live case of 2026-10-07 (novox/hq issue 299), replayed: the node-uplink seat gained its first
// verbs, `resolvers` and `links`, both optional while its holders catch up, and the holders serve
// neither. The working set is read from the store, whose rows carry no optional mark, so the seat comes
// back through UseSeats as it does live. Its holder on every machine answers nothing for it on the bus,
// and must not be judged silent: a seat whose verbs are all optional asks no holder to answer.
func TestAHolderOfASeatWhoseVerbsAreAllOptionalIsNotSilent(t *testing.T) {
defer catalogue.UseSeats(catalogue.DefaultSeats())
var rows []catalogue.Seat
for _, s := range catalogue.DefaultSeats() {
stored := s
stored.Serves = nil
for _, v := range s.Serves {
v.Optional = false // the store never keeps the mark
stored.Serves = append(stored.Serves, v)
}
rows = append(rows, stored)
}
catalogue.UseSeats(rows)
uplink, ok := catalogue.SeatNamed("node-uplink")
if !ok || len(uplink.Serves) == 0 {
t.Fatalf("node-uplink must serve verbs for this replay: %+v", uplink)
}
for _, v := range uplink.Serves {
if !v.Optional {
t.Fatalf("node-uplink's %s is required; this replay is of a seat whose verbs are all optional", v.Name)
}
}
nodes := []string{"anchor", "home-server", "workstation", "laptop"}
heard := map[string]bool{}
var recorded []catalogue.Held
for _, n := range nodes {
heard[n] = true
recorded = append(recorded, catalogue.Held{Claim: "node-uplink", Scope: catalogue.ScopeNode, Node: n,
Module: "networkmanager"})
}
expected := holdersToHear(catalogue.SeatsWithAProtocol(), recorded, nil, heard, nil, time.Now())
if _, asked := expected["node-uplink"]; asked {
t.Fatalf("node-uplink's holders are expected to answer though every verb of the seat is optional: %v",
expected["node-uplink"])
}
// Nothing answers for the seat, as live: no silence is said about it.
for _, o := range silentHolders(expected, map[string]map[string]bool{}) {
if o.ID == "node-uplink."+o.Machine {
t.Errorf("a holder serving none of a seat's optional verbs was judged silent: %s", o.Summary)
}
}
}
// Silence is still judged on a required verb: a seat with one required and one optional verb, held on a
// machine that is heard from, whose holder answers nothing — silent.
func TestAHolderServingNoRequiredVerbIsStillSilent(t *testing.T) {
seat := catalogue.Seat{Name: "example-seat", Scope: catalogue.ScopeNode, Serves: []catalogue.Verb{
{Name: "state"}, {Name: "later", Optional: true}}}
recorded := []catalogue.Held{{Claim: "example-seat", Scope: catalogue.ScopeNode, Node: "laptop", Module: "m"}}
expected := holdersToHear([]catalogue.Seat{seat}, recorded, nil, map[string]bool{"laptop": true}, nil, time.Now())
if !expected["example-seat"]["laptop"] {
t.Fatalf("a holder of a seat with a required verb is not expected to answer: %v", expected)
}
out := silentHolders(expected, map[string]map[string]bool{})
if len(out) != 1 || out[0].ID != "example-seat.laptop" || out[0].Token != "silent" {
t.Fatalf("a holder serving no required verb is not said to be silent: %+v", out)
}
if got := silentHolders(expected, map[string]map[string]bool{"example-seat": {"laptop": true}}); len(got) != 0 {
t.Fatalf("a holder that answers is said to be silent: %+v", got)
}
}
func TestHoldingNeedsAnAnswer(t *testing.T) {
for _, c := range []struct {
name string
serves []catalogue.Verb
want bool
}{
{"no verb", nil, false},
{"only optional verbs", []catalogue.Verb{{Name: "a", Optional: true}, {Name: "b", Optional: true}}, false},
{"a required verb among optional ones", []catalogue.Verb{{Name: "a", Optional: true}, {Name: "b"}}, true},
{"only required verbs", []catalogue.Verb{{Name: "a"}}, true},
} {
if got := holdingNeedsAnAnswer(catalogue.Seat{Name: "s", Serves: c.serves}); got != c.want {
t.Errorf("%s: holdingNeedsAnAnswer = %v, want %v", c.name, got, c.want)
}
}
}
// The control-node withholds the login shell's `execute` (novox/hq ADR 0268): its holder there serves
// nothing on the seat, and must not be judged silent for it, as the store's rows read it back.
func TestALoginShellWithholdingExecuteIsNotSilent(t *testing.T) {
defer catalogue.UseSeats(catalogue.DefaultSeats())
var rows []catalogue.Seat
for _, s := range catalogue.DefaultSeats() {
stored := s
stored.Serves = nil
for _, v := range s.Serves {
v.Optional = false // the store never keeps the mark
stored.Serves = append(stored.Serves, v)
}
rows = append(rows, stored)
}
catalogue.UseSeats(rows)
recorded := []catalogue.Held{{Claim: catalogue.LoginShellSeat, Scope: catalogue.ScopeNode, Node: "anchor", Module: "zsh"}}
expected := holdersToHear(catalogue.SeatsWithAProtocol(), recorded, nil, map[string]bool{"anchor": true}, nil, time.Now())
if _, asked := expected[catalogue.LoginShellSeat]; asked {
t.Fatalf("the login shell's holder is expected to answer, so withholding execute would be said silent: %v",
expected[catalogue.LoginShellSeat])
}
}
-221
View File
@@ -1,221 +0,0 @@
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)
}
}
-356
View File
@@ -1,356 +0,0 @@
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)
}
}
@@ -1,102 +0,0 @@
package main
import (
"context"
"testing"
"time"
"github.com/novox/mesh-controller/internal/inventory"
)
// aMachineJoining is a machine with a live token issued for a tunnel key, given its address — what
// `token issue --overlay-key` records before it pushes the hub (novox/hq ADR 0169).
func aMachineJoining(t *testing.T, ctx context.Context, inv *inventory.Inventory, name string,
validFor time.Duration) string {
t.Helper()
record, err := inv.AddNode(ctx, name)
if err != nil {
t.Fatal(err)
}
if _, err := inv.IssueToken(ctx, name, validFor); err != nil {
t.Fatal(err)
}
key := aPublicKey(t)
if err := inv.RecordOverlayKey(ctx, record.ID, key); err != nil {
t.Fatal(err)
}
if err := inv.BindTokenToKey(ctx, record.ID, key); err != nil {
t.Fatal(err)
}
cidr, err := overlayRange(ctx, inv)
if err != nil {
t.Fatal(err)
}
if _, err := inv.AssignAddress(ctx, record.ID, cidr); err != nil {
t.Fatal(err)
}
return key
}
// hubPeers are the keys the hub's composed tunnel carries.
func hubPeers(t *testing.T, ctx context.Context, inv *inventory.Inventory) map[string]bool {
t.Helper()
g, err := network(ctx, inv, map[string]bool{"anchor": true, "laptop": true}, nil)
if err != nil {
t.Fatal(err)
}
out := map[string]bool{}
for _, p := range g.Graph()["anchor"] {
out[p.Key] = true
}
return out
}
// **A machine joining is a peer of the hub while its token can be used, and not after** (novox/hq
// ADR 0169): the hub's composed tunnel carries a machine whose token was issued for its key, so the
// tunnel answers the first time it knocks; a token that expired unused takes the peer with it at the
// hub's next composition.
func TestAMachineJoiningIsAPeerOfTheHubUntilItsTokenExpires(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
joining := aMachineJoining(t, ctx, inv, "joiner", time.Hour)
expired := aMachineJoining(t, ctx, inv, "late", 2*time.Second)
time.Sleep(2100 * time.Millisecond)
peers := hubPeers(t, ctx, inv)
if !peers[joining] {
t.Fatalf("the hub does not carry the machine its live token was issued for: %v", peers)
}
if peers[expired] {
t.Fatalf("the hub still carries a machine whose token expired unused: %v", peers)
}
// A token issued without a key gives the hub nothing to carry: there is no key to carry.
if _, err := inv.AddNode(ctx, "keyless"); err != nil {
t.Fatal(err)
}
if _, err := inv.IssueToken(ctx, "keyless", time.Hour); err != nil {
t.Fatal(err)
}
if after := hubPeers(t, ctx, inv); len(after) != len(peers) {
t.Fatalf("a token issued without a key changed the hub's peers: %v, was %v", after, peers)
}
}
// **A tunnel key that is not one is refused before anything is recorded** (novox/hq ADR 0169): a
// token issued for it would be a tunnel the hub could never answer.
func TestATokenIsNotIssuedForSomethingThatIsNotATunnelKey(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
record, err := open.inventory.AddNode(ctx, "joiner")
if err != nil {
t.Fatal(err)
}
if _, _, err := throughTheTunnel(ctx, open, record, "not-a-key", "10.77.0.1:4222"); err == nil {
t.Fatal("a token was issued for something that is not a tunnel key")
}
if held := placementOf(t, ctx, open.inventory, "joiner"); held.Key != "" || held.Address != "" {
t.Fatalf("a refused key left a record behind: %+v", held)
}
}
+4 -4
View File
@@ -232,7 +232,7 @@ func licenceKey(ctx context.Context, args []string) error {
// and printing the value here would put the one copy that matters on a terminal.
fmt.Printf("sealed to %d holder(s). The mesh has discarded the key and cannot read it back\n",
sealed)
fmt.Printf(" run `push --behind` to deliver it\n")
fmt.Printf(" run `push` to deliver it\n")
return nil
}
@@ -340,7 +340,7 @@ func licenceSetGrant(ctx context.Context, args []string) error {
return err
}
fmt.Printf("%s now holds a refresh token for %s, sealed to that node's key and readable by it "+
"alone.\n the control plane stored the box without opening it; run `push --behind` to deliver it\n",
"alone.\n the control plane stored the box without opening it; run `push` to deliver it\n",
"the manager", name)
return nil
}
@@ -423,7 +423,7 @@ func licenceSubmitRefresh(ctx context.Context, args []string) error {
"manager node alone"
}
fmt.Printf("submitted a refresh for %s: a new access token sealed to %d holder(s), and %s.\n"+
" run `push --behind` to deliver it\n", name, sealed, rotatedNote)
" run `push` to deliver it\n", name, sealed, rotatedNote)
return nil
}
@@ -456,7 +456,7 @@ func licenceRefresh(ctx context.Context, args []string) error {
return err
}
fmt.Printf("refreshed %s: a new access token sealed to %d holder(s), and the refresh token left "+
"with its manager.\n run `push --behind` to deliver it\n", name, sealed)
"with its manager.\n run `push` to deliver it\n", name, sealed)
return nil
}
-413
View File
@@ -1,413 +0,0 @@
package main
import (
"context"
"fmt"
"slices"
"sort"
"strings"
"time"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// A machine says how its network is (novox/hq ADR 0241, which extends ADR 0240 from what a module runs
// to the machine it runs on).
//
// **Every machine's node-engine judges its own networking** — the resolver file the uplink holder
// declared, the names through every resolver it lists, the tunnel's handshake with the hub, the bus, the
// default route — on the two-look rule, and states it beside its resources. The controller keeps the
// newest statement per machine and raises from all of them together:
//
// - **an outside writer of the resolver file is its own finding**, `machine.<m>.<owner>.rewritten`:
// the file the uplink holder declares was rewritten by another program, named where the engine could
// name it. The names failing through what that program wrote are that finding's consequence, said in
// it — never a second condition;
// - **what is the machine's own** — its route, its tunnel, its resolvers answering wrong, a resolver
// that is no mesh machine — is `machine.<m>.network`;
// - **what points at another machine is said once, there** (the provider hold of ADR 0240 rule 5, for
// the network): a failure toward the hub or toward a mesh resolver is held under that machine when it
// is down on the record — silent, or its own network unhealthy — or when a second machine finds the
// same; then `machine.<x>.unreachable` names every machine that cannot reach it, and none of them
// raises anything of its own for it. One machine alone failing toward a healthy one is its own.
//
// Each is a warning; urgent on the control node, or when the bus cannot be reached, or for the hub.
// Cleared on the first statement that no longer says it. An engine older than this judging says nothing
// of its network, and nothing is raised for it.
// The conditions a machine's network raises.
const (
kindMachineNetwork = "machine-network"
kindNetworkRewritten = "network-rewritten"
kindNetworkUnreachable = "network-unreachable"
sourceNetwork = "network"
)
// networkKinds are the kinds this judging owns: every open one it no longer says, it clears.
var networkKinds = []string{kindMachineNetwork, kindNetworkRewritten, kindNetworkUnreachable}
// networkFacts is what one judging of every machine's network reads.
type networkFacts struct {
healths map[string]inventory.NodeHealth
// byAddress is each machine's address on the private network; hub the hub's name; control the
// control node's.
byAddress map[string]string
hub string
control string
// silent is every machine whose silence is an open condition.
silent map[string]bool
}
// judgeNetworks raises and clears every machine's network conditions from every machine's newest
// statement, after one machine's statement was kept.
func judgeNetworks(ctx context.Context, inv *inventory.Inventory, k *conditions.Keeper, now time.Time) error {
healths, err := inv.Healths(ctx)
if err != nil {
return err
}
overlays, err := inv.Overlays(ctx)
if err != nil {
return err
}
open, err := k.Open(ctx)
if err != nil {
return err
}
f := networkFacts{healths: healths, byAddress: map[string]string{}, control: controlHost(ctx, inv),
silent: map[string]bool{}}
for _, o := range overlays {
if o.Address != "" {
f.byAddress[o.Address] = o.Name
}
if o.Hub {
f.hub = o.Name
}
}
for _, c := range open {
if c.Subject.Scope == conditions.ScopeMachine && c.Kind == "silent" {
f.silent[c.Subject.ID] = true
}
}
var problems []string
said := map[string]bool{}
for _, o := range networkObservations(f) {
said[o.Key()] = true
if _, err := k.Observe(ctx, o); err != nil {
problems = append(problems, err.Error())
}
}
undecided := undecidedParts(f)
for _, c := range open {
if !slices.Contains(networkKinds, c.Kind) || said[c.Key] || heldUndecided(c, undecided) {
continue
}
why := "no machine says it any more"
if c.Subject.Machine != "" {
why = c.Subject.Machine + "'s network no longer says it"
}
if _, err := k.Clear(ctx, c.Key, why); err != nil {
problems = append(problems, err.Error())
}
}
if len(problems) > 0 {
return fmt.Errorf("%s", strings.Join(problems, "; "))
}
return nil
}
// undecidedParts is, per machine, every part of its newest statement not yet judged: starting, or unknown
// — one look failed and a second decides (novox/hq issue 348). Such a part does not say its fault is gone.
// A statement whose parts are all decided but whose whole is unknown or starting holds every part. Pure.
//
// On 2026-10-09 the control node's resolver refused every question from 10:58 to 11:18 UTC. A build of
// the node-engine sent at 10:59:34 restarted it; its first statement judged the names once (unknown, "one
// look failed; a second decides"), and that statement cleared the control node's network condition while
// its last evidence still said "connection refused". The second look raised it again forty seconds later —
// after the send — and the gate failed the build for a fault from before it.
func undecidedParts(f networkFacts) map[string]map[string]bool {
out := map[string]map[string]bool{}
for m, h := range f.healths {
if h.Network == nil {
continue
}
parts := map[string]bool{}
for _, p := range h.Network.Parts {
if p.State == link.StateUnknown || p.State == link.StateStarting {
parts[p.Part] = true
}
}
if len(parts) == 0 && (h.Network.State == link.StateUnknown || h.Network.State == link.StateStarting) {
parts["*"] = true
}
if len(parts) > 0 {
out[m] = parts
}
}
return out
}
// heldUndecided says an open network condition is kept rather than cleared: a part its newest evidence
// names is undecided in the newest statement of a machine it is about. A condition about other parts
// clears as before. Pure.
func heldUndecided(c conditions.Condition, undecided map[string]map[string]bool) bool {
said := ""
if len(c.Evidence) > 0 {
said = c.Evidence[0].Said
}
for _, m := range append([]string{c.Subject.Machine}, c.Subject.Also...) {
parts := undecided[m]
if parts["*"] {
return true
}
for part := range parts {
if strings.Contains(said, part+" since ") || strings.Contains(said, part+": ") {
return true
}
}
}
return false
}
// pointed is one machine's failing part that points at another machine.
type pointed struct {
from string
part inventory.NetworkPart
}
// networkObservations is every network condition the statements say now. Pure.
func networkObservations(f networkFacts) []conditions.Observation {
machines := make([]string, 0, len(f.healths))
for m := range f.healths {
machines = append(machines, m)
}
sort.Strings(machines)
unhealthy := func(m string) []inventory.NetworkPart {
h := f.healths[m]
if h.Network == nil {
return nil
}
var out []inventory.NetworkPart
for _, p := range h.Network.Parts {
if p.State == link.StateUnhealthy {
out = append(out, p)
}
}
return out
}
// The machines a part points at, other than its own: the hub, and each mesh resolver by its address.
// An address that is no mesh machine's — the resolver a VPN client wrote in — is the machine's own.
targets := func(m string, p inventory.NetworkPart) ([]string, bool) {
if len(p.Toward) == 0 {
return nil, false
}
var out []string
for _, t := range p.Toward {
x := f.byAddress[t]
if t == link.TowardHub {
x = f.hub
}
if x == "" || x == m {
return nil, false
}
if !slices.Contains(out, x) {
out = append(out, x)
}
}
return out, true
}
// First pass: what points at whom.
pointing := map[string][]pointed{}
for _, m := range machines {
for _, p := range unhealthy(m) {
if xs, ok := targets(m, p); ok {
for _, x := range xs {
pointing[x] = append(pointing[x], pointed{from: m, part: p})
}
}
}
}
from := func(x string) []string {
var out []string
for _, pt := range pointing[x] {
if !slices.Contains(out, pt.from) {
out = append(out, pt.from)
}
}
sort.Strings(out)
return out
}
// A machine is down on the record when its silence is open or its own network is unhealthy, or when
// two machines find it unreachable: then what points at it is held there.
down := func(x string) bool {
return f.silent[x] || len(unhealthy(x)) > 0 || len(from(x)) >= 2
}
var out []conditions.Observation
saysOwn := map[string]bool{}
for _, m := range machines {
parts := unhealthy(m)
if len(parts) == 0 {
continue
}
var own []inventory.NetworkPart
var rewritten *inventory.NetworkPart
for i, p := range parts {
if p.Part == link.PartResolvConf {
rewritten = &parts[i]
continue
}
if xs, ok := targets(m, p); ok && allDown(xs, down) {
continue // held at the machines it points at
}
own = append(own, p)
}
if rewritten != nil {
out = append(out, rewrittenObservation(m, *rewritten, parts, f))
// The names failing through what another program wrote are that finding's, said in it.
kept := own[:0]
for _, p := range own {
if p.Part != link.PartNames {
kept = append(kept, p)
}
}
own = kept
}
if len(own) > 0 {
out = append(out, machineNetworkObservation(m, own, f, from(m)))
saysOwn[m] = true
}
}
// Said once, at the machine everybody points at — unless its own network condition already says it
// (listed there), or its silence does.
targetsSorted := make([]string, 0, len(pointing))
for x := range pointing {
targetsSorted = append(targetsSorted, x)
}
sort.Strings(targetsSorted)
for _, x := range targetsSorted {
if !down(x) || saysOwn[x] || f.silent[x] {
continue
}
out = append(out, unreachableObservation(x, pointing[x], from(x), f))
}
return out
}
func allDown(xs []string, down func(string) bool) bool {
for _, x := range xs {
if !down(x) {
return false
}
}
return len(xs) > 0
}
// rewrittenObservation is the resolver file rewritten by another program: its own finding, naming the
// writer where the engine could, and what it costs the machine.
func rewrittenObservation(m string, p inventory.NetworkPart, all []inventory.NetworkPart, f networkFacts) conditions.Observation {
writer := ""
if p.Writer != "" {
writer = " (" + p.Writer + ")"
}
cost := "the names through it are not yet judged"
said := []string{p.Part + ": " + p.Said}
for _, q := range all {
if q.Part == link.PartNames {
cost = strings.TrimSuffix(q.Reason, ".")
said = append(said, q.Part+": "+q.Said)
}
}
if cost == "the names through it are not yet judged" {
cost = "the names still resolve through what it wrote"
}
id := m
if p.Owner != "" {
// Named by the module whose file it is: a send that moved that module is what the gate
// holds it on (issue 281's rule — what names a moved module is that module's).
id = m + "." + p.Owner
}
severity := conditions.Warning
if m == f.control {
severity = conditions.Urgent
}
summary := fmt.Sprintf("the resolver file on %s was rewritten by another program%s — %s until the "+
"node-engine writes it back at its next reconcile, or that program gives it back", m, writer, cost)
if p.Owner != "" {
summary = fmt.Sprintf("the resolver file %s writes on %s was rewritten by another program%s — %s until "+
"the node-engine writes it back at its next reconcile, or that program gives it back", p.Owner, m, writer, cost)
}
return conditions.Observation{Scope: conditions.ScopeMachine, ID: id, Token: "rewritten", Kind: kindNetworkRewritten,
Machine: m, Severity: severity, Source: sourceNetwork, Summary: summary,
Said: fmt.Sprintf("since %s: %s", p.Since.UTC().Format("2006-01-02 15:04:05 MST"), strings.Join(said, " | "))}
}
// machineNetworkObservation is what is wrong with a machine's own networking.
func machineNetworkObservation(m string, parts []inventory.NetworkPart, f networkFacts, waiting []string) conditions.Observation {
var words, said []string
severity := conditions.Warning
for _, p := range parts {
words = append(words, p.Reason)
said = append(said, fmt.Sprintf("%s since %s: %s", p.Part, p.Since.UTC().Format("2006-01-02 15:04:05 MST"), p.Said))
if p.Part == link.PartBus {
severity = conditions.Urgent
}
}
if m == f.control || m == f.hub {
severity = conditions.Urgent
}
summary := fmt.Sprintf("%s's network is not healthy: %s", m, strings.Join(words, "; "))
if len(waiting) > 0 {
severity = conditions.Urgent
summary += fmt.Sprintf("; %s cannot reach it", strings.Join(waiting, ", "))
}
return conditions.Observation{Scope: conditions.ScopeMachine, ID: m, Token: "network", Kind: kindMachineNetwork,
Machine: m, Severity: severity, Source: sourceNetwork, Summary: summary, Said: strings.Join(said, " | ")}
}
// unreachableObservation is one machine others cannot reach, said once there.
func unreachableObservation(x string, pts []pointed, from []string, f networkFacts) conditions.Observation {
var what []string
var said []string
for _, pt := range pts {
w := map[string]string{link.PartTunnel: "the tunnel to it", link.PartBus: "the bus on it",
link.PartNames: "its resolver"}[pt.part.Part]
if w == "" {
w = pt.part.Part
}
if !slices.Contains(what, w) {
what = append(what, w)
}
said = append(said, fmt.Sprintf("%s: %s: %s", pt.from, pt.part.Part, pt.part.Said))
}
severity := conditions.Warning
if x == f.hub || x == f.control || slices.Contains(what, "the bus on it") {
severity = conditions.Urgent
}
return conditions.Observation{Scope: conditions.ScopeMachine, ID: x, Token: "unreachable", Kind: kindNetworkUnreachable,
Machine: x, Also: from, Severity: severity, Source: sourceNetwork,
Summary: fmt.Sprintf("%s cannot be reached from %s: %s", x, strings.Join(from, ", "), strings.Join(what, ", ")),
Said: strings.Join(said, " | ")}
}
// networkLines is what `node show` says of a machine's networking.
func networkLines(h inventory.NodeHealth, had bool, now time.Time) []string {
if !had || h.Network == nil {
return []string{" its node-engine does not say how its network is — it is older than that judging (ADR 0241)"}
}
out := []string{fmt.Sprintf(" its network: %s since %s", h.Network.State, h.Network.Since.Local().Format("2006-01-02 15:04"))}
for _, p := range h.Network.Parts {
line := fmt.Sprintf(" %-10s %s", p.State, p.Part)
if p.Reason != "" {
line += " — " + p.Reason
}
if p.Writer != "" {
line += " (" + p.Writer + ")"
}
out = append(out, line)
}
return out
}
-306
View File
@@ -1,306 +0,0 @@
package main
import (
"encoding/json"
"os"
"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 machine says how its network is (novox/hq ADR 0241, "how it is checked"): the resolver file rewritten
// by another program is one finding, naming the writer, with the names it costs said in it; what is the
// machine's own is `machine.<m>.network`; what points at another machine that is down is said once, there;
// one machine alone failing toward a healthy one is its own; the control node, the hub and the bus are
// urgent; an engine that says nothing of its network raises nothing; and the gate waits on what is shown to
// be another's.
func aNetwork(state string, parts ...inventory.NetworkPart) *inventory.NetworkHealth {
for i := range parts {
if parts[i].State == "" {
parts[i].State = link.StateUnhealthy
}
parts[i].Since = h0
}
return &inventory.NetworkHealth{State: state, Since: h0, Parts: parts}
}
func netFacts(healths map[string]*inventory.NetworkHealth) networkFacts {
f := networkFacts{healths: map[string]inventory.NodeHealth{}, hub: "anchor", control: "anchor",
byAddress: map[string]string{"10.77.0.1": "anchor", "10.77.0.2": "laptop", "10.77.0.3": "spare"},
silent: map[string]bool{}}
for m, n := range healths {
f.healths[m] = inventory.NodeHealth{Node: m, Network: n}
}
return f
}
var (
rewrittenByVPN = inventory.NetworkPart{Part: link.PartResolvConf, Reason: "the resolver file was rewritten by another program",
Said: "/etc/resolv.conf lists 172.16.5.5 where 10.77.0.1 is declared", Writer: "FortiClient", Owner: "networkmanager"}
namesThroughVPN = inventory.NetworkPart{Part: link.PartNames, Reason: "mesh names do not resolve",
Said: "172.16.5.5 — anchor.internal (IPv4): says no such name", Toward: []string{"172.16.5.5"}}
tunnelDown = inventory.NetworkPart{Part: link.PartTunnel, Reason: "the tunnel to the hub has not handshaken for over five minutes",
Said: "mesh0's newest handshake with the hub was 9m0s ago", Toward: []string{link.TowardHub}}
noRoute = inventory.NetworkPart{Part: link.PartRoute, Reason: "the machine has no default route", Said: "no default route"}
)
func keysOf(obs []conditions.Observation) []string {
var keys []string
for _, o := range obs {
keys = append(keys, o.Key())
}
return keys
}
func TestAResolverFileRewrittenIsOneFindingNamingItsWriterAndWhatItCosts(t *testing.T) {
obs := networkObservations(netFacts(map[string]*inventory.NetworkHealth{
"anchor": aNetwork(link.StateHealthy),
"laptop": aNetwork(link.StateUnhealthy, rewrittenByVPN, namesThroughVPN),
}))
if keys := keysOf(obs); len(keys) != 1 || keys[0] != "machine.laptop.networkmanager.rewritten" {
t.Fatalf("want one finding, the rewrite, got %v", keys)
}
o := obs[0]
for _, want := range []string{"laptop", "rewritten by another program (FortiClient)", "mesh names do not resolve",
"next reconcile"} {
if !strings.Contains(o.Summary, want) {
t.Errorf("the summary does not say %q: %s", want, o.Summary)
}
}
if strings.Contains(o.Summary, "172.16.") || strings.Contains(o.Summary, "/etc/") {
t.Errorf("an address or a path reached the summary: %s", o.Summary)
}
if !strings.Contains(o.Said, "172.16.5.5") || o.Severity != conditions.Warning || o.Machine != "laptop" {
t.Errorf("the evidence or the severity is wrong: %+v", o)
}
}
func TestOneMachineFailingTowardAHealthyHubIsItsOwn(t *testing.T) {
obs := networkObservations(netFacts(map[string]*inventory.NetworkHealth{
"anchor": aNetwork(link.StateHealthy),
"laptop": aNetwork(link.StateUnhealthy, tunnelDown),
"spare": aNetwork(link.StateHealthy),
}))
if keys := keysOf(obs); len(keys) != 1 || keys[0] != "machine.laptop.network" {
t.Fatalf("want the laptop's own, got %v", keys)
}
if obs[0].Severity != conditions.Warning {
t.Fatalf("a laptop's own tunnel is a warning, got %s", obs[0].Severity)
}
}
func TestTwoMachinesThatCannotReachTheHubAreSaidOnceAtTheHub(t *testing.T) {
obs := networkObservations(netFacts(map[string]*inventory.NetworkHealth{
"anchor": aNetwork(link.StateHealthy),
"laptop": aNetwork(link.StateUnhealthy, tunnelDown),
"spare": aNetwork(link.StateUnhealthy, tunnelDown),
}))
if keys := keysOf(obs); len(keys) != 1 || keys[0] != "machine.anchor.unreachable" {
t.Fatalf("want one condition at the hub, got %v", keys)
}
o := obs[0]
if o.Severity != conditions.Urgent || !strings.Contains(o.Summary, "laptop, spare") || len(o.Also) != 2 {
t.Fatalf("the hub's condition is %+v", o)
}
}
func TestWhatPointsAtASilentHubIsHeldUnderItsSilence(t *testing.T) {
f := netFacts(map[string]*inventory.NetworkHealth{"laptop": aNetwork(link.StateUnhealthy, tunnelDown)})
f.silent["anchor"] = true
if obs := networkObservations(f); len(obs) != 0 {
t.Fatalf("the hub's silence says it; got %v", keysOf(obs))
}
}
func TestAHubWhoseOwnNetworkIsUnhealthyListsWhoCannotReachIt(t *testing.T) {
obs := networkObservations(netFacts(map[string]*inventory.NetworkHealth{
"anchor": aNetwork(link.StateUnhealthy, noRoute),
"laptop": aNetwork(link.StateUnhealthy, tunnelDown),
}))
if keys := keysOf(obs); len(keys) != 1 || keys[0] != "machine.anchor.network" {
t.Fatalf("want the hub's own, holding the laptop's, got %v", keys)
}
if o := obs[0]; o.Severity != conditions.Urgent || !strings.Contains(o.Summary, "laptop cannot reach it") {
t.Fatalf("the hub's condition is %+v", o)
}
}
func TestOneMachineFailingAHealthyMeshResolverIsItsOwnAndTwoAreTheResolvers(t *testing.T) {
silentResolver := inventory.NetworkPart{Part: link.PartNames, Reason: "1 of its 2 resolvers do not answer as the mesh's do",
Said: "10.77.0.3 — no answer within 1s", Toward: []string{"10.77.0.3"}}
obs := networkObservations(netFacts(map[string]*inventory.NetworkHealth{
"anchor": aNetwork(link.StateHealthy), "spare": aNetwork(link.StateHealthy),
"laptop": aNetwork(link.StateUnhealthy, silentResolver),
}))
if keys := keysOf(obs); len(keys) != 1 || keys[0] != "machine.laptop.network" {
t.Fatalf("one machine alone: want its own, got %v", keys)
}
obs = networkObservations(netFacts(map[string]*inventory.NetworkHealth{
"anchor": aNetwork(link.StateUnhealthy, silentResolver), "spare": aNetwork(link.StateHealthy),
"laptop": aNetwork(link.StateUnhealthy, silentResolver),
}))
if keys := keysOf(obs); len(keys) != 1 || keys[0] != "machine.spare.unreachable" {
t.Fatalf("two machines: want it said once at the resolver's machine, got %v", keys)
}
if !strings.Contains(obs[0].Summary, "its resolver") {
t.Fatalf("the resolver's machine is said %s", obs[0].Summary)
}
}
func TestTheControlNodeAndTheBusAreUrgent(t *testing.T) {
f := netFacts(map[string]*inventory.NetworkHealth{"anchor": aNetwork(link.StateUnhealthy, rewrittenByVPN)})
if obs := networkObservations(f); len(obs) != 1 || obs[0].Severity != conditions.Urgent {
t.Fatalf("the control node's rewritten file: %+v", obs)
}
bus := inventory.NetworkPart{Part: link.PartBus, Reason: "the bus cannot be reached", Said: "no link"}
f = netFacts(map[string]*inventory.NetworkHealth{"laptop": aNetwork(link.StateUnhealthy, bus, noRoute)})
if obs := networkObservations(f); len(obs) != 1 || obs[0].Severity != conditions.Urgent {
t.Fatalf("the bus unreachable from the laptop: %+v", obs)
}
}
func TestAnEngineThatSaysNothingOfItsNetworkRaisesNothing(t *testing.T) {
f := netFacts(map[string]*inventory.NetworkHealth{"laptop": nil, "anchor": aNetwork(link.StateHealthy)})
if obs := networkObservations(f); len(obs) != 0 {
t.Fatalf("got %v", keysOf(obs))
}
}
// Through the store and the keeper: the statement kept, the rewrite raised from it, cleared when the file
// is written back, and node show saying it.
func TestARewrittenResolverFileIsRaisedFromTheStatementAndClearedWhenWrittenBack(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, "laptop", link.Health{Contract: link.ReadinessContract, At: at, Network: n}, at); err != nil {
t.Fatal(err)
}
}
openKeys := func() []string {
t.Helper()
list, err := k.Open(ctx)
if err != nil {
t.Fatal(err)
}
var keys []string
for _, c := range list {
keys = append(keys, c.Key)
}
return keys
}
healthy := &link.NetworkHealth{State: link.StateHealthy, Since: h0, Parts: []link.NetworkPart{
{Part: link.PartResolvConf, State: link.StateHealthy, Since: h0}}}
rewritten := &link.NetworkHealth{State: link.StateUnhealthy, Since: h0.Add(time.Minute), Parts: []link.NetworkPart{
{Part: link.PartResolvConf, State: link.StateUnhealthy, Reason: rewrittenByVPN.Reason, Said: rewrittenByVPN.Said,
Writer: "FortiClient", Owner: "networkmanager", Since: h0.Add(time.Minute)},
{Part: link.PartNames, State: link.StateUnhealthy, Reason: namesThroughVPN.Reason, Said: namesThroughVPN.Said,
Toward: namesThroughVPN.Toward, Since: h0.Add(time.Minute)}}}
say(h0, healthy)
if keys := openKeys(); len(keys) != 0 {
t.Fatalf("a healthy network raised %v", keys)
}
say(h0.Add(time.Minute), rewritten)
if keys := openKeys(); len(keys) != 1 || keys[0] != "machine.laptop.networkmanager.rewritten" {
t.Fatalf("the rewrite raised %v", keys)
}
kept, had, err := inv.HealthOf(ctx, "laptop")
if err != nil || !had || kept.Network == nil || kept.Network.Parts[0].Writer != "FortiClient" {
t.Fatalf("the statement's network was not kept: %+v %v", kept.Network, err)
}
if lines := strings.Join(networkLines(kept, had, h0), "\n"); !strings.Contains(lines, "FortiClient") ||
!strings.Contains(lines, "unhealthy resolv-conf") {
t.Fatalf("node show says:\n%s", lines)
}
say(h0.Add(2*time.Minute), healthy)
if keys := openKeys(); len(keys) != 0 {
t.Fatalf("written back, still open: %v", keys)
}
// An engine older than the judging says no network: nothing raised, and node show says it is not known.
say(h0.Add(3*time.Minute), nil)
kept, had, _ = inv.HealthOf(ctx, "laptop")
if keys := openKeys(); len(keys) != 0 || kept.Network != nil {
t.Fatalf("an older engine: %v %+v", keys, kept.Network)
}
if lines := strings.Join(networkLines(kept, had, h0), "\n"); !strings.Contains(lines, "older than that judging") {
t.Fatalf("node show says:\n%s", lines)
}
}
// The gate: a resolver file another program rewrote waits the judging rather than failing it at the
// bound; the same, when the send moved the module whose file it is, is that module's; a machine's own
// network fault holds the machine as a whole, as before.
func TestTheGateWaitsOnARewriteItDidNotMakeAndHoldsTheOwnerOnOneItMoved(t *testing.T) {
since := h0
rewrite := conditions.Condition{Key: "machine.laptop.networkmanager.rewritten", Kind: kindNetworkRewritten,
Subject: conditions.Subject{Scope: conditions.ScopeMachine, ID: "laptop.networkmanager", Machine: "laptop"},
Summary: "the resolver file was rewritten", Raised: since.Add(time.Minute), Source: sourceNetwork}
f := gateFacts{judged: true, open: []conditions.Condition{rewrite}}
if w := aboutTheMachine("laptop", []string{"letta"}, since, f); w.waiting == "" || w.whole != "" || len(w.on) != 0 {
t.Fatalf("a rewrite the send did not make: %+v", w)
}
if w := aboutTheMachine("laptop", []string{"networkmanager", "letta"}, since, f); w.on["networkmanager"] == "" ||
w.on["letta"] != "" || w.waiting != "" {
t.Fatalf("a rewrite of the file a moved module owns: %+v", w)
}
own := conditions.Condition{Key: "machine.laptop.network", Kind: kindMachineNetwork,
Subject: conditions.Subject{Scope: conditions.ScopeMachine, ID: "laptop", Machine: "laptop"},
Summary: "laptop's network is not healthy", Raised: since.Add(time.Minute), Source: sourceNetwork}
if w := aboutTheMachine("laptop", []string{"letta"}, since, gateFacts{judged: true,
open: []conditions.Condition{own}}); w.whole == "" || w.waiting != "" {
t.Fatalf("the machine's own network: %+v", w)
}
unreachable := conditions.Condition{Key: "machine.anchor.unreachable", Kind: kindNetworkUnreachable,
Subject: conditions.Subject{Scope: conditions.ScopeMachine, ID: "anchor", Machine: "anchor", Also: []string{"laptop", "spare"}},
Summary: "anchor cannot be reached", Raised: since.Add(time.Minute), Source: sourceNetwork}
if w := aboutTheMachine("laptop", []string{"letta"}, since, gateFacts{judged: true,
open: []conditions.Condition{unreachable}}); w.waiting == "" || w.whole != "" {
t.Fatalf("a machine that cannot reach the hub: %+v", w)
}
}
// TestTheDrillsStatementsRaiseAndClearTheRewrite replays the drill of ADR 0241 (mesh-host
// internal/network TestDrill…): what a node-engine said in a throwaway container while its resolver file
// was declared, rewritten as a VPN client rewrites it, and written back — recorded, with the mesh's names
// and addresses replaced by this test mesh's. Healthy raises nothing; the rewrite, on its second look, is
// raised as one finding naming the writer; written back, it clears.
func TestTheDrillsStatementsRaiseAndClearTheRewrite(t *testing.T) {
raw, err := os.ReadFile("testdata/network-drill.json")
if err != nil {
t.Fatal(err)
}
var said []link.Health
if err := json.Unmarshal(raw, &said); err != nil {
t.Fatal(err)
}
open := aMesh(t)
ctx := t.Context()
k := conditionsFrom
var raisedAt []int
for i, h := range said {
if err := stateHealth(ctx, open.inventory, k, "laptop", h, h.At); err != nil {
t.Fatal(err)
}
list, err := k.Open(ctx)
if err != nil {
t.Fatal(err)
}
for _, c := range list {
if c.Key != "machine.laptop.networkmanager.rewritten" || !strings.Contains(c.Summary, "(FortiClient)") {
t.Fatalf("statement %d raised %s: %s", i, c.Key, c.Summary)
}
raisedAt = append(raisedAt, i)
}
}
// Five statements: healthy, healthy, one failing look (still healthy), unhealthy, written back.
if len(raisedAt) != 1 || raisedAt[0] != 3 {
t.Fatalf("the rewrite was open after statements %v; want after the fourth alone", raisedAt)
}
}
-157
View File
@@ -1,157 +0,0 @@
package main
import (
"context"
"fmt"
"strings"
"time"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// A failed unit is never silent (novox/hq issue 315).
//
// **A module's failed unit was never raised, and neither was the machine's.** Liveness judges what a
// module runs long-lived; a module whose daemon is a package's unit, started by D-Bus activation, states
// no service, and its unit failed at every start while the machine read healthy. The profile's
// `service-manager` said `degraded`, and nothing raised that.
//
// The node-engine now reads every failed unit in the managers the mesh places units in, and says whose
// each is:
//
// - **a module's** — the declaration states it, writes its file, or installs the package its file
// belongs to — is among the module's resources, unhealthy, of kind `unit`: the module's own
// `module.<module>.<machine>.unhealthy`, naming the unit, raised on the second statement and held by
// the gate as any unhealthy resource is (ADR 0240 §4);
// - **the machine's** — no module places it: a mount of the machine's own table, a unit a removed
// package left behind — is one finding for the machine, `machine.<m>.units`, listing them, so a
// degraded service manager is never silent. A warning; cleared on the first statement that lists none.
//
// The engine applies the two-look rule itself (ADR 0241 §2), so the machine's finding is raised on the
// statement that first says it. It is nothing a send did — no module places what it lists — so the gate
// never holds a send on it. An engine older than this reading says nothing of its units, and nothing is
// raised or cleared for it.
// The condition a machine's own failed units raise.
const (
kindMachineUnits = "machine-units"
sourceUnits = "units"
)
// unitsKept is a statement's units as the inventory keeps them.
func unitsKept(u *link.UnitsHealth) *inventory.UnitsHealth {
if u == nil {
return nil
}
out := &inventory.UnitsHealth{State: u.State, Failed: []inventory.FailedUnit{}, Unread: u.Unread}
for _, f := range u.Failed {
out.Failed = append(out.Failed, inventory.FailedUnit{Unit: f.Unit, Scope: f.Scope, Load: f.Load, Result: f.Result,
Resource: f.Resource, Since: f.Since})
}
return out
}
// judgeUnits raises the machine's own failed units as one finding, or clears it when the statement lists
// none. A statement that says nothing of units — an older engine — leaves it as it is.
func judgeUnits(ctx context.Context, k *conditions.Keeper, node string, u *link.UnitsHealth) error {
if k == nil || u == nil {
return nil
}
o, raise := machineUnitsObservation(node, u)
if raise {
_, err := k.Observe(ctx, o)
return err
}
open, err := k.Open(ctx)
if err != nil {
return err
}
for _, c := range open {
if c.Kind == kindMachineUnits && c.Key == o.Key() {
why := fmt.Sprintf("%s's service managers list no failed unit the mesh does not place", node)
if u.State == link.UnitsRunning {
why = fmt.Sprintf("%s's service managers are running, no unit failed", node)
}
_, err := k.Clear(ctx, c.Key, why)
return err
}
}
return nil
}
// machineUnitsObservation is the machine's own failed units in one finding, and whether there are any.
// The summary names each unit and its manager, which is what a person looks for; how each failed and
// since when is evidence. Pure.
func machineUnitsObservation(node string, u *link.UnitsHealth) (conditions.Observation, bool) {
o := conditions.Observation{Scope: conditions.ScopeMachine, ID: node, Token: "units", Kind: kindMachineUnits,
Machine: node, Severity: conditions.Warning, Source: sourceUnits}
if len(u.Failed) == 0 {
return o, false
}
var names, said, plain []string
for _, f := range u.Failed {
plain = append(plain, unitPlainWords(f.Unit))
where := ""
if f.Scope == "user" {
where = " (the account's own manager)"
}
names = append(names, f.Unit+where)
line := fmt.Sprintf("%s in the %s manager: %s", f.Unit, orSystem(f.Scope), orNotSaid(f.Result))
if f.Load != "" && f.Load != "loaded" {
line += ", its unit " + f.Load
}
if f.Resource != "" {
line += ", named by the mesh's own " + f.Resource
}
line += ", since " + f.Since.UTC().Format("2006-01-02 15:04:05 MST")
said = append(said, line)
}
o.Summary = fmt.Sprintf("%s's service manager is degraded: %d failed unit(s) no module places — %s. "+
"Each is the machine's own: mend or remove it there, or have a module place it",
node, len(u.Failed), strings.Join(names, ", "))
o.Said = strings.Join(said, "; ")
failed := "1 failed service"
if len(u.Failed) > 1 {
failed = fmt.Sprintf("%d failed services", len(u.Failed))
}
o.Headline = fmt.Sprintf("%s on %s", conditions.Capital(failed), node)
o.Needs = fmt.Sprintf("mend or remove them on %s, or silence this if they do not matter.", node)
o.Explanation = fmt.Sprintf("On %s, %s failed. No module manages them, so the mesh does not repair them.",
node, namesWords(plain, 3))
o.Actions = []conditions.Action{conditions.SilenceAction(o.Key())}
o.Resolved = "No failed services on " + node + " any more"
return o, true
}
func orSystem(scope string) string {
if scope == "" {
return "system"
}
return scope
}
// unitsLines is what `node show` says of a machine's service managers.
func unitsLines(h inventory.NodeHealth, had bool, _ time.Time) []string {
if !had || h.Units == nil {
return []string{" its node-engine does not say which units failed — it is older than that reading (issue 315)"}
}
out := []string{" its service managers: " + h.Units.State}
for _, f := range h.Units.Failed {
line := fmt.Sprintf(" failed %s (%s) — no module places it", f.Unit, orSystem(f.Scope))
if f.Result != "" {
line += ", " + f.Result
}
out = append(out, line)
}
for _, r := range h.Resources {
if r.Kind == link.KindUnit {
out = append(out, fmt.Sprintf(" failed %s — %s's, %s", r.Target, r.Module, r.Reason))
}
}
for _, w := range h.Units.Unread {
out = append(out, " unread "+w)
}
return out
}
-128
View File
@@ -1,128 +0,0 @@
package main
import (
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/link"
)
// A failed unit is never silent (novox/hq issue 315, "how it is checked"): a module's failed unit raises
// the module's condition on the second statement, naming the unit; the machine's own failed units raise
// one finding for the machine, listing them, cleared when none is listed; an engine that says nothing of
// its units raises and clears nothing; and the gate never holds a send on the machine's own.
// desktop is the workstation as found on 2026-10-08, with the mesh's names: the Razer daemon's packaged
// user unit, the module's; two mounts of the machine's own table and a unit a removed package left
// behind, the machine's.
func desktop(at time.Time) link.Health {
return link.Health{Contract: link.ReadinessContract, At: at,
Resources: []link.ResourceHealth{{Module: "openrazer", Resource: "openrazer.daemon", Kind: link.KindUnit,
Target: "openrazer-daemon.service", State: link.StateUnhealthy,
Reason: "failed in the account's own service manager (exit-code)", Since: h0, Streak: 2}},
Units: &link.UnitsHealth{State: link.UnitsDegraded, Failed: []link.FailedUnit{
{Unit: "mnt-recalbox.mount", Scope: "system", Load: "loaded", Result: "exit-code", Since: h0},
{Unit: "storage-media.mount", Scope: "system", Load: "loaded", Result: "timeout", Since: h0},
{Unit: "greenclip.service", Scope: "user", Load: "not-found", Result: "start-limit-hit", Since: h0},
}}}
}
func TestAFailedUnitIsTheModulesConditionAndTheMachinesOwnAreOneFinding(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
inv, k := open.inventory, conditionsFrom
openConditions := func() map[string]conditions.Condition {
t.Helper()
list, err := k.Open(ctx)
if err != nil {
t.Fatal(err)
}
out := map[string]conditions.Condition{}
for _, c := range list {
out[c.Key] = c
}
return out
}
say := func(at time.Time, h link.Health) {
t.Helper()
if err := stateHealth(ctx, inv, k, "laptop", h, at); err != nil {
t.Fatal(err)
}
}
say(h0, desktop(h0))
got := openConditions()
machine, raised := got["machine.laptop.units"]
if !raised || len(got) != 1 {
t.Fatalf("the machine's own failed units are one finding at once (the engine looked twice): %v", got)
}
for _, unit := range []string{"mnt-recalbox.mount", "storage-media.mount", "greenclip.service"} {
if !strings.Contains(machine.Summary, unit) {
t.Errorf("the finding names %s: %s", unit, machine.Summary)
}
}
if strings.Contains(machine.Summary, "openrazer") || machine.Severity != conditions.Warning {
t.Errorf("the module's unit is the module's, and the machine's a warning: %+v", machine)
}
say(h0.Add(time.Minute), desktop(h0.Add(time.Minute)))
got = openConditions()
module, raised := got["module.openrazer.laptop.unhealthy"]
if !raised || !strings.Contains(module.Summary, "openrazer-daemon.service") ||
!strings.Contains(module.Summary, "account's own service manager") {
t.Fatalf("the module's failed unit raises its condition on the second statement, naming the unit: %+v", got)
}
kept, had, err := inv.HealthOf(ctx, "laptop")
if err != nil || !had || kept.Units == nil || len(kept.Units.Failed) != 3 {
t.Fatalf("the statement's units were not kept: %+v %v", kept.Units, err)
}
lines := strings.Join(unitsLines(kept, had, h0), "\n")
for _, want := range []string{"degraded", "storage-media.mount (system) — no module places it",
"openrazer-daemon.service — openrazer's"} {
if !strings.Contains(lines, want) {
t.Errorf("node show does not say %q:\n%s", want, lines)
}
}
// An older engine says nothing of its units: nothing cleared on its word.
older := desktop(h0.Add(2 * time.Minute))
older.Units = nil
say(h0.Add(2*time.Minute), older)
if _, still := openConditions()["machine.laptop.units"]; !still {
t.Fatal("a statement saying nothing of units cleared the machine's finding")
}
// The operator mends the mounts and removes the leftover link; the module's unit still fails.
mended := desktop(h0.Add(3 * time.Minute))
mended.Units.Failed = []link.FailedUnit{}
say(h0.Add(3*time.Minute), mended)
got = openConditions()
if _, still := got["machine.laptop.units"]; still {
t.Fatalf("no failed unit of the machine's own, still open: %v", got)
}
if _, still := got["module.openrazer.laptop.unhealthy"]; !still {
t.Fatalf("the module's unit still fails, and its condition stands: %v", got)
}
// And the module's unit mended: running, nothing open.
well := link.Health{Contract: link.ReadinessContract, At: h0.Add(4 * time.Minute),
Units: &link.UnitsHealth{State: link.UnitsRunning, Failed: []link.FailedUnit{}}}
say(h0.Add(4*time.Minute), well)
if got = openConditions(); len(got) != 0 {
t.Fatalf("a running service manager leaves nothing open: %v", got)
}
}
func TestTheGateNeverHoldsASendOnTheMachinesOwnUnits(t *testing.T) {
since := h0
units := conditions.Condition{Key: "machine.laptop.units", Kind: kindMachineUnits,
Subject: conditions.Subject{Scope: conditions.ScopeMachine, ID: "laptop", Machine: "laptop"},
Summary: "laptop's service manager is degraded", Raised: since.Add(time.Minute), Source: sourceUnits}
if w := aboutTheMachine("laptop", []string{"openrazer"}, since, gateFacts{judged: true,
open: []conditions.Condition{units}}); w.whole != "" || w.waiting != "" || len(w.on) != 0 {
t.Fatalf("the machine's own failed units held a send: %+v", w)
}
}
+1 -48
View File
@@ -15,7 +15,6 @@ import (
"os/signal"
"syscall"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/identity"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/licences"
@@ -54,9 +53,6 @@ func run() error {
return fmt.Errorf("no command given")
}
// Every connection this process dials says what it is (novox/hq issue 327).
broker.ConnectionName = connectionName(args[0], os.Getenv(verbVar))
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Whatever this process holds of the controller's lease is given back as it ends (novox/hq to-be
@@ -78,8 +74,6 @@ func run() error {
return rotateCommand(ctx, args[1:])
case "ask":
return askCommand(ctx, args[1:])
case "rehearse":
return rehearseCommand(ctx, args[1:])
case "builds":
return buildsCommand(ctx, args[1:])
// The build queue, controlled by hand (novox/hq ADR 0219).
@@ -99,15 +93,6 @@ func run() error {
return pauseCommand(ctx, args[0], args[1:])
case "collection":
return collectionCommand(ctx, args[1:])
// What the records say the registries may keep, asked on demand (novox/hq ADR 0251).
case "artifacts":
return artifactsCommand(ctx, args[1:])
case "collect":
return collectCommand(ctx, args[1:])
case "images":
return imagesCommand(ctx, args[1:])
case "mirrors":
return mirrorsCommand(ctx, args[1:])
case "plans":
return plansCommand(ctx, args[1:])
case "delivery":
@@ -138,7 +123,7 @@ func run() error {
// The merge gate: every machine of the snapshot composed with a change (novox/hq to-be 45 §9).
case "merge-gate":
return mergeGateCommand(ctx, args[1:])
// A pull request's merge check run here exactly as the build seat runs it (novox/hq issue 286).
// A pull request's merge check run here exactly as the build seat runs it (novox/hq issue 283).
case "check-here":
return checkHereCommand(ctx, args[1:])
case "upgrade":
@@ -218,10 +203,6 @@ func run() error {
func usage() {
fmt.Fprint(os.Stderr, `mesh-controller — the control plane
On a node the operator types these as 'mesh-cli <command>' (in zsh, 'nox <command>'): asked
through the node's engine, and run as the controller's terminal only on the control-node
(novox/hq ADR 0272).
migrate bring each context's schema up to date
prepare the same, asked the way the mesh asks any module (ADR 0135)
node add <name> [--adopted] create a node record; --adopted: the machine is in use
@@ -259,10 +240,6 @@ func usage() {
cleanup [list] [--json] every retired consumer per provider: age, size, why
cleanup delete <node> <module> <consumer> --why <text> the provider deletes that one retired consumer
cleanup delete --older-than <days> --why <text> [--confirm] list those older; delete only with --confirm
artifacts [--json] [--repository <r>] [--collected] every artifact the mesh made: kept and why, eligible, collected (ADR 0251)
collect [--json] [--most <n>] what the store's sweep would let go of, now; nothing is done
collect --confirm --why <text> [--most <n>] run the sweep now: hold every kept archive, let go of the eligible
images <node> [--json] every container image the machine's declaration names, now and as last sent
seats [--json] every seat this mesh defines, what it delivers, and who holds it
seat rename <from> <to> rename a seat; its former name still resolves (ADR 0122)
seat <name> --to <node>/<module> hand a seat to that assignment as one act; never empty in between (ADR 0131)
@@ -381,15 +358,6 @@ func (b builds) Built(ctx context.Context, result link.BuildResult) error {
return nil
}
manifest, _, err := takeIn(ctx, b.inv, result)
// The assignments pending on this build, made or ended (novox/hq issue 325), said in the daemon's log
// after what became of the build.
if b.open != nil {
defer func() {
for _, line := range settlePendingOnBuild(ctx, b.open, result, manifest.Module, err) {
fmt.Printf("%s: %s\n", result.ID, line)
}
}()
}
// When it was asked, so a plan takes as its outcome only a build asked for it or after it
// (novox/hq 04-ISSUES/219). Zero when the id does not say.
asked, _ := link.BuildAskedAt(result.ID)
@@ -426,18 +394,3 @@ func (b builds) Built(ctx context.Context, result link.BuildResult) error {
statusFrom.nudge()
return nil
}
// verbVar carries the seat verb a command runs for, from the serving controller to the process it starts.
const verbVar = "MESH_VERB"
// connectionName is what this process's connections say they are in the bus's list (novox/hq issue 327):
// the serving controller, a verb's own process and which verb, or a command run at a shell and which.
func connectionName(command, verb string) string {
switch {
case command == "serve":
return "mesh-controller serving"
case verb != "":
return "mesh-controller verb " + verb
}
return "mesh-controller command " + command
}
+4 -28
View File
@@ -108,7 +108,7 @@ type mergeVerdict struct {
Facts time.Time `json:"facts"`
Failures []string `json:"failures,omitempty"`
// Errors are what kept the gate from judging: a machine the mesh composes that the gate could not
// raise as it is. Any one makes the verdict an error — never a pass (novox/hq issue 285).
// raise as it is. Any one makes the verdict an error — never a pass (novox/hq issue 282).
Errors []string `json:"errors,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Notes []string `json:"notes,omitempty"`
@@ -135,7 +135,6 @@ type mergeComposed struct {
Problems []string `json:"problems,omitempty"`
Withheld []string `json:"withheld,omitempty"`
Unbound []string `json:"unbound,omitempty"`
Unplaced []string `json:"unplaced,omitempty"`
LeftOut map[string]string `json:"left-out,omitempty"`
Resources []string `json:"resources,omitempty"`
}
@@ -223,7 +222,7 @@ func mergeGateCommand(ctx context.Context, args []string) error {
}
// errMergeGateCouldNotJudge is the gate's own error: the mesh as it is could not be raised, so nothing
// the change does to it can be seen. Never a pass (novox/hq issue 285).
// the change does to it can be seen. Never a pass (novox/hq issue 282).
var errMergeGateCouldNotJudge = errors.New("the merge gate could not judge the change")
// errMergeGateFailed is the gate's own failure: the verdict says why, so the error says nothing more.
@@ -352,7 +351,7 @@ func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error)
v.Failures = append(v.Failures, fmt.Sprintf("%s: nothing could be sent to it with this change — %s",
gm.Described, firstOr(newOnly(gm.Change.Problems, gm.Base.Problems), gm.Change.Problems)))
case !gm.Base.Composes && m.Declaration.Composes:
// **The mesh composes it and the gate could not raise it as it is** (novox/hq issue 285): a fact
// **The mesh composes it and the gate could not raise it as it is** (novox/hq issue 282): a fact
// the snapshot does not carry, or one the gate does not raise. Then the change is judged against a
// machine that is not the mesh's — broken against broken, which passes whatever the change does —
// so the gate cannot judge, and says so: an error, never a pass.
@@ -373,12 +372,6 @@ func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error)
v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves a credential bound elsewhere — %s",
gm.Described, u))
}
// A contribution its holder's template renders nothing for (novox/hq ADR 0255): the machine
// would be sent without it, so a change that adds one is refused, naming it.
for _, u := range newOnly(gm.Change.Unplaced, gm.Base.Unplaced) {
v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves a contribution unplaced — %s",
gm.Described, u))
}
for module, why := range gm.Change.LeftOut {
if _, was := gm.Base.LeftOut[module]; !was {
v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves %s out of its declaration — %s",
@@ -787,7 +780,6 @@ func composeEveryMachine(ctx context.Context, in mergeCheckInput, shelf map[stri
for _, u := range declared.unbound {
c.Unbound = append(c.Unbound, u.String())
}
c.Unplaced = declared.unplaced
sort.Strings(c.Withheld)
sort.Strings(c.Unbound)
out[m.Name] = c
@@ -1013,11 +1005,6 @@ func raiseFromFacts(ctx context.Context, open *stores, f snapshot.Facts, shelf m
return notes, err
}
}
if m.AgentAccount != "" {
if err := inv.SetAgentAccount(ctx, m.Name, m.AgentAccount, m.AgentAccountHome); err != nil {
return notes, err
}
}
if m.PublicDomain != "" {
if err := inv.SetPublicDomain(ctx, m.Name, m.PublicDomain); err != nil {
return notes, err
@@ -1204,18 +1191,7 @@ func graphOfFacts(f snapshot.Facts) ([]inventory.Entry, map[string][]inventory.R
entries = append(entries, inventory.Entry{Manifest: manifest, Provided: mod.Provided,
Source: inventory.Source{Repository: mod.Repository, Path: mod.Path, BuiltFrom: mod.Commit}})
for _, r := range mod.Reads {
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})
}
read[mod.Name] = append(read[mod.Name], inventory.ReadRepository{Repository: r})
}
}
var edges []inventory.Edge
+5 -5
View File
@@ -323,11 +323,11 @@ func busMesh(t *testing.T) snapshot.Facts {
return f
}
// **Issue 285**: every machine running a module on the bus failed to compose in the gate's store, with
// **Issue 282**: every machine running a module on the bus failed to compose in the gate's store, with
// the change and without — the store held the module's credential and no account for it, which
// composition refuses (issue 203) — and the gate passed every change, "0 of 4 compose". The account the
// mesh issued is raised with its credential, so the machine composes in the gate as on the mesh.
func TestIssue285AModuleOnTheBusComposesInTheGate(t *testing.T) {
func TestIssue282AModuleOnTheBusComposesInTheGate(t *testing.T) {
f := busMesh(t)
v := gateJudged(t, f, "")
if v.Verdict != "pass" {
@@ -343,10 +343,10 @@ func TestIssue285AModuleOnTheBusComposesInTheGate(t *testing.T) {
}
}
// **Issue 285**: a machine the mesh composes that the gate cannot raise as it is leaves the change judged
// **Issue 282**: a machine the mesh composes that the gate cannot raise as it is leaves the change judged
// against a machine that is not the mesh's — broken against broken, which passes whatever the change does.
// That is an error, never a pass.
func TestIssue285AMachineTheGateCannotRaiseIsAnErrorNeverAPass(t *testing.T) {
func TestIssue282AMachineTheGateCannotRaiseIsAnErrorNeverAPass(t *testing.T) {
f := busMesh(t)
for i := range f.Machines {
// A fact the snapshot does not carry: the module's credential, gone from the laptop's.
@@ -386,7 +386,7 @@ func TestAWithheldPathIsStoodInForByAPath(t *testing.T) {
}
}
// **Issue 286**: a check run by hand clones beside a change what the seat clones — one rule, read by the
// **Issue 283**: a check run by hand clones beside a change what the seat clones — one rule, read by the
// controller's ask and by the facts — and finds the repository it checks from its origin.
func TestACheckByHandClonesWhatTheSeatClones(t *testing.T) {
for dir, refs := range map[string]map[string]string{
-271
View File
@@ -1,271 +0,0 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"slices"
"strings"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// The operator's command, `mesh-cli` (alias `nox`), answered here (novox/hq ADR 0272).
//
// mesh-cli asks the node-engine on its own machine; the engine reads the asking account from the kernel and asks
// this controller on its own node's subject. Here it is judged — the controller's terminal, an ordinary call, or
// nothing — and run as every verb's line is: a fresh process of this binary, with this process's environment.
//
// **The terminal** is a line from a node's operator account, on the control-node (§4). Phase 2 adds a node whose
// agents run under an account of their own, judged unable to become root (ADR 0266, not built yet); until then no
// other node is the terminal, because agents there run as its operator. **An ordinary call** is a line from that
// account elsewhere: it meets every refusal of the generic `command` verb and runs with `MESH_VERB=mesh-cli`, so a
// terminal-only change is refused with its reason. **Any other account is refused**, root included: those two
// accounts already reach the mesh's verbs through the mesh MCP server, and no other account gains anything here.
// cliVerb is what a line from mesh-cli that is not the terminal runs as: the verb its process names, so every
// terminal-only refusal applies and says it came through mesh-cli.
const cliVerb = "mesh-cli"
// cliServers are commands that serve until stopped. Run for mesh-cli they would hold a second server under this
// one until the call's bound killed it.
var cliServers = map[string]bool{"serve": true, "api": true, "board": true}
// cliVerdict is how a line is run, or why it is not.
type cliVerdict struct {
terminal bool
// why says why the line is or is not the terminal, in words the operator reads under the answer.
why string
// refused says why nothing runs.
refused string
}
// judgeCLI decides how a line from mesh-cli runs (ADR 0272 §4). nodes is every node the mesh knows; control is
// every node the controller's module is assigned to, which is exactly one in a mesh that is well.
func judgeCLI(node string, asked link.CLIAsked, nodes []inventory.Node, control []string) cliVerdict {
var record *inventory.Node
for i := range nodes {
if nodes[i].Name == node {
record = &nodes[i]
break
}
}
switch {
case record == nil:
return cliVerdict{refused: fmt.Sprintf("%s is not a node this mesh knows, so nothing ran", node)}
case asked.UID == 0 || asked.Account == "root":
return cliVerdict{refused: "mesh-cli answers the operator's own account, never root: run it as yourself, " +
"not under sudo. Nothing ran"}
case record.Account == "":
return cliVerdict{refused: fmt.Sprintf("the mesh does not know %s's operator account (`node account <node> <login>`), "+
"so no account there is answered. Nothing ran", node)}
case asked.Account != record.Account:
return cliVerdict{refused: fmt.Sprintf("mesh-cli answers %s's operator account (%s) only, and this line came "+
"from %s. Nothing ran", node, record.Account, asked.Account)}
}
if len(control) != 1 {
return cliVerdict{why: fmt.Sprintf("not the controller's terminal: the mesh names %d control-nodes, and the "+
"terminal is the one control-node's operator account", len(control))}
}
if control[0] == node {
// **A person at a terminal, not a service of the operator's** (review of ADR 0272): the tool runner and the
// account's user units run as the operator too, and only a login session is the terminal.
if asked.Session == "" {
return cliVerdict{why: fmt.Sprintf("not the controller's terminal: %s on %s asked from no login session — a "+
"service or a user unit running as the operator, not a person at a terminal", asked.Account, node)}
}
return cliVerdict{terminal: true, why: fmt.Sprintf("the controller's terminal: %s on the control-node %s, "+
"login session %s", asked.Account, node, asked.Session)}
}
return cliVerdict{why: fmt.Sprintf("not the controller's terminal: agents on %s may run as %s, so a "+
"terminal-only change is made from the control-node %s (novox/hq ADR 0272)", node, asked.Account, control[0])}
}
// controlNodes is every node the controller's module is assigned to.
func controlNodes(ctx context.Context, inv *inventory.Inventory, nodes []inventory.Node) ([]string, error) {
var out []string
for _, n := range nodes {
modules, err := inv.Assigned(ctx, n.Name)
if err != nil {
return nil, err
}
if slices.Contains(modules, controllerModule) {
out = append(out, n.Name)
}
}
return out, nil
}
// controllerModule is the module that runs the controller, and so marks the control-node.
const controllerModule = "mesh-controller"
// answerMeshCLI is the serving controller's answer to mesh-cli.
func answerMeshCLI(inv *inventory.Inventory) link.CLIHandler {
return func(ctx context.Context, node string, asked link.CLIAsked) link.CLIAnswer {
if handingOver.Load() {
return link.CLIRefusal("this controller is stopping and runs no new command; ask again in a moment. Nothing ran")
}
nodes, err := inv.Nodes(ctx)
if err != nil {
return link.CLIRefusal("the mesh's nodes cannot be read, so nothing ran: " + err.Error())
}
control, err := controlNodes(ctx, inv, nodes)
if err != nil {
return link.CLIRefusal("which node is the control-node cannot be read, so nothing ran: " + err.Error())
}
return runForMeshCLI(ctx, node, asked, judgeCLI(node, asked, nodes, control))
}
}
// cliJournal says one line in the controller's journal (its standard output); a variable so a test can read it.
var cliJournal = func(line string) { fmt.Println(line) }
// runForMeshCLI runs a judged line and answers what it said. Every line is said in the journal first: its call,
// who asked on which node, its command word only, and how it runs (review of ADR 0272).
func runForMeshCLI(ctx context.Context, node string, asked link.CLIAsked, v cliVerdict) link.CLIAnswer {
how := "as an ordinary call"
switch {
case v.refused != "":
how = "refused: " + v.refused
case v.terminal:
how = "as the controller's terminal"
}
call := link.CallIDIn(ctx)
if call == "" {
call = "(no call)"
}
cliJournal(fmt.Sprintf("mesh-cli %s: %s on %s asked %q, %s", call, asked.Account, node, asked.Line[0], how))
if v.refused != "" {
return link.CLIRefusal(v.refused)
}
// Standard input is the terminal's alone: a secret given at the terminal reaches `secret accept`, and no ordinary
// call is handed what the asker's standard input held (novox/hq ADR 0259 §10, ADR 0272).
if len(asked.Stdin) > 0 && !v.terminal {
return link.CLIAnswer{Exit: 1, Why: v.why, Refused: "standard input is given to a line that runs as the " +
"controller's terminal alone, and this one does not. Nothing ran"}
}
if cliServers[asked.Line[0]] {
return link.CLIAnswer{Exit: 1, Why: v.why, Refused: fmt.Sprintf("%s serves until stopped, and is not a "+
"command line mesh-cli runs. Nothing ran", asked.Line[0])}
}
verb, line := "", asked.Line
if !v.terminal {
composed, err := ordinaryLine(asked.Line)
if err != nil {
return link.CLIAnswer{Exit: 1, Why: v.why, Refused: err.Error()}
}
verb, line = cliVerb, composed
}
cmd := selfCommand(ctx, line)
cmd.Env = commandEnvironment(fmt.Sprintf("%s through mesh-cli on %s", asked.Account, node), verb, v.terminal)
// No standard input unless mesh-cli carried one for a terminal line: a command that reads one gets nothing, and
// fails saying so (ADR 0272 §5).
cmd.Stdin = nil
if len(asked.Stdin) > 0 {
cmd.Stdin = bytes.NewReader(asked.Stdin)
}
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err := cmd.Run()
answer := link.CLIAnswer{Stdout: stdout.Bytes(), Stderr: stderr.Bytes(), Terminal: v.terminal, Why: v.why}
var exit *exec.ExitError
switch {
case err == nil:
case errors.As(err, &exit):
answer.Exit = exit.ExitCode()
if answer.Exit < 0 {
// Killed: by the call's bound, or by this controller stopping.
answer.Exit = 1
answer.Stderr = append(answer.Stderr, []byte(fmt.Sprintf("\nmesh-cli: the command was stopped (%v)\n",
exit))...)
}
default:
answer.Exit = 1
answer.Refused = fmt.Sprintf("could not run %s from this controller's own build: %v", strings.Join(asked.Line, " "), err)
}
return answer
}
// settingsForms is what an ordinary `settings` line may say: the settings verb's own forms.
const settingsForms = "settings set <module> <values> [--replace] [--node <node>], settings clear <module> " +
"[--node <node>], settings show <module> [--history] [--node <node>], or settings preferences [<module>] " +
"[--node <node>]"
// ordinaryLine is the command line an ordinary call runs (ADR 0272 §4): a `settings` line composed exactly as the
// settings verb composes its own, so its refusals — the terminal-only keys among them — are that verb's (review of
// ADR 0272); any other line as the generic `command` verb takes it, with its refusals.
func ordinaryLine(argv []string) ([]string, error) {
if len(argv) == 0 || argv[0] != "settings" {
// What the generic verb runs, and nothing it would refuse: its reading forms only, and never a command that
// is the terminal's alone (novox/hq ADR 0266) — the two checks argvFor makes of the `command` verb's line,
// made of the words as given rather than re-split from one string.
if err := refusedAsTheGenericCommand(argv); err != nil {
return nil, err
}
if err := terminalOnly(argv); err != nil {
return nil, err
}
return argv, nil
}
args := map[string]any{}
var words []string
rest := argv[1:]
for i := 0; i < len(rest); i++ {
w := rest[i]
switch {
case w == "--replace" || w == "-replace":
args["replace"] = "true"
case w == "--history" || w == "-history":
args["history"] = "true"
case w == "--node" || w == "-node":
if i+1 >= len(rest) {
return nil, fmt.Errorf("--node names no node; settings takes %s. Nothing ran", settingsForms)
}
i++
args["node"] = rest[i]
case strings.HasPrefix(w, "--node=") || strings.HasPrefix(w, "-node="):
_, args["node"], _ = strings.Cut(w, "=")
case strings.HasPrefix(w, "-"):
return nil, fmt.Errorf("settings takes no %s through mesh-cli outside the terminal; it takes %s. "+
"Nothing ran", w, settingsForms)
default:
words = append(words, w)
}
}
wrong := fmt.Errorf("through mesh-cli outside the terminal, settings takes the settings verb's forms: %s. "+
"Nothing ran", settingsForms)
if len(words) == 0 {
return nil, wrong
}
switch words[0] {
case "set":
if len(words) != 3 {
return nil, wrong
}
args["module"], args["values"] = words[1], words[2]
case "clear":
if len(words) != 2 {
return nil, wrong
}
args["module"], args["clear"] = words[1], "true"
case "show":
if len(words) != 2 {
return nil, wrong
}
args["module"] = words[1]
case "preferences":
if len(words) > 2 {
return nil, wrong
}
args["list"] = "preferences"
if len(words) == 2 {
args["module"] = words[1]
}
default:
return nil, wrong
}
return argvFor("settings", args)
}
-99
View File
@@ -1,99 +0,0 @@
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")
}
}
-305
View File
@@ -1,305 +0,0 @@
package main
import (
"context"
"encoding/base64"
"encoding/json"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
"github.com/novox/mesh-controller/internal/testbus"
)
// echoEnvironment makes the test binary, run as a command line, say the verb and caller it was given (TestMain).
const echoEnvironment = "MESH_TEST_ECHO_ENVIRONMENT"
var cliNodes = []inventory.Node{
{Name: "control", Account: "operator"},
{Name: "laptop", Account: "operator"},
{Name: "unnamed"},
}
func cliAsked(account string, uid uint32, line ...string) link.CLIAsked {
return link.CLIAsked{Line: line, Account: account, UID: uid, Session: "session-1.scope"}
}
// Who is the controller's terminal, and who is an ordinary call or refused (novox/hq ADR 0272 §4).
func TestMeshCLIIsTheTerminalOnlyForTheControlNodesOperator(t *testing.T) {
control := []string{"control"}
cases := []struct {
name, node string
asked link.CLIAsked
control []string
terminal bool
refused string
why string
}{
{"the control-node's operator", "control", cliAsked("operator", 1000, "status"), control, true, "", "the controller's terminal"},
{"another node's operator", "laptop", cliAsked("operator", 1000, "status"), control, false, "", "agents on laptop may run as operator"},
{"another account", "control", cliAsked("agent", 1001, "status"), control, false, "operator account (operator) only", ""},
{"root", "control", cliAsked("root", 0, "status"), control, false, "never root", ""},
{"a node with no operator account", "unnamed", cliAsked("operator", 1000, "status"), control, false, "does not know unnamed's operator account", ""},
{"a node the mesh does not know", "elsewhere", cliAsked("operator", 1000, "status"), control, false, "not a node this mesh knows", ""},
{"two control-nodes", "control", cliAsked("operator", 1000, "status"), []string{"control", "laptop"}, false, "", "2 control-nodes"},
}
for _, c := range cases {
v := judgeCLI(c.node, c.asked, cliNodes, c.control)
if v.terminal != c.terminal {
t.Errorf("%s: terminal %v, want %v (%+v)", c.name, v.terminal, c.terminal, v)
}
if c.refused == "" && v.refused != "" || c.refused != "" && !strings.Contains(v.refused, c.refused) {
t.Errorf("%s: refused %q, want %q", c.name, v.refused, c.refused)
}
if c.why != "" && !strings.Contains(v.why, c.why) {
t.Errorf("%s: why %q, want %q", c.name, v.why, c.why)
}
}
}
// The terminal runs its line without MESH_VERB, even where this process carries one; an ordinary call names
// mesh-cli; both record who asked through mesh-cli.
func TestTheTerminalRunsWithoutAVerbAndAnOrdinaryCallNamesMeshCLI(t *testing.T) {
t.Setenv(echoEnvironment, "1")
t.Setenv("MESH_VERB", "leaked-from-the-serving-process")
// The serving controller marks itself (ADR 0266); its terminal line for mesh-cli is still the terminal's.
t.Setenv(servedVar, "1")
ctx := context.Background()
a := runForMeshCLI(ctx, "control", cliAsked("operator", 1000, "status"), cliVerdict{terminal: true, why: "the terminal"})
if a.Exit != 0 || a.Refused != "" || !a.Terminal {
t.Fatalf("the terminal's line did not run: %+v", a)
}
if got := string(a.Stdout); !strings.Contains(got, `verb=""`) || !strings.Contains(got, "operator through mesh-cli on control") ||
!strings.Contains(got, "terminal=true") {
t.Fatalf("the terminal's line ran with %s", got)
}
a = runForMeshCLI(ctx, "laptop", cliAsked("operator", 1000, "status"), cliVerdict{why: "not the terminal"})
if a.Exit != 0 || a.Terminal || a.Why != "not the terminal" {
t.Fatalf("an ordinary line did not run as one: %+v", a)
}
if got := string(a.Stdout); !strings.Contains(got, `verb="mesh-cli"`) || !strings.Contains(got, "terminal=false") {
t.Fatalf("an ordinary line ran with %s", got)
}
}
// An ordinary call meets every refusal of the generic command verb, and nothing runs; a server is never run.
func TestAnOrdinaryCallMeetsTheCommandVerbsRefusals(t *testing.T) {
t.Setenv(echoEnvironment, "1")
ctx := context.Background()
ordinary := cliVerdict{why: "not the terminal"}
a := runForMeshCLI(ctx, "laptop", cliAsked("operator", 1000, "cleanup", "delete", "x"), ordinary)
if a.Refused == "" || len(a.Stdout) != 0 || a.Exit != 1 || a.Why != "not the terminal" {
t.Fatalf("a repair without --why ran as an ordinary call: %+v", a)
}
a = runForMeshCLI(ctx, "laptop", cliAsked("operator", 1000, "settings", "set", "claude-code", "{}"), ordinary)
if a.Refused != "" || !strings.Contains(string(a.Stdout), `verb="mesh-cli"`) {
t.Fatalf("an ordinary settings set did not run through the settings verb's path with MESH_VERB set: %+v", a)
}
for _, server := range []string{"serve", "api", "board"} {
a := runForMeshCLI(ctx, "control", cliAsked("operator", 1000, server), cliVerdict{terminal: true})
if a.Refused == "" || len(a.Stdout) != 0 {
t.Fatalf("%s was run for mesh-cli: %+v", server, a)
}
}
a = runForMeshCLI(ctx, "control", cliAsked("agent", 1001, "status"), cliVerdict{refused: "agent is not answered"})
if a.Refused != "agent is not answered" || len(a.Stdout) != 0 {
t.Fatalf("a refused line ran: %+v", a)
}
}
// **Which node is the terminal does not follow a verb** (review of ADR 0272): the controller's module is assigned
// and unassigned at the terminal alone, so no caller of `assign` can move the terminal to a node of its choosing.
// Asked through the acts themselves, as the verbs ask them; refused before any store is touched (none is given).
func TestTheControllersModuleIsMovedAtTheTerminalAlone(t *testing.T) {
t.Setenv("MESH_VERB", "assign")
ctx := context.Background()
if _, err := assignWith(ctx, nil, "laptop", assignOptions{}, "zsh", "mesh-controller"); err == nil ||
!strings.Contains(err.Error(), "terminal") {
t.Fatalf("assigning the controller through a verb was not refused: %v", err)
}
if _, err := assign(ctx, nil, "laptop", "mesh-controller"); err == nil || !strings.Contains(err.Error(), "terminal") {
t.Fatalf("assigning the controller through a verb was not refused: %v", err)
}
t.Setenv("MESH_VERB", "unassign")
if _, err := unassign(ctx, nil, "control", "mesh-controller"); err == nil || !strings.Contains(err.Error(), "terminal") {
t.Fatalf("unassigning the controller through a verb was not refused: %v", err)
}
// Another module through a verb, and the controller's at the terminal, are not refused for it.
if err := refusedMovingTheController([]string{"zsh"}); err != nil {
t.Fatalf("another module was refused: %v", err)
}
t.Setenv("MESH_VERB", "")
if err := refusedMovingTheController([]string{"mesh-controller"}); err != nil {
t.Fatalf("the terminal was refused: %v", err)
}
}
// An ordinary `settings set|clear` goes down the settings verb's own path: composed as that verb composes it, and
// run with MESH_VERB set, so its refusals — the terminal-only keys among them — are the settings command's own,
// not a blanket refusal of the generic command (review of ADR 0272).
func TestAnOrdinarySettingsLineTakesTheSettingsVerbsPath(t *testing.T) {
cases := []struct {
line []string
want string
err string
}{
{[]string{"settings", "set", "zsh", `{"execute":"withhold"}`, "--node", "laptop"}, `settings set zsh {"execute":"withhold"} --node laptop`, ""},
{[]string{"settings", "set", "zsh", "{}", "--replace"}, "settings set zsh {} --replace", ""},
{[]string{"settings", "clear", "zsh", "--node", "laptop"}, "settings clear zsh --node laptop", ""},
{[]string{"settings", "show", "zsh", "--history"}, "settings show zsh --history", ""},
{[]string{"settings", "preferences"}, "settings preferences", ""},
{[]string{"settings", "set", "zsh"}, "", "settings"},
{[]string{"settings", "set", "zsh", "{}", "--sideways"}, "", "--sideways"},
}
for _, c := range cases {
argv, err := ordinaryLine(c.line)
if c.err != "" {
if err == nil || !strings.Contains(err.Error(), c.err) {
t.Errorf("%q: refused with %v, want %q", c.line, err, c.err)
}
continue
}
if err != nil || strings.Join(argv, " ") != c.want {
t.Errorf("%q: composed %q (%v), want %q", c.line, argv, err, c.want)
}
}
// Anything else still meets the generic command verb's refusals.
if _, err := ordinaryLine([]string{"retire", "delete", "x"}); err == nil {
t.Error("a repair composed as an ordinary line")
}
}
// Every line is said in the controller's journal, with its call, who asked where and how it ran — its command word
// only, never the rest of the line (review of ADR 0272).
func TestEveryMeshCLILineIsSaidInTheJournal(t *testing.T) {
t.Setenv(echoEnvironment, "1")
var said []string
was := cliJournal
cliJournal = func(line string) { said = append(said, line) }
t.Cleanup(func() { cliJournal = was })
ctx := link.WithCallID(context.Background(), "call-1")
runForMeshCLI(ctx, "control", cliAsked("operator", 1000, "settings", "set", "x", `{"password":"s3cret"}`),
cliVerdict{terminal: true, why: "the terminal"})
runForMeshCLI(ctx, "control", cliAsked("agent", 1001, "status"), cliVerdict{refused: "agent is not answered"})
all := strings.Join(said, "\n")
if len(said) != 2 || !strings.Contains(all, "call-1") || !strings.Contains(all, "operator on control") ||
!strings.Contains(all, "as the controller's terminal") || !strings.Contains(all, "refused") {
t.Fatalf("the journal said %q", said)
}
if strings.Contains(all, "s3cret") {
t.Fatalf("the journal carries the line's values: %q", said)
}
}
// **An ordinary line runs only what the generic command verb would** (review of ADR 0272, ADR 0266): its reading
// forms, and never a command that is the terminal's alone. Each of these, from another node's operator, is refused
// and runs nothing.
func TestAnOrdinaryLineRunsNothingTheCommandVerbWouldRefuse(t *testing.T) {
t.Setenv(echoEnvironment, "1")
for _, line := range [][]string{
{"node", "account", "control", "x"},
{"node", "agent-account", "control", "x", "--clear"},
{"token", "issue", "laptop"},
{"secret", "export", "x"},
{"operator", "key", "set", "x"},
{"assign", "laptop", "zsh"},
} {
if _, err := ordinaryLine(line); err == nil {
t.Errorf("%q composed as an ordinary line", line)
}
a := runForMeshCLI(context.Background(), "laptop", cliAsked("operator", 1000, line...), cliVerdict{why: "not the terminal"})
if a.Refused == "" || len(a.Stdout) != 0 {
t.Errorf("%q ran as an ordinary line: %+v", line, a)
}
}
if argv, err := ordinaryLine([]string{"status"}); err != nil || argv[0] != "status" {
t.Fatalf("a read was refused: %v", err)
}
}
// **`calls` never shows a mesh-cli line's answer** (review of ADR 0272): the verb's own answer is read for a line
// served over a bus, and neither the answer's text nor the base64 JSON writes its bytes in is there.
func TestTheCallsVerbNeverShowsAMeshCLILinesAnswer(t *testing.T) {
conn, err := nats.Connect(testbus.URL(t))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
stop, err := link.OverNATS{Conn: conn}.ServeCLI(func(context.Context, string, link.CLIAsked) link.CLIAnswer {
return link.CLIAnswer{Stdout: []byte("s3cret-join")}
}, nil)
if err != nil {
t.Fatal(err)
}
defer stop()
body, _ := json.Marshal(link.CLIAsked{Line: []string{"token", "issue", "x"}, Account: "operator"})
msg, err := conn.Request(link.CLISubject("control"), body, 5*time.Second)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(msg.Data), base64.StdEncoding.EncodeToString([]byte("s3cret-join"))) {
t.Fatalf("the asker was not given its answer: %s", msg.Data)
}
recent, _ := link.Calls.Recent()
var id string
for _, c := range recent {
if c.Seat == link.CLISeat {
id = c.ID
break
}
}
shown, err := callsAnswer(link.Calls, id)
if err != nil {
t.Fatal(err)
}
said, _ := json.Marshal(shown)
if strings.Contains(string(said), "s3cret-join") ||
strings.Contains(string(said), base64.StdEncoding.EncodeToString([]byte("s3cret-join"))) {
t.Fatalf("calls showed a mesh-cli line's answer: %s", said)
}
}
// **Only the terminal's line carries the terminal's mark** (review of ADR 0272): a mark the serving controller's
// environment holds — leaked, or set by anything — is stripped from every other command line it runs, a verb's and
// an ordinary mesh-cli line alike, so neither reads as the terminal.
func TestTheTerminalsMarkIsStrippedFromEveryOtherLine(t *testing.T) {
t.Setenv(echoEnvironment, "1")
t.Setenv(cliTerminalVar, "1")
t.Setenv(servedVar, "1")
for _, env := range [][]string{commandEnvironment("someone", "status", false)} {
for _, kv := range env {
if strings.HasPrefix(kv, cliTerminalVar+"=") {
t.Fatalf("a verb's line carries the terminal's mark: %s", kv)
}
}
}
a := runForMeshCLI(context.Background(), "laptop", cliAsked("operator", 1000, "status"), cliVerdict{why: "not the terminal"})
if got := string(a.Stdout); !strings.Contains(got, "terminal=false") || !strings.Contains(got, `verb="mesh-cli"`) {
t.Fatalf("an ordinary line with the mark in the serving environment ran as %s", got)
}
a = runForMeshCLI(context.Background(), "control", cliAsked("operator", 1000, "status"), cliVerdict{terminal: true})
if got := string(a.Stdout); !strings.Contains(got, "terminal=true") {
t.Fatalf("the terminal's line ran as %s", got)
}
}
// **Only a login session is the terminal** (review of ADR 0272): the operator's account on the control-node, asking
// from a service — the tool runner, a user unit — and not a login session, is an ordinary call.
func TestOnlyALoginSessionIsTheTerminal(t *testing.T) {
control := []string{"control"}
v := judgeCLI("control", link.CLIAsked{Line: []string{"status"}, Account: "operator", UID: 1000}, cliNodes, control)
if v.terminal || v.refused != "" || !strings.Contains(v.why, "login session") {
t.Fatalf("a line from no login session reads %+v", v)
}
v = judgeCLI("control", link.CLIAsked{Line: []string{"status"}, Account: "operator", UID: 1000, Session: "session-3.scope"},
cliNodes, control)
if !v.terminal {
t.Fatalf("a line from a login session on the control-node reads %+v", v)
}
}
-237
View File
@@ -1,237 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"regexp"
"strings"
"github.com/novox/mesh-controller/internal/artifacts"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
)
// The bases the mesh copied into its artifact store, and the copies no record names (novox/hq ADR 0257).
//
// A build that stands on an image published elsewhere copies it in first (ADR 0096, 0097). Since ADR 0257
// each copy is recorded where it is made, and a copy is kept while a kept build stood on it. Copies made
// before then by a build that failed — or before the records kept what a build stood on — are in the store
// with no record naming them, and ADR 0189 §3 keeps the sweep away from anything no record names. This
// verb is how a person says such a copy is the mesh's: it records it, and the sweep then decides as it
// does for every other copy. It deletes nothing.
// mirrorRepository is a repository only the mirror writes: one image's repository, or a module's own
// repository of a base from before ADR 0257.
var mirrorRepository = regexp.MustCompile(`^(upstream/[a-z0-9][a-z0-9._/-]*|[a-z0-9][a-z0-9._-]*/on-[a-z0-9_]+)$`)
// mirrorReference reads a reference a person gave into its recorded form, refusing anything that is not
// a manifest in a repository the mirror writes.
func mirrorReference(given string) (string, error) {
reference := catalogue.Recorded(strings.TrimSpace(given))
path, ours := catalogue.InArtifactStore(reference)
if !ours {
return "", fmt.Errorf("%q is not a reference into the artifact store (artifact-store://<repository>@sha256:<hex>)", given)
}
repository, digest, ok := strings.Cut(path, "@sha256:")
if !ok || len(digest) != 64 || strings.Trim(digest, "0123456789abcdef") != "" {
return "", fmt.Errorf("%q names no manifest by digest", given)
}
if !mirrorRepository.MatchString(repository) {
return "", fmt.Errorf("%q is in %s, which is not a repository the mirror writes "+
"(upstream/<host>/<path>, or <module>/on-<argument>)", given, repository)
}
return reference, nil
}
type mirrorEntry struct {
Reference string `json:"reference"`
State string `json:"state"`
Why []string `json:"why,omitempty"`
}
type mirrorsAnswer struct {
DryRun bool `json:"dry_run,omitempty"`
// Mirrors is every copy the records hold that is not yet let go of.
Mirrors []mirrorEntry `json:"mirrors"`
Counts struct {
Kept int `json:"kept"`
Eligible int `json:"eligible"`
Collected int `json:"collected"`
} `json:"counts"`
// Recorded, AlreadyRecorded and Refused answer a record: what was (or, a dry run, would be)
// recorded, what the records already held, and what was refused, with why.
Recorded []string `json:"recorded,omitempty"`
// Then says what recording does: a recorded copy is eligible, and the next sweep lets it go.
Then string `json:"then,omitempty"`
AlreadyRecorded []string `json:"already_recorded,omitempty"`
Refused map[string]string `json:"refused,omitempty"`
}
// mirrorsOf is the copies among the recorded states.
func mirrorsOf(states []inventory.ArtifactState, mirrored map[string]bool) mirrorsAnswer {
a := mirrorsAnswer{Mirrors: []mirrorEntry{}}
for _, s := range states {
if !mirrored[s.Reference] {
continue
}
switch s.State {
case inventory.ArtifactKept:
a.Counts.Kept++
case inventory.ArtifactEligible:
a.Counts.Eligible++
case inventory.ArtifactCollected:
a.Counts.Collected++
continue
}
a.Mirrors = append(a.Mirrors, mirrorEntry{Reference: s.Reference, State: s.State, Why: s.Why})
}
return a
}
// splitReferences reads references separated by spaces or commas.
func splitReferences(given string) []string {
return strings.FieldsFunc(given, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' || r == '\t' })
}
// recordMirrors decides, for each reference given, whether it may be recorded: in a repository the
// mirror writes, not already recorded, and held by the store. A real run records those.
func recordMirrors(ctx context.Context, inv *inventory.Inventory, store artifacts.Store, given []string,
known map[string]bool, why string, real bool) (mirrorsAnswer, error) {
a := mirrorsAnswer{DryRun: !real, Mirrors: []mirrorEntry{}, Refused: map[string]string{}}
var record []string
seen := map[string]bool{}
for _, g := range given {
reference, err := mirrorReference(g)
if err != nil {
a.Refused[g] = err.Error()
continue
}
if seen[reference] {
continue
}
seen[reference] = true
if known[reference] {
a.AlreadyRecorded = append(a.AlreadyRecorded, reference)
continue
}
if store.Address == "" {
a.Refused[g] = "this mesh has no artifact store on its network to ask whether it holds this"
continue
}
held, err := store.HoldsManifest(ctx, reference)
switch {
case err != nil:
a.Refused[g] = err.Error()
continue
case !held:
a.Refused[g] = "the artifact store does not hold it"
continue
}
record = append(record, reference)
}
a.Recorded = record
if len(record) > 0 {
verb := "would become"
if real {
verb = "became"
}
a.Then = fmt.Sprintf("%d cop%s %s eligible: the next sweep (after any build, or collect) lets each go "+
"unless one of the five kept builds of a module the mesh holds stood on it", len(record),
map[bool]string{true: "y", false: "ies"}[len(record) == 1], verb)
}
if real && len(record) > 0 {
if err := inv.RecordMirrored(ctx, "", why, record); err != nil {
return a, fmt.Errorf("recording %d copies: %w", len(record), err)
}
}
return a, nil
}
func mirrorsCommand(ctx context.Context, args []string) error {
set := flag.NewFlagSet("mirrors", flag.ContinueOnError)
asJSON := set.Bool("json", false, "answer as JSON")
record := set.String("record", "", "references of copies no record names, separated by spaces or commas, to record as the mesh's")
confirm := set.Bool("confirm", false, "record them, rather than only say what would be recorded")
f := addHandActFlags(set)
positionals, err := parseAround(set, args)
if err != nil {
return err
}
if len(positionals) != 0 {
return errors.New("mirrors [--json] [--record <references> [--confirm --why <text>]]")
}
if *confirm {
if strings.TrimSpace(*record) == "" {
return errors.New("mirrors: --confirm records what --record names, and it names nothing. Nothing was done")
}
if err := f.require("mirrors"); err != nil {
return err
}
}
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
inv := open.inventory
states, err := inv.Artifacts(ctx)
if err != nil {
return err
}
known, err := inv.Mirrored(ctx)
if err != nil {
return err
}
var answer mirrorsAnswer
if strings.TrimSpace(*record) == "" {
answer = mirrorsOf(states, known)
} else {
shelf, err := inv.Catalogue(ctx)
if err != nil {
return err
}
address, err := artifactStoreAddress(ctx, inv, shelf, "")
if err != nil {
return err
}
answer, err = recordMirrors(ctx, inv, artifacts.Store{Address: address}, splitReferences(*record), known,
strings.TrimSpace(*f.why), *confirm)
if err != nil {
return err
}
// The hand act is logged once the records say what it did, never before: an act that
// failed half-way is not logged as done.
if *confirm && len(answer.Recorded) > 0 {
f.record(ctx, "mirrors", append([]string{"--record"}, answer.Recorded...))
}
}
if *asJSON {
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(answer)
}
if answer.DryRun && len(answer.Recorded) > 0 {
fmt.Println("a dry run: nothing was recorded (--confirm --why <text> to record)")
}
if answer.Then != "" {
fmt.Println(answer.Then)
}
for _, r := range answer.Recorded {
fmt.Printf(" record %s\n", r)
}
for _, r := range answer.AlreadyRecorded {
fmt.Printf(" already %s\n", r)
}
for g, why := range answer.Refused {
fmt.Printf(" refused %s: %s\n", g, why)
}
for _, m := range answer.Mirrors {
fmt.Printf(" %-9s %s %s\n", m.State, m.Reference, strings.Join(m.Why, ", "))
}
return nil
}
-298
View File
@@ -1,298 +0,0 @@
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/novox/mesh-controller/internal/artifacts"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// The bases the mesh copied in, and recording a copy no record names (novox/hq ADR 0257).
func TestTheMirrorsVerbComposesItsCommandAndRefusesWhatItDoesNotTake(t *testing.T) {
r := ref("route-proxy", "on-go_base", 1)
for _, c := range []struct {
args map[string]any
want string
}{
{map[string]any{}, "mirrors --json"},
{map[string]any{"record": r}, "mirrors --json --record " + r},
{map[string]any{"record": r, "confirm": "true", "why": "copied before copies were recorded"},
"mirrors --json --record " + r + " --confirm --why copied before copies were recorded"},
} {
argv, err := argvFor("mirrors", c.args)
if err != nil || strings.Join(argv, " ") != c.want {
t.Errorf("mirrors %v: %q %v, want %q", c.args, argv, err, c.want)
}
}
for _, args := range []map[string]any{
{"record": r, "confirm": "true"}, // recorded without a why
{"record": r, "why": "x"}, // a why for a dry run
{"confirm": "true", "why": "x"}, // nothing to record
{"record": r, "confirm": "yes", "why": "x"}, // a switch is true or false
{"delete": "true"},
} {
if argv, err := argvFor("mirrors", args); err == nil {
t.Errorf("mirrors %v was composed as %q", args, argv)
}
}
if repairingCommand([]string{"mirrors", "--json", "--record", r, "--confirm", "--why", "x"}) != "mirrors" {
t.Error("recording a copy through the generic verb would go unrecorded")
}
if repairingCommand([]string{"mirrors", "--json", "--record", r}) != "" {
t.Error("a dry run was taken for an act by hand")
}
if !personsDecision(link.HandAct{Verb: "mirrors"}) {
t.Error("recording a copy would count toward a healer the mesh lacks")
}
}
func TestOnlyACopyInARepositoryTheMirrorWritesIsTaken(t *testing.T) {
for given, ok := range map[string]bool{
ref("route-proxy", "on-go_base", 1): true,
catalogue.ArtifactStoreScheme + "upstream/docker.io/library/golang@sha256:" + strings.Repeat("a", 64): true,
ref("route-proxy", "server", 1): false, // a module's own image
catalogue.ArtifactStoreScheme + "novox/invoicing-api@sha256:" + strings.Repeat("a", 64): false,
archiveRef("web", "on-tools", 1): false, // a blob
catalogue.ArtifactStoreScheme + "web/on-x@sha256:abc": false, // not a whole digest
"docker.io/library/golang@sha256:" + strings.Repeat("a", 64): false,
} {
if _, err := mirrorReference(given); (err == nil) != ok {
t.Errorf("%s: taken %v, want %v (%v)", given, err == nil, ok, err)
}
}
}
// heldStore answers a manifest HEAD with 200 for the digests it holds, and records every request.
func heldStore(t *testing.T, holds ...string) (artifacts.Store, *[]string) {
t.Helper()
var asked []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
asked = append(asked, r.Method+" "+r.URL.Path)
if r.Method == http.MethodHead && slices.ContainsFunc(holds, func(d string) bool { return strings.HasSuffix(r.URL.Path, d) }) {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
}))
t.Cleanup(server.Close)
return artifacts.Store{Address: strings.TrimPrefix(server.URL, "http://")}, &asked
}
func TestRecordingACopyTakesWhatTheStoreHoldsAndDeletesNothing(t *testing.T) {
inv := inventory.ForTest(t)
held := ref("route-proxy", "on-go_base", 1)
absent := ref("route-proxy", "on-go_base", 2)
known := ref("nats", "on-nats_base", 3)
notACopy := ref("route-proxy", "server", 4)
if err := inv.RecordMirrored(t.Context(), "b1", "", []string{known}); err != nil {
t.Fatal(err)
}
store, asked := heldStore(t, fmt.Sprintf("%064x", 1))
given := []string{held, absent, known, notACopy}
mirrored, err := inv.Mirrored(t.Context())
if err != nil {
t.Fatal(err)
}
// A dry run says, and records nothing.
dry, err := recordMirrors(t.Context(), inv, store, given, mirrored, "", false)
if err != nil {
t.Fatal(err)
}
if !dry.DryRun || !slices.Equal(dry.Recorded, []string{held}) || !slices.Equal(dry.AlreadyRecorded, []string{known}) ||
len(dry.Refused) != 2 || dry.Refused[absent] == "" || dry.Refused[notACopy] == "" {
t.Fatalf("a dry run answered %+v", dry)
}
if !strings.Contains(dry.Then, "would become eligible") || !strings.Contains(dry.Then, "next sweep") {
t.Fatalf("a dry run did not say what recording does: %q", dry.Then)
}
if again, _ := inv.Mirrored(t.Context()); again[held] {
t.Fatal("a dry run recorded a copy")
}
// A real one records what the store holds, with the person's why, and the sweep may now decide.
real, err := recordMirrors(t.Context(), inv, store, given, mirrored, "copied before copies were recorded", true)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(real.Recorded, []string{held}) {
t.Fatalf("recorded %v", real.Recorded)
}
left, err := inv.ToCollect(t.Context())
if err != nil {
t.Fatal(err)
}
if !slices.Contains(left, held) {
t.Fatalf("a recorded copy no kept build stood on is not offered to collect: %v", left)
}
for _, r := range *asked {
if !strings.HasPrefix(r, "HEAD ") {
t.Fatalf("recording a copy asked the store %s", r)
}
}
}
// indexRegistry holds indexes and platforms of one image's repository, answers GETs with their
// documents, refuses GETs for the digests in fail, and records every delete.
type indexRegistry struct {
mu sync.Mutex
indexes map[string][]string
held map[string]bool
fail map[string]bool
deleted []string
}
func (f *indexRegistry) serve(t *testing.T) artifacts.Store {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
digest := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
switch r.Method {
case http.MethodGet:
if f.fail[digest] {
w.WriteHeader(http.StatusInternalServerError)
return
}
if children, ok := f.indexes[digest]; ok && f.held[digest] {
var named []string
for _, c := range children {
named = append(named, `{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"`+c+`"}`)
}
_, _ = w.Write([]byte(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[` +
strings.Join(named, ",") + `]}`))
return
}
if f.held[digest] {
_, _ = w.Write([]byte(`{"schemaVersion":2,"layers":[]}`))
return
}
w.WriteHeader(http.StatusNotFound)
case http.MethodHead:
if f.held[digest] {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
case http.MethodDelete:
if !f.held[digest] {
w.WriteHeader(http.StatusNotFound)
return
}
delete(f.held, digest)
f.deleted = append(f.deleted, digest)
w.WriteHeader(http.StatusAccepted)
default:
w.WriteHeader(http.StatusBadRequest)
}
}))
t.Cleanup(server.Close)
return artifacts.Store{Address: strings.TrimPrefix(server.URL, "http://")}
}
func copyDigest(n int) string { return fmt.Sprintf("sha256:%064x", n) }
// twoCopies records, in one image's repository, a kept copy (stood on by a held module's build) naming
// platforms 11 and 12, and an eligible one (a failed build's) naming 12 and 13.
func twoCopies(t *testing.T, inv *inventory.Inventory) (kept, eligible string, reg *indexRegistry) {
t.Helper()
const repository = "upstream/docker.io/library/golang"
kept = catalogue.ArtifactStoreScheme + repository + "@" + copyDigest(1)
eligible = catalogue.ArtifactStoreScheme + repository + "@" + copyDigest(2)
if err := inv.RegisterModule(t.Context(), catalogue.Manifest{Module: "proxy", Version: "1"},
inventory.Source{Repository: "https://forge.invalid/proxy.git"}); err != nil {
t.Fatal(err)
}
failed := inventory.Build{ID: "f1", Repository: "https://forge.invalid/proxy.git", On: "a-build-machine",
Failed: "a recipe refused", Mirrored: []string{eligible}}
if err := inv.RecordBuild(t.Context(), failed); err != nil {
t.Fatal(err)
}
ok := inventory.Build{ID: "b1", Repository: "https://forge.invalid/proxy.git", Module: "proxy", On: "a-build-machine",
Commit: "c0ffee", Made: []inventory.Artifact{{Name: "app", Kind: "image", Reference: ref("proxy", "app", 7)}},
Against: []string{kept}, Mirrored: []string{kept}}
if err := inv.RecordBuild(t.Context(), ok); err != nil {
t.Fatal(err)
}
reg = &indexRegistry{
indexes: map[string][]string{copyDigest(1): {copyDigest(11), copyDigest(12)}, copyDigest(2): {copyDigest(12), copyDigest(13)}},
held: map[string]bool{copyDigest(1): true, copyDigest(2): true, copyDigest(11): true, copyDigest(12): true, copyDigest(13): true},
fail: map[string]bool{},
}
return kept, eligible, reg
}
// Through the records: a confirmed collect lets the eligible index go with the platform only it names,
// and leaves the platform the kept index names.
func TestAConfirmedCollectLetsAnIndexGoWithItsOwnPlatformsOnly(t *testing.T) {
inv := inventory.ForTest(t)
_, eligible, reg := twoCopies(t, inv)
store := reg.serve(t)
references, err := inv.ToCollect(t.Context())
if err != nil {
t.Fatal(err)
}
if !slices.Equal(references, []string{eligible}) {
t.Fatalf("offered %v, want the failed build's copy", references)
}
a := runCollect(t.Context(), inv, store, references, nil, true,
sweepBounds{most: 10, budget: 5 * time.Second, platforms: true})
if !slices.Equal(a.LetGo, []string{eligible}) || a.Stopped != "" {
t.Fatalf("let go %v, stopped %q", a.LetGo, a.Stopped)
}
if !slices.Equal(reg.deleted, []string{copyDigest(13), copyDigest(2)}) {
t.Fatalf("deleted %v; want its own platform, then the index", reg.deleted)
}
}
// The sweep after a build leaves an eligible index in place and eligible: let go of alone, its
// platforms would stay for ever under a record that says collected. A later collect a person confirms
// takes it with the platform only it names. An image the same sweep reaches is let go of as before.
func TestTheSweepAfterABuildLeavesAnIndexForAConfirmedCollect(t *testing.T) {
inv := inventory.ForTest(t)
_, eligible, reg := twoCopies(t, inv)
reg.held[copyDigest(5)] = true
image := catalogue.ArtifactStoreScheme + "upstream/docker.io/library/golang@" + copyDigest(5)
store := reg.serve(t)
r := sweep(t.Context(), inv, store, []string{eligible, image}, nil, afterBuild)
if r.Indexes != 1 || !slices.Equal(r.LetGo, []string{image}) || !slices.Equal(reg.deleted, []string{copyDigest(5)}) {
t.Fatalf("after a build: %d indexes left, let go %v, deleted %v; want the index left and the image gone",
r.Indexes, r.LetGo, reg.deleted)
}
left, err := inv.ToCollect(t.Context())
if err != nil {
t.Fatal(err)
}
if !slices.Equal(left, []string{eligible}) {
t.Fatalf("after the sweep the records offer %v; want the index still eligible", left)
}
a := runCollect(t.Context(), inv, store, left, nil, true,
sweepBounds{most: 10, budget: 5 * time.Second, platforms: true})
if !slices.Equal(a.LetGo, []string{eligible}) ||
!slices.Equal(reg.deleted, []string{copyDigest(5), copyDigest(13), copyDigest(2)}) {
t.Fatalf("a confirmed collect let go %v, deleted %v; want the index with its own platform", a.LetGo, reg.deleted)
}
}
// A kept index the store will not answer for stops a confirmed collect before its first delete.
func TestASpareListThatCannotBeReadStopsTheSweepBeforeAnyDelete(t *testing.T) {
inv := inventory.ForTest(t)
_, eligible, reg := twoCopies(t, inv)
reg.fail[copyDigest(1)] = true
store := reg.serve(t)
a := runCollect(t.Context(), inv, store, []string{eligible}, nil, true,
sweepBounds{most: 10, budget: 5 * time.Second, platforms: true})
if len(a.LetGo) != 0 || len(reg.deleted) != 0 || !strings.Contains(a.Stopped, "nothing was let go") {
t.Fatalf("let go %v, deleted %v, stopped %q", a.LetGo, reg.deleted, a.Stopped)
}
}
+4 -58
View File
@@ -40,50 +40,6 @@ type merges interface {
AnnouncedMerges(ctx context.Context, since time.Time) ([]link.AnnouncedMerge, error)
}
// unheardMerges is the announcements of merges the controller has not heard (novox/hq ADR 0276): a merge kept
// in a batch is not acted on until its batch is cut, which can be past mergeGrace behind a long walk, and is
// not missed for that.
type unheardMerges struct {
merges
inv *inventory.Inventory
}
func (u unheardMerges) AnnouncedMerges(ctx context.Context, since time.Time) ([]link.AnnouncedMerge, error) {
all, err := u.merges.AnnouncedMerges(ctx, since)
if err != nil {
return nil, err
}
var out []link.AnnouncedMerge
for _, a := range all {
_, kept, err := u.inv.MergeOf(ctx, a.Owner+"/"+a.Repo, a.Commit)
if err != nil {
return nil, err
}
if !kept {
out = append(out, a)
}
}
return out, nil
}
// owedToTheRecord says a merge read as history is still owed to the record (novox/hq ADR 0276): the merges
// reader knows, when it keeps them (unheardMerges).
func owedToTheRecord(ctx context.Context, announced merges, m link.SourceMoved, entries []inventory.Entry,
read map[string][]inventory.ReadRepository) bool {
u, ok := announced.(unheardMerges)
if !ok {
return false
}
merged, err := time.Parse(time.RFC3339Nano, m.MergedAt)
if err != nil {
return false
}
raw := m
raw.MergedAt = ""
owed, err := owedLate(ctx, u.inv, m, merged, wouldMove(raw, entries, read))
return err == nil && owed
}
// catchingUpOnMerges reads back the forge's announcements on a timer, until the context ends.
func catchingUpOnMerges(ctx context.Context, open *stores, announced merges) {
f := following{open}
@@ -92,7 +48,7 @@ func catchingUpOnMerges(ctx context.Context, open *stores, announced merges) {
if err != nil {
return nil, nil, err
}
read, err := readForPlanning(ctx, open.inventory)
read, err := open.inventory.ReadRepositories(ctx)
return entries, read, err
}
failing := ""
@@ -105,7 +61,7 @@ func catchingUpOnMerges(ctx context.Context, open *stores, announced merges) {
case <-tick.C:
}
watchedMerges.begin()
err := catchUpOnMerges(ctx, time.Now(), unheardMerges{announced, open.inventory}, catalogued, f.SourceMoved, func(format string, args ...any) {
err := catchUpOnMerges(ctx, time.Now(), announced, catalogued, f.SourceMoved, func(format string, args ...any) {
fmt.Printf(format+"\n", args...)
})
// What the pass found is what S5 says (novox/hq to-be 45 §3); a pass that could not read
@@ -146,15 +102,10 @@ func catchUpOnMerges(ctx context.Context, now time.Time, announced merges,
return err
}
for _, a := range all {
// A merge the forge said no time of is dated by its announcement, so a packaging module's look can
// make it history once acted on, and the catch-up does not act on it again every pass (ADR 0267).
if a.SourceMoved.MergedAt == "" && !a.At.IsZero() {
a.SourceMoved.MergedAt = a.At.UTC().Format(time.RFC3339)
}
if now.Sub(a.At) < mergeGrace {
continue
}
if len(wouldMove(a.SourceMoved, entries, read)) == 0 && !owedToTheRecord(ctx, announced, a.SourceMoved, entries, read) {
if len(wouldMove(a.SourceMoved, entries, read)) == 0 {
continue
}
if entries, read, err = catalogued(ctx); err != nil {
@@ -162,12 +113,7 @@ func catchUpOnMerges(ctx context.Context, now time.Time, announced merges,
}
moves := wouldMove(a.SourceMoved, entries, read)
if len(moves) == 0 {
if !owedToTheRecord(ctx, announced, a.SourceMoved, entries, read) {
continue
}
raw := a.SourceMoved
raw.MergedAt = ""
moves = wouldMove(raw, entries, read)
continue
}
var names []string
for _, e := range moves {
+10 -442
View File
@@ -8,7 +8,6 @@ import (
"sync/atomic"
"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"
@@ -73,7 +72,7 @@ func stateHealth(ctx context.Context, inv *inventory.Inventory, k *conditions.Ke
for _, r := range h.Resources {
kept := inventory.ResourceHealth{Module: r.Module, Resource: r.Resource, Kind: r.Kind, Target: r.Target,
State: r.State, Reason: r.Reason, Since: r.Since, Streak: r.Streak, Restarts: r.Restarts,
Check: r.Check, Needs: r.Needs, Account: r.Account, Root: r.Root}
Check: r.Check, Needs: r.Needs}
resources = append(resources, kept)
if r.State == link.StateUnhealthy && r.Module != "" {
unhealthy[r.Module] = append(unhealthy[r.Module], kept)
@@ -83,16 +82,8 @@ func stateHealth(ctx context.Context, inv *inventory.Inventory, k *conditions.Ke
for module := range unhealthy {
streaks[module] = prev.Streaks[module] + 1
}
var network *inventory.NetworkHealth
if h.Network != nil {
network = &inventory.NetworkHealth{State: h.Network.State, Since: h.Network.Since, Parts: []inventory.NetworkPart{}}
for _, p := range h.Network.Parts {
network.Parts = append(network.Parts, inventory.NetworkPart{Part: p.Part, State: p.State, Reason: p.Reason,
Said: p.Said, Writer: p.Writer, Owner: p.Owner, Toward: p.Toward, Since: p.Since, Streak: p.Streak})
}
}
stored, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: node, Contract: h.Contract, SaidAt: h.At,
HeardAt: now, Resources: resources, Streaks: streaks, Network: network, Units: unitsKept(h.Units)})
HeardAt: now, Resources: resources, Streaks: streaks})
if err != nil || !stored {
if err == nil {
healthRefused.Add(1)
@@ -102,32 +93,13 @@ func stateHealth(ctx context.Context, inv *inventory.Inventory, k *conditions.Ke
if k == nil {
return nil
}
err = judgeModuleHealth(ctx, inv, k, node, unhealthy, streaks, now)
// And the machine's own failed units (issue 315): what no module places, one finding for the machine.
if uerr := judgeUnits(ctx, k, node, h.Units); uerr != nil {
if err == nil {
err = uerr
} else {
err = fmt.Errorf("%w; %v", err, uerr)
}
}
// And every machine's network, from every machine's newest statement (ADR 0241): a statement about one
// machine can hold another's finding, or release it.
if nerr := judgeNetworks(ctx, inv, k, now); nerr != nil {
if err == nil {
return nerr
}
return fmt.Errorf("%w; %v", err, nerr)
}
return err
return judgeModuleHealth(ctx, k, node, unhealthy, streaks, now)
}
// judgeModuleHealth raises a module's condition on a machine on the second statement in a row that says a
// resource of it is unhealthy — or on the first while it is already open — and clears every one this
// statement no longer says. **A consumer whose findings wait on an unhealthy provider is held** (to-be 48
// §6): raised as nothing of its own, listed at the provider's condition, which is urgent while anyone
// waits on it.
func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditions.Keeper, node string,
// statement no longer says.
func judgeModuleHealth(ctx context.Context, k *conditions.Keeper, node string,
unhealthy map[string][]inventory.ResourceHealth, streaks map[string]int, now time.Time) error {
open, err := k.Open(ctx)
if err != nil {
@@ -135,17 +107,10 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi
}
standing := map[string]conditions.Condition{}
for _, c := range open {
if (c.Kind == kindModuleUnhealthy || c.Kind == kindReloginNeeded || c.Kind == kindUsedAsFound) &&
c.Subject.Machine == node {
if c.Kind == kindModuleUnhealthy && c.Subject.Machine == node {
standing[c.Key] = c
}
}
var hold *holding
if inv != nil {
if hold, err = readHolding(ctx, inv, open); err != nil {
return err
}
}
var problems []string
modules := make([]string, 0, len(unhealthy))
for m := range unhealthy {
@@ -153,58 +118,9 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi
}
sort.Strings(modules)
seen := map[string]bool{}
// became is, per module, the kind of condition this statement says of it: what a standing one of the
// other kind turned into.
became := map[string]string{}
heldOn := map[string]string{}
providers := map[catalogue.Chosen]bool{}
for _, m := range modules {
// **A directory used as found is said as that** (novox/hq issue 339): the operator's to hand over at the
// machine, never urgent — nothing is broken by the wait that a person was not told of — and its own kind,
// so the gate never reads it as a fault of the build that happened to be sent beside it.
if said, waits := foundWait(m, node, unhealthy[m]); waits {
o := usedAsFoundObservation(m, node, said, unhealthy[m])
seen[o.Key()] = true
became[m] = kindUsedAsFound
if _, isOpen := standing[o.Key()]; streaks[m] < moduleUnhealthyAfter && !isOpen {
continue
}
if _, err := k.Observe(ctx, o); err != nil {
problems = append(problems, err.Error())
}
continue
}
// **A wait for a person's new login is said as that** (novox/hq ADR 0254): one plain sentence to the
// operator, never urgent, cleared on the first statement that no longer says it.
if said, waits := personWait(m, node, unhealthy[m]); waits {
o := reloginObservation(m, node, said, operatorOn(ctx, inv, node), unhealthy[m])
// **A provider waiting for a login says who waits on it** (novox/hq issue 318 review): its
// consumers are held under it, and its own condition is where they are said.
if hold != nil {
sayWaitingOn(&o, hold.waitersOn(catalogue.Chosen{Node: node, Module: m}))
}
seen[o.Key()] = true
became[m] = kindReloginNeeded
if _, isOpen := standing[o.Key()]; streaks[m] < moduleUnhealthyAfter && !isOpen {
continue
}
if _, err := k.Observe(ctx, o); err != nil {
problems = append(problems, err.Error())
}
continue
}
o := moduleUnhealthyObservation(m, node, unhealthy[m])
if hold != nil {
if p, held := hold.heldUnder(node, m, unhealthy[m]); held {
// Held under the provider's condition: nothing of its own, and the provider's says it waits.
heldOn[o.Key()] = p.Module + " on " + p.Node
providers[p] = true
continue
}
sayWaitingOn(&o, hold.waitersOn(catalogue.Chosen{Node: node, Module: m}))
}
seen[o.Key()] = true
became[m] = kindModuleUnhealthy
c, isOpen := standing[o.Key()]
if streaks[m] < moduleUnhealthyAfter && !isOpen {
continue // unconfirmed: one statement can be wrong; `node show` lists it
@@ -221,35 +137,7 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi
continue
}
module := strings.TrimSuffix(c.Subject.ID, "."+node)
why := fmt.Sprintf("%s says no resource of %s is unhealthy", node, module)
if c.Kind == kindReloginNeeded {
why = fmt.Sprintf("%s says %s no longer waits for a new login", node, module)
}
if c.Kind == kindUsedAsFound {
why = fmt.Sprintf("%s says no directory of %s is used as found any more", node, module)
}
if on, held := heldOn[key]; held {
why = fmt.Sprintf("what %s finds on %s waits on %s, which is unhealthy: held under its condition", module, node, on)
}
// **A condition that became the other kind** is not "working again" (issue 318 review): its clearing
// line says what it became.
resolved := ""
switch {
case c.Kind == kindModuleUnhealthy && became[module] == kindReloginNeeded:
why = fmt.Sprintf("%s on %s now waits only for a new login", module, node)
resolved = fmt.Sprintf("%s on %s now waits only for a new login", module, node)
case c.Kind == kindReloginNeeded && became[module] == kindModuleUnhealthy:
why = fmt.Sprintf("%s on %s no longer waits for a new login, and is not healthy", module, node)
resolved = fmt.Sprintf("The new login on %s is done, and %s still does not work", node, module)
}
if _, err := k.ClearSaying(ctx, key, why, resolved); err != nil {
problems = append(problems, err.Error())
}
}
// And each provider a consumer here now waits on, when its own condition is open: said again with who
// waits on it, so the wait is listed at the provider whichever machine's statement arrived first.
for p := range providers {
if err := sayWaiters(ctx, k, hold, p, now); err != nil {
if _, err := k.Clear(ctx, key, fmt.Sprintf("%s says no resource of %s is unhealthy", node, module)); err != nil {
problems = append(problems, err.Error())
}
}
@@ -259,136 +147,11 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi
return nil
}
// sayWaiters observes a provider's open condition again, with who waits on it, from its machine's newest
// statement. Nothing when its condition is not open: it is raised by its own statements, on its own looks.
func sayWaiters(ctx context.Context, k *conditions.Keeper, hold *holding, p catalogue.Chosen, now time.Time) error {
var raisedAt *conditions.Condition
for i, c := range hold.open {
if c.Key == moduleUnhealthyKey(p.Module, p.Node) || c.Key == reloginKey(p.Module, p.Node) {
raisedAt = &hold.open[i]
}
}
if raisedAt == nil {
return nil
}
var rs []inventory.ResourceHealth
for _, r := range hold.healths[p.Node].Resources {
if r.Module == p.Module && r.State == link.StateUnhealthy {
rs = append(rs, r)
}
}
if len(rs) == 0 {
return nil
}
o := moduleUnhealthyObservation(p.Module, p.Node, rs)
if said, waits := personWait(p.Module, p.Node, rs); waits {
o = reloginObservation(p.Module, p.Node, said, operatorOn(ctx, hold.inv, p.Node), rs)
}
if o.Key() != raisedAt.Key {
return nil // its own statement says it next
}
sayWaitingOn(&o, hold.waitersOn(p))
_, err := k.Observe(ctx, o)
return err
}
// reloginKey is a module's relogin-needed condition on a machine.
func reloginKey(module, node string) string {
return conditions.Key(conditions.ScopeModule, module+"."+node, kindReloginNeeded)
}
// operatorOn is the operator's account on a machine, or "" when it is not known (to-be 29).
func operatorOn(ctx context.Context, inv *inventory.Inventory, node string) string {
if inv == nil {
return ""
}
n, err := inv.NodeByName(ctx, node)
if err != nil {
return ""
}
return n.Account
}
// reloginObservation is a module waiting for a person's new login on a machine (novox/hq ADR 0254): the
// operator's, a warning, its summary the one sentence that says what to do; the resources are evidence. Its
// plain words (ADR 0253) are the kind's: it needs the operator, and offers no answer — no verb can log a
// person in again, and a restart of the module's service would start it in the same session. operator is
// the machine's operator account, when known: the words say "your account" only for it.
func reloginObservation(module, node, said, operator string, rs []inventory.ResourceHealth) conditions.Observation {
o := moduleUnhealthyObservation(module, node, rs)
o.Token, o.Kind, o.Resolver, o.Summary = kindReloginNeeded, kindReloginNeeded, conditions.ResolverOperator, said
accounts := waitingAccounts(module, rs)
yours := operator != "" && len(accounts) > 0
for _, a := range accounts {
yours = yours && a == operator
}
w := reloginWords(module, node, yours)
o.Headline, o.Explanation, o.Resolved, o.Needs, o.Actions = w.Headline, w.Explanation, w.Resolved, w.Needs, nil
return o
}
// reloginWords is what the operator reads of a module waiting for a new login on a machine (ADR 0253,
// ADR 0254): never quiet, since only a person can do it, and no button, since nothing else can. yours says
// the account waiting is the operator's own on that machine; otherwise the words do not claim it is.
func reloginWords(module, node string, yours bool) words {
if yours {
return words{Headline: fmt.Sprintf("%s waits for a new login on %s", module, node),
Needs: fmt.Sprintf("log out of %s completely and log in again, or restart it.", node),
Explanation: fmt.Sprintf("%s put your account in a group it needs. You logged in before that, so %s "+
"cannot run until you log in again. Its update is in place and nothing was undone.",
conditions.Capital(module), module),
Resolved: fmt.Sprintf("%s runs on %s after your new login", module, node)}
}
return words{Headline: fmt.Sprintf("%s waits for a new login on %s", module, node),
Needs: fmt.Sprintf("have the account it names log out of %s completely and log in again, or restart %s.", node, node),
Explanation: fmt.Sprintf("%s put an account on %s in a group it needs. That account logged in before that, "+
"so %s cannot run until it logs in again. Its update is in place and nothing was undone.",
conditions.Capital(module), node, module),
Resolved: fmt.Sprintf("%s runs on %s after the new login", module, node)}
}
// waitingAccounts is every account of a module whose resource says it waits for a new login, sorted.
func waitingAccounts(module string, rs []inventory.ResourceHealth) []string {
seen := map[string]bool{}
var out []string
for _, r := range rs {
if r.Module == module && r.Kind == link.KindAccount && r.State == link.StateUnhealthy &&
strings.HasPrefix(r.Reason, link.ReasonRelogin) && accountOf(r) != "" && !seen[accountOf(r)] {
seen[accountOf(r)] = true
out = append(out, accountOf(r))
}
}
sort.Strings(out)
return out
}
// sayWaitingOn adds to a module's condition the consumers held under it (to-be 48 §6): urgent while anyone
// waits on it, whether it is not working or waits for a new login.
func sayWaitingOn(o *conditions.Observation, waiters []string) {
if len(waiters) == 0 {
return
}
o.Severity = conditions.Urgent
o.Said += "; " + waitingWords(waiters)
o.Summary += fmt.Sprintf("; %d consumer(s) wait on it", len(waiters))
o.Explanation += fmt.Sprintf(" %d module(s) that depend on it wait for it.", len(waiters))
}
// moduleUnhealthyObservation is a module unhealthy on a machine, in words: the summary names the module,
// the machine and what is wrong with each resource; the detail — targets, streaks, since — is evidence.
func moduleUnhealthyObservation(module, node string, rs []inventory.ResourceHealth) conditions.Observation {
var words, said, plain []string
needs := moduleNeeds(node, rs)
var words, said []string
for _, r := range rs {
plain = append(plain, resourcePlainWords(r))
if r.Kind == link.KindUnit {
// A failed unit is named by the unit, which is what a person looks for (issue 315); the
// resource that places it — a package, a file — is evidence.
words = append(words, fmt.Sprintf("its unit %s %s", r.Target, r.Reason))
said = append(said, fmt.Sprintf("%s (unit %s, placed by %s): %s, since %s", r.Target, r.Target, r.Resource,
orNotSaid(r.Reason), r.Since.UTC().Format("2006-01-02 15:04:05 MST")))
continue
}
words = append(words, fmt.Sprintf("its %s %s %s", r.Kind, r.Resource, reasonWords(r)))
said = append(said, fmt.Sprintf("%s (%s %s): %s, %d look(s) in a row, %d restart(s) counted, since %s",
r.Resource, r.Kind, r.Target, orNotSaid(r.Reason), r.Streak, r.Restarts,
@@ -396,22 +159,12 @@ func moduleUnhealthyObservation(module, node string, rs []inventory.ResourceHeal
}
return conditions.Observation{Scope: conditions.ScopeModule, ID: module + "." + node, Token: "unhealthy",
Kind: kindModuleUnhealthy, Machine: node, Severity: conditions.Warning, Source: sourceHealth,
Summary: fmt.Sprintf("%s on %s is not healthy: %s", module, node, strings.Join(words, "; ")),
Said: strings.Join(said, "; "),
Headline: fmt.Sprintf("%s not working on %s", 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)),
Needs: needs,
Actions: moduleActions(node, rs),
Resolved: fmt.Sprintf("%s works again on %s", module, node)}
Summary: fmt.Sprintf("%s on %s is not healthy: %s", module, node, strings.Join(words, "; ")),
Said: strings.Join(said, "; ")}
}
// reasonWords is why a resource is unhealthy, as a person reads it.
func reasonWords(r inventory.ResourceHealth) string {
// An account waiting for a new login (ADR 0252) is said in the mesh's words, not the engine's.
if r.Kind == link.KindAccount && strings.HasPrefix(r.Reason, link.ReasonRelogin) {
return fmt.Sprintf("waits for a new login of %s, which is in the group and logged in before it was", accountOf(r))
}
switch r.Reason {
case "restarting":
return fmt.Sprintf("keeps restarting (%d restart(s) counted)", r.Restarts)
@@ -435,71 +188,9 @@ func orNotSaid(s string) string {
return s
}
// kindReloginNeeded is a module's condition while it waits for a person's new login on a machine (novox/hq
// ADR 0254): its own kind, so that neither the gate nor anyone reading the conditions takes it for a fault.
const kindReloginNeeded = "relogin-needed"
// personWait is whether everything unhealthy of a module on a machine waits for one person's new login, and
// that wait in one plain sentence (novox/hq ADR 0254, issue 318). Narrow on purpose, so that a fault is never
// excused:
//
// - at least one of the module's resources is its account (kind account), unhealthy with a reason that
// starts "relogin needed" (ADR 0252): the database lists the account in the group, and the session
// running began before;
// - every other unhealthy resource of the module runs in the own service manager of one of those very
// accounts (Account names it): the manager that began before the group and does not hold it.
//
// Anything else unhealthy of the module — a container, a unit of the machine's own manager, a unit in
// another account's manager, a resource whose engine does not say whose manager it is in, an account
// not in its group or that could not be read — is not a wait, and the module is judged as before. A
// resource still starting is not unhealthy and does not make a wait either. Pure.
func personWait(module, machine string, rs []inventory.ResourceHealth) (string, bool) {
accounts := waitingAccounts(module, rs)
if len(accounts) == 0 {
return "", false
}
waiting := map[string]bool{}
for _, a := range accounts {
waiting[a] = true
}
var units []string
for _, r := range rs {
if r.Module != module || r.State != link.StateUnhealthy {
continue
}
switch {
case r.Kind == link.KindAccount && strings.HasPrefix(r.Reason, link.ReasonRelogin) && waiting[accountOf(r)]:
case r.Kind != link.KindAccount && r.Account != "" && waiting[r.Account]:
units = append(units, r.Target)
default:
return "", false
}
}
said := fmt.Sprintf("relogin needed on %s: %s waits for a new login of %s, which is in its group and whose "+
"login began before it was; log out of %[1]s completely and log in again, or restart it", machine, module,
strings.Join(accounts, ", "))
if len(units) > 0 {
sort.Strings(units)
said += fmt.Sprintf(" (until then %s cannot run)", strings.Join(units, ", "))
}
return said, true
}
// accountOf is the account a resource of kind account is: named by the engine, or its target.
func accountOf(r inventory.ResourceHealth) string {
if r.Account != "" {
return r.Account
}
return r.Target
}
// moduleHealthWord is the gate's reading of a module's stated health on a machine (ADR 0240 §4, ADR 0236
// §2 as amended): good when every long-running resource of it is stated healthy in a statement heard since
// the send; not yet otherwise, saying which. A machine that never stated health is judged as before.
//
// **A wait for a person is its own reading** (novox/hq ADR 0254): when everything unhealthy of the module
// waits for one person's new login (personWait), the reading is healthPerson — not a fault of the build,
// and not something a machine can meet within a bound.
func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (health, string) {
if f.healthErr != nil {
return healthNotYet, "what " + machine + " says of its resources' health cannot be read: " + firstLine(f.healthErr.Error())
@@ -511,64 +202,24 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea
if h.HeardAt.Before(since) {
return healthNotYet, fmt.Sprintf("%s has not said how what %s runs is since it was sent", machine, module)
}
wait, waits := personWait(module, machine, h.Resources)
// **Only a build whose own send put the account in a new group is excused** (issue 318 review): read from
// what the controller sent, never from when the machine says the wait began — that time is the engine's
// memory, reset by its restart and moved by a change of words. A build that adds no account group cannot
// have brought a wait, so a later build sent while the login is still owed is judged as before.
if waits && !f.groupsAdded[module] {
waits = false
}
var found []string
for _, r := range h.Resources {
if r.Module != module {
continue
}
if waits && r.State == link.StateUnhealthy {
continue
}
// **A directory used as found before this send waits for a person** (novox/hq issue 339): the node-engine
// left its owner and mode, and only someone at the machine can hand it over. It is no fault of this
// build, so it does not hold the module's walk — an urgent fix still goes through — and the verdict
// carries the wait. Found by this very send, the send brought it, and it is judged as unhealthy.
if usedAsFound(r) && r.Since.Before(since) {
found = append(found, r.Resource)
continue
}
switch r.State {
case link.StateHealthy:
case link.StateStarting:
return healthNotYet, fmt.Sprintf("its %s %s on %s is still starting", r.Kind, r.Resource, machine)
case link.StateUnhealthy:
if on, held := f.heldOn[module+"@"+machine]; held {
return healthWaiting, fmt.Sprintf("its %s %s on %s waits on %s, which is unhealthy", r.Kind,
r.Resource, machine, on)
}
return healthNotYet, fmt.Sprintf("its %s %s on %s %s", r.Kind, r.Resource, machine, reasonWords(r))
default:
return healthNotYet, fmt.Sprintf("its %s %s on %s is %s%s", r.Kind, r.Resource, machine, r.State,
reasonAfter(r.Reason))
}
}
if waits || len(found) > 0 {
var said []string
if waits {
said = append(said, wait)
}
if len(found) > 0 {
said = append(said, fmt.Sprintf("on %s, %s uses %s as found and waits for the operator to hand it over "+
"(`nox node hand-over %s <directory>` on the control-node)", machine, module, strings.Join(found, ", "), machine))
}
return healthPerson, strings.Join(said, "; ")
}
return healthGood, ""
}
// usedAsFound is a directory the node-engine states it uses as found (novox/hq issue 339).
func usedAsFound(r inventory.ResourceHealth) bool {
return r.Kind == link.KindDirectory && r.State == link.StateUnhealthy && strings.HasPrefix(r.Reason, link.ReasonUsedAsFound)
}
func reasonAfter(s string) string {
if s == "" {
return ""
@@ -602,86 +253,3 @@ func healthLines(h inventory.NodeHealth, had bool, now time.Time) []string {
}
return out
}
// accountGroups is every account group a manifest declares, as "<account>/<group>": a user resource's
// groups (ADR 0252).
func accountGroups(m catalogue.Manifest) map[string]bool {
out := map[string]bool{}
for _, r := range m.Resources {
if t, _ := r["type"].(string); t != "user" {
continue
}
name, _ := r["name"].(string)
groups, _ := r["groups"].([]any)
for _, g := range groups {
if group, ok := g.(string); ok && group != "" {
out[name+"/"+group] = true
}
}
}
return out
}
// addsAccountGroups is whether a move from one manifest of a module to another puts an account in a group the
// earlier one did not (issue 318 review): the only send that can bring a wait for a new login. A module new to
// the machine (no earlier manifest) adds every group it declares.
func addsAccountGroups(from catalogue.Manifest, hadFrom bool, to catalogue.Manifest) bool {
before := map[string]bool{}
if hadFrom {
before = accountGroups(from)
}
for g := range accountGroups(to) {
if !before[g] {
return true
}
}
return false
}
// kindUsedAsFound is a module's condition while the node-engine uses one of its directories as found (novox/hq
// issue 339): its own kind, the operator's, never urgent, and never read by the gate as a fault of a build.
const kindUsedAsFound = "directory-used-as-found"
// usedAsFoundKey is a module's used-as-found condition on a machine.
func usedAsFoundKey(module, node string) string {
return conditions.Key(conditions.ScopeModule, module+"."+node, kindUsedAsFound)
}
// foundWait is whether everything unhealthy of a module on a machine is a directory used as found, and that in
// one sentence. Anything else unhealthy beside it is judged as a fault, with the directory among its resources.
func foundWait(module, node string, rs []inventory.ResourceHealth) (string, bool) {
var ids, why []string
for _, r := range rs {
if r.Module != module || r.State != link.StateUnhealthy {
continue
}
if !usedAsFound(r) {
return "", false
}
ids = append(ids, r.Resource)
why = append(why, strings.TrimSpace(strings.TrimPrefix(r.Reason, link.ReasonUsedAsFound)))
}
if len(ids) == 0 {
return "", false
}
return fmt.Sprintf("%s on %s uses %s as found: %s", module, node, strings.Join(ids, ", "),
strings.Join(why, "; ")), true
}
// usedAsFoundObservation is a module whose directory the node-engine uses as found, in words: the operator's, a
// warning however long it stays, its summary naming the directories and their owners; the paths are evidence.
func usedAsFoundObservation(module, node, said string, rs []inventory.ResourceHealth) conditions.Observation {
o := moduleUnhealthyObservation(module, node, rs)
o.Token, o.Kind, o.Resolver, o.Severity, o.Summary = kindUsedAsFound, kindUsedAsFound, conditions.ResolverOperator,
conditions.Warning, said
o.Headline = fmt.Sprintf("%s waits for a directory on %s", module, node)
o.Explanation = fmt.Sprintf("A directory of %s was already on %s, with another owner or mode than %s declares. "+
"The mesh left it as it was rather than hand it to an account, so %s may not be able to use it.",
module, node, module, module)
// Plain words (ADR 0253): the line itself — `nox node hand-over <node> <path>` on the control-node (ADR 0272,
// issue 356) — is in the summary and the evidence, which name the directory; a path is never in these.
o.Needs = "hand the directory over from the control-node, as the operator; the details name it and the line to type."
o.Resolved = fmt.Sprintf("%s's directory on %s is the mesh's", module, node)
o.Actions = nil
return o
}
+13 -390
View File
@@ -10,12 +10,10 @@ import (
"os"
"sort"
"strings"
"time"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
"github.com/novox/mesh-controller/internal/overlay"
)
@@ -184,9 +182,6 @@ func moduleCommand(ctx context.Context, args []string) error {
Requires []string `json:"requires,omitempty"`
Claims []string `json:"claims,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
// Replaces is what each of its own tools replaces (novox/hq ADR 0245), for the console's
// search and the agent's instructions.
Replaces map[string][]string `json:"replaces,omitempty"`
}
out := make([]listed, 0, len(entries))
for _, e := range entries {
@@ -194,7 +189,7 @@ func moduleCommand(ctx context.Context, args []string) error {
l := listed{Module: m.Module, Version: m.Version, Built: e.Source.BuiltFrom, Head: e.Source.Head,
Current: e.Provided || e.Source.Repository == "" || e.Source.Current(), Provided: e.Provided,
On: append([]string{}, e.On...), Provides: m.Offers(), Requires: m.Requires,
Capabilities: m.Capabilities, Tools: declaresTools(m), Replaces: m.Replaces}
Capabilities: m.Capabilities, Tools: declaresTools(m)}
for _, c := range m.Claims {
l.Claims = append(l.Claims, c.At()+"/"+c.Name)
}
@@ -357,20 +352,9 @@ func moduleCommand(ctx context.Context, args []string) error {
func assignCommand(ctx context.Context, verb string, args []string) error {
// Several modules in one act (novox/hq ADR 0207): holders that depend on each other — the
// service manager and the package manager — can only go on, or come off, together.
set := flag.NewFlagSet(verb, flag.ContinueOnError)
// A module known and not built: ask for its build, and keep the assignment pending on it (novox/hq
// issue 325). Only assign reads it.
build := set.Bool("build", false, "for a module known and not built: ask for its build, and assign it when it registers")
args, err := parseAround(set, args)
if err != nil {
return err
}
if len(args) < 2 {
return fmt.Errorf("%s <node> <module> [<module>…]", verb)
}
if *build && verb == "unassign" {
return errors.New("unassign takes no --build")
}
open, err := openStores(ctx)
if err != nil {
return err
@@ -379,9 +363,7 @@ func assignCommand(ctx context.Context, verb string, args []string) error {
// The act itself is in acts.go, so the command API refuses exactly what this refuses
// (novox/hq ADR 0035). What differs between the surfaces is how the answer is printed.
act := func(ctx context.Context, open *stores, node string, modules ...string) (string, error) {
return assignWith(ctx, open, node, assignOptions{Build: *build}, modules...)
}
act := assign
if verb == "unassign" {
act = unassign
}
@@ -398,10 +380,7 @@ func assignCommand(ctx context.Context, verb string, args []string) error {
func settingsCommand(ctx context.Context, args []string) error {
if len(args) == 0 {
return errors.New("settings show <module> [--node <node>] [--history], settings set <module> <file> " +
"[--node <node>] [--replace], settings clear <module> [--node <node>], settings preferences " +
"[<module>] [--node <node>], settings propose <module> <file | --clear> [--node <node>] [--replace], " +
"or settings proposals [<id>]")
return errors.New("settings set <module> <file> [--node <node>], or settings clear <module> [--node <node>]")
}
open, err := openStores(ctx)
if err != nil {
@@ -412,41 +391,10 @@ func settingsCommand(ctx context.Context, args []string) error {
set := flag.NewFlagSet("settings", flag.ContinueOnError)
node := set.String("node", "", "one machine, rather than the whole mesh")
// **What a set removes is refused unless meant** (novox/hq ADR 0217). A layer is replaced whole,
// and on 2026-10-05 setting one placement dropped a machine's whole layer for a module without a
// word (novox/hq issue 304). Adding and changing keys needs nothing; removing one needs this.
replace := set.Bool("replace", false, "for set and propose: remove the keys the new layer does not name")
history := set.Bool("history", false, "for show: the layers this one replaced, the latest first")
clear := set.Bool("clear", false, "for propose: propose that the layer be removed")
positionals, err := parseAround(set, args[1:])
if err != nil {
return err
}
switch args[0] {
case "propose":
// A trusted setting, proposed by anyone and set only on the operator's warrant (novox/hq ADR 0277):
// the stores are opened there, so the proposal and the verb's refusals below never meet.
if len(positionals) < 1 || len(positionals) > 2 {
return errors.New("settings propose <module> <settings.json | {…}> [--node <node>] [--replace], or settings propose <module> --clear [--node <node>]")
}
values := ""
if len(positionals) == 2 {
values = positionals[1]
}
return proposeCommand(ctx, positionals[0], *node, values, *clear, *replace)
case "proposals":
if len(positionals) > 1 || *node != "" || *clear || *replace || *history {
return errors.New("settings proposals [<id>]")
}
id := ""
if len(positionals) == 1 {
id = positionals[0]
}
return proposalsCommand(ctx, id)
}
if *clear {
return errors.New("--clear is for settings propose; a layer is cleared with settings clear")
}
where := "the whole mesh"
if *node != "" {
@@ -470,288 +418,34 @@ func settingsCommand(ctx context.Context, args []string) error {
if err := json.Unmarshal(raw, &values); err != nil {
return fmt.Errorf("%s is not a settings file: %w", positionals[1], err)
}
before, _, err := inv.Layer(ctx, *node, positionals[0])
if err != nil {
if err := inv.SetSettings(ctx, *node, positionals[0], values); err != nil {
return err
}
if err := refuseTerminalSettingsThroughAVerb(ctx, inv, before, values, positionals[0], where); err != nil {
return err
}
added, changed, removed := settingsChange(before, values)
if len(removed) > 0 && !*replace {
return fmt.Errorf("%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). Nothing was changed",
positionals[0], where, strings.Join(removed, ", "), positionals[0], nodeFlag(*node))
}
if err := inv.SetSettingsBy(ctx, *node, positionals[0], values, setByWords()); err != nil {
return err
}
fmt.Printf("%s on %s:\n", positionals[0], where)
if len(added)+len(changed)+len(removed) == 0 {
fmt.Println(" nothing changed")
}
for _, p := range added {
fmt.Printf(" + %s\n", p)
}
for _, p := range changed {
fmt.Printf(" ~ %s\n", p)
}
for _, p := range removed {
fmt.Printf(" - %s\n", p)
}
if before != nil {
fmt.Printf(" the layer it replaced is kept: settings show %s%s --history\n", positionals[0], nodeFlag(*node))
}
if *node != "" {
fmt.Printf(" run `plan %s --diff` to see what it changes, `push %s` to send it\n", *node, *node)
} else {
fmt.Println(" run `push --behind` to send it to the machines running it")
}
return nil
case "show":
if len(positionals) != 1 {
return errors.New("settings show <module> [--node <node>] [--history]")
var keys []string
for k := range values {
keys = append(keys, k)
}
if *history {
past, err := inv.SettingsHistory(ctx, *node, positionals[0])
if err != nil {
return err
}
if len(past) == 0 {
fmt.Printf("%s on %s: no layer has been replaced\n", positionals[0], where)
return nil
}
for _, p := range past {
shown, _ := json.MarshalIndent(p.Values, " ", " ")
by := ""
if p.SetBy != "" {
by = "; " + p.SetBy
}
fmt.Printf("%s on %s, until %s (%s%s):\n %s\n", positionals[0], where,
p.ReplacedAt.Local().Format("2006-01-02 15:04:05"), p.ReplacedBy, by, shown)
}
return nil
}
values, has, err := inv.Layer(ctx, *node, positionals[0])
if err != nil {
return err
}
if !has {
fmt.Printf("%s on %s: no layer — the module's definition says\n", positionals[0], where)
} else {
shown, err := json.MarshalIndent(values, "", " ")
if err != nil {
return err
}
fmt.Println(string(shown))
// And who set it (novox/hq ADR 0277): a layer the operator approved on their phone says so.
if setBy, setAt, has, err := inv.LayerOrigin(ctx, *node, positionals[0]); err != nil {
return err
} else if has && setBy != "" {
fmt.Printf(" %s\n", setBy)
} else if has {
fmt.Printf(" set at %s; who set it was not kept\n", setAt.Local().Format("2006-01-02 15:04"))
}
}
// Every value the module gives a default or a layer sets, and where it came from (novox/hq
// ADR 0262): the default, the mesh's layer, or this node's. Said after the layer, which stays
// the first thing printed because a caller reads it before replacing it (ADR 0217).
known, err := inv.Catalogue(ctx)
if err != nil {
return err
}
m, ok := known[positionals[0]]
if !ok || len(m.Settings) == 0 {
return nil
}
var layers []catalogue.Layer
if *node != "" {
if mesh, has, err := inv.Layer(ctx, "", positionals[0]); err != nil {
return err
} else if has {
layers = append(layers, catalogue.Layer{From: catalogue.MeshWideLayer, Values: mesh})
}
if has {
layers = append(layers, catalogue.Layer{From: *node, Values: values})
}
} else if has {
layers = append(layers, catalogue.Layer{From: catalogue.MeshWideLayer, Values: values})
}
fmt.Print(describeEffective(positionals[0], where, catalogue.Effective(m, layers)))
return nil
case "preferences":
// Every module's preferences, and each machine's value with its source (novox/hq ADR 0262).
if len(positionals) > 1 {
return errors.New("settings preferences [<module>] [--node <node>]")
}
only := ""
if len(positionals) == 1 {
only = positionals[0]
}
if *node != "" {
// A machine the mesh does not know is refused, never answered with an empty listing.
if _, err := inv.NodeByName(ctx, *node); err != nil {
return err
}
}
entries, err := inv.Catalogued(ctx)
if err != nil {
return err
}
var listed []preferencesOf
for _, e := range entries {
m := e.Manifest
if len(m.Settings) == 0 || (only != "" && m.Module != only) {
continue
}
if *node != "" && !containsString(e.On, *node) {
// Asked for one machine: a module not on it has no value there to say.
continue
}
p := preferencesOf{Manifest: m, On: map[string][]catalogue.SettingSource{}}
for _, n := range e.On {
if *node != "" && n != *node {
continue
}
p.Nodes = append(p.Nodes, n)
layers, err := inv.SettingsFor(ctx, n, m.Module)
if err != nil {
return err
}
p.On[n] = catalogue.Effective(m, layers)
}
listed = append(listed, p)
}
if only != "" && len(listed) == 0 {
fmt.Printf("%s declares no preferences%s\n", only, onNode(*node))
return nil
}
if len(listed) == 0 && *node != "" {
fmt.Printf("no module on %s declares a preference\n", *node)
return nil
}
fmt.Print(describePreferences(listed))
sort.Strings(keys)
fmt.Printf("%s on %s: %s\n", positionals[0], where, strings.Join(keys, ", "))
fmt.Println(" run `push` to send it")
return nil
case "clear":
if len(positionals) != 1 {
return errors.New("settings clear <module> [--node <node>]")
}
before, _, err := inv.Layer(ctx, *node, positionals[0])
if err != nil {
return err
}
if err := refuseTerminalSettingsThroughAVerb(ctx, inv, before, nil, positionals[0], where); err != nil {
return err
}
if err := inv.ClearSettingsBy(ctx, *node, positionals[0], setByWords()); err != nil {
if err := inv.ClearSettings(ctx, *node, positionals[0]); err != nil {
return err
}
fmt.Printf("%s on %s is back to what the module says\n", positionals[0], where)
return nil
default:
return fmt.Errorf("settings has no %q; it has show, set, clear, preferences, propose and proposals", args[0])
return fmt.Errorf("settings has no %q; it has set and clear", args[0])
}
}
// setByWords is who sets a layer from this process, as the layer keeps it (novox/hq ADR 0277): the caller at the
// controller's terminal, or through which verb.
func setByWords() string {
if verb, through := throughAVerb(); through {
return "set by " + link.Caller() + " through " + verb + " at " + time.Now().Local().Format("2006-01-02 15:04")
}
return "set at the controller's terminal by " + link.Caller() + " at " + time.Now().Local().Format("2006-01-02 15:04")
}
// describeEffective says each setting's value on a machine or the whole mesh, where it came from, and
// the module's default when a layer overrides it.
func describeEffective(module, where string, values []catalogue.SettingSource) string {
var b strings.Builder
fmt.Fprintf(&b, "%s on %s, every value and where it comes from:\n", module, where)
for _, v := range values {
value, _ := json.Marshal(v.Value)
fmt.Fprintf(&b, " %s = %s (%s", v.Key, value, v.From)
if v.HasDefault && !v.FromDefault {
d, _ := json.Marshal(v.Default)
fmt.Fprintf(&b, "; the default is %s", d)
}
b.WriteString(")\n")
}
return b.String()
}
// onNode is ` on <node>` for one machine, nothing for the whole mesh.
func onNode(node string) string {
if node == "" {
return ""
}
return " on " + node
}
// preferencesOf is one module's preferences and its value on each machine it is assigned to.
type preferencesOf struct {
Manifest catalogue.Manifest
Nodes []string
On map[string][]catalogue.SettingSource
}
// describePreferences lists each module's preferences — key, default and why — and, per machine it is
// assigned to, the value and where it comes from (novox/hq ADR 0262).
func describePreferences(modules []preferencesOf) string {
if len(modules) == 0 {
return "no module declares a preference\n"
}
var b strings.Builder
for i, p := range modules {
if i > 0 {
b.WriteString("\n")
}
on := "assigned nowhere"
if len(p.Nodes) > 0 {
on = "on " + strings.Join(p.Nodes, ", ")
}
fmt.Fprintf(&b, "%s (%s)\n", p.Manifest.Module, on)
keys := make([]string, 0, len(p.Manifest.Settings))
for k := range p.Manifest.Settings {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
d := p.Manifest.Settings[k]
def, _ := json.Marshal(d.Default)
fmt.Fprintf(&b, " %s, default %s: %s\n", k, def, d.Why)
for _, n := range p.Nodes {
for _, s := range p.On[n] {
if s.Key != k {
continue
}
v, _ := json.Marshal(s.Value)
from := s.From
if s.FromDefault {
from = catalogue.DefaultLayer
} else if s.From != catalogue.MeshWideLayer {
from = "the node"
}
fmt.Fprintf(&b, " %s: %s (%s)\n", n, v, from)
}
}
}
}
return b.String()
}
// nodeFlag is ` --node <node>` for a machine's layer, nothing for the whole mesh's.
func nodeFlag(node string) string {
if node == "" {
return ""
}
return " --node " + node
}
// describeOffers says what a module provides, and marks the ones answered from anywhere in the
// mesh — because "provides a database" and "provides a shell" are read the same way and mean
// entirely different things about where the answer has to be.
@@ -986,7 +680,7 @@ func issueWith(ctx context.Context, inv *inventory.Inventory, m catalogue.Manife
Emits: m.EmitsAll(), Consumes: m.Consumes, Serves: m.Tools,
}); needed {
if busAddress == "" {
fmt.Printf(" %s consumes; its consumer is created when the bus is reachable (`push --behind`, then "+
fmt.Printf(" %s consumes; its consumer is created when the bus is reachable (`push`, then "+
"`rollout mint` again is harmless)\n", m.Module)
} else {
js, err := broker.Dial(busAddress)
@@ -1048,9 +742,6 @@ func whereItComesFrom(repository, ref, commit, path string, self bool) (inventor
// definition that got past the check — written elsewhere, or checked by nobody — is refused here
// in the same words. A name meant on purpose is declared with its reason and passes.
func namesNoInstallation(m catalogue.Manifest) error {
if problems := catalogue.TrustProblems(m); len(problems) > 0 {
return fmt.Errorf("%s", strings.Join(problems, "; "))
}
named := catalogue.InstallationProblems(m)
if len(named) == 0 {
return nil
@@ -1114,71 +805,3 @@ func declaresTools(m catalogue.Manifest) bool {
}
return false
}
// The keys no verb may change are catalogue.TerminalKeys (novox/hq issue 339). `places` says where the
// node-engine creates and, as root, owns a module's directories, with an owner the setting names; `accesses` says
// which of the machine's paths are mounted into a module's container; a provider's trust anchors say what every
// consumer trusts. Set through a verb, any of them lets any caller of the mesh's verbs — an agent among them —
// have root hand it a directory, mount one of the machine's into a container it reaches, or have the mesh trust
// an authority of its own. They are the operator's, typed at the controller's terminal.
// throughAVerb says whether this process runs a seat verb's command line: the serving controller names the
// verb in the environment of every command it runs for one (runVerb), and a person at the terminal runs none.
// Every verb route reaches a command through runVerb — the named verbs and the generic `command` alike — so
// this is the one place that knows, whatever line the verb composed.
func throughAVerb() (string, bool) {
verb := os.Getenv(verbVar)
return verb, verb != ""
}
// sameLayer says whether two layers hold the same values, an absent layer and an empty one alike.
func sameLayer(a, b map[string]any) bool {
if len(a) == 0 && len(b) == 0 {
return true
}
ra, _ := json.Marshal(a)
rb, _ := json.Marshal(b)
return string(ra) == string(rb)
}
// refuseTerminalSettingsThroughAVerb refuses a layer change through a verb that would add, change or remove
// places or accesses; a change that leaves both as they were is not refused.
func refuseTerminalSettingsThroughAVerb(ctx context.Context, inv *inventory.Inventory, before, after map[string]any,
module, where string) error {
verb, through := throughAVerb()
if !through {
return nil
}
// Judged against the module's definition as the catalogue holds it: what it serves and which of its files
// are trusted. A catalogue that cannot be read refuses rather than judging against nothing.
shelf, err := inv.Catalogue(ctx)
if err != nil {
return fmt.Errorf("which settings of %s are the terminal's cannot be read, so nothing was changed: %w", module, err)
}
// A trusted mergeable file takes any key, so its module's whole layer is the terminal's (novox/hq issue 340).
if files := catalogue.TrustedMergeable(shelf[module]); len(files) > 0 && !sameLayer(before, after) {
return fmt.Errorf("the settings of %s on %s are set at the controller's terminal (`mesh-cli` on the control-node) or on "+
"the operator's warrant, never through a verb alone (this line came through %q): %s merges whatever key a "+
"layer sets into a file root or a consumer trusts, so any key could point the module at a listener of the "+
"caller's, and whoever may call a verb includes agents (novox/hq issue 340; a file nothing trusts says "+
"\"trusted\": false). Propose it instead: the settings verb with propose puts the exact values to the "+
"operator on a channel that proves who answers, and the layer is set on their Approve (novox/hq ADR 0277). "+
"Nothing was changed",
module, where, verb, strings.Join(files, ", "))
}
for _, key := range catalogue.TerminalKeys(shelf[module]) {
was, _ := json.Marshal(before[key])
now, _ := json.Marshal(after[key])
if string(was) == string(now) {
continue
}
return fmt.Errorf("%s of %s on %s is set at the controller's terminal (`mesh-cli` on the control-node) or on the "+
"operator's warrant, never through a verb alone (this line came through %q): it says where root creates and "+
"owns a module's directories, which of the machine's paths are mounted into its container, what the mesh's "+
"consumers trust, or what a file root or a person's session obeys takes, and whoever may call a verb "+
"includes agents (novox/hq issue 339; issue 340 for a mergeable file's own keys). Propose it instead: the "+
"settings verb with propose puts the exact values to the operator on a channel that proves who answers, and "+
"the layer is set on their Approve (novox/hq ADR 0277). Nothing was changed", key, module, where, verb)
}
return nil
}
+3 -16
View File
@@ -232,22 +232,9 @@ func network(ctx context.Context, inv *inventory.Inventory, on map[string]bool,
if err != nil {
return nil, err
}
// **A machine joining is on the network before it is anything else** (novox/hq ADR 0169). Its
// token was issued for its tunnel key and gave it an address, so while that token can still be
// used the hub carries it as a peer: it brings its tunnel up from the token and enrols over it.
// When the token is spent the machine is on the network by what it runs, as every other is; when
// it expires unused, the peer goes with it at the hub's next composition.
joining, err := inv.NodesWithALiveToken(ctx)
if err != nil {
return nil, err
}
isJoining := map[string]bool{}
for _, name := range joining {
isJoining[name] = true
}
nodes := make([]overlay.Node, 0, len(places))
for _, p := range places {
if !on[p.Name] && !(isJoining[p.Name] && p.Key != "" && p.Address != "") {
if !on[p.Name] {
continue
}
n := overlay.Node{
@@ -426,10 +413,10 @@ func overlayShow(ctx context.Context, open *stores) error {
tunnel.Interface, tunnel.Range, tunnel.Port)
case n.Hub && hubName == n.Name && tunnel.Interface != "":
fmt.Printf(" hub — found a tunnel on %s and did NOT take it over: its key is not the tunnel's; "+
"`nox-mesh-host overlay take --tunnel %s` on the machine takes it", tunnel.Interface, tunnel.Interface)
"`mesh-host overlay take --tunnel %s` on the machine takes it", tunnel.Interface, tunnel.Interface)
case n.Hub:
fmt.Print(" hub — found no tunnel; if the machine runs the predecessor's, " +
"`nox-mesh-host overlay take --tunnel <iface>` there adopts it (novox/hq ADR 0105)")
"`mesh-host overlay take --tunnel <iface>` there adopts it (novox/hq ADR 0105)")
case !n.Reachable():
fmt.Print(" not dialable")
}
@@ -1,94 +0,0 @@
package main
import (
"context"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// asksWithPaths records every build a merge asks for, as repository, path and ref.
func asksWithPaths(t *testing.T) *[][3]string {
t.Helper()
var asked [][3]string
was := askABuild
askABuild = func(_ context.Context, source buildSource, path, ref string) (string, error) {
asked = append(asked, [3]string{source.Repository, path, ref})
return "b-" + path, nil
}
t.Cleanup(func() { askABuild = was })
return &asked
}
// The live case of 2026-10-07 (novox/hq issue 300), replayed: a merge of the catalogue adds one module's
// directory and touches nothing else the mesh holds. Its delivery plan said "builds new: …, sent nowhere";
// the merge said it changed nothing any module the mesh holds is built from, and built nothing, so the
// module was never registered and `assign` refused it. The merge asks for its build, at the branch merged
// into, from the repository as the mesh spells it — and opens no plan, since nothing it holds moved.
func TestAMergeBuildsTheNewModuleItAdds(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
asked := asksWithPaths(t)
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "networkmanager", Version: "1"},
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/networkmanager", Ref: "main",
BuiltFrom: "c0", Head: "c0"}); err != nil {
t.Fatal(err)
}
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "3da80a4b00",
Paths: []string{"modules/systemd-resolved/module.json", "modules/systemd-resolved/cmd/main.go"},
ModuleDirs: []string{"modules/systemd-resolved"}, ModuleDirsSaid: true}
if err := (following{open: open}).SourceMoved(ctx, m); err != nil {
t.Fatal(err)
}
want := [3]string{"novox/mesh-catalog", "modules/systemd-resolved", "main"}
if len(*asked) != 1 || (*asked)[0] != want {
t.Fatalf("the merge adding modules/systemd-resolved asked for %v, not %v", *asked, want)
}
if plans, err := inv.OpenPlans(ctx); err != nil || len(plans) != 0 {
t.Fatalf("a merge moving nothing the mesh holds opened a plan: %+v %v", plans, err)
}
}
// A merge that changes a held module and adds a new one does both: the held one walks its plan, the new
// one is asked for beside it. A merge adding nothing builds nothing new.
func TestAMergeBuildsItsNewModuleBesideItsPlan(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
asked := asksWithPaths(t)
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"},
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/app", Ref: "main",
BuiltFrom: "c0", Head: "c0"}); err != nil {
t.Fatal(err)
}
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "c1aaaaaaaa",
Paths: []string{"modules/app/index.ts", "modules/fresh/module.json"},
ModuleDirs: []string{"modules/app", "modules/fresh"}, ModuleDirsSaid: true}
if err := (following{open: open}).SourceMoved(ctx, m); err != nil {
t.Fatal(err)
}
paths := map[string]bool{}
for _, a := range *asked {
paths[a[1]] = true
}
if !paths["modules/fresh"] || !paths["modules/app"] || len(*asked) != 2 {
t.Fatalf("asked %v; want the new module and the plan's first tier", *asked)
}
plans, err := inv.OpenPlans(ctx)
if err != nil || len(plans) != 1 || plans[0].Modules["fresh"] != nil || plans[0].Modules["app"] == nil {
t.Fatalf("the plan should walk app alone: %+v %v", plans, err)
}
*asked = nil
m2 := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "c2aaaaaaaa",
Paths: []string{"README.md"}, ModuleDirsSaid: true}
if err := (following{open: open}).SourceMoved(ctx, m2); err != nil {
t.Fatal(err)
}
if len(*asked) != 0 {
t.Fatalf("a merge adding no module asked for %v", *asked)
}
}
+5 -316
View File
@@ -2,23 +2,16 @@ package main
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net"
"os"
"path/filepath"
"github.com/novox/mesh-controller/internal/conditions"
"strings"
"time"
"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"
"github.com/novox/mesh-controller/internal/token"
)
@@ -30,7 +23,7 @@ import (
func nodeCommand(ctx context.Context, args []string) error {
if len(args) == 0 {
return errors.New("node add <name>, node list, node show <name>, " + publicDomainUsage + ", or " + handOverUsage)
return errors.New("node add <name>, node list, node show <name>, or " + publicDomainUsage)
}
open, err := openStores(ctx)
if err != nil {
@@ -102,175 +95,17 @@ func nodeCommand(ctx context.Context, args []string) error {
"containers reaching outward. The machine reports which of its links face outside; see " +
"`node show <name>`")
case "agent-account":
// The account agents run as on this machine, when it is not the operator's (novox/hq ADR 0266). Here,
// at the controller's terminal, and nowhere else: no verb and no setting names it, so no agent can
// name itself another account.
return nodeAgentAccount(ctx, inv, args[1:])
case "account":
// The operator's login on this machine (novox/hq to-be 29): what a home-scoped file is
// owned by and which account `ssh <node>` uses. Reports with no argument; sets with one;
// an optional second argument is the home when it is not /home/<account>.
return nodeAccount(ctx, inv, args[1:])
case "hand-over":
// A directory the node-engine uses as found, handed to the mesh (novox/hq issue 356, issue 339). Here, at
// the controller's terminal, and nowhere else: at the next apply root gives the directory to the account
// the module declares, and whoever may call a verb includes agents.
return nodeHandOver(ctx, open, args[1:])
case "setuid-search":
// A fresh search for setuid programs on a node (novox/hq issue 361), after the operator changed by hand
// what the last one found. Here, at the controller's terminal, and nowhere else: a search never makes a
// machine free wrongly, but asked again and again it would keep the machine unjudged and its disks busy,
// and whoever may call a verb includes agents.
return nodeSetuidSearch(ctx, open, args[1:])
default:
return fmt.Errorf("node has no %q; it has add, list, show, public-domain, account, agent-account, hand-over "+
"and setuid-search", args[0])
return fmt.Errorf("node has no %q; it has add, list, show, public-domain and account", args[0])
}
}
const handOverUsage = "node hand-over <node> <directory> — hand a directory the node-engine on <node> uses as found " +
"to the mesh: its next apply gives it the declared owner and mode. The directory's absolute path, as the module's " +
"condition names it"
// handOverLine reads a hand-over's line: the node and the directory's absolute path, exactly as the engine states
// it. Judged before anything is asked, and judged again by the engine, which is the one that acts.
func handOverLine(args []string) (node, path string, err error) {
if len(args) != 2 {
return "", "", errors.New(handOverUsage)
}
node, path = args[0], args[1]
if node == "" || strings.HasPrefix(node, "-") {
return "", "", fmt.Errorf("%q is not a node's name; %s", node, handOverUsage)
}
if !filepath.IsAbs(path) {
return "", "", fmt.Errorf("%q is not an absolute path; %s", path, handOverUsage)
}
if filepath.Clean(path) != path {
return "", "", fmt.Errorf("%q is not the directory's path as the engine states it (no `..`, no doubled or "+
"trailing separator); %s", path, handOverUsage)
}
return node, path, nil
}
// handOverBy is who hands the directory over, in the words a verb's caller is recorded in: the operator through
// mesh-cli on the control-node, or whoever runs this controller's binary at its terminal.
func handOverBy() string {
if by := strings.TrimSpace(os.Getenv(link.CallerVar)); by != "" {
return by
}
return "the controller's terminal"
}
// nodeHandOver asks the node's engine to take a directory it uses as found as the mesh's, and says what came of
// it. The engine records the hand-over or refuses; nothing is recorded here, because the directory is the
// machine's and the engine is the one that reads it. The ask is signed with the mesh's key (issue 356's review).
func nodeHandOver(ctx context.Context, open *stores, args []string) error {
known := func(node string) error {
_, err := open.inventory.NodeByName(ctx, node)
return err
}
ask := func(node, path, by string) (link.HandOverAnswer, error) {
ident, err := open.Identity(ctx)
if err != nil {
return link.HandOverAnswer{}, fmt.Errorf("the mesh's signing key cannot be read, so nothing was asked of %s: %w",
node, err)
}
address, err := broker.BusAddress()
if err != nil {
return link.HandOverAnswer{}, err
}
js, err := broker.Dial(address)
if err != nil {
return link.HandOverAnswer{}, fmt.Errorf("cannot reach the bus, so nothing was asked of %s: %w", node, err)
}
defer js.Close()
return link.AskHandOver(ctx, js.Conn(), ident, node, path, by, link.HandOverWithin)
}
return handOverAsked(args, known, ask, os.Stdout)
}
// handOverAsked is the hand-over's line with its two acts given: whether the mesh knows the node, and the ask.
// Nothing is asked of a line or a node that is refused, and the engine's refusal is this command's failure —
// never a success with the refusal printed.
func handOverAsked(args []string, known func(node string) error,
ask func(node, path, by string) (link.HandOverAnswer, error), out io.Writer) error {
node, path, err := handOverLine(args)
if err != nil {
return err
}
if err := known(node); err != nil {
return fmt.Errorf("nothing was asked: %w", err)
}
answer, err := ask(node, path, handOverBy())
if err != nil {
return err
}
if answer.Refused != "" {
return fmt.Errorf("%s refused: %s", node, answer.Refused)
}
fmt.Fprintln(out, answer.Said)
fmt.Fprintf(out, " the module's condition clears once %s applies; `nox push %s` applies it now\n", node, node)
return nil
}
const setuidSearchUsage = "node setuid-search <node> — throw away the node-engine's last search for setuid " +
"programs on <node> and start a full one: after a setuid-root program it found was removed by hand. Until it " +
"completes, root-free says the node is not judged yet"
// nodeSetuidSearch asks the node's engine for a fresh search, signed with the mesh's key as a hand-over is.
func nodeSetuidSearch(ctx context.Context, open *stores, args []string) error {
known := func(node string) error {
_, err := open.inventory.NodeByName(ctx, node)
return err
}
ask := func(node, by string) (link.HandOverAnswer, error) {
ident, err := open.Identity(ctx)
if err != nil {
return link.HandOverAnswer{}, fmt.Errorf("the mesh's signing key cannot be read, so nothing was asked of %s: %w",
node, err)
}
address, err := broker.BusAddress()
if err != nil {
return link.HandOverAnswer{}, err
}
js, err := broker.Dial(address)
if err != nil {
return link.HandOverAnswer{}, fmt.Errorf("cannot reach the bus, so nothing was asked of %s: %w", node, err)
}
defer js.Close()
return link.AskSetuidSearch(ctx, js.Conn(), ident, node, by, link.HandOverWithin)
}
return setuidSearchAsked(args, known, ask, os.Stdout)
}
// setuidSearchAsked is the line with its two acts given: whether the mesh knows the node, and the ask. The
// engine's refusal is this command's failure.
func setuidSearchAsked(args []string, known func(node string) error,
ask func(node, by string) (link.HandOverAnswer, error), out io.Writer) error {
if len(args) != 1 || args[0] == "" || strings.HasPrefix(args[0], "-") {
return errors.New(setuidSearchUsage)
}
node := args[0]
if err := known(node); err != nil {
return fmt.Errorf("nothing was asked: %w", err)
}
answer, err := ask(node, handOverBy())
if err != nil {
return err
}
if answer.Refused != "" {
return fmt.Errorf("%s refused: %s", node, answer.Refused)
}
fmt.Fprintln(out, answer.Said)
fmt.Fprintf(out, " the controller's root-free verb shows the search's progress until it completes\n")
return nil
}
// addNode creates a node record, adopted when the operator says so (novox/hq ADR 0100).
func addNode(ctx context.Context, inv *inventory.Inventory, args []string) error {
set := flag.NewFlagSet("node add", flag.ContinueOnError)
@@ -338,57 +173,6 @@ func nodeAccount(ctx context.Context, inv *inventory.Inventory, positionals []st
return nil
}
const agentAccountUsage = "node agent-account <name> — what it is now; " +
"<name> <account> [home] to name the account agents run as (home defaults to /home/<account>); " +
"<name> --clear to have them run as the operator account again"
// nodeAgentAccount reports, names or clears the account agents run as on a node (novox/hq ADR 0266). Read-
// shaped with no account, like public-domain; clearing is asked for by name.
func nodeAgentAccount(ctx context.Context, inv *inventory.Inventory, args []string) error {
set := flag.NewFlagSet("node agent-account", flag.ContinueOnError)
clear := set.Bool("clear", false, "agents run as the operator account again")
positionals, err := parseAround(set, args)
if err != nil {
return err
}
if len(positionals) == 0 || len(positionals) > 3 {
return errors.New(agentAccountUsage)
}
node := positionals[0]
switch {
case *clear && len(positionals) > 1:
return fmt.Errorf("name an agent account for %s or --clear, not both", node)
case *clear:
if err := inv.SetAgentAccount(ctx, node, "", ""); err != nil {
return err
}
fmt.Printf("agents on %s run as the operator account again\n", node)
fmt.Printf(" run `push %s` to send it; the agent account itself is kept (the mesh never deletes a login)\n", node)
return nil
case len(positionals) >= 2:
home := ""
if len(positionals) == 3 {
home = positionals[2]
}
if err := inv.SetAgentAccount(ctx, node, positionals[1], home); err != nil {
return err
}
fmt.Printf("agents on %s run as %s\n", node, positionals[1])
fmt.Printf(" run `push %s` to send it; the self-check says once its node-engine has judged it "+
"unable to become root\n", node)
return nil
default:
n, err := inv.NodeByName(ctx, node)
if err != nil {
return err
}
for _, line := range agentAccountLines(ctx, inv, n) {
fmt.Println(strings.TrimPrefix(line, " "))
}
return nil
}
}
const publicDomainUsage = "node public-domain <name> — what it is now; " +
"<name> <domain> to set it; <name> --clear to take it away"
@@ -463,8 +247,6 @@ func tokenCommand(ctx context.Context, args []string) error {
validFor := set.Duration("for", time.Hour, "how long the token may be used")
adopted := set.Bool("adopted", false,
"the machine joining is in use: it is adopted, and keeps what is found on it")
tunnelKey := set.String("overlay-key", "",
"the public half of the tunnel key the machine made (`nox-mesh-host key`): it joins through the tunnel")
if err := set.Parse(args[1:]); err != nil {
return err
}
@@ -518,14 +300,6 @@ func tokenCommand(ctx context.Context, args []string) error {
default:
return err
}
// **Through the tunnel** (novox/hq ADR 0169): the machine's key recorded, its address given, the
// hub sent it as a peer — all before the token is shown, so the tunnel answers the first time the
// machine knocks. The bus is then reached at its address on the private network.
if *tunnelKey != "" {
if made.Tunnel, made.Broker, err = throughTheTunnel(ctx, open, issued.Node, *tunnelKey, made.Broker); err != nil {
return err
}
}
encoded, err := made.Encode()
if err != nil {
return err
@@ -550,78 +324,6 @@ func tokenCommand(ctx context.Context, args []string) error {
return nil
}
// throughTheTunnel makes a machine a peer of the hub for its token, and says what the token carries
// for it: its first tunnel, and the bus at its address on the private network (novox/hq ADR 0169).
//
// The hub is pushed here, before the token is shown. A token shown before the hub knew the key is a
// tunnel that does not answer, and a machine that cannot tell that from a bus that is down.
func throughTheTunnel(ctx context.Context, open *stores, node inventory.Node, key, busAt string) (
*token.Tunnel, string, error) {
inv := open.inventory
key = strings.TrimSpace(key)
if raw, err := base64.StdEncoding.DecodeString(key); err != nil || len(raw) != 32 {
return nil, "", fmt.Errorf("%q is not a tunnel public key: it is 32 bytes in base64, as "+
"`nox-mesh-host key` prints it", key)
}
// The bus on the private network is its holder's address at the bus's own port, so the port must
// be known before anything is recorded.
_, port, err := net.SplitHostPort(busAt)
if err != nil || port == "" {
return nil, "", fmt.Errorf("the bus's address %q has no port to reach it on", busAt)
}
places, err := inv.Overlays(ctx)
if err != nil {
return nil, "", err
}
var hub *inventory.Overlay
for i := range places {
if places[i].Hub {
hub = &places[i]
}
}
if hub == nil || hub.Key == "" || hub.Endpoint == "" || hub.Address == "" {
return nil, "", errors.New("this mesh has no hub with a key, an address and an endpoint to " +
"dial, so there is no tunnel to join through: place one (`overlay place <node> --hub " +
"--endpoint <host>:<port>`), or issue the token without --overlay-key")
}
if err := inv.RecordOverlayKey(ctx, node.ID, key); err != nil {
return nil, "", err
}
if err := inv.BindTokenToKey(ctx, node.ID, key); err != nil {
return nil, "", err
}
cidr, err := overlayRange(ctx, inv)
if err != nil {
return nil, "", err
}
address, err := inv.AssignAddress(ctx, node.ID, cidr)
if err != nil {
return nil, "", err
}
// The bus at its holder's address on the private network, reached through the hub like the rest
// of the range; the hub's own when no machine is recorded as holding it yet.
busAddress := hub.Address
if holders, err := seatHolders(ctx, inv); err != nil {
return nil, "", err
} else if h, held := holders[catalogue.BrokerSeat]; held {
for _, p := range places {
if p.Name == h.Node && p.Address != "" {
busAddress = p.Address
}
}
}
if err := sendTo(ctx, open, []string{hub.Name}); err != nil {
return nil, "", fmt.Errorf("%s was made a peer of the hub, and the hub could not be sent "+
"it, so the tunnel would not answer — the token is not shown; issue it again once %s "+
"can be pushed: %w", node.Name, hub.Name, err)
}
// An address, not a name — nothing resolves before the machine has joined (novox/hq ADR 0004).
return &token.Tunnel{
Key: key, Address: address + "/32", Range: cidr,
HubKey: hub.Key, HubEndpoint: hub.Endpoint,
}, net.JoinHostPort(busAddress, port), nil
}
// issueFor is the inventory's half of issuing a token: the record, made when it is new, adopted
// when the operator says so, and the one-time secret for it. The node in what it returns carries
// its mode, which is what the token says.
@@ -803,10 +505,6 @@ func showNode(ctx context.Context, inv *inventory.Inventory, name string) error
if err := showMode(ctx, inv, node); err != nil {
return err
}
// Whom agents run as here, and whether that account can become root without a person (ADR 0266).
for _, line := range agentAccountLines(ctx, inv, node) {
fmt.Println(line)
}
// The domain its routed names are composed under, when it has one (novox/hq ADR 0066). Shown
// only when set: a machine that serves nothing to the outside has no domain, and saying so of
@@ -848,12 +546,6 @@ func showNode(ctx context.Context, inv *inventory.Inventory, name string) error
for _, line := range healthLines(h, had, time.Now()) {
fmt.Println(line)
}
for _, line := range networkLines(h, had, time.Now()) {
fmt.Println(line)
}
for _, line := range unitsLines(h, had, time.Now()) {
fmt.Println(line)
}
}
held, err := inv.Profile(ctx, name)
@@ -901,14 +593,11 @@ func orNotReported(s string) string {
}
// printJSON prints a value as indented JSON, the shape every `--json` answers in.
func printJSON(v any) error { return printJSONTo(os.Stdout, v) }
// printJSONTo is printJSON to a writer of the caller's.
func printJSONTo(w io.Writer, v any) error {
func printJSON(v any) error {
body, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
fmt.Fprintln(w, string(body))
fmt.Println(string(body))
return nil
}
+2 -2
View File
@@ -157,7 +157,7 @@ func TestAMergeRebuildsTheModulesItChanged(t *testing.T) {
{"a file directly among the modules", merge([]string{"modules/README.md"}, false), ""},
{"a root file beside a module's", merge([]string{"merge-check.sh", "modules/keycloak/x.ts"}, false), "keycloak"},
} {
if got := named(whatTheMergeTouched(candidates, known, c.m, nil)); got != c.want {
if got := named(whatTheMergeTouched(candidates, known, c.m)); got != c.want {
t.Errorf("%s: rebuilt %q, wanted %q", c.what, got, c.want)
}
}
@@ -285,7 +285,7 @@ func TestAChangeInsideAModuleIsThatModulesHeldOrNot(t *testing.T) {
{"an old announcer saying nothing", merge(showcase, nil, false), ""},
{"a manifest the merge removed, said or not", merge([]string{"modules/gone/module.json", "modules/gone/x.ts"}, nil, true), ""},
} {
if got := named(whatTheMergeTouched(candidates, known, c.m, nil)); got != c.want {
if got := named(whatTheMergeTouched(candidates, known, c.m)); got != c.want {
t.Errorf("%s: rebuilt %q, wanted %q", c.what, got, c.want)
}
}
-752
View File
@@ -1,752 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"sort"
"strings"
"time"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// A module not registered yet, and the assignment that waits for it (novox/hq issue 325, ADR 0261).
//
// On 2026-10-08 the catalogue's pull request adding `sensors` merged; a minute later `assign g14 sensors`
// answered "no module of that name: sensors", and a few minutes later the same call worked. The merge had
// asked for the build (issue 300) and the build had not finished. The answer read as "you forgot to register
// it", and nothing in it said a build was on its way.
//
// So an assignment of a module the catalogue does not hold looks further before it refuses, and says which
// of three cases it is in:
//
// - **a build is in flight**: a build request for the module's directory has no outcome yet, or its build
// succeeded a moment ago and is being registered. The assignment is kept as a **pending assignment**,
// which the controller makes when the build registers the module (ADR 0261).
// - **known, not built**: the controller asked for the directory before, and the build failed, was not
// registered, said nothing within its bound, or could not be asked. Said, with `build` and assign's
// own build argument, which asks for the build and keeps the assignment pending on it.
// - **unknown**: no module registered and no build request kept by that name. Refused as before, with the
// closest names.
//
// **Pending by default while a build is in flight** (ADR 0261), without an argument to ask for it: an
// assignment is what a person meant and is kept even when its machine does not resolve (acts.go), and
// `unassign` withdraws it. Asking for a second act once the build lands is the two acts by hand issue 300
// removed. A build is never asked for by default: it runs on another machine, and a directory whose last
// build failed is a fault to look at before it is a thing to retry.
//
// **Settling is the controller's tick, never a read** (settlingPending): `status`, the board and the summary
// only read the rows.
// buildRequestBound is how long a build request may go without an outcome before it is no longer read as in
// flight, and a pending assignment waiting for it expires. Generous: a build waits behind others on the
// build seat, and a pending assignment that waits a little longer costs nothing.
const buildRequestBound = 2 * time.Hour
// registerGrace is how long a build heard as built is read as still in flight while it is not registered:
// the outcome is recorded first and registered after it, in the same take-in. Past it, the build was
// recorded and not registered, and says so.
const registerGrace = 5 * time.Minute
// pendingShownFor is how long an ended pending assignment is listed in `status`.
const pendingShownFor = 24 * time.Hour
// settleEvery is how often the controller settles the pending assignments.
const settleEvery = time.Minute
// claimStaleAfter is how long a pending assignment may be held as applying before the tick reads the claim
// as left by a controller that stopped, and settles it from what the mesh holds.
const claimStaleAfter = 5 * time.Minute
// kindPendingEnded is a pending assignment that ended without being made: expired or refused.
const kindPendingEnded = "assignment-not-made"
// assignOptions are what an assignment was asked with beside its machine and modules.
type assignOptions struct {
// Build asks for the build of a module known and not built, and keeps the assignment pending on it.
Build bool
}
// recordBuildRequest keeps a build request so `assign` can find it, once it is asked. Said, never fatal: the
// build was asked.
func recordBuildRequest(ctx context.Context, inv *inventory.Inventory, r inventory.BuildRequest) {
if err := inv.RecordBuildRequest(ctx, r); err != nil {
fmt.Fprintf(os.Stderr, "build %s was asked, and could not be kept as a build request, so `assign` "+
"will not know it is coming: %v\n", r.ID, err)
}
}
// The cases of a module the catalogue does not hold (novox/hq issue 325).
const (
unknownModule = iota
buildInFlight
knownNotBuilt
)
// notHeld is where a module the catalogue does not hold stands: its case, the build request it was read
// from, and the sentence that says it.
type notHeld struct {
kind int
request inventory.RequestOutcome
said string
}
// whereIs looks for a module the catalogue does not hold among the build requests the controller keeps.
func whereIs(ctx context.Context, inv *inventory.Inventory, module string, now time.Time) (notHeld, error) {
requests, err := inv.RequestsNamed(ctx, module)
if err != nil {
return notHeld{}, err
}
if r, ok := inFlight(requests, now); ok {
being := "being built"
if r.Heard {
being = "built and being registered"
}
return notHeld{kind: buildInFlight, request: r, said: fmt.Sprintf("%s is not registered yet: it is %s "+
"from %s since %s, as build %s; it can be assigned once that build registers it", module, being,
requestSource(r), clock(r.At), r.ID)}, nil
}
if len(requests) > 0 {
return notHeld{kind: knownNotBuilt, request: requests[0], said: fmt.Sprintf("%s is known and not "+
"registered: %s", module, whyNotBuilt(requests[0], now))}, nil
}
// Only what was looked at is claimed: the catalogue, and the build requests the controller keeps.
said := fmt.Sprintf("%v: %s — the catalogue holds no module of that name, and no build request the "+
"controller kept (the last 30 days) is for a directory of that name", inventory.ErrNoSuchModule, module)
if near := closestNames(ctx, inv, module); len(near) > 0 {
said += "; the closest names it holds: " + strings.Join(near, ", ")
}
return notHeld{kind: unknownModule, said: said}, nil
}
// notInCatalogue is the modules named that the catalogue does not hold.
func notInCatalogue(ctx context.Context, open *stores, modules []string) ([]string, error) {
shelf, err := open.inventory.Catalogue(ctx)
if err != nil {
return nil, err
}
var missing []string
for _, m := range modules {
if _, ok := shelf[m]; !ok {
missing = append(missing, m)
}
}
return missing, nil
}
// openPending is the open pending assignment of a module to a machine, if there is one.
func openPending(ctx context.Context, inv *inventory.Inventory, node, module string) (*inventory.PendingAssignment, error) {
rows, err := inv.PendingFor(ctx, node, module)
if err != nil {
return nil, err
}
for i := range rows {
if rows[i].Open() {
return &rows[i], nil
}
}
return nil, nil
}
// notRegistered answers an assignment of a module the catalogue does not hold: pending on a build in flight,
// known and not built, or unknown (novox/hq issue 325). An answer with no error is a pending assignment kept.
func notRegistered(ctx context.Context, open *stores, node, module string, opts assignOptions) (string, error) {
inv := open.inventory
if _, err := inv.NodeByName(ctx, node); err != nil {
return "", err
}
now := time.Now()
where, err := whereIs(ctx, inv, module, now)
if err != nil {
return "", err
}
waiting, err := openPending(ctx, inv, node, module)
if err != nil {
return "", err
}
switch where.kind {
case buildInFlight:
lead := where.said
if opts.Build {
lead += "\n no second build was asked: this one is running"
}
return keepPending(ctx, open, node, module, where.request, waiting, lead)
case knownNotBuilt:
last := where.request
if !opts.Build {
also := ""
if waiting != nil {
also = fmt.Sprintf("\n %s already has a pending assignment of %s, waiting for build %s; build "+
"\"true\" asks for a new build and makes it wait for that one", node, module, waiting.Build)
}
return "", fmt.Errorf("%w: %s%s\n assign it again with build \"true\" to ask for its build and keep this "+
"assignment until the build registers it; or `build` it (repository %s, path %s) and assign it "+
"once it is registered", inventory.ErrNoSuchModule, where.said, also, last.Repository, orRoot(last.Path))
}
if waiting != nil && waiting.State == inventory.PendingApplying {
return "", fmt.Errorf("%s is being assigned %s now; nothing was asked", node, module)
}
source := buildSource{Repository: last.Repository, Seat: last.Seat}
id, err := askABuild(ctx, source, last.Path, last.Ref)
if err != nil {
return "", fmt.Errorf("%s\n its build could not be asked for: %w; nothing was assigned", where.said, err)
}
asked := inventory.RequestOutcome{BuildRequest: inventory.BuildRequest{ID: id, Repository: last.Repository,
Seat: last.Seat, Path: last.Path, Ref: last.Ref, For: "assign", At: now.UTC()}}
recordBuildRequest(ctx, inv, asked.BuildRequest)
lead := fmt.Sprintf("%s\n build %s of %s is asked for now; %s can be assigned once it registers it",
where.said, id, requestSource(asked), module)
return keepPending(ctx, open, node, module, asked, waiting, lead)
}
answer := strings.TrimPrefix(where.said, inventory.ErrNoSuchModule.Error()) +
"\n a module in a repository is built with `build` (its repository and path), then assigned"
if opts.Build {
answer += "\n build \"true\" asks for nothing here: the controller has no build request saying where a " +
"module of that name is"
}
return "", fmt.Errorf("%w%s", inventory.ErrNoSuchModule, answer)
}
// keepPending records the assignment as pending on a build request — or, where one is already open, makes it
// wait for that build — and says so.
func keepPending(ctx context.Context, open *stores, node, module string, r inventory.RequestOutcome,
waiting *inventory.PendingAssignment, lead string) (string, error) {
inv := open.inventory
if waiting != nil {
if waiting.Build == r.ID {
return lead + fmt.Sprintf("\n %s already waits for %s on this build: nothing changed", node, module), nil
}
moved, err := inv.RepointPending(ctx, waiting.ID, r.ID, r.Repository, r.Path)
if err != nil {
return "", err
}
if !moved {
return lead + fmt.Sprintf("\n the pending assignment of %s to %s ended while this was asked: `status` "+
"says how", module, node), nil
}
return lead + fmt.Sprintf("\n the pending assignment of %s to %s, which waited for build %s, now waits "+
"for build %s", module, node, waiting.Build, r.ID), nil
}
_, err := inv.RecordPending(ctx, inventory.PendingAssignment{Node: node, Module: module, Build: r.ID,
Repository: r.Repository, Path: r.Path})
if errors.Is(err, inventory.ErrAlreadyPending) {
return lead + fmt.Sprintf("\n %s already waits for %s: nothing changed", node, module), nil
}
if err != nil {
return "", err
}
// The controller makes it, not this act (ADR 0261): when the build registers the module, or at its next
// tick if the module was registered between the look and the record.
return lead + fmt.Sprintf("\n the assignment is kept as pending: the controller assigns %s to %s when the "+
"build registers it, and `status` lists it until then. If the build fails it expires, says why and "+
"raises a condition; `unassign %s %s` withdraws it", module, node, node, module), nil
}
// inFlight is the newest build request for the module still to register it: no outcome yet within the
// bound, or a successful outcome heard a moment ago and being registered.
func inFlight(requests []inventory.RequestOutcome, now time.Time) (inventory.RequestOutcome, bool) {
for _, r := range requests {
if stillComing(r, now) {
return r, true
}
}
return inventory.RequestOutcome{}, false
}
func stillComing(r inventory.RequestOutcome, now time.Time) bool {
if r.Heard {
return r.Failed == "" && now.Sub(r.HeardAt) < registerGrace
}
return r.NotAsked == "" && now.Sub(r.At) < buildRequestBound
}
// whyNotBuilt says what came of a module's last build request. What was heard comes first: an outcome is the
// last word whatever its asker said.
func whyNotBuilt(r inventory.RequestOutcome, now time.Time) string {
where := fmt.Sprintf("%s in %s", orRoot(r.Path), r.Repository)
switch {
case r.Heard && r.Failed != "":
return fmt.Sprintf("%s; its last build, %s at %s, failed: %s", where, r.ID, clock(r.HeardAt),
firstLine(r.Failed))
case r.Heard && r.Module != "" && r.Module != r.Name():
return fmt.Sprintf("%s; its last build, %s, built it as %s", where, r.ID, r.Module)
case r.Heard:
return fmt.Sprintf("%s; its last build, %s at %s, was recorded and not registered — `builds` says why",
where, r.ID, clock(r.HeardAt))
case r.NotAsked != "" && r.For == "merge":
return fmt.Sprintf("%s; the merge that added it (%s) could not ask for its build: %s", where,
short(r.Commit), firstLine(r.NotAsked))
case r.NotAsked != "":
return fmt.Sprintf("%s; build %s, asked by %s, was not handed over: %s", where, r.ID,
askerOr(r.For), firstLine(r.NotAsked))
case r.OutcomeUnknown != "":
return fmt.Sprintf("%s; build %s was asked at %s, its asker stopped waiting (%s), and no outcome has "+
"been heard in %s", where, r.ID, clock(r.At), firstLine(r.OutcomeUnknown), now.Sub(r.At).Round(time.Minute))
default:
return fmt.Sprintf("%s; build %s was asked at %s, and no outcome has been heard in %s", where, r.ID,
clock(r.At), now.Sub(r.At).Round(time.Minute))
}
}
func askerOr(who string) string {
if who == "" {
return "the controller"
}
return who
}
// requestSource is a build request's source as a person reads it: repository@commit (directory).
func requestSource(r inventory.RequestOutcome) string {
at := short(r.Commit)
if at == "" {
at = r.Ref
}
if at == "" {
at = "its default branch"
}
return fmt.Sprintf("%s@%s (%s)", r.Repository, at, orRoot(r.Path))
}
// clock is a moment as a person reads it, in the controller's local time.
func clock(t time.Time) string { return t.Local().Format("2006-01-02 15:04:05 MST") }
// closestNames is up to three names near the one asked: registered modules and directories the controller
// asked to build.
func closestNames(ctx context.Context, inv *inventory.Inventory, name string) []string {
var candidates []string
if shelf, err := inv.Catalogue(ctx); err == nil {
for m := range shelf {
candidates = append(candidates, m)
}
}
if asked, err := inv.RequestedNames(ctx); err == nil {
candidates = append(candidates, asked...)
}
type scored struct {
name string
distance int
}
seen := map[string]bool{}
var near []scored
limit := max(2, len(name)/3)
for _, c := range candidates {
if seen[c] || c == name {
continue
}
seen[c] = true
d := editDistance(name, c)
if d <= limit || strings.Contains(c, name) || strings.Contains(name, c) {
near = append(near, scored{c, d})
}
}
sort.Slice(near, func(i, j int) bool {
if near[i].distance != near[j].distance {
return near[i].distance < near[j].distance
}
return near[i].name < near[j].name
})
var out []string
for i := 0; i < len(near) && i < 3; i++ {
out = append(out, near[i].name)
}
return out
}
// editDistance is the Levenshtein distance between two names.
func editDistance(a, b string) int {
ra, rb := []rune(a), []rune(b)
prev := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
cur := make([]int, len(rb)+1)
cur[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost)
}
prev = cur
}
return prev[len(rb)]
}
// applyPending makes a pending assignment now that its module is registered (ADR 0261). Under the machine's
// hold it claims the row (waiting → applying), so a withdrawal cannot land between the look and the act,
// then assigns by the same act a person's assign is, then says what came of it: applied, or refused with the
// refusal. Returns what it said, or nothing when the row no longer waited.
func applyPending(ctx context.Context, open *stores, p inventory.PendingAssignment, by string) string {
inv := open.inventory
ctx, release, err := holdNodes(ctx, open, []string{p.Node})
if err != nil {
// Left waiting: the next tick tries again.
return fmt.Sprintf("the pending assignment of %s to %s waits: %s could not be held: %v", p.Module, p.Node,
p.Node, err)
}
defer release()
claimed, err := inv.ClaimPending(ctx, p.ID)
if err != nil {
return fmt.Sprintf("the pending assignment of %s to %s could not be taken for making: %v", p.Module, p.Node, err)
}
if !claimed {
return ""
}
answer, err := assign(ctx, open, p.Node, p.Module)
state, note := inventory.PendingApplied, ""
switch {
case err != nil:
state = inventory.PendingRefused
note = fmt.Sprintf("the pending assignment of %s to %s was refused when build %s registered it: %s",
p.Module, p.Node, by, firstLine(err.Error()))
case strings.Contains(answer, "already runs"):
note = fmt.Sprintf("%s already runs %s: the pending assignment is met", p.Node, p.Module)
default:
// The same as a person's assign: nothing is sent by this act.
note = fmt.Sprintf("%s is assigned %s: build %s registered it (pending since %s); `push %s` sends it",
p.Node, p.Module, by, clock(p.Since), p.Node)
}
settled, serr := inv.SettlePending(ctx, p.ID, inventory.PendingApplying, state, note)
if serr != nil {
return fmt.Sprintf("%s — and it could not be recorded as settled: %v", note, serr)
}
if !settled {
return ""
}
return note
}
// expirePending ends a waiting pending assignment whose build will not register its module, with why.
func expirePending(ctx context.Context, inv *inventory.Inventory, p inventory.PendingAssignment, why string) string {
note := fmt.Sprintf("the pending assignment of %s to %s expired: %s; nothing was assigned. Assign it again with "+
"build \"true\" to ask for the build again", p.Module, p.Node, why)
settled, err := inv.SettlePending(ctx, p.ID, inventory.PendingWaiting, inventory.PendingExpired, note)
if err != nil {
return fmt.Sprintf("%s — and it could not be recorded: %v", note, err)
}
if !settled {
return ""
}
return note
}
// settlePendingOnBuild is a build's outcome reaching the assignments waiting for it, wherever the outcome is
// taken in (novox/hq issue 325): a module registered makes every assignment pending on it; a build of a
// pending assignment that failed, or was not registered, ends it with why. Returns what was said.
func settlePendingOnBuild(ctx context.Context, open *stores, result link.BuildResult, module string, takeErr error) []string {
inv := open.inventory
waiting, err := inv.Pending(ctx, time.Time{})
if err != nil {
return []string{fmt.Sprintf("the pending assignments could not be read after build %s: %v", result.ID, err)}
}
registered := module != "" && (takeErr == nil || errors.Is(takeErr, inventory.ErrSuperseded))
var said []string
for _, p := range waiting {
switch {
case registered && p.Module == module:
if line := applyPending(ctx, open, p, result.ID); line != "" {
said = append(said, line)
}
case !registered && p.Build == result.ID:
why := firstLine(result.Failed)
if why == "" && takeErr != nil {
why = firstLine(takeErr.Error())
}
what := fmt.Sprintf("build %s of %s failed: %s", result.ID, orRoot(p.Path), why)
if result.Failed == "" {
what = fmt.Sprintf("build %s of %s was recorded and not registered: %s", result.ID, orRoot(p.Path), why)
}
if line := expirePending(ctx, inv, p, what); line != "" {
said = append(said, line)
}
}
}
return said
}
// settlingPending is the controller's tick over the pending assignments (ADR 0261), every settleEvery until
// the context ends. What it does is printed to the controller's log, kept in each row's note (which `status`
// lists for a day), and raised as a condition for an assignment that was not made.
func settlingPending(ctx context.Context, open *stores) {
for {
for _, line := range settlePending(ctx, open, time.Now()) {
fmt.Println(line)
}
for _, line := range raiseUnknownFields(ctx, open.inventory) {
fmt.Println(line)
}
select {
case <-ctx.Done():
return
case <-time.After(settleEvery):
}
}
}
// settlePending is one tick: claims left by a controller that stopped are settled from what the mesh holds;
// a module registered meanwhile is assigned; a build heard and not registered, or silent past its bound,
// ends its assignment; ended ones are raised as conditions and cleared once answered; ended rows older than
// KeptFor are deleted. Returns what it did.
func settlePending(ctx context.Context, open *stores, now time.Time) []string {
inv := open.inventory
var said []string
say := func(format string, args ...any) { said = append(said, fmt.Sprintf(format, args...)) }
shelf, err := inv.Catalogue(ctx)
if err != nil {
say("the pending assignments are not settled this time: the catalogue could not be read: %v", err)
return said
}
stuck, err := inv.Stuck(ctx, now.Add(-claimStaleAfter))
if err != nil {
say("claims left by a stopped controller could not be read: %v", err)
}
for _, p := range stuck {
assigned, err := inv.Assigned(ctx, p.Node)
if err != nil {
say("the claim on %s for %s could not be settled: %v", p.Node, p.Module, err)
continue
}
if containsString(assigned, p.Module) {
if _, err := inv.SettlePending(ctx, p.ID, inventory.PendingApplying, inventory.PendingApplied,
fmt.Sprintf("%s is assigned %s (settled after the controller that made it stopped)", p.Node, p.Module)); err != nil {
say("the claim on %s for %s could not be settled: %v", p.Node, p.Module, err)
}
continue
}
if err := inv.ReleaseClaim(ctx, p.ID); err != nil {
say("the claim on %s for %s could not be released: %v", p.Node, p.Module, err)
}
}
waiting, err := inv.Pending(ctx, time.Time{})
if err != nil {
say("the pending assignments could not be read to settle them: %v", err)
}
for _, p := range waiting {
if _, ok := shelf[p.Module]; ok {
if line := applyPending(ctx, open, p, p.Build); line != "" {
said = append(said, line)
}
continue
}
requests, err := inv.RequestsNamed(ctx, p.Module)
if err != nil {
say("the build requests for %s could not be read: %v", p.Module, err)
continue
}
var r *inventory.RequestOutcome
for i := range requests {
if requests[i].ID == p.Build {
r = &requests[i]
}
}
why := ""
switch {
case r == nil && now.Sub(p.Since) > buildRequestBound:
why = fmt.Sprintf("build %s is no longer on record", p.Build)
case r != nil && !stillComing(*r, now) && (r.Heard || r.NotAsked != "" || r.OutcomeUnknown != ""):
why = whyNotBuilt(*r, now)
case r != nil && !stillComing(*r, now):
why = fmt.Sprintf("no outcome of build %s was heard within %s of asking", p.Build, buildRequestBound)
}
if why == "" {
continue
}
if line := expirePending(ctx, inv, p, why); line != "" {
said = append(said, line)
}
}
said = append(said, raisePendingEnded(ctx, inv)...)
if n, err := inv.ForgetEndedPending(ctx, now); err != nil {
say("ended pending assignments could not be deleted: %v", err)
} else if n > 0 {
say("deleted %d pending assignment(s) ended more than %s ago", n, inventory.KeptFor)
}
return said
}
func containsString(xs []string, x string) bool {
for _, y := range xs {
if y == x {
return true
}
}
return false
}
// pendingObservation is the condition for a pending assignment that was not made: one per pending
// assignment, its row's id the last part of its id, so one row's condition is never another's.
func pendingObservation(p inventory.PendingAssignment) conditions.Observation {
return conditions.Observation{Scope: conditions.ScopeMachine, ID: fmt.Sprintf("%s.%s.%d", p.Node, p.Module, p.ID),
Token: kindPendingEnded, Kind: kindPendingEnded, Machine: p.Node, Severity: conditions.Warning,
Resolver: conditions.ResolverOperator, Source: "pending assignments",
Summary: p.Note}
}
// raisePendingEnded raises a condition for each pending assignment that expired or was refused, once, and
// clears it when it is answered: the module was assigned to that machine since, a newer pending assignment of
// it is open, or a person took it back with `unassign`. Where this process keeps no conditions, nothing is
// stamped, and the serving controller's tick raises it.
func raisePendingEnded(ctx context.Context, inv *inventory.Inventory) []string {
keeper := conditionsFrom
if keeper == nil {
return nil
}
var said []string
toRaise, err := inv.ToRaise(ctx)
if err != nil {
return []string{fmt.Sprintf("pending assignments not made could not be read to raise them: %v", err)}
}
for _, p := range toRaise {
if _, err := keeper.Observe(ctx, pendingObservation(p)); err != nil {
said = append(said, fmt.Sprintf("the condition for %s on %s could not be raised: %v", p.Module, p.Node, err))
continue
}
if err := inv.MarkPending(ctx, p.ID, "raised"); err != nil {
said = append(said, fmt.Sprintf("the condition for %s on %s was raised and not recorded: %v", p.Module, p.Node, err))
}
}
toClear, err := inv.ToClear(ctx)
if err != nil {
return append(said, fmt.Sprintf("pending assignments raised could not be read to clear them: %v", err))
}
for _, p := range toClear {
why, answered, err := pendingAnswered(ctx, inv, p)
if err != nil {
said = append(said, fmt.Sprintf("whether %s on %s is answered could not be read: %v", p.Module, p.Node, err))
continue
}
if !answered {
continue
}
if _, err := keeper.Clear(ctx, pendingObservation(p).Key(), why); err != nil {
said = append(said, fmt.Sprintf("the condition for %s on %s could not be cleared: %v", p.Module, p.Node, err))
continue
}
if err := inv.MarkPending(ctx, p.ID, "cleared"); err != nil {
said = append(said, fmt.Sprintf("the condition for %s on %s was cleared and not recorded: %v", p.Module, p.Node, err))
}
}
return append(said, clearOrphaned(ctx, keeper, inv)...)
}
// pendingAnswered says whether a pending assignment that was not made has been answered since, and how.
func pendingAnswered(ctx context.Context, inv *inventory.Inventory, p inventory.PendingAssignment) (string, bool, error) {
if p.Acknowledged != nil {
return "taken back with unassign", true, nil
}
assigned, err := inv.Assigned(ctx, p.Node)
if err != nil {
return "", false, err
}
if containsString(assigned, p.Module) {
return p.Module + " is assigned to " + p.Node + " since", true, nil
}
rows, err := inv.PendingFor(ctx, p.Node, p.Module)
if err != nil {
return "", false, err
}
// A newer pending assignment answers it only while it is open or once it was made: one that itself
// ended unmade is its own condition, and answers nothing.
for _, r := range rows {
if r.ID != p.ID && r.Since.After(p.Since) && (r.Open() || r.State == inventory.PendingApplied) {
return "assigned again, pending on another build", true, nil
}
}
return "", false, nil
}
// clearOrphaned clears every open condition of an assignment not made whose pending assignment is no longer
// on record: its machine was removed, which takes its pending assignments with it.
func clearOrphaned(ctx context.Context, keeper *conditions.Keeper, inv *inventory.Inventory) []string {
open, err := keeper.Open(ctx)
if err != nil {
return []string{fmt.Sprintf("the open conditions could not be read to clear orphaned ones: %v", err)}
}
byID := map[int64]string{}
var ids []int64
for _, c := range open {
if c.Kind != kindPendingEnded {
continue
}
// `<scope>.<node>.<module>.<row>.<kind>`: the row is the part before the kind.
parts := strings.Split(c.Key, ".")
if len(parts) < 2 {
continue
}
var id int64
if _, err := fmt.Sscan(parts[len(parts)-2], &id); err != nil {
continue
}
byID[id] = c.Key
ids = append(ids, id)
}
if len(ids) == 0 {
return nil
}
known, err := inv.PendingKnown(ctx, ids)
if err != nil {
return []string{fmt.Sprintf("the pending assignments could not be read to clear orphaned conditions: %v", err)}
}
var said []string
for id, key := range byID {
if known[id] {
continue
}
if _, err := keeper.Clear(ctx, key, "its pending assignment is no longer on record: its machine was removed"); err != nil {
said = append(said, fmt.Sprintf("the condition %s could not be cleared: %v", key, err))
}
}
return said
}
// withdrawPending is `unassign` of a module only pending on a machine, under the machine's hold: a waiting
// pending assignment is withdrawn; one being made now is refused, never overridden; one that expired or was
// refused is taken back, which answers its condition. False when there is none.
func withdrawPending(ctx context.Context, inv *inventory.Inventory, node, module string) (string, bool, error) {
rows, err := inv.PendingFor(ctx, node, module)
if err != nil {
return "", false, err
}
var takenBack []string
for _, p := range rows {
switch p.State {
case inventory.PendingApplying:
return "", true, fmt.Errorf("%s is being assigned %s now, as build %s registered it: nothing was "+
"withdrawn; `unassign %s %s` again once it is assigned takes it off", node, module, p.Build, node, module)
case inventory.PendingWaiting:
note := fmt.Sprintf("the pending assignment of %s to %s is withdrawn; build %s goes on, and registers "+
"%s assigned nowhere", module, node, p.Build, module)
settled, err := inv.SettlePending(ctx, p.ID, inventory.PendingWaiting, inventory.PendingWithdrawn, note)
if err != nil {
return "", false, err
}
if !settled {
return "", true, fmt.Errorf("the pending assignment of %s to %s changed while it was withdrawn: "+
"`status` says how; nothing was withdrawn", module, node)
}
return note, true, nil
case inventory.PendingExpired, inventory.PendingRefused:
if p.Cleared != nil || p.Acknowledged != nil {
continue
}
if err := inv.MarkPending(ctx, p.ID, "acknowledged"); err != nil {
return "", false, err
}
takenBack = append(takenBack, fmt.Sprintf("build %s (%s)", p.Build, p.State))
}
}
if len(takenBack) > 0 {
return fmt.Sprintf("the pending assignment(s) of %s to %s that were not made are taken back: %s; their "+
"conditions clear", module, node, strings.Join(takenBack, ", ")), true, nil
}
return "", false, nil
}
-864
View File
@@ -1,864 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"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"
)
// failedBuild settles the merge's build of modules/sensors as failed.
func failedBuild(t *testing.T, open *stores, id string) {
t.Helper()
if err := (builds{open.inventory, open}).Built(t.Context(), outcome(id, "modules/sensors", "3da80a4b00aa",
"", "boom")); err != nil {
t.Fatal(err)
}
}
func conditionOpen(t *testing.T, p inventory.PendingAssignment) bool {
t.Helper()
_, found, err := conditionsFrom.Get(t.Context(), pendingObservation(p).Key())
if err != nil {
t.Fatal(err)
}
return found
}
func rowOf(t *testing.T, open *stores, id int64) inventory.PendingAssignment {
t.Helper()
rows, err := open.inventory.Pending(t.Context(), time.Unix(0, 0))
if err != nil {
t.Fatal(err)
}
for _, r := range rows {
if r.ID == id {
return r
}
}
t.Fatalf("pending assignment %d is not on record", id)
return inventory.PendingAssignment{}
}
// novox/hq issue 325: an assignment of a module the catalogue does not hold says which case it is in — a
// build in flight (kept pending), known and not built (said, with build), or unknown (refused, with the
// closest names) — and a pending assignment is made when its build registers the module, or ends with why.
// aCatalogueMesh is aMesh with one module held from the catalogue, so a merge of it is acted on.
func aCatalogueMesh(t *testing.T) *stores {
t.Helper()
open := aMesh(t)
if err := open.inventory.RegisterModule(t.Context(), catalogue.Manifest{Module: "networkmanager", Version: "1"},
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/networkmanager", Ref: "main",
BuiltFrom: "c0", Head: "c0"}); err != nil {
t.Fatal(err)
}
return open
}
// mergeAdding is the catalogue's merge adding one module's directory, acted on as the bus hands it over.
func mergeAdding(t *testing.T, open *stores, dir, commit string) {
t.Helper()
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: commit,
Paths: []string{dir + "/module.json"}, ModuleDirs: []string{dir}, ModuleDirsSaid: true}
if err := (following{open: open}).SourceMoved(t.Context(), m); err != nil {
t.Fatal(err)
}
}
// outcome is a build's outcome as the build seat announces it: registered as module, or failed.
func outcome(id, dir, commit, module, failed string) link.BuildResult {
r := link.BuildResult{ID: id, Repository: "novox/mesh-catalog", Path: dir, Ref: "main", Commit: commit,
On: "anchor", Failed: failed, Source: &link.SourceOnSeat{Repository: "novox/mesh-catalog", Seat: "git"}}
if failed == "" {
r.Module = module
r.Manifest, _ = json.Marshal(catalogue.Manifest{Module: module, Version: "1"})
}
return r
}
func assignedTo(t *testing.T, open *stores, node string) []string {
t.Helper()
got, err := open.inventory.Assigned(t.Context(), node)
if err != nil {
t.Fatal(err)
}
return got
}
func pendingOf(t *testing.T, open *stores) []inventory.PendingAssignment {
t.Helper()
got, err := open.inventory.Pending(t.Context(), time.Now().Add(-time.Hour))
if err != nil {
t.Fatal(err)
}
return got
}
// A build in flight: the merge asked for it, the request is kept as the merge's, the assignment is kept
// pending and said so with the build, status reads it without settling anything, and the build's outcome
// makes it — saying that a push sends it, and nothing more.
func TestAnAssignmentWaitsForTheBuildInFlight(t *testing.T) {
open := aCatalogueMesh(t)
ctx := t.Context()
asksWithPaths(t)
mergeAdding(t, open, "modules/sensors", "3da80a4b00aa")
requests, err := open.inventory.RequestsNamed(ctx, "sensors")
if err != nil || len(requests) != 1 || requests[0].For != "merge" || requests[0].Commit != "3da80a4b00aa" {
t.Fatalf("the merge's build request: %+v %v", requests, err)
}
said, err := assign(ctx, open, "laptop", "sensors")
if err != nil {
t.Fatalf("an assignment while its build runs was refused: %v", err)
}
t.Logf("assign laptop sensors, while its build runs:\n%s", said)
for _, want := range []string{"being built from novox/mesh-catalog@3da80a4b (modules/sensors)",
"build b-modules/sensors", "kept as pending", "the controller assigns sensors to laptop", "`unassign laptop sensors`"} {
if !strings.Contains(said, want) {
t.Fatalf("the answer does not say %q:\n%s", want, said)
}
}
if slices.Contains(assignedTo(t, open, "laptop"), "sensors") {
t.Fatal("a module not registered was assigned")
}
pending := pendingOf(t, open)
if len(pending) != 1 || pending[0].State != inventory.PendingWaiting || pending[0].Build != "b-modules/sensors" {
t.Fatalf("pending: %+v", pending)
}
if again, err := assign(ctx, open, "laptop", "sensors"); err != nil || !strings.Contains(again, "already waits") {
t.Fatalf("a second assignment: %q %v", again, err)
}
asked, err := theThreeQuestions(ctx, open)
if err != nil {
t.Fatal(err)
}
if asked.well() {
t.Fatal("status called the mesh well while an assignment waits")
}
body, err := statusAsJSON(asked)
if err != nil {
t.Fatal(err)
}
var doc meshStatus
if err := json.Unmarshal(body, &doc); err != nil {
t.Fatal(err)
}
if len(doc.Pending) != 1 || doc.Pending[0].State != "waiting" || doc.Pending[0].Module != "sensors" {
t.Fatalf("status says pending %+v", doc.Pending)
}
if err := (builds{open.inventory, open}).Built(ctx, outcome("b-modules/sensors", "modules/sensors",
"3da80a4b00aa", "sensors", "")); err != nil {
t.Fatal(err)
}
if !slices.Contains(assignedTo(t, open, "laptop"), "sensors") {
t.Fatal("the build registered sensors and the pending assignment was not made")
}
pending = pendingOf(t, open)
if len(pending) != 1 || pending[0].State != inventory.PendingApplied ||
!strings.HasSuffix(pending[0].Note, "`push laptop` sends it") {
t.Fatalf("pending after the build: %+v", pending)
}
}
// **A read settles nothing** (review of #150, point 6): status — and so the board, the summary and the
// probe, which compose from the same reading — leaves a pending assignment whose module is registered as it
// is; the controller's tick makes it.
func TestAStatusReadSettlesNothing(t *testing.T) {
open := aCatalogueMesh(t)
ctx := t.Context()
asksWithPaths(t)
mergeAdding(t, open, "modules/sensors", "3da80a4b00aa")
if _, err := assign(ctx, open, "laptop", "sensors"); err != nil {
t.Fatal(err)
}
register(t, open, catalogue.Manifest{Module: "sensors", Version: "1"})
for range 2 {
if _, err := theThreeQuestions(ctx, open); err != nil {
t.Fatal(err)
}
}
if slices.Contains(assignedTo(t, open, "laptop"), "sensors") || pendingOf(t, open)[0].State != inventory.PendingWaiting {
t.Fatalf("a status read settled a pending assignment: %+v", pendingOf(t, open))
}
said := settlePending(ctx, open, time.Now())
if !slices.Contains(assignedTo(t, open, "laptop"), "sensors") || pendingOf(t, open)[0].State != inventory.PendingApplied {
t.Fatalf("the tick did not make it: %v %+v", said, pendingOf(t, open))
}
}
// The build of a pending assignment fails: it expires with the build's words and nothing is assigned; the
// tick raises it as a condition once; it then reads as known and not built; and `unassign` takes it back,
// after which the tick clears the condition. Status is not called well while the condition is open, and is
// again once it clears — no fixed day of "not well" (review point 6).
func TestAPendingAssignmentExpiresWhenItsBuildFails(t *testing.T) {
open := aCatalogueMesh(t)
ctx := t.Context()
asksWithPaths(t)
mergeAdding(t, open, "modules/sensors", "3da80a4b00aa")
if _, err := assign(ctx, open, "laptop", "sensors"); err != nil {
t.Fatal(err)
}
if err := (builds{open.inventory, open}).Built(ctx, outcome("b-modules/sensors", "modules/sensors",
"3da80a4b00aa", "", "go build: undefined: sensorsRead")); err != nil {
t.Fatal(err)
}
pending := pendingOf(t, open)
if len(pending) != 1 || pending[0].State != inventory.PendingExpired {
t.Fatalf("pending after a failed build: %+v", pending)
}
for _, want := range []string{"expired", "build b-modules/sensors of modules/sensors failed",
"undefined: sensorsRead", "nothing was assigned", "build \"true\""} {
if !strings.Contains(pending[0].Note, want) {
t.Fatalf("the expiry does not say %q: %s", want, pending[0].Note)
}
}
if slices.Contains(assignedTo(t, open, "laptop"), "sensors") {
t.Fatal("a failed build's module was assigned")
}
settlePending(ctx, open, time.Now())
key := pendingObservation(pending[0]).Key()
c, found, err := conditionsFrom.Get(ctx, key)
if err != nil || !found || c.Kind != kindPendingEnded {
t.Fatalf("no condition %s for the assignment not made: %+v %v %v", key, c, found, err)
}
if asked, err := theThreeQuestions(ctx, open); err != nil || asked.well() || len(asked.conditions) == 0 {
t.Fatalf("status does not hold the open condition: %v", err)
}
_, err = assign(ctx, open, "laptop", "sensors")
if !errors.Is(err, inventory.ErrNoSuchModule) {
t.Fatalf("a module whose build failed: %v", err)
}
for _, want := range []string{"sensors is known and not registered", "its last build, b-modules/sensors",
"failed: go build: undefined: sensorsRead", "build \"true\"", "repository novox/mesh-catalog, path modules/sensors"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("the refusal does not say %q:\n%v", want, err)
}
}
said, err := unassign(ctx, open, "laptop", "sensors")
if err != nil || !strings.Contains(said, "taken back") {
t.Fatalf("unassign of an expired pending assignment: %q %v", said, err)
}
settlePending(ctx, open, time.Now())
if _, found, _ := conditionsFrom.Get(ctx, key); found {
t.Fatal("the condition stayed open after the assignment was taken back")
}
// Nothing of it keeps status from being well any more: no open pending assignment, no open condition of it.
asked, err := theThreeQuestions(ctx, open)
if err != nil || pendingOpen(asked.pending) {
t.Fatalf("status still counts the pending assignment: %+v %v", asked.pending, err)
}
for _, c := range asked.conditions {
if c.Kind == kindPendingEnded {
t.Fatalf("status still holds the condition: %+v", c)
}
}
}
// The condition also clears when the module is later assigned to the machine.
func TestTheConditionClearsWhenTheModuleIsAssignedLater(t *testing.T) {
open := aCatalogueMesh(t)
ctx := t.Context()
asksWithPaths(t)
mergeAdding(t, open, "modules/sensors", "3da80a4b00aa")
if _, err := assign(ctx, open, "laptop", "sensors"); err != nil {
t.Fatal(err)
}
if err := (builds{open.inventory, open}).Built(ctx, outcome("b-modules/sensors", "modules/sensors",
"3da80a4b00aa", "", "boom")); err != nil {
t.Fatal(err)
}
settlePending(ctx, open, time.Now())
key := pendingObservation(pendingOf(t, open)[0]).Key()
register(t, open, catalogue.Manifest{Module: "sensors", Version: "1"})
if _, err := assign(ctx, open, "laptop", "sensors"); err != nil {
t.Fatal(err)
}
settlePending(ctx, open, time.Now())
if _, found, _ := conditionsFrom.Get(ctx, key); found {
t.Fatal("the condition stayed open after the module was assigned")
}
}
// Known and not built, asked with build: the build is asked from where the last one was, the request kept as
// assign's, and the assignment kept pending on the new build.
func TestAssignWithBuildAsksForAKnownModule(t *testing.T) {
open := aCatalogueMesh(t)
ctx := t.Context()
inv := open.inventory
if err := inv.RecordBuildRequest(ctx, inventory.BuildRequest{ID: "build-old", Repository: "novox/mesh-catalog",
Seat: "git", Path: "modules/sensors", Ref: "main", For: "build", At: time.Now().Add(-time.Hour)}); err != nil {
t.Fatal(err)
}
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-old", Repository: "novox/mesh-catalog", Ref: "main",
Path: "modules/sensors", On: "anchor", Failed: "no space left on device"}); err != nil {
t.Fatal(err)
}
asked := asksWithPaths(t)
said, err := assignWith(ctx, open, "laptop", assignOptions{Build: true}, "sensors")
if err != nil {
t.Fatal(err)
}
if len(*asked) != 1 || (*asked)[0] != [3]string{"novox/mesh-catalog", "modules/sensors", "main"} {
t.Fatalf("asked %v", *asked)
}
for _, want := range []string{"known and not registered", "no space left on device",
"build b-modules/sensors of novox/mesh-catalog@main (modules/sensors) is asked for now", "kept as pending"} {
if !strings.Contains(said, want) {
t.Fatalf("the answer does not say %q:\n%s", want, said)
}
}
pending := pendingOf(t, open)
if len(pending) != 1 || pending[0].Build != "b-modules/sensors" {
t.Fatalf("pending: %+v", pending)
}
requests, _ := inv.RequestsNamed(ctx, "sensors")
if len(requests) != 2 || requests[0].ID != "b-modules/sensors" || requests[0].For != "assign" {
t.Fatalf("the request is not kept as assign's: %+v", requests)
}
}
// **build "true" with a pending assignment already waiting** (review point 5): the waiting one is made to
// wait for the new build, and no build is asked that nothing waits on.
func TestAssignWithBuildRepointsTheWaitingAssignment(t *testing.T) {
open := aCatalogueMesh(t)
ctx := t.Context()
inv := open.inventory
if err := inv.RecordBuildRequest(ctx, inventory.BuildRequest{ID: "build-old", Repository: "novox/mesh-catalog",
Seat: "git", Path: "modules/sensors", Ref: "main", For: "merge", At: time.Now().Add(-time.Hour)}); err != nil {
t.Fatal(err)
}
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-old", Repository: "novox/mesh-catalog", Ref: "main",
Path: "modules/sensors", On: "anchor", Failed: "boom"}); err != nil {
t.Fatal(err)
}
// Waiting on the failed build, its expiry not yet settled.
if _, err := inv.RecordPending(ctx, inventory.PendingAssignment{Node: "laptop", Module: "sensors",
Build: "build-old", Repository: "novox/mesh-catalog", Path: "modules/sensors"}); err != nil {
t.Fatal(err)
}
_, err := assign(ctx, open, "laptop", "sensors")
if err == nil || !strings.Contains(err.Error(), "already has a pending assignment of sensors, waiting for build build-old") {
t.Fatalf("known and not built with a pending assignment waiting: %v", err)
}
asked := asksWithPaths(t)
said, err := assignWith(ctx, open, "laptop", assignOptions{Build: true}, "sensors")
if err != nil || !strings.Contains(said, "which waited for build build-old, now waits for build b-modules/sensors") {
t.Fatalf("assign with build: %q %v", said, err)
}
if len(*asked) != 1 {
t.Fatalf("asked %v", *asked)
}
pending := pendingOf(t, open)
if len(pending) != 1 || pending[0].Build != "b-modules/sensors" || pending[0].State != inventory.PendingWaiting {
t.Fatalf("pending: %+v", pending)
}
// The new build in flight now: build "true" again asks nothing.
if said, err := assignWith(ctx, open, "laptop", assignOptions{Build: true}, "sensors"); err != nil ||
!strings.Contains(said, "no second build was asked") || len(*asked) != 1 {
t.Fatalf("a second build true while the build runs: %q %v, asked %v", said, err, *asked)
}
}
// **A failed ask never reads as in flight** (review point 2): a merge whose ask could not be made keeps the
// request as not asked, and assign says the merge could not ask; a request whose asker could not hand it over
// or stopped waiting reads the same, unless its outcome was heard.
func TestAFailedAskIsNotABuildInFlight(t *testing.T) {
open := aCatalogueMesh(t)
ctx := t.Context()
inv := open.inventory
was := askABuild
askABuild = func(context.Context, buildSource, string, string) (string, error) {
return "", errors.New("cannot submit a build: nats: timeout")
}
t.Cleanup(func() { askABuild = was })
mergeAdding(t, open, "modules/sensors", "3da80a4b00aa")
_, err := assign(ctx, open, "laptop", "sensors")
if err == nil || !strings.Contains(err.Error(), "the merge that added it (3da80a4b) could not ask for its build: cannot submit") {
t.Fatalf("a merge that could not ask: %v", err)
}
if len(pendingOf(t, open)) != 0 {
t.Fatal("a pending assignment waits on a build never asked")
}
if err := inv.RecordBuildRequest(ctx, inventory.BuildRequest{ID: "build-waited", Repository: "novox/mesh-catalog",
Seat: "git", Path: "modules/gauges", Ref: "main", For: "build"}); err != nil {
t.Fatal(err)
}
if err := inv.MarkNotAsked(ctx, "build-waited", "cannot submit a build: no responders"); err != nil {
t.Fatal(err)
}
_, err = assign(ctx, open, "laptop", "gauges")
if err == nil || !strings.Contains(err.Error(), "build build-waited, asked by build, was not handed over") {
t.Fatalf("a build not handed over: %v", err)
}
// Heard first, the outcome stands: marking it afterwards changes nothing.
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-waited", Repository: "novox/mesh-catalog", Ref: "main",
Path: "modules/gauges", On: "anchor", Failed: "the real failure"}); err != nil {
t.Fatal(err)
}
_, err = assign(ctx, open, "laptop", "gauges")
if err == nil || !strings.Contains(err.Error(), "failed: the real failure") {
t.Fatalf("an outcome heard after a failed wait: %v", err)
}
}
// A plan's tier keeps its requests as the plan's (review point 3).
func TestAPlansBuildRequestIsThePlans(t *testing.T) {
open := aCatalogueMesh(t)
ctx := t.Context()
asksWithPaths(t)
if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"},
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/app", Ref: "main",
BuiltFrom: "c0", Head: "c0"}); err != nil {
t.Fatal(err)
}
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "c1aaaaaaaa",
Paths: []string{"modules/app/index.ts"}, ModuleDirs: []string{"modules/app"}, ModuleDirsSaid: true}
if err := (following{open: open}).SourceMoved(ctx, m); err != nil {
t.Fatal(err)
}
requests, err := open.inventory.RequestsNamed(ctx, "app")
if err != nil || len(requests) != 1 || requests[0].For != "plan" {
t.Fatalf("the plan's request: %+v %v", requests, err)
}
}
// **A build heard as built and not registered yet is in flight** (review point 4): the outcome is recorded
// before the module is registered; for that moment assign keeps the assignment pending. Past the grace it is
// recorded and not registered, and the tick ends the pending assignment with that.
func TestABuildBeingRegisteredIsInFlight(t *testing.T) {
open := aCatalogueMesh(t)
ctx := t.Context()
inv := open.inventory
if err := inv.RecordBuildRequest(ctx, inventory.BuildRequest{ID: "build-done", Repository: "novox/mesh-catalog",
Seat: "git", Path: "modules/sensors", Ref: "main", For: "merge"}); err != nil {
t.Fatal(err)
}
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-done", Repository: "novox/mesh-catalog", Ref: "main",
Path: "modules/sensors", Module: "sensors", On: "anchor"}); err != nil {
t.Fatal(err)
}
said, err := assign(ctx, open, "laptop", "sensors")
if err != nil || !strings.Contains(said, "built and being registered") || !strings.Contains(said, "kept as pending") {
t.Fatalf("a build being registered: %q %v", said, err)
}
settlePending(ctx, open, time.Now().Add(registerGrace+time.Minute))
p := pendingOf(t, open)
if len(p) != 1 || p[0].State != inventory.PendingExpired || !strings.Contains(p[0].Note, "recorded and not registered") {
t.Fatalf("past the grace: %+v", p)
}
}
// Unknown: refused as no module of that name, claiming only what was looked at, with the closest names; build
// asks for nothing.
func TestAnUnknownModuleIsRefusedWithTheClosestNames(t *testing.T) {
open := aCatalogueMesh(t)
ctx := t.Context()
register(t, open, catalogue.Manifest{Module: "sensord", Version: "1"})
asked := asksWithPaths(t)
_, err := assignWith(ctx, open, "laptop", assignOptions{Build: true}, "sensors")
if !errors.Is(err, inventory.ErrNoSuchModule) {
t.Fatalf("an unknown module: %v", err)
}
for _, want := range []string{"no module of that name: sensors", "the catalogue holds no module of that name",
"no build request the controller kept (the last 30 days)", "the closest names it holds: sensord",
"asks for nothing here"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("the refusal does not say %q:\n%v", want, err)
}
}
if strings.Contains(err.Error(), "merge") {
t.Fatalf("the refusal claims a merge it never looked at: %v", err)
}
if len(*asked) != 0 || len(pendingOf(t, open)) != 0 {
t.Fatalf("an unknown module asked %v, pending %+v", *asked, pendingOf(t, open))
}
}
// Several modules in one act, one of them not registered: nothing is assigned and nothing kept pending, and
// each missing one is said.
func TestAnActWithAModuleNotRegisteredIsRefusedWhole(t *testing.T) {
open := aCatalogueMesh(t)
ctx := t.Context()
asksWithPaths(t)
register(t, open, catalogue.Manifest{Module: "known", Version: "1"})
mergeAdding(t, open, "modules/sensors", "3da80a4b00aa")
_, err := assign(ctx, open, "laptop", "known", "sensors")
if err == nil || !strings.Contains(err.Error(), "nothing was assigned") || !strings.Contains(err.Error(), "being built") {
t.Fatalf("an act naming a module in flight: %v", err)
}
if slices.Contains(assignedTo(t, open, "laptop"), "known") || len(pendingOf(t, open)) != 0 {
t.Fatal("an act refused whole was made in part")
}
}
// unassign withdraws a waiting pending assignment; the build goes on and registers the module assigned
// nowhere: a withdrawn row is never claimed.
func TestUnassignWithdrawsAPendingAssignment(t *testing.T) {
open := aCatalogueMesh(t)
ctx := t.Context()
asksWithPaths(t)
mergeAdding(t, open, "modules/sensors", "3da80a4b00aa")
if _, err := assign(ctx, open, "laptop", "sensors"); err != nil {
t.Fatal(err)
}
said, err := unassign(ctx, open, "laptop", "sensors")
if err != nil || !strings.Contains(said, "withdrawn") {
t.Fatalf("unassign of a pending assignment: %q %v", said, err)
}
if err := (builds{open.inventory, open}).Built(ctx, outcome("b-modules/sensors", "modules/sensors",
"3da80a4b00aa", "sensors", "")); err != nil {
t.Fatal(err)
}
settlePending(ctx, open, time.Now())
if slices.Contains(assignedTo(t, open, "laptop"), "sensors") {
t.Fatal("a withdrawn assignment was made")
}
}
// **A claim is never overridden** (review point 1): a pending assignment being made is claimed
// (waiting → applying); an unassign meanwhile is refused and says so, and leaves the claim; making a row
// already withdrawn makes nothing. A claim left by a controller that stopped is settled by the tick from what
// the mesh holds: back to waiting when the module is not assigned, applied when it is.
func TestAWithdrawalNeverOverridesAClaim(t *testing.T) {
open := aCatalogueMesh(t)
ctx := t.Context()
inv := open.inventory
asksWithPaths(t)
mergeAdding(t, open, "modules/sensors", "3da80a4b00aa")
if _, err := assign(ctx, open, "laptop", "sensors"); err != nil {
t.Fatal(err)
}
p := pendingOf(t, open)[0]
if ok, err := inv.ClaimPending(ctx, p.ID); err != nil || !ok {
t.Fatalf("claim: %v %v", ok, err)
}
_, err := unassign(ctx, open, "laptop", "sensors")
if err == nil || !strings.Contains(err.Error(), "is being assigned sensors now") {
t.Fatalf("unassign of a claimed pending assignment: %v", err)
}
if got := pendingOf(t, open)[0]; got.State != inventory.PendingApplying {
t.Fatalf("the claim was overridden: %+v", got)
}
// Pending(zero) is the waiting ones alone (review point 7).
if waiting, err := inv.Pending(ctx, time.Time{}); err != nil || len(waiting) != 0 {
t.Fatalf("Pending(zero) read a claimed row: %+v %v", waiting, err)
}
// Left by a controller that stopped: back to waiting, since sensors is not assigned.
settlePending(ctx, open, time.Now().Add(claimStaleAfter+time.Minute))
if got := pendingOf(t, open)[0]; got.State != inventory.PendingWaiting {
t.Fatalf("a stale claim was not released: %+v", got)
}
// Withdrawn, then the build registers it: applyPending finds nothing to claim.
if _, err := unassign(ctx, open, "laptop", "sensors"); err != nil {
t.Fatal(err)
}
register(t, open, catalogue.Manifest{Module: "sensors", Version: "1"})
if line := applyPending(ctx, open, p, "b-modules/sensors"); line != "" {
t.Fatalf("a withdrawn pending assignment was made: %s", line)
}
if slices.Contains(assignedTo(t, open, "laptop"), "sensors") {
t.Fatal("a withdrawn pending assignment was assigned")
}
// A stale claim whose module was assigned is applied.
q, err := inv.RecordPending(ctx, inventory.PendingAssignment{Node: "anchor", Module: "sensors", Build: "b-x"})
if err != nil {
t.Fatal(err)
}
if ok, _ := inv.ClaimPending(ctx, q.ID); !ok {
t.Fatal("claim")
}
if _, err := inv.Assign(ctx, "anchor", "sensors"); err != nil {
t.Fatal(err)
}
settlePending(ctx, open, time.Now().Add(claimStaleAfter+time.Minute))
rows, _ := inv.PendingFor(ctx, "anchor", "sensors")
if len(rows) != 1 || rows[0].State != inventory.PendingApplied {
t.Fatalf("a stale claim of an assigned module: %+v", rows)
}
}
// A build that says nothing within the bound: the pending assignment expires at the next tick, saying so,
// rather than waiting for ever; and a module registered by a process that kept no pending assignment is
// assigned at the next tick.
func TestAPendingAssignmentNeverWaitsSilently(t *testing.T) {
open := aCatalogueMesh(t)
ctx := t.Context()
inv := open.inventory
for _, r := range []inventory.BuildRequest{
{ID: "build-lost", Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/lost", Ref: "main",
For: "merge", At: time.Now().Add(-buildRequestBound / 2)},
{ID: "build-heard", Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/heard", Ref: "main",
For: "merge", At: time.Now()},
} {
if err := inv.RecordBuildRequest(ctx, r); err != nil {
t.Fatal(err)
}
}
for _, m := range []string{"lost", "heard"} {
if _, err := assign(ctx, open, "laptop", m); err != nil {
t.Fatal(err)
}
}
register(t, open, catalogue.Manifest{Module: "heard", Version: "1"})
settlePending(ctx, open, time.Now().Add(buildRequestBound))
if !slices.Contains(assignedTo(t, open, "laptop"), "heard") {
t.Fatal("a module registered meanwhile was not assigned at the next tick")
}
byModule := map[string]inventory.PendingAssignment{}
for _, p := range pendingOf(t, open) {
byModule[p.Module] = p
}
if p := byModule["lost"]; p.State != inventory.PendingExpired || !strings.Contains(p.Note, "no outcome of build build-lost") {
t.Fatalf("a build that said nothing: %+v", p)
}
if p := byModule["heard"]; p.State != inventory.PendingApplied {
t.Fatalf("a module registered meanwhile: %+v", p)
}
// Ended rows are deleted after KeptFor (review point 7); open ones never, nor one whose condition is
// still open (second review, bug B).
if _, err := inv.RecordPending(ctx, inventory.PendingAssignment{Node: "anchor", Module: "lost", Build: "build-lost"}); err != nil {
t.Fatal(err)
}
said := settlePending(ctx, open, time.Now().Add(inventory.KeptFor+time.Hour))
rows, err := inv.Pending(ctx, time.Unix(0, 0))
if err != nil {
t.Fatal(err)
}
for _, r := range rows {
if !r.Open() && (r.Raised == nil || r.Cleared != nil) {
t.Fatalf("an ended pending assignment with no open condition outlived %s: %+v (%v)", inventory.KeptFor, r, said)
}
}
if !strings.Contains(strings.Join(said, "\n"), "deleted 1 pending assignment(s)") {
t.Fatalf("the applied row was not deleted: %v", said)
}
}
// The seat's assign passes build on; unassign takes none.
func TestTheSeatsAssignPassesBuild(t *testing.T) {
argv, err := argvFor("assign", map[string]any{"node": "g14", "module": "sensors", "build": "true"})
if err != nil || !slices.Equal(argv, []string{"assign", "g14", "sensors", "--build"}) {
t.Fatalf("assign with build: %v %v", argv, err)
}
if _, err := argvFor("unassign", map[string]any{"node": "g14", "module": "sensors", "build": "true"}); err == nil {
t.Fatal("unassign took build")
}
}
// The condition's words are plain.
func TestAnAssignmentNotMadeIsSaidPlainly(t *testing.T) {
o := pendingObservation(inventory.PendingAssignment{Node: "g14", Module: "sensors",
Note: "the pending assignment of sensors to g14 expired: build b-1 of modules/sensors failed: boom"})
w := plainWordings[kindPendingEnded](o)
if why, ok := conditions.PlainWords(w, "g14"); !ok {
t.Fatalf("not plain: %s %+v", why, w)
}
if w.Headline != "sensors was not put on g14" {
t.Fatalf("headline %q", w.Headline)
}
}
// **One row's condition is never another's** (second review, bug A). The sequence: the first pending
// assignment's build fails and the tick raises it; the person assigns again with build "true", a second
// pending assignment; its build fails too before the next tick. That tick raises the second, and must not
// clear it by judging the first answered by the second: each row has its own condition, and a newer row that
// itself ended unmade answers nothing.
func TestASecondAssignmentNotMadeKeepsItsCondition(t *testing.T) {
open := aCatalogueMesh(t)
ctx := t.Context()
asked := 0
was := askABuild
askABuild = func(context.Context, buildSource, string, string) (string, error) {
asked++
return fmt.Sprintf("b-%d", asked), nil
}
t.Cleanup(func() { askABuild = was })
mergeAdding(t, open, "modules/sensors", "3da80a4b00aa")
if _, err := assign(ctx, open, "laptop", "sensors"); err != nil {
t.Fatal(err)
}
failedBuild(t, open, "b-1")
settlePending(ctx, open, time.Now())
first := pendingOf(t, open)[0]
if !conditionOpen(t, first) {
t.Fatal("the first assignment not made raised nothing")
}
if _, err := assignWith(ctx, open, "laptop", assignOptions{Build: true}, "sensors"); err != nil {
t.Fatal(err)
}
failedBuild(t, open, "b-2")
settlePending(ctx, open, time.Now())
var second inventory.PendingAssignment
for _, p := range pendingOf(t, open) {
if p.Build == "b-2" {
second = p
}
}
if second.State != inventory.PendingExpired {
t.Fatalf("the second: %+v", second)
}
if !conditionOpen(t, second) {
t.Fatal("the second assignment's condition was cleared in the tick that raised it")
}
if !conditionOpen(t, rowOf(t, open, first.ID)) {
t.Fatal("the first was judged answered by a second that itself was not made")
}
// unassign takes both back, and the next tick clears both.
if said, err := unassign(ctx, open, "laptop", "sensors"); err != nil || !strings.Contains(said, "b-1") ||
!strings.Contains(said, "b-2") {
t.Fatalf("unassign: %q %v", said, err)
}
settlePending(ctx, open, time.Now())
if conditionOpen(t, first) || conditionOpen(t, second) {
t.Fatal("a condition stayed open after both were taken back")
}
}
// **A raised row outlives the pruning until its condition clears** (second review, bug B); and a machine's
// removal, which takes its rows with it, clears their conditions at the next tick.
func TestARaisedRowIsKeptUntilItsConditionClears(t *testing.T) {
open := aCatalogueMesh(t)
ctx := t.Context()
inv := open.inventory
asksWithPaths(t)
mergeAdding(t, open, "modules/sensors", "3da80a4b00aa")
if _, err := assign(ctx, open, "laptop", "sensors"); err != nil {
t.Fatal(err)
}
failedBuild(t, open, "b-modules/sensors")
settlePending(ctx, open, time.Now())
row := pendingOf(t, open)[0]
settlePending(ctx, open, time.Now().Add(inventory.KeptFor+time.Hour))
if rowOf(t, open, row.ID).Raised == nil || !conditionOpen(t, row) {
t.Fatal("a raised row was pruned, or its condition closed, while the condition was open")
}
if _, err := unassign(ctx, open, "laptop", "sensors"); err != nil {
t.Fatal(err)
}
settlePending(ctx, open, time.Now())
settlePending(ctx, open, time.Now().Add(inventory.KeptFor+time.Hour))
if known, err := inv.PendingKnown(ctx, []int64{row.ID}); err != nil || known[row.ID] {
t.Fatalf("a cleared row outlived %s: %v", inventory.KeptFor, err)
}
// On anchor, then anchor removed: the row goes, and its condition with it at the next tick.
if _, err := assign(ctx, open, "anchor", "sensors"); err == nil {
t.Fatal("known and not built was not refused")
}
q, err := inv.RecordPending(ctx, inventory.PendingAssignment{Node: "anchor", Module: "sensors", Build: "b-x"})
if err != nil {
t.Fatal(err)
}
if _, err := inv.SettlePending(ctx, q.ID, inventory.PendingWaiting, inventory.PendingExpired, "boom"); err != nil {
t.Fatal(err)
}
settlePending(ctx, open, time.Now())
q.Node = "anchor"
if !conditionOpen(t, q) {
t.Fatal("not raised")
}
if _, err := inv.RemoveNodeForTest(ctx, "anchor"); err != nil {
t.Fatal(err)
}
settlePending(ctx, open, time.Now())
if conditionOpen(t, q) {
t.Fatal("a removed machine's condition stayed open")
}
}
// **A claim left by a controller that stopped** (second review): the tick settles it from what the mesh
// holds — back to waiting when the module is not assigned there, applied when it is — and leaves a fresh
// claim alone.
func TestAStaleClaimIsSettledFromWhatTheMeshHolds(t *testing.T) {
open := aCatalogueMesh(t)
ctx := t.Context()
inv := open.inventory
claimed := func(node string) inventory.PendingAssignment {
p, err := inv.RecordPending(ctx, inventory.PendingAssignment{Node: node, Module: "sensors", Build: "b-x"})
if err != nil {
t.Fatal(err)
}
if ok, err := inv.ClaimPending(ctx, p.ID); err != nil || !ok {
t.Fatalf("claim: %v %v", ok, err)
}
return p
}
notAssigned, assigned := claimed("laptop"), claimed("anchor")
register(t, open, catalogue.Manifest{Module: "unrelated", Version: "1"})
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "sensors", Version: "1"}, inventory.Source{}); err != nil {
t.Fatal(err)
}
if _, err := inv.Assign(ctx, "anchor", "sensors"); err != nil {
t.Fatal(err)
}
settlePending(ctx, open, time.Now())
if rowOf(t, open, notAssigned.ID).State != inventory.PendingApplying || rowOf(t, open, assigned.ID).State != inventory.PendingApplying {
t.Fatal("a fresh claim was settled")
}
settlePending(ctx, open, time.Now().Add(claimStaleAfter+time.Minute))
if got := rowOf(t, open, assigned.ID); got.State != inventory.PendingApplied {
t.Fatalf("a stale claim of an assigned module: %+v", got)
}
// Released to waiting, and in the same tick made, since sensors is registered now.
if got := rowOf(t, open, notAssigned.ID); got.State != inventory.PendingApplied ||
!slices.Contains(assignedTo(t, open, "laptop"), "sensors") {
t.Fatalf("a stale claim of a module not assigned: %+v", got)
}
}
// **A waited build whose asker stopped waiting is asked, outcome unknown** (second review): it may still run,
// so it reads as in flight until its outcome or its bound, never as not asked; a build never handed over
// is not asked.
func TestABuildNoLongerWaitedForIsStillInFlight(t *testing.T) {
open := aCatalogueMesh(t)
ctx := t.Context()
inv := open.inventory
for _, id := range []string{"build-timeout", "build-nothandedover"} {
dir := "modules/" + strings.TrimPrefix(id, "build-")
if err := inv.RecordBuildRequest(ctx, inventory.BuildRequest{ID: id, Repository: "novox/mesh-catalog",
Seat: "git", Path: dir, Ref: "main", For: "build"}); err != nil {
t.Fatal(err)
}
}
markWaitFailed(ctx, "build-timeout", errors.New("no build machine answered within 10m0s"))
markWaitFailed(ctx, "build-nothandedover", fmt.Errorf("%w: cannot submit a build: nats: timeout", link.ErrNotHandedOver))
said, err := assign(ctx, open, "laptop", "timeout")
if err != nil || !strings.Contains(said, "being built") || !strings.Contains(said, "kept as pending") {
t.Fatalf("a build no longer waited for: %q %v", said, err)
}
if _, err := assign(ctx, open, "laptop", "nothandedover"); err == nil || !strings.Contains(err.Error(), "was not handed over") {
t.Fatalf("a build never handed over: %v", err)
}
settlePending(ctx, open, time.Now().Add(buildRequestBound+time.Minute))
rows, _ := inv.PendingFor(ctx, "laptop", "timeout")
if len(rows) != 1 || rows[0].State != inventory.PendingExpired || !strings.Contains(rows[0].Note, "its asker stopped waiting") {
t.Fatalf("past the bound: %+v", rows)
}
}
-312
View File
@@ -1,312 +0,0 @@
package main
import (
"context"
"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"
)
// A wait for a person is not a failure, and one module's verdict is not every module's (novox/hq ADR 0254,
// issue 318).
func relogin(module, account string) inventory.ResourceHealth {
return inventory.ResourceHealth{Module: module, Resource: module + "." + account, Kind: link.KindAccount, Target: account,
Account: account, State: link.StateUnhealthy, Reason: link.ReasonRelogin + ": " + account + " is in the group " + module +
", and its running session began before it was; log out of every session and in again, or reboot"}
}
func userUnit(module, account string) inventory.ResourceHealth {
return inventory.ResourceHealth{Module: module, Resource: module + ".daemon", Kind: link.KindUnit,
Target: module + "-daemon.service", Account: account, State: link.StateUnhealthy,
Reason: "failed in the account's own service manager (exit-code)"}
}
// Only what depends on the new login alone is a wait; anything else unhealthy of the module is judged as
// before, so that a fault is never excused.
func TestOnlyWhatDependsOnTheNewLoginIsAWait(t *testing.T) {
notInGroup := relogin("lights", "operator")
notInGroup.Reason = "not in the group lights: the apply has not put operator there; its outcome says why"
systemUnit := userUnit("lights", "operator")
systemUnit.Account = ""
otherAccount := userUnit("lights", "guest")
container := inventory.ResourceHealth{Module: "lights", Resource: "lights.web", Kind: "container", Target: "lights",
State: link.StateUnhealthy, Reason: "down"}
otherModule := userUnit("sound", "operator")
starting := userUnit("lights", "operator")
starting.State = link.StateStarting
byTarget := relogin("lights", "operator")
byTarget.Account = "" // an account said by its target alone is still that account
for _, c := range []struct {
name string
rs []inventory.ResourceHealth
waits bool
}{
{"the account alone", []inventory.ResourceHealth{relogin("lights", "operator")}, true},
{"the account and its unit in that account's manager", []inventory.ResourceHealth{relogin("lights", "operator"),
userUnit("lights", "operator")}, true},
{"an account named by its target", []inventory.ResourceHealth{byTarget, userUnit("lights", "operator")}, true},
{"another module's unit is not this module's", []inventory.ResourceHealth{relogin("lights", "operator"), otherModule}, true},
{"a unit still starting is no fault", []inventory.ResourceHealth{relogin("lights", "operator"), starting}, true},
{"a unit and no account", []inventory.ResourceHealth{userUnit("lights", "operator")}, false},
{"an account not in its group", []inventory.ResourceHealth{notInGroup, userUnit("lights", "operator")}, false},
{"a unit whose manager is not said", []inventory.ResourceHealth{relogin("lights", "operator"), systemUnit}, false},
{"a unit in another account's manager", []inventory.ResourceHealth{relogin("lights", "operator"), otherAccount}, false},
{"a container beside the wait", []inventory.ResourceHealth{relogin("lights", "operator"), container}, false},
} {
said, waits := personWait("lights", "laptop", c.rs)
if waits != c.waits {
t.Errorf("%s: waits %v; want %v", c.name, waits, c.waits)
continue
}
if waits && !strings.HasPrefix(said, "relogin needed on laptop: lights waits for a new login of operator") {
t.Errorf("%s: said %q", c.name, said)
}
}
}
// The gate reads a wait for a person as its own reading, never a fault, and passes with it; the same module
// with a container down beside it is not yet healthy, as before.
func TestAWaitForAPersonIsAPassCarriedAlong(t *testing.T) {
now := time.Now()
since := now.Add(-time.Minute)
account := relogin("lights", "operator")
f := gateFacts{now: now, health: map[string]inventory.NodeHealth{"laptop": {Node: "laptop", HeardAt: now,
Resources: []inventory.ResourceHealth{account, userUnit("lights", "operator")}}},
groupsAdded: map[string]bool{"lights": true}}
if h, why := moduleHealthWord("lights", "laptop", since, f); h != healthPerson || !strings.Contains(why, "relogin needed on laptop") {
t.Fatalf("a wait for a new login reads %v %q; want a wait for a person", h, why)
}
// **Only a move that put the account in a new group is excused** (issue 318 review): a later build that
// adds no group did not bring the wait, and is judged as before.
f.groupsAdded["lights"] = false
if h, why := moduleHealthWord("lights", "laptop", since, f); h != healthNotYet {
t.Fatalf("a wait the move did not bring reads %v %q; want not yet", h, why)
}
f.groupsAdded["lights"] = true
h := f.health["laptop"]
h.Resources = append(h.Resources, inventory.ResourceHealth{Module: "lights", Resource: "lights.web", Kind: "container",
Target: "lights", State: link.StateUnhealthy, Reason: "down"})
f.health["laptop"] = h
if got, why := moduleHealthWord("lights", "laptop", since, f); got != healthNotYet {
t.Fatalf("a container down beside the wait reads %v %q; want not yet", got, why)
}
}
// The module's condition says the wait in one sentence for a person, and clears on the first statement that
// no longer says it — said as the module's own fault when its unit still fails after the new login.
func TestTheReloginConditionSaysTheWaitAndClearsAfterTheLogin(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
k, _ := withConditionsInMemory(t)
at := h0
say := func(rs ...link.ResourceHealth) {
t.Helper()
at = at.Add(time.Second)
for i := range rs {
rs[i].Since = at
}
if err := stateHealth(ctx, open.inventory, k, "laptop", link.Health{Contract: link.ReadinessContract, At: at,
Resources: rs}, at); err != nil {
t.Fatal(err)
}
}
asLink := func(r inventory.ResourceHealth) link.ResourceHealth {
return link.ResourceHealth{Module: r.Module, Resource: r.Resource, Kind: r.Kind, Target: r.Target, State: r.State,
Reason: r.Reason, Account: r.Account}
}
account, unit := asLink(relogin("lights", "operator")), asLink(userUnit("lights", "operator"))
openNow := func() []conditions.Condition {
t.Helper()
list, err := k.Open(ctx)
if err != nil {
t.Fatal(err)
}
return list
}
say(account, unit)
say(account, unit)
list := openNow()
if len(list) != 1 || list[0].Key != "module.lights.laptop.relogin-needed" {
t.Fatalf("raised %+v; want lights' relogin-needed alone", list)
}
c := list[0]
if c.Severity != conditions.Warning || c.Resolver != conditions.ResolverOperator ||
!strings.HasPrefix(c.Summary, "relogin needed on laptop:") {
t.Fatalf("the condition is %s %s %q", c.Severity, c.Resolver, c.Summary)
}
// After the new login the account is healthy, and the unit, still failing, is the module's own fault.
account.State, account.Reason = link.StateHealthy, ""
say(account, unit)
list = openNow()
if len(list) != 1 || list[0].Key != "module.lights.laptop.unhealthy" {
t.Fatalf("after the login: %+v; want lights' own unhealthy condition alone", list)
}
unit.State, unit.Reason = link.StateHealthy, ""
say(account, unit)
if list = openNow(); len(list) != 0 {
t.Fatalf("after the unit runs: %+v; want nothing open", list)
}
}
// A module that is healthy on its own keeps its pass when another module of the same send fails at the
// bound: it is neither put back nor left without a verdict for every other walk to wait on.
func TestAModuleHealthyOnItsOwnKeepsItsPassWhenItsSendFails(t *testing.T) {
b := aBacklog(t)
ctx := t.Context()
inv := b.open.inventory
releaseHeard = func(context.Context, *stores) (map[string]bool, error) {
return map[string]bool{"laptop": true}, nil
}
backlogFacts := gatherGateFacts
gatherGateFacts = func(ctx context.Context, open *stores, component string) (gateFacts, error) {
f, err := backlogFacts(ctx, open, component)
f.health = map[string]inventory.NodeHealth{"laptop": {Node: "laptop", HeardAt: time.Now(),
Resources: []inventory.ResourceHealth{
{Module: "app", Resource: "app.web", Kind: "container", Target: "app", State: link.StateHealthy},
{Module: "late", Resource: "late.web", Kind: "container", Target: "late", State: link.StateUnhealthy,
Reason: "down"}}}}
return f, err
}
gateEvery, gateBound = 0, 300*time.Millisecond
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
advancePlans(ctx, b.open)
if p := b.release(t); p.State != inventory.PlanRolling {
break
}
time.Sleep(20 * time.Millisecond)
}
p := b.release(t)
if p.State != inventory.PlanFailed {
t.Fatalf("the walk is %s: %s; want it failed on late", p.State, p.Note)
}
if v, found, err := inv.GateOf(ctx, "build-app-c2"); err != nil || !found || v.Verdict != inventory.GatePassed ||
!strings.Contains(v.Why, "on its own") {
t.Fatalf("app's verdict: %+v (found %v, %v); want its own pass kept", v, found, err)
}
if failed, _ := inv.GateFailed(ctx, "build-late-c2"); !failed {
t.Fatal("late's build is not marked failed at its gate")
}
current, _ := inv.CurrentBuilds(ctx)
if current["app"].Commit != "c2" || current["late"].Commit != "c1" {
t.Fatalf("registered: app %s, late %s; want app kept at c2 and late put back to c1", current["app"].Commit,
current["late"].Commit)
}
if !strings.Contains(p.Note, "passed on their own and kept: app") {
t.Errorf("the walk does not say what kept its pass: %s", p.Note)
}
}
// What the operator reads of the wait (ADR 0253, ADR 0254): it needs them, so it is never quiet, and it offers
// no button, since nothing but their own new login can do it.
func TestTheReloginConditionNeedsTheOperatorAndOffersNoButton(t *testing.T) {
o := reloginObservation("openrazer", "g14", "relogin needed on g14: …", "operator", []inventory.ResourceHealth{
relogin("openrazer", "operator"), userUnit("openrazer", "operator")})
plainExample(t, o, "openrazer waits for a new login on g14",
"Needs you: log out of g14 completely and log in again, or restart it. Openrazer put your account in a "+
"group it needs. You logged in before that, so openrazer cannot run until you log in again. Its "+
"update is in place and nothing was undone.")
if len(o.Actions) != 0 || o.Needs == "" {
t.Errorf("relogin: needs %q, actions %+v; want needs and no button", o.Needs, o.Actions)
}
// **Not "your account" when it is not the operator's** (issue 318 review).
other := reloginObservation("openrazer", "g14", "relogin needed on g14: …", "operator", []inventory.ResourceHealth{
relogin("openrazer", "guest"), userUnit("openrazer", "guest")})
plainExample(t, other, "openrazer waits for a new login on g14",
"Needs you: have the account it names log out of g14 completely and log in again, or restart g14. Openrazer "+
"put an account on g14 in a group it needs. That account logged in before that, so openrazer cannot run "+
"until it logs in again. Its update is in place and nothing was undone.")
if unknown := reloginObservation("openrazer", "g14", "…", "", []inventory.ResourceHealth{relogin("openrazer", "operator")}); strings.Contains(unknown.Explanation, "your account") {
t.Errorf("an operator not known is still told it is their account: %q", unknown.Explanation)
}
// The kind's own wording, for a condition raised without words, names the module whole, dots and all,
// and never claims the account is the operator's.
w := plainWordings[kindReloginNeeded](conditions.Observation{Scope: conditions.ScopeModule, ID: "razer.lights.g14", Machine: "g14"})
if w.Headline != "razer.lights waits for a new login on g14" || w.Needs == "" || len(w.Actions) != 0 ||
strings.Contains(w.Explanation, "your account") {
t.Errorf("the kind's wording: %+v", w)
}
}
// When a module not working turns into one waiting for a new login, and back, the clearing line says what it
// became, never that it works again (issue 318 review).
func TestAConditionThatBecameTheOtherKindSaysSoWhenItClears(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
store := conditions.NewInMemory()
told := &conditions.Told{}
k := conditions.NewKeeper(ctx, conditions.Options{Store: store, History: store, Teller: told})
t.Cleanup(func() { k.Close(context.Background()) })
at := h0
say := func(rs ...link.ResourceHealth) {
t.Helper()
at = at.Add(time.Second)
for i := range rs {
rs[i].Since = at
}
if err := stateHealth(ctx, open.inventory, k, "laptop", link.Health{Contract: link.ReadinessContract, At: at,
Resources: rs}, at); err != nil {
t.Fatal(err)
}
}
unit := link.ResourceHealth{Module: "lights", Resource: "lights.daemon", Kind: link.KindUnit, Target: "lights.service",
Account: "operator", State: link.StateUnhealthy, Reason: "failed in the account's own service manager (exit-code)"}
account := link.ResourceHealth{Module: "lights", Resource: "lights.operator", Kind: link.KindAccount, Target: "operator",
Account: "operator", State: link.StateUnhealthy, Reason: link.ReasonRelogin + ": operator is in the group lights"}
say(unit)
say(unit) // lights not working
say(account, unit)
say(account, unit) // now it waits for a login
var resolved []string
for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline) && len(resolved) == 0; {
time.Sleep(20 * time.Millisecond)
for _, e := range told.Said() {
if e.Change == conditions.ChangeCleared {
resolved = append(resolved, e.Condition.Key+": "+e.Condition.Resolved)
// The clearing line is held to the plain rule too (ADR 0253).
w := conditions.Words{Headline: e.Condition.Headline, Explanation: e.Condition.Explanation,
Resolved: e.Condition.Resolved, Needs: e.Condition.Needs}
if why, ok := conditions.PlainWords(w, "laptop"); !ok {
t.Errorf("%s: its clearing words are not plain: %s", e.Condition.Key, why)
}
}
}
}
if len(resolved) != 1 || !strings.Contains(resolved[0], "module.lights.laptop.unhealthy: lights on laptop now waits only for a new login") {
t.Fatalf("cleared %v; want the not-working condition cleared as now waiting for a login", resolved)
}
}
// Which moves add an account group, read from the builds' manifests (issue 318 review).
func TestOnlyAMoveThatAddsAnAccountGroupCanBringAWait(t *testing.T) {
user := func(groups ...any) catalogue.Manifest {
return catalogue.Manifest{Module: "lights", Resources: []map[string]any{{"id": "operator", "type": "user",
"name": "operator", "groups": groups}}}
}
none := catalogue.Manifest{Module: "lights"}
for _, c := range []struct {
name string
from catalogue.Manifest
had bool
to catalogue.Manifest
addsSome bool
}{
{"a group added", none, true, user("lights"), true},
{"the same group kept", user("lights"), true, user("lights"), false},
{"a second group added", user("lights"), true, user("lights", "video"), true},
{"a group taken away", user("lights", "video"), true, user("lights"), false},
{"new to the machine, with a group", none, false, user("lights"), true},
{"new to the machine, no group", none, false, none, false},
} {
if got := addsAccountGroups(c.from, c.had, c.to); got != c.addsSome {
t.Errorf("%s: adds %v; want %v", c.name, got, c.addsSome)
}
}
}
-909
View File
@@ -1,909 +0,0 @@
package main
// The plain words of every condition kind the controller raises (novox/hq ADR 0253): what the operator
// reads — a headline, one or two sentences on what it means and whether to act, and the line said when it
// clears. The summary beside them keeps the ids, commits and verbs for whoever looks closer.
//
// A kind whose words need more than the condition's subject (a walk's modules, a unit's name, a hand-act's
// cause) is worded where it is raised, and its kind here is the fallback. Every kind raised in the test
// suite is held to having words, and to their being plain (plain_words_test.go).
import (
"fmt"
"sort"
"strings"
"time"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
type words = conditions.Words
// worded is a wording that needs only the condition's subject.
func worded(fn func(o conditions.Observation) words) func(conditions.Observation) words { return fn }
func init() {
for kind, fn := range plainWordings {
conditions.Wording(kind, fn)
}
}
// machineOr is the machine a condition concerns, or what to say when none is named.
func machineOr(o conditions.Observation, none string) string {
if o.Machine != "" {
return o.Machine
}
return none
}
// idPart is the n-th dotted part of a condition's id, or "".
func idPart(o conditions.Observation, n int) string {
parts := strings.Split(o.ID, ".")
if n < len(parts) {
return parts[n]
}
return ""
}
// dataWords is "<item> of <module>" for a data condition keyed by machine, module and item.
func dataWords(o conditions.Observation) (what, module string) {
module, item := idPart(o, 1), strings.Join(strings.Split(o.ID, ".")[min(2, len(strings.Split(o.ID, "."))):], " ")
switch {
case module != "" && item != "":
return item + " of " + module, module
case module != "":
return "data of " + module, module
}
return "data", "a module"
}
var plainWordings = map[string]func(conditions.Observation) words{
// Machines and their node-engine.
"silent": worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
w := words{Headline: m + " is not answering",
Explanation: fmt.Sprintf("The mesh has not heard from %s for several minutes. A machine asleep or away is "+
"normal.", m),
Resolved: m + " answers again"}
if o.Severity == conditions.Urgent {
w.Needs = fmt.Sprintf("check that %s is on and online.", m)
w.Explanation = fmt.Sprintf("The mesh has not heard from %s for half an hour, and the mesh is run from it: "+
"nothing changes anywhere until it is back.", m)
}
return w
}),
"sent-not-reported": worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
return words{Headline: m + " has not confirmed a change",
Explanation: fmt.Sprintf("The mesh sent %s new instructions and %s has not said it applied them yet. "+
"It usually catches up by itself.", m, m),
Resolved: m + " applied the change"}
}),
"tools-silent": worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
return words{Headline: "Tools on " + m + " are not answering",
Explanation: fmt.Sprintf("The tool runner on %s has not checked in, so nothing can be done on %s through "+
"the mesh until it does. A machine asleep is normal.", m, m),
Resolved: "Tools on " + m + " answer again"}
}),
kindAwaitingPush: worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
return words{Headline: m + " waits for new instructions",
Explanation: fmt.Sprintf("%s's new instructions go out with the next push. Nothing is broken meanwhile.",
conditions.Capital(m)),
Resolved: m + " got its new instructions"}
}),
"declaration-refused": worded(func(o conditions.Observation) words {
if o.Scope == conditions.ScopeMesh {
return words{Headline: "No machine can get new instructions",
Needs: "decide whether to undo the last catalogue change; the details say what fails.",
Explanation: "The mesh cannot work out its private network, so no machine's instructions can be " +
"made. Machines keep running what they have.",
Resolved: "Machines can get new instructions again"}
}
m := machineOr(o, "a machine")
return words{Headline: m + " cannot get new instructions",
Needs: "decide whether to undo the last catalogue change; the details say what fails.",
Explanation: fmt.Sprintf("What the mesh would send %s is wrong and would be refused, so nothing new "+
"reaches it. It keeps running what it has.", m),
Resolved: m + " can get new instructions again"}
}),
kindAgentCanBecomeRoot: worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
return words{Headline: "Sessions on " + m + " could become root",
Needs: "take the sessions' own account out of every group and rule that grants root; the details say which.",
Explanation: fmt.Sprintf("Assistant sessions on %s run under an account of their own, so that none "+
"can take over the machine without you. The machine cannot show that this holds now, so an answer "+
"from your phone authorises nothing there until it does.", m),
Resolved: "Sessions on " + m + " cannot become root again"}
}),
"own-address-banned": worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
return words{Headline: m + " has banned the mesh",
Needs: fmt.Sprintf("lift the ban on %s, or wait for it to expire.", m),
Explanation: fmt.Sprintf("The intrusion protection on %s banned one of the mesh's own addresses, so the "+
"mesh is locked out of it.", m),
Resolved: m + " no longer bans the mesh"}
}),
"core-behind": worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
return words{Headline: m + " runs an old node-engine",
Explanation: fmt.Sprintf("%s runs an older build of the mesh's own software than the mesh holds. It "+
"works, and gets the newer one with a later delivery.", conditions.Capital(m)),
Resolved: m + " runs the current node-engine"}
}),
kindMachineUnits: worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
return words{Headline: "Failed services on " + m,
Needs: fmt.Sprintf("mend or remove them on %s, or silence this if they do not matter.", m),
Explanation: "No module manages them, so the mesh does not repair them.",
Resolved: "No failed services on " + m + " any more", Actions: []conditions.Action{conditions.SilenceAction(o.Key())}}
}),
kindMachineNetwork: worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
return words{Headline: m + " has a network problem",
Explanation: fmt.Sprintf("%s's own network check failed: its name lookups, tunnel, bus connection or "+
"route. It often passes again once a network change settles.", conditions.Capital(m)),
Resolved: m + "'s network is fine again"}
}),
kindNetworkRewritten: worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
return words{Headline: m + "'s network settings were changed",
Explanation: fmt.Sprintf("Something other than the mesh rewrote %s's network settings, often a VPN "+
"client. Mesh names may not resolve there until it is undone.", m),
Resolved: m + "'s network settings are the mesh's again"}
}),
kindNetworkUnreachable: worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
return words{Headline: m + " cannot be reached",
Explanation: fmt.Sprintf("Other machines cannot reach %s over the mesh's network. A machine away or "+
"asleep is normal.", m),
Resolved: m + " can be reached again"}
}),
// A pending assignment that was not made (novox/hq issue 325, ADR 0261).
kindPendingEnded: worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
module := idPart(o, 1)
if module == "" {
module = "a module"
}
return words{Headline: module + " was not put on " + m,
Needs: "decide whether to build it again or take the assignment back; the details say why.",
Explanation: fmt.Sprintf("An assignment of %s to %s waited for its build, and the build did not "+
"register it, so it was not made.", module, m),
Resolved: "Resolved: " + module + " on " + m + " is settled"}
}),
kindBindingKept: worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
return words{Headline: "A module's data source is held on " + m,
Needs: "decide where its data lives; the details name both places.",
Explanation: fmt.Sprintf("A module on %s should switch to another provider of its data, and the mesh "+
"kept the old one because switching would leave the data behind.", m),
Resolved: "Resolved: the data source on " + m + " is decided"}
}),
kindBindingMoving: worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
return words{Headline: "A module's data source moves on " + m,
Needs: "move its data to the new provider before the next push.",
Explanation: fmt.Sprintf("At the next push a module on %s switches to another provider of its data.", m),
Resolved: "Resolved: the data source move on " + m + " is settled"}
}),
kindBindingMoved: worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
return words{Headline: "A module's data source moved on " + m,
Needs: "check that it found its data.",
Explanation: fmt.Sprintf("A module on %s switched to another provider, and its data may still be with the old one.", m),
Resolved: "Resolved: the data source move on " + m + " is settled"}
}),
// Modules and providers.
kindModuleUnhealthy: worded(func(o conditions.Observation) words {
thing := conditions.ThingWords(o)
return words{Headline: conditions.Capital(thing) + " is not working",
Explanation: fmt.Sprintf("%s is not healthy. It clears as soon as it runs again.", conditions.Capital(thing)),
Resolved: conditions.Capital(thing) + " works again"}
}),
kindReloginNeeded: worded(func(o conditions.Observation) words {
// A module's name may hold dots: it is the id without its machine.
module := ""
if o.Scope == conditions.ScopeModule && o.Machine != "" {
module = strings.TrimSuffix(o.ID, "."+o.Machine)
}
return reloginWords(orModule(module), machineOr(o, "a machine"), false)
}),
kindUsedAsFound: worded(func(o conditions.Observation) words {
module := ""
if o.Scope == conditions.ScopeModule && o.Machine != "" {
module = strings.TrimSuffix(o.ID, "."+o.Machine)
}
w := usedAsFoundObservation(orModule(module), machineOr(o, "a machine"), o.Summary, nil)
return words{Headline: w.Headline, Explanation: w.Explanation, Needs: w.Needs, Resolved: w.Resolved}
}),
kindProviderFailing: worded(func(o conditions.Observation) words {
thing, consumer := conditions.ThingWords(o), idPart(o, 2)
if consumer == "" {
consumer = "a module"
}
return words{Headline: conditions.Capital(thing) + " keeps failing " + consumer,
Explanation: fmt.Sprintf("%s provides something %s needs, such as a database, and keeps failing to set "+
"it up. It tries again by itself.", conditions.Capital(thing), consumer),
Resolved: conditions.Capital(thing) + " serves " + consumer + " again"}
}),
kindProviderSilent: worded(func(o conditions.Observation) words {
thing := conditions.ThingWords(o)
return words{Headline: conditions.Capital(thing) + " went quiet",
Explanation: fmt.Sprintf("%s said it keeps failing a module that depends on it, and then stopped "+
"reporting.", conditions.Capital(thing)),
Resolved: conditions.Capital(thing) + " reports again"}
}),
kindRetireWaiting: worded(func(o conditions.Observation) words {
thing := conditions.ThingWords(o)
return words{Headline: "Retiring data on " + thing + " waits for you",
Needs: "approve or reject retiring it; the details list what would go.",
Explanation: fmt.Sprintf("%s keeps data the mesh no longer asks for, too much to retire on its own.",
conditions.Capital(thing)),
Resolved: "Resolved: the retirement on " + thing + " is decided"}
}),
kindRetireRejected: worded(func(o conditions.Observation) words {
thing := conditions.ThingWords(o)
return words{Headline: "Unused data kept on " + thing,
Explanation: fmt.Sprintf("You chose to keep data on %s that the mesh no longer asks for; it is still there.", thing),
Resolved: "Resolved: the unused data on " + thing + " is gone"}
}),
kindCleanupWaiting: worded(func(o conditions.Observation) words {
var where string
if o.Scope == conditions.ScopeProvider {
where = conditions.ThingWords(o)
} else {
what, _ := dataWords(o)
where = what + " on " + machineOr(o, "a machine")
}
return words{Headline: "Retired data waits for cleanup",
Needs: "delete it or keep it; nothing is deleted without your word.",
Explanation: fmt.Sprintf("Retired data of %s has been kept for over thirty days.", where),
Resolved: "Resolved: the retired data is dealt with"}
}),
// Data.
kindDataUnmeasured: worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
return words{Headline: "Data on " + m + " could not be measured",
Explanation: fmt.Sprintf("The backup holder on %s did not say what it measured, so nothing is known about "+
"the data there. The next measurement may settle it.", m),
Resolved: "Data on " + m + " is measured again"}
}),
kindProtectionMissing: worded(func(o conditions.Observation) words {
what, _ := dataWords(o)
return words{Headline: conditions.Capital(what) + " is unprotected",
Needs: "check the storage it is on.",
Explanation: fmt.Sprintf("%s on %s is said to be protected by redundant storage, and it is not on any "+
"the mesh can see.", conditions.Capital(what), machineOr(o, "its machine")),
Resolved: conditions.Capital(what) + " is protected again"}
}),
kindDataMissing: worded(func(o conditions.Observation) words {
what, _ := dataWords(o)
return words{Headline: conditions.Capital(what) + " is gone",
// No silence from a click: data loss is never waved away from a notification (ADR 0258).
Needs: "restore it from a backup, or silence this " + FromMeshMCPServer,
Explanation: fmt.Sprintf("Where %s is kept on %s, nothing exists any more.", what, machineOr(o, "its machine")),
Resolved: conditions.Capital(what) + " is back"}
}),
kindDataShrank: worded(func(o conditions.Observation) words {
what, _ := dataWords(o)
m := machineOr(o, "its machine")
if idPart(o, 1) == "dataset" {
what = "a dataset"
}
return words{Headline: conditions.Capital(what) + " shrank on " + m,
// No silence from a click: data loss is never waved away from a notification (ADR 0258).
Needs: "restore the last good copy from a backup, or silence this " + FromMeshMCPServer,
Explanation: "More than half of what it held is gone within a week.",
Resolved: "Resolved: the shrinking on " + m + " is explained"}
}),
kindDataQuiet: worded(func(o conditions.Observation) words {
what, _ := dataWords(o)
return words{Headline: conditions.Capital(what) + " stopped changing",
Needs: "check that what writes it still runs.",
Explanation: fmt.Sprintf("%s on %s is normally written all the time and has not been for longer than "+
"usual.", conditions.Capital(what), machineOr(o, "its machine")),
Resolved: conditions.Capital(what) + " is written again"}
}),
kindBackupStale: worded(func(o conditions.Observation) words {
what, _ := dataWords(o)
m := machineOr(o, "its machine")
return words{Headline: "No recent backup of " + what,
Needs: fmt.Sprintf("check that backups run on %s.", m),
Explanation: fmt.Sprintf("%s on %s has no good backup recent enough. If the machine failed now, what "+
"changed since would be lost.", conditions.Capital(what), m),
Resolved: conditions.Capital(what) + " is backed up again"}
}),
kindArrayDegraded: worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
return words{Headline: "Storage on " + m + " is degraded",
Needs: fmt.Sprintf("check the disks of %s.", m),
Explanation: "Its redundant storage lost a disk or is rebuilding; the data on it is less protected until it is whole.",
Resolved: "Storage on " + m + " is whole again"}
}),
kindEmptyReplacement: worded(func(o conditions.Observation) words {
module := idPart(o, 1)
if o.Scope == conditions.ScopeProvider {
module = idPart(o, 2)
}
if module == "" {
module = "a module"
}
return words{Headline: conditions.Capital(module) + " runs on empty data",
Needs: "decide where its data lives: move the data, or move it back.",
Explanation: fmt.Sprintf("%s on %s is using an empty copy of its data while the full copy is kept "+
"elsewhere.", conditions.Capital(module), machineOr(o, "its machine")),
Resolved: conditions.Capital(module) + " has its data again"}
}),
kindDataHeldTwice: worded(func(o conditions.Observation) words {
consumer := idPart(o, 1)
if consumer == "" {
consumer = "a module"
}
return words{Headline: conditions.Capital(consumer) + "'s data is on several machines",
Needs: "decide which copy to keep.",
Explanation: fmt.Sprintf("%s has live data on more than one machine and uses only one.", conditions.Capital(consumer)),
Resolved: conditions.Capital(consumer) + "'s data is in one place again"}
}),
// Deliveries and walks.
"stalled": worded(func(o conditions.Observation) words {
if o.Scope == conditions.ScopeDelivery {
return words{Headline: "A delivery is held too long",
Explanation: "A delivery has been held past its time limit.", Needs: conditions.ResolverNeeds(o),
Resolved: "Resolved: the delivery moves again"}
}
return words{Headline: "A delivery is stuck halfway",
Explanation: "Its walk across the machines has not moved for longer than usual. Nothing is lost.",
Resolved: "Resolved: the delivery moves again"}
}),
kindBatchNotCut: worded(func(o conditions.Observation) words {
return words{Headline: "Merged changes are not being delivered",
Explanation: "Merges collected for one delivery should have been planned and were not. Nothing is lost.",
Resolved: "Resolved: the merged changes are being delivered"}
}),
kindBatchBehindWalk: worded(func(o conditions.Observation) words {
return words{Headline: "Merged changes wait behind a slow delivery",
Explanation: "Merges collected for the next delivery wait for the delivery before them, which is taking " +
"longer than it should. Nothing is lost.",
Resolved: "Resolved: the merged changes no longer wait"}
}),
kindWalkWaiting: worded(func(o conditions.Observation) words {
return words{Headline: "A delivery is waiting to start",
Explanation: "A merged change is built, and mesh-delivery (the module that decides when a delivery goes " +
"out) has not let it start yet.",
Resolved: "Resolved: the delivery is no longer waiting"}
}),
"merge-not-acted": worded(func(o conditions.Observation) words {
return words{Headline: "A merge was never picked up",
Explanation: "A pull request was merged, and the controller never heard of it, so nothing was built or " +
"sent. The mesh catches up on missed merges by itself.",
Resolved: "Resolved: the merge was picked up"}
}),
"ask-lost": worded(func(o conditions.Observation) words {
return words{Headline: "A build has no result",
Explanation: "A build was asked for and never answered. It is asked again with the next delivery.",
Resolved: "Resolved: the build has its result"}
}),
kindRolledBack: worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
module := idPart(o, 0)
if o.Scope != conditions.ScopeBuild && o.Scope != conditions.ScopeCore {
module = "a module"
}
if o.Scope == conditions.ScopeCore {
return words{Headline: "A core update was put back on " + m,
Explanation: fmt.Sprintf("A new build of the mesh's own %s did not become healthy on %s, so the build "+
"before was put back and runs.", module, m),
Resolved: "Resolved: the core on " + m + " is healthy"}
}
return words{Headline: conditions.Capital(module) + " update put back on " + m,
Explanation: fmt.Sprintf("The new build of %s did not become healthy on %s, so the mesh put back the one "+
"before, which runs. The change is not delivered until it is fixed.", module, m),
Resolved: "Resolved: " + module + " on " + m + " is settled"}
}),
kindRollbackFailed: worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
return words{Headline: "Putting back an update failed on " + m,
Needs: fmt.Sprintf("check what runs on %s; the details say which build.", m),
Explanation: fmt.Sprintf("A build that failed on %s could not be put back to the one before.", m),
Resolved: "Resolved: " + m + " runs a good build again"}
}),
"release-held": worded(func(o conditions.Observation) words {
return releaseHeldWords(nil, o.Also)
}),
"facts-stale": worded(func(o conditions.Observation) words {
return words{Headline: "Merge checks use outdated facts",
Explanation: "The facts a merge check judges a change against are out of date; they are gathered again " +
"by themselves.",
Resolved: "Merge checks use current facts again"}
}),
// The controller and the core.
"controller-deaf": worded(func(o conditions.Observation) words {
return words{Headline: "The controller stopped listening",
Needs: "restart the controller if this stays, " + FromMeshMCPServer,
Explanation: "The controller, which coordinates the mesh, has taken no messages for minutes while some " +
"wait. Changes and repairs do not happen until it recovers.",
Resolved: "The controller listens again"}
}),
"self-check-silent": worded(func(o conditions.Observation) words {
return words{Headline: "The mesh's self-check stopped",
Needs: "restart the controller if this stays, " + FromMeshMCPServer,
Explanation: "The self-check, which looks over the whole mesh every few minutes, has not finished a run. " +
"Problems may go unnoticed until it runs again.",
Resolved: "The self-check runs again"}
}),
"watchdogs-silent": worded(func(o conditions.Observation) words {
return words{Headline: "The mesh's watchdogs stopped",
Needs: "restart the controller if this stays, " + FromMeshMCPServer,
Explanation: "The watchdogs, which notice when something expected does not happen, have not run, so " +
"missed signals are not noticed.",
Resolved: "The watchdogs run again"}
}),
"lease-lost": worded(func(o conditions.Observation) words {
return words{Headline: "The controller lost its lease",
Needs: "make sure only one controller runs; the details say which machines claim it.",
Explanation: "The controller holds a lease so that only one copy of it acts at a time. It lost or could " +
"not renew it, so two could act at once, or none.",
Resolved: "The controller holds its lease again"}
}),
"lease-split": worded(func(o conditions.Observation) words {
return words{Headline: "Two controllers may be acting",
Needs: "make sure only one controller runs; the details say which machines claim it.",
Explanation: "The lease that lets one controller act at a time does not agree with its record.",
Resolved: "One controller acts again"}
}),
"stale-writer": worded(func(o conditions.Observation) words {
return words{Headline: "Outdated instructions are being sent",
Explanation: "Something kept sending instructions older than what the machines already hold, and they " +
"were refused, which is harmless.",
Resolved: "Resolved: no more outdated instructions"}
}),
"status-slow": worded(func(o conditions.Observation) words {
return words{Headline: "The mesh answers slowly",
Explanation: "Asking the mesh for its status took longer than ten seconds. It still works, only slowly.",
Resolved: "The mesh answers quickly again"}
}),
kindCoreUnhealthy: worded(func(o conditions.Observation) words {
if o.Scope == conditions.ScopeBus {
return words{Headline: "The bus is not healthy",
Needs: "check the machine the bus runs on; the details say what fails.",
Explanation: "The bus, which carries every message in the mesh, is not healthy. Instructions, builds " +
"and tool calls may fail until it recovers.",
Resolved: "The bus is healthy again"}
}
m := machineOr(o, "its machine")
return words{Headline: "The mesh's own software fails on " + m,
Explanation: fmt.Sprintf("A part of the mesh's own software on %s is not healthy; it is restarted by itself.", m),
Resolved: "The mesh's own software on " + m + " is healthy again"}
}),
"call-hung": worded(func(o conditions.Observation) words {
return words{Headline: "A tool call is hanging",
Explanation: "A call to one of the mesh's tools has run far past its time limit; it is ended by itself.",
Resolved: "Resolved: the tool call ended"}
}),
"probe-failed": worded(func(o conditions.Observation) words {
return words{Headline: "A check of the mesh could not run",
Explanation: "One of the mesh's own checks could not run, so what it watches is unknown for now. It runs " +
"again every few minutes.",
Resolved: "The check runs again"}
}),
kindHealersBraked: worded(func(o conditions.Observation) words {
return words{Headline: "All automatic repairs stopped",
Needs: "read the details and decide whether the repairs may run again.",
Explanation: "The healers (the mesh's automatic repairs) acted more often in an hour than allowed, which " +
"looks like a loop, so all of them stopped.",
Resolved: "Automatic repairs run again"}
}),
"healer-wanted": worded(func(o conditions.Observation) words {
cause := strings.TrimPrefix(o.ID, "hand-acts.")
if o.Scope != conditions.ScopeMesh {
cause = "something"
}
return words{Headline: conditions.Capital(strings.ReplaceAll(cause, ".", " ")) + " keeps being fixed by hand",
Explanation: "A person keeps repairing this by hand, and an automatic repair is wanted for it. Nothing is " +
"broken now.",
Resolved: "Resolved: no more hand repairs of " + strings.ReplaceAll(cause, ".", " ")}
}),
// The bus and seats.
"resolver-wrong": worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
return words{Headline: "Name lookups fail on " + m,
Needs: fmt.Sprintf("check that %s is on and online.", m),
Explanation: fmt.Sprintf("The mesh's name resolver on %s does not answer machine names correctly, so "+
"machines that rely on it may not find each other.", m),
Resolved: "Name lookups work on " + m + " again"}
}),
"holder-silent": worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
seat := idPart(o, 0)
if o.Scope != conditions.ScopeSeat {
seat = "a seat"
}
return words{Headline: conditions.Capital(seat) + " on " + m + " is not answering",
Explanation: fmt.Sprintf("Whatever serves %s on %s does not answer, so its tools do nothing there. A "+
"machine asleep is normal.", seat, m),
Resolved: conditions.Capital(seat) + " on " + m + " answers again"}
}),
kindBusObjectsUnasserted: worded(func(o conditions.Observation) words {
return words{Headline: "The bus could not be fully set up",
Explanation: "When instructions were sent, the bus's queues could not all be checked; they are checked " +
"again with the next send.",
Resolved: "The bus is fully set up again"}
}),
kindBusMaintenance: worded(func(o conditions.Observation) words {
return words{Headline: "The bus is being upgraded",
Explanation: "The mesh's message system is being upgraded; some things pause until it is done.",
Resolved: "The bus upgrade is done"}
}),
kindBusStepWaiting: worded(func(o conditions.Observation) words {
return words{Headline: "Sends wait for a bus upgrade",
Needs: "start the bus upgrade " + FromMeshMCPServer,
Explanation: "A new version of the mesh's message system is built, and only a person installs it. Until " +
"then nothing else is sent to the machine that runs it.",
Resolved: "Resolved: the bus upgrade started, and sends go on"}
}),
kindBusUpgradeFailed: worded(func(o conditions.Observation) words {
return words{Headline: "The bus upgrade failed",
Needs: "decide whether to put the bus back to the version before; the details say how.",
Explanation: "The upgrade of the mesh's message system did not end healthy in time.",
Resolved: "Resolved: the bus is healthy after its upgrade"}
}),
kindConsumerBehind: worded(func(o conditions.Observation) words {
return words{Headline: "Messages pile up for a listener",
Explanation: "One of the mesh's listeners on the bus is far behind, so what it handles happens late. A " +
"healer restarts it if it stays.",
Resolved: "The listener caught up"}
}),
"consumer-wrong": worded(func(o conditions.Observation) words {
return words{Headline: "A listener on the bus is set up wrong",
Explanation: "One of the mesh's listeners on the bus is missing or not as defined; the next send sets " +
"it up again.",
Resolved: "The listener is set up right again"}
}),
"consumer-lost": worded(func(o conditions.Observation) words {
return words{Headline: "A listener on the bus is gone",
Explanation: "One of the mesh's listeners is no longer on the bus; the next send sets it up again.",
Resolved: "The listener is back"}
}),
"slow-consumer": worded(func(o conditions.Observation) words {
return words{Headline: "A listener on the bus is too slow",
Explanation: "The bus reports a listener too slow to keep up, so messages to it are late.",
Resolved: "The listener keeps up again"}
}),
kindBusReconnects: worded(func(o conditions.Observation) words {
return words{Headline: "A client keeps losing the bus",
Explanation: "One of the mesh's clients lost its connection to the bus again and again in the last hour. " +
"While it reconnects, what it says and what it is asked waits.",
Resolved: "Resolved: the client stays connected"}
}),
"max-deliveries": worded(func(o conditions.Observation) words {
return words{Headline: "A listener gave up on messages",
Needs: "deliver them again or drop them, from the mesh MCP server.",
Explanation: "A listener on the bus could not handle messages after several tries, so what they asked " +
"for was not done. The mesh keeps them until you deliver them again or drop them.",
Resolved: "Resolved: the messages given up on were delivered again or dropped"}
}),
"refused": worded(func(o conditions.Observation) words {
return words{Headline: "The bus refuses some messages",
Explanation: "The bus refused messages from a part of the mesh, so what they carried did not happen.",
Resolved: "Resolved: the bus takes the messages again"}
}),
"stream-wrong": worded(func(o conditions.Observation) words {
return words{Headline: "Part of the bus's storage is wrong",
Needs: "check the machine the bus runs on; the details say what is missing.",
Explanation: "A store the controller keeps on the bus is missing or not as defined, so what it keeps there may be lost.",
Resolved: "The bus's storage is right again"}
}),
"archives-unheld": worded(func(o conditions.Observation) words {
return words{Headline: "Kept builds are about to be cleaned up",
Explanation: "Some kept builds are not claimed by any module, so the store's cleanup would delete them.",
Resolved: "Resolved: every kept build is claimed"}
}),
"archives-missing": worded(func(o conditions.Observation) words {
return words{Headline: "Kept builds are missing",
Explanation: "Some builds the mesh relies on are no longer in the store; they are built again when needed.",
Resolved: "Resolved: the kept builds are back"}
}),
}
// humanDuration is a duration as a person says it: "56 minutes", "36 hours", "3 days".
func humanDuration(d time.Duration) string {
switch {
case d < 2*time.Minute:
return "a minute"
case d < 2*time.Hour:
return fmt.Sprintf("%d minutes", int(d.Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%d hours", int(d.Hours()))
}
return fmt.Sprintf("%d days", int(d.Hours()/24))
}
// repoName is a repository as a person says it: its name without its owner.
func repoName(repository string) string {
if i := strings.LastIndex(repository, "/"); i >= 0 {
return repository[i+1:]
}
return repository
}
// namesWords lists names as a person says them: "a", "a and b", "a, b and 2 more".
func namesWords(names []string, most int) string {
switch {
case len(names) == 0:
return ""
case len(names) == 1:
return names[0]
case len(names) <= most:
return strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1]
}
return strings.Join(names[:most], ", ") + fmt.Sprintf(" and %d more", len(names)-most)
}
// planModules are the modules a walk moves, sorted.
func planModules(p inventory.Plan) []string {
out := make([]string, 0, len(p.Modules))
for m := range p.Modules {
out = append(out, m)
}
sort.Strings(out)
return out
}
// deliveryWhat is what a delivery changes, as a person names it: its modules when they are few, else
// their count and the repository.
func deliveryWhat(modules []string, repository string) string {
if len(modules) > 0 && len(modules) <= 2 {
return namesWords(modules, 2)
}
if len(modules) > 2 {
return fmt.Sprintf("%d modules of %s", len(modules), repoName(repository))
}
return repoName(repository)
}
// deliveryName is a delivery's name in a headline: "openrazer delivery", "mesh-catalog delivery".
func deliveryName(modules []string, repository string) string {
if len(modules) > 0 && len(modules) <= 2 {
return namesWords(modules, 2) + " delivery"
}
return repoName(repository) + " delivery"
}
// walkWaitingWords explains a walk waiting for its delivery's word.
func walkWaitingWords(w waitFacts, in time.Duration, severity conditions.Severity) string {
what := deliveryWhat(w.modules, w.repository)
if severity == conditions.Urgent {
return fmt.Sprintf("The change to %s is merged and built, and mesh-delivery (the module that decides when a "+
"delivery goes out) has not let it start for %s, so mesh-delivery may be stuck.", what, humanDuration(in))
}
return fmt.Sprintf("The change to %s is merged and built, and has waited %s for mesh-delivery (the module that "+
"decides when a delivery goes out) to let it start. It becomes a question for you if it still waits after %s.",
what, humanDuration(in), humanDuration(waitUrgentAfter))
}
// waitingNeeds is what the operator does about a walk waiting past its urgent bound: nothing before it.
// Start and Stop are also asked of the operator (novox/hq ADR 0259); the condition's own words keep saying
// where they are given without a channel, and the ask's text drops that (askText).
func waitingNeeds(severity conditions.Severity) string {
if severity == conditions.Urgent {
return "start it, or stop it, " + FromMeshMCPServer
}
return ""
}
// waitingActions are the answers to a walk waiting past its urgent bound: start it, or stop it — the plan's
// own verbs, approved by the operator (novox/hq ADR 0259). None before the bound.
func waitingActions(plan string, severity conditions.Severity) []conditions.Action {
if severity != conditions.Urgent || plan == "" {
return nil
}
return []conditions.Action{
{Label: "Start", Verb: "mesh-controller.plans", Level: conditions.LevelApprove,
Arguments: map[string]string{"go": plan, "why": "", "cause": conditions.CauseOperatorAnswer}},
{Label: "Stop", Verb: "mesh-controller.plans", Level: conditions.LevelApprove,
Arguments: map[string]string{"stop": plan, "why": "", "cause": conditions.CauseOperatorAnswer}},
}
}
// moduleNeeds is what the operator can do about a module unhealthy on a machine: log in again where its
// account's groups wait for it (ADR 0252), restart a failed service, or nothing where the mesh restarts it.
// No answer is offered for a restart: a desk click performs only an acknowledgement (ADR 0258).
func moduleNeeds(node string, rs []inventory.ResourceHealth) string {
unit := ""
for _, r := range rs {
if strings.Contains(r.Reason, "relogin needed") {
return reloginNeeds(node)
}
if r.Kind == link.KindUnit && unit == "" {
unit = unitPlainWords(r.Target)
}
}
if unit != "" {
// Also asked of the operator (moduleActions); the ask's text drops where (askText).
return fmt.Sprintf("restart its service %s on %s %s", unit, node, FromMeshMCPServer)
}
return ""
}
// moduleActions are the answers to a module unhealthy on a machine: restart its failed service there,
// approved by the operator (novox/hq ADR 0259) — none when the mesh restarts it, or a new login is what it
// waits for.
func moduleActions(node string, rs []inventory.ResourceHealth) []conditions.Action {
for _, r := range rs {
if strings.Contains(r.Reason, "relogin needed") {
return nil
}
}
for _, r := range rs {
if r.Kind != link.KindUnit || r.Target == "" {
continue
}
scope := "system"
if r.Account != "" {
scope = "user"
}
return []conditions.Action{{Label: "Restart", Verb: "node-service-manager.restart", Machine: node,
Level: conditions.LevelApprove, Arguments: map[string]string{"unit": r.Target, "scope": scope}}}
}
return nil
}
// FromMeshMCPServer ends what the operator needs when no notification can do it (ADR 0258), naming the mesh MCP
// server (the glossary's word; "console" is retired): the answer is not an
// acknowledgement, so it is given where the operator is known to be the one asking, until answers are
// authorised (to-be 46 phases 5 and 6).
const FromMeshMCPServer = "from the mesh MCP server; this notification cannot do it."
// releaseHeldWords are the plain words of updates held after a walk failed: which modules wait,
// on which machines, and where the operator releases them (ADR 0258: a release is not an acknowledgement, so
// no notification gives it). Raised with the modules and machines (backlogObservation); the kind's fallback
// knows neither.
func releaseHeldWords(modules, machines []string) words {
what := "Some module updates"
headline := "Updates wait for your release"
if len(modules) > 0 {
what = "Updates of " + namesWords(modules, 3)
if h := what + " wait for your release"; len(h) <= conditions.HeadlineMax {
headline = h
} else if h := fmt.Sprintf("%d module updates wait for your release", len(modules)); len(h) <= conditions.HeadlineMax {
headline = h
}
}
where := ""
if len(machines) > 0 {
where = " on " + namesWords(machines, 4)
}
return words{Headline: headline,
Needs: "release them " + FromMeshMCPServer,
Explanation: fmt.Sprintf("%s%s wait for a person to release them, because the last walk failed."+
" They are not delivered until then, and stay held if you leave them.", what, where),
Resolved: "Resolved: the held updates are released"}
}
// reloginNeeds is what an account waiting for its groups needs (ADR 0252).
func reloginNeeds(node string) string {
return fmt.Sprintf("log out of %s completely and log in again, or restart it.", node)
}
// stalledWords are the plain words of a delivery held past its bound, as mesh-delivery says it.
func stalledWords(l stalledLine, o conditions.Observation) (headline, explanation, resolved, needs string,
actions []conditions.Action) {
repository, _, _ := strings.Cut(l.ID, "@")
name := repoName(repository)
if l.State == "unannounced" {
// A pull request the forge never announced (novox/hq issue 347): no delivery exists, so there is nothing to
// stop, release or close; a new commit on its branch is announced and checked.
long := "for too long"
if d, err := time.ParseDuration(l.For); err == nil {
long = "for " + humanDuration(d)
}
if o.Resolver == conditions.ResolverOperator {
needs = "push a new commit to its branch; the forge announces it and the mesh checks it."
}
pull := "A pull request of " + name
if l.Number > 0 {
pull = fmt.Sprintf("Pull request %s #%d", name, l.Number)
}
return fmt.Sprintf("%s has had no merge check %s", pull, long),
fmt.Sprintf("%s has been open %s on a branch that requires the merge check, and the mesh was never asked "+
"to check it: the forge never announced it. It cannot merge until it is checked.", pull, long),
fmt.Sprintf("%s has a merge check now, or is closed", pull), needs, nil
}
held := l.State
if held == "" {
held = "held"
}
long := "too long"
if d, err := time.ParseDuration(l.For); err == nil {
long = "for " + humanDuration(d)
}
if o.Resolver == conditions.ResolverOperator {
// Asked of the operator, approved on a channel that proves who answered (novox/hq ADR 0259); the
// router says where each can be answered, so the words do not.
release := conditions.Action{Label: "Release", Verb: "mesh-delivery.release", Level: conditions.LevelApprove,
Arguments: map[string]string{"id": l.ID, "why": ""}}
stop := conditions.Action{Label: "Stop", Verb: "mesh-delivery.stop", Level: conditions.LevelApprove,
Arguments: map[string]string{"id": l.ID, "why": ""}}
switch held {
case "held":
needs, actions = "release it, or stop it, "+FromMeshMCPServer, []conditions.Action{release, stop}
case "ready", "checked":
needs = "merge its pull request, or close it."
default:
needs, actions = "stop it "+FromMeshMCPServer, []conditions.Action{stop}
}
}
return fmt.Sprintf("Delivery of %s %s %s", name, held, long),
fmt.Sprintf("A delivery of %s has been %s %s, past its limit.", name, held, long),
fmt.Sprintf("Delivery of %s is no longer %s", name, held), needs, actions
}
// causeWords is a hand-act's cause as a person says it.
func causeWords(cause string) string {
return strings.NewReplacer(".", " ", "_", " ").Replace(cause)
}
// unitPlainWords is a unit as a person names it: "greenclip", "storage-media (a mount)".
func unitPlainWords(unit string) string {
for suffix, what := range map[string]string{".service": "", ".mount": "a mount", ".timer": "a timer",
".socket": "a socket", ".path": "a path unit", ".automount": "an automount", ".scope": "a scope"} {
if name, ok := strings.CutSuffix(unit, suffix); ok {
if what == "" {
return strings.ReplaceAll(name, ".", " ")
}
return strings.ReplaceAll(name, ".", " ") + " (" + what + ")"
}
}
return strings.ReplaceAll(unit, ".", " ")
}
// resourcePlainWords is what is wrong with one of a module's resources, as a person says it.
func resourcePlainWords(r inventory.ResourceHealth) string {
if r.Kind == link.KindUnit {
return "its service " + unitPlainWords(r.Target) + " " + unitReasonWords(r.Reason)
}
what := "its " + strings.ReplaceAll(r.Kind, "-", " ")
switch r.Reason {
case "restarting":
return what + " keeps restarting"
case "down":
return what + " is not running"
}
if r.Check != "" {
return what + " fails its " + r.Check + " check"
}
return what + " is not healthy"
}
// unitReasonWords is how a unit failed, as a person says it, from the node-engine's words (which may be
// systemd's result, such as exit-code, or a sentence carrying it).
func unitReasonWords(reason string) string {
switch {
case strings.Contains(reason, "start-limit"):
return "failed too often to be started again"
case strings.Contains(reason, "exit-code"):
return "stopped with an error"
case strings.Contains(reason, "core-dump"), strings.Contains(reason, "signal"):
return "crashed"
case strings.Contains(reason, "timeout"):
return "timed out"
}
return "failed"
}
// orModule is a module's name, or "a module" when none is known.
func orModule(m string) string {
if m == "" {
return "a module"
}
return m
}
-349
View File
@@ -1,349 +0,0 @@
package main
import (
"regexp"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// The conditions the operator read on 2026-10-08, raised again from the same facts (novox/hq ADR 0253):
// each keeps its summary for whoever looks closer, and now carries a headline, an explanation and a
// resolved line the operator reads at a glance — no plan id, commit, key, verb or clock time in them.
// plainExample checks one finding's words as the keeper will keep them: plain, within their bounds,
// opened by the verdict, and saying what the operator needs; and its actions by label.
func plainExample(t *testing.T, o conditions.Observation, headline, explanation string, actions ...string) {
t.Helper()
machines := append([]string{o.Machine}, o.Also...)
w := conditions.Words{Headline: o.Headline, Explanation: o.Explanation, Resolved: o.Resolved, Needs: o.Needs,
Actions: o.Actions}
if why, ok := conditions.PlainWords(w, machines...); !ok {
t.Errorf("%s: not plain: %s\n %+v", o.Key(), why, w)
}
said := conditions.Verdict(o.Needs, o.Explanation)
if o.Headline != headline || said != explanation {
t.Errorf("%s: says\n %q\n %q\nwant\n %q\n %q", o.Key(), o.Headline, said, headline, explanation)
}
var labels []string
for _, a := range o.Actions {
labels = append(labels, a.Label)
}
if strings.Join(labels, ",") != strings.Join(actions, ",") {
t.Errorf("%s: actions %v, want %v", o.Key(), labels, actions)
}
quiet := ""
if o.Needs == "" && o.Severity == conditions.Warning {
quiet = " (needs nothing and is a warning: no popup; it stays in conditions and the history)"
}
t.Logf("\nBEFORE: %s\nAFTER: %s%s\n %s\n actions %v\nCLEARS: %s", o.Summary, o.Headline, quiet,
said, labels, o.Resolved)
}
// **A delivery waiting**: the popup read "the walk of novox/mesh-catalog a6385479 has waited 56m0s for
// mesh-delivery's word to start: `mesh-delivery.show` for the delivery that landed as a6385479 says why;
// `plans go plan-1791454185265004861 --why …` starts it by hand". Under four hours it needs nothing — no
// popup; past them, the operator starts or stops it from the notification.
func TestADeliveryWaitingNeedsNothingUntilItsBoundThenOffersStartAndStop(t *testing.T) {
now := time.Date(2026, 10, 8, 11, 36, 0, 0, time.UTC)
f := calm(now)
f.waits = []waitFacts{{id: "plan-1791454185265004861", repository: "novox/mesh-catalog", commit: "a6385479c0ffee",
awaits: "mesh-delivery", since: now.Add(-56 * time.Minute), modules: []string{"openrazer"}}}
got := watchWaits(f)
if len(got) != 1 {
t.Fatalf("%+v", got)
}
plainExample(t, got[0], "openrazer delivery waiting to start",
"Nothing for you to do. The change to openrazer is merged and built, and has waited 56 minutes for "+
"mesh-delivery (the module that decides when a delivery goes out) to let it start. It becomes a question "+
"for you if it still waits after 4 hours.")
if !strings.Contains(got[0].Summary, "plans go plan-1791454185265004861") {
t.Errorf("the summary lost the way on for whoever looks closer: %q", got[0].Summary)
}
// Past four hours it is urgent, and asks the operator to start or stop it (novox/hq ADR 0259): the plan's
// own verbs, approved, which the controller performs on the warrant. The router says where to answer.
f.waits[0].since = now.Add(-5 * time.Hour)
got = watchWaits(f)
plainExample(t, got[0], "openrazer delivery waiting to start",
"Needs you: start it, or stop it, from the mesh MCP server; this notification cannot do it. The change to openrazer is merged and built, and mesh-delivery (the "+
"module that decides when a delivery goes out) has not let it start for 5 hours, so mesh-delivery may "+
"be stuck.", "Start", "Stop")
for i, want := range []string{"go", "stop"} {
a := got[0].Actions[i]
if a.Verb != "mesh-controller.plans" || a.Arguments[want] != "plan-1791454185265004861" ||
a.Level != conditions.LevelApprove || a.Arguments["cause"] != conditions.CauseOperatorAnswer {
t.Errorf("%s: %+v", a.Label, a)
}
}
// Many modules are counted, not listed in the headline.
f.waits[0].modules = []string{"a", "b", "c", "d"}
got = watchWaits(f)
if got[0].Headline != "mesh-catalog delivery waiting to start" || !strings.Contains(got[0].Explanation, "4 modules of mesh-catalog") {
t.Errorf("%+v", got[0])
}
}
// **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: it is asked of the
// operator at the approve level (novox/hq ADR 0259), so a desk click never performs it (ADR 0258).
func TestAModuleUnhealthyAsksForARestartInWords(t *testing.T) {
o := moduleUnhealthyObservation("openrazer", "g14", []inventory.ResourceHealth{{Kind: link.KindUnit,
Resource: "openrazer-daemon", Target: "openrazer-daemon.service", Account: "jochen",
Reason: "failed in the account's own service manager (exit-code)", Since: time.Now()}})
plainExample(t, o, "openrazer not working on g14",
"Needs you: restart its service openrazer-daemon on g14 from the mesh MCP server; this notification cannot do it. "+
"openrazer on g14 is not healthy: its service openrazer-daemon stopped with an error. It clears as soon "+
"as it runs again.", "Restart")
if a := o.Actions[0]; a.Verb != "node-service-manager.restart" || a.Machine != "g14" || a.Level != conditions.LevelApprove ||
a.Arguments["unit"] != "openrazer-daemon.service" || a.Arguments["scope"] != "user" {
t.Errorf("restart: %+v", a)
}
// An account waiting for a new login (ADR 0252) asks for the login, held to the plain rule.
o = moduleUnhealthyObservation("openrazer", "g14", []inventory.ResourceHealth{{Kind: "account",
Resource: "operator-in-group", Target: "jochen", Reason: "relogin needed: the account is in the group"}})
if o.Needs != "log out of g14 completely and log in again, or restart it." || len(o.Actions) != 0 {
t.Errorf("relogin: %q %+v", o.Needs, o.Actions)
}
w := conditions.Words{Headline: o.Headline, Explanation: o.Explanation, Resolved: o.Resolved, Needs: o.Needs}
if why, ok := conditions.PlainWords(w, "g14"); !ok {
t.Errorf("the relogin words are not plain: %s", why)
}
// And the kind issue 318 raises for it (ADR 0254).
rw := plainWordings["relogin-needed"](conditions.Observation{Scope: conditions.ScopeModule, ID: "openrazer.g14",
Machine: "g14", Kind: "relogin-needed", Severity: conditions.Warning})
if why, ok := conditions.PlainWords(rw, "g14"); !ok || rw.Needs == "" || len(rw.Actions) != 0 {
t.Errorf("relogin-needed: %s %+v", why, rw)
}
if strings.Contains(o.Summary, "session") || !strings.Contains(o.Summary, "waits for a new login of jochen") {
t.Errorf("relogin summary: %q", o.Summary)
}
}
// **Failed units on a machine**: "shanks's service manager is degraded: 3 failed unit(s) no module places —
// mnt-recalbox.mount, storage-media.mount, greenclip.service (the account's own manager). …"
func TestFailedUnitsOnAMachineAreNamedWithoutTheirSuffixes(t *testing.T) {
o, raise := machineUnitsObservation("shanks", &link.UnitsHealth{State: "degraded", Failed: []link.FailedUnit{
{Unit: "mnt-recalbox.mount", Result: "exit-code"}, {Unit: "storage-media.mount", Result: "exit-code"},
{Unit: "greenclip.service", Scope: "user", Result: "exit-code"}}})
if !raise {
t.Fatal("not raised")
}
plainExample(t, o, "3 failed services on shanks",
"Needs you: mend or remove them on shanks, or silence this if they do not matter. On shanks, "+
"mnt-recalbox (a mount), storage-media (a mount) and greenclip failed. No module manages them, so the "+
"mesh does not repair them.", "Silence for a week")
}
// **A healer wanted**: "\"push\" was repaired by hand 35 times in 14 days, the last by g14.node-tools, through
// the mesh-controller seat: a healer is wanted for it". Nothing is broken: no popup.
func TestAHealerWantedNeedsNothingFromTheOperator(t *testing.T) {
now := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
f := calm(now)
for i := 0; i < 35; i++ {
a := actByHand(now.Add(-time.Duration(i+1)*time.Hour), "push")
a.Verb, a.By = "push", "g14.node-tools, through the mesh-controller seat"
f.handActs = append(f.handActs, a)
}
got := watchHandActs(f)
if len(got) != 1 {
t.Fatalf("%+v", got)
}
plainExample(t, got[0], "Push keeps being fixed by hand",
"Nothing for you to do. A person repaired push by hand 35 times in 14 days, so an automatic repair is "+
"wanted for it. Nothing is broken now.")
}
// **A delivery held past its bound**, as mesh-delivery says it: "the delivery novox/hq@055550802096 has been
// held for 36h2m6s, past its bound of 24h0m0s (it waits for the operator): healer H2 may none: …".
func TestADeliveryHeldAsksForReleaseOrStopInWords(t *testing.T) {
got := stalledObservations([]stalledLine{{ID: "novox/hq@055550802096", State: "held", For: "36h2m6s",
Bound: "24h0m0s", H2: "none: the state is the operator's", Says: "it waits for the operator"}})
plainExample(t, got[0], "Delivery of hq held for 36 hours",
"Needs you: release it, or stop it, from the mesh MCP server; this notification cannot do it. A delivery of hq has been held for 36 hours, past its limit.",
"Release", "Stop")
for i, verb := range []string{"mesh-delivery.release", "mesh-delivery.stop"} {
if a := got[0].Actions[i]; a.Verb != verb || a.Arguments["id"] != "novox/hq@055550802096" || a.Level != conditions.LevelApprove {
t.Errorf("%+v", a)
}
}
}
// **Every kind the controller raises has plain words**, and its words are plain for a subject of every
// shape it is raised with.
func TestEveryWordingIsPlain(t *testing.T) {
subjects := []conditions.Observation{
{Scope: conditions.ScopeMachine, ID: "ace", Machine: "ace"},
{Scope: conditions.ScopeMachine, ID: "ace.immich.library", Machine: "ace"},
{Scope: conditions.ScopeModule, ID: "openrazer.g14", Machine: "g14"},
{Scope: conditions.ScopeProvider, ID: "postgres.anchor.app_db", Machine: "anchor"},
{Scope: conditions.ScopePlan, ID: "plan-1791454185265004861"},
{Scope: conditions.ScopeDelivery, ID: "novox/hq@055550802096"},
{Scope: conditions.ScopeCore, ID: "controller.anchor", Machine: "anchor"},
{Scope: conditions.ScopeBus, ID: "mesh"},
{Scope: conditions.ScopeSeat, ID: "node-backup.ace", Machine: "ace"},
{Scope: conditions.ScopeMesh, ID: "hand-acts.disk-load"},
{Scope: conditions.ScopeBuild, ID: "openrazer.g14", Machine: "g14"},
}
for kind, fn := range plainWordings {
if !conditions.Worded(kind) {
t.Errorf("%s is not registered", kind)
}
for _, s := range subjects {
s.Kind = kind
w := fn(s)
w.Explanation = conditions.Verdict(w.Needs, w.Explanation)
if why, ok := conditions.PlainWords(w, s.Machine); !ok {
t.Errorf("%s about %s %s: %s\n %+v", kind, s.Scope, s.ID, why, w)
}
}
}
}
// **An answer on a notification is no repair** (novox/hq ADR 0258): silencing chosen by the operator on the
// desk does not count toward a healer wanted; the same silence by hand for another cause still does.
func TestAnOperatorsAnswerIsNoHandRepair(t *testing.T) {
now := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
f := calm(now)
for i := 0; i < 3; i++ {
a := actByHand(now.Add(-time.Duration(i+1)*time.Hour), conditions.CauseOperatorAnswer)
a.Verb = "conditions silence"
f.handActs = append(f.handActs, a)
}
if got := watchHandActs(f); len(got) != 0 {
t.Fatalf("an answer counted as a repair: %+v", got)
}
for i := range f.handActs {
f.handActs[i].Cause = "machine-units"
}
if got := watchHandActs(f); len(got) != 1 {
t.Fatalf("a silence by hand stopped counting: %+v", got)
}
}
// **Data loss is never silenced from a notification** (ADR 0258): data missing or shrunk offers no answer,
// and says where it is silenced.
func TestDataLossOffersNoSilence(t *testing.T) {
for _, kind := range []string{kindDataMissing, kindDataShrank} {
w := plainWordings[kind](conditions.Observation{Scope: conditions.ScopeMachine, ID: "ace.immich.library",
Machine: "ace", Kind: kind, Severity: conditions.Urgent})
if len(w.Actions) != 0 || !strings.HasSuffix(w.Needs, FromMeshMCPServer) {
t.Errorf("%s: %+v", kind, w)
}
if why, ok := conditions.PlainWords(w, "ace"); !ok {
t.Errorf("%s: %s", kind, why)
}
}
}
// **Updates held after a failed release** (2026-10-08): the popup read "Needs you: release them, or leave
// them held." — naming neither what waits nor where it is released. It names the modules and machines, and
// the mesh MCP server.
func TestUpdatesHeldNameWhatWaitsAndWhereItIsReleased(t *testing.T) {
saved := backlogNow
t.Cleanup(func() { backlogNow = saved })
backlogNow.held = "release-1791457717307061152 failed (failed its gate on g14); what waits is released again by a person"
backlogNow.waiting = map[string][]inventory.CarriedMove{
"shanks": {{Module: "openrazer"}},
"g14": {{Module: "openrazer"}, {Module: "sensors"}},
}
got := backlogObservation()
if len(got) != 1 {
t.Fatalf("raised %d", len(got))
}
plainExample(t, got[0], "Updates of openrazer and sensors wait for your release",
"Needs you: release them from the mesh MCP server; this notification cannot do it. Updates of openrazer and "+
"sensors on g14 and shanks wait for a person to release them, because the last walk failed. "+
"They are not delivered until then, and stay held if you leave them.")
}
// **What held them is said in the glossary's words** (2026-10-08 review): the backlog is held after any
// walk failed, not a release, and a "check" is a pull request's status. So the words say the walk failed,
// and never that a release failed or a check did — with the modules and machines named or not.
func TestUpdatesHeldSayTheWalkFailed(t *testing.T) {
for _, w := range []words{
releaseHeldWords([]string{"openrazer"}, []string{"g14"}),
releaseHeldWords(nil, nil),
plainWordings["release-held"](conditions.Observation{Scope: conditions.ScopeMesh, ID: "release"}),
} {
if !strings.Contains(w.Explanation, "because the last walk failed.") {
t.Errorf("does not say the walk failed: %q", w.Explanation)
}
for _, wrong := range []string{"release failed", "check"} {
if strings.Contains(w.Explanation, wrong) {
t.Errorf("says %q: %q", wrong, w.Explanation)
}
}
}
}
// mcpVerb is a need that opens with a verb only the mesh MCP server performs (ADR 0258 §1): release, stop,
// start, restart, and a restore.
var mcpVerb = regexp.MustCompile(`^(release|stop|start|restart|restore)\b`)
// **A need no notification can answer says where it is answered** (ADR 0258 §1): every wording whose need
// opens with a verb the mesh MCP server performs, and offers no action, ends with FromMeshMCPServer — the
// kinds worded here for every subject shape, and those worded where they are raised. A new kind that misses
// it fails here; release-held did (2026-10-08).
func TestANeedNoNotificationAnswersNamesTheMeshMCPServer(t *testing.T) {
check := func(what string, w words) {
t.Helper()
if w.Needs == "" || len(w.Actions) > 0 || !mcpVerb.MatchString(w.Needs) {
return
}
if !strings.HasSuffix(w.Needs, FromMeshMCPServer) {
t.Errorf("%s needs %q without %q", what, w.Needs, FromMeshMCPServer)
}
}
subjects := []conditions.Observation{
{Scope: conditions.ScopeMachine, ID: "ace", Machine: "ace"},
{Scope: conditions.ScopeMachine, ID: "ace.immich.library", Machine: "ace"},
{Scope: conditions.ScopeModule, ID: "openrazer.g14", Machine: "g14"},
{Scope: conditions.ScopeDelivery, ID: "novox/hq@055550802096"},
{Scope: conditions.ScopeCore, ID: "controller.anchor", Machine: "anchor"},
{Scope: conditions.ScopeMesh, ID: "release", Also: []string{"g14"}},
}
for kind, fn := range plainWordings {
for _, s := range subjects {
for _, sev := range []conditions.Severity{conditions.Warning, conditions.Urgent} {
s.Kind, s.Severity, s.Resolver = kind, sev, conditions.ResolverOperator
check(kind+" about "+s.ID, fn(s))
}
}
}
check("a walk waiting", words{Needs: waitingNeeds(conditions.Urgent)})
check("a module's failed service", words{Needs: moduleNeeds("g14",
[]inventory.ResourceHealth{{Kind: link.KindUnit, Target: "openrazer-daemon.service"}})})
for _, state := range []string{"held", "ready", "failing"} {
_, _, _, needs, actions := stalledWords(stalledLine{ID: "novox/hq@055550802096", State: state, For: "36h"},
conditions.Observation{Resolver: conditions.ResolverOperator})
check("a delivery "+state, words{Needs: needs, Actions: actions})
}
check("updates held", releaseHeldWords([]string{"openrazer"}, []string{"g14"}))
}
// **A pull request the forge never announced says what to do about it** (novox/hq issue 347): mesh-delivery says one
// as a stalled line in state `unannounced` — no delivery exists, so there is nothing to stop, release or close —
// and the operator's one act is a new commit on its branch, which the forge announces.
func TestAnUnannouncedPullRequestSaysToPushANewCommit(t *testing.T) {
l := stalledLine{ID: "novox/hq@bfe82315f42c", Number: 243, State: "unannounced", For: "11m0s", Bound: "10m0s",
H2: "none: the forge never announced it, so there is no delivery to close — the operator's",
Says: "novox/hq#243 is open on main, which requires the merge check, and its head has had no merge check"}
obs := stalledObservations([]stalledLine{l})
if len(obs) != 1 || obs[0].Resolver != conditions.ResolverOperator {
t.Fatalf("an unannounced pull request is not the operator's: %+v", obs)
}
o := obs[0]
if strings.Contains(o.Needs, "stop") || strings.Contains(o.Needs, "release") || !strings.Contains(o.Needs, "commit") {
t.Fatalf("it says to %q", o.Needs)
}
if !strings.Contains(o.Headline, "no merge check") || !strings.Contains(o.Headline, "#243") ||
!strings.Contains(o.Explanation, "never") {
t.Fatalf("it reads %q / %q", o.Headline, o.Explanation)
}
}
+7 -61
View File
@@ -127,17 +127,10 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
return catalogue.Resolution{}, nil, err
}
// A recorded module is composed at the build this machine runs, on any send but a person's push
// (novox/hq issue 295, ADR 0245).
if _, err := keepRecorded(ctx, open, nodeName, shelf); err != nil {
return catalogue.Resolution{}, nil, err
}
resolved, err := catalogue.Resolve(shelf, assigned,
catalogue.Node{Name: nodeName, Site: site, Capabilities: capabilities,
At: onNetwork[nodeName], PublicDomain: publicDomain,
Account: who.Account, AccountHome: who.AccountHome,
AgentAccount: who.AgentAccount, AgentAccountHome: who.AgentAccountHome}, world)
Account: who.Account, AccountHome: who.AccountHome}, world)
if err != nil {
// The node's own set does not compose. Marked, because this is the only failure here that
// a mesh-wide gatherer may pass over — see notResolvable.
@@ -420,7 +413,7 @@ func declarationWith(ctx context.Context, open *stores, node string,
out := sendable{Resources: composed.Resources, Adoption: adoption,
Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers,
LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut, withheld: with.Withheld,
unbound: with.Unbound, foreseen: composed.Foreseen, unplaced: composed.Unplaced}
unbound: with.Unbound, foreseen: composed.Foreseen}
// And which build of each module it carries, for the send to record (novox/hq issue 259, ADR
// 0221). Read only on the send path: a question about what would be sent records nothing.
if choosing == Allocating {
@@ -440,16 +433,6 @@ func declarationWith(ctx context.Context, open *stores, node string,
names = append(names, m.Module)
}
out.Builds = carriedBuilds(names, composed.LeftOut, current, before)
// A recorded module kept at the build the machine runs is recorded as carrying that one (ADR 0245).
kept, err := recordedKept(ctx, open, node)
if err != nil {
return sendable{}, err
}
for m, was := range kept {
if _, carried := out.Builds[m]; carried {
out.Builds[m] = was
}
}
out.Bindings = boundToData(plan, composed.LeftOut)
}
return out, nil
@@ -516,8 +499,9 @@ func sortedKeysOf(m map[string]string) []string {
// 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) {
for _, m := range declared.LeftOut {
fmt.Printf("%s: %s left out — what the machine holds for it is kept and its containers are "+
"untouched. %s\n", node, m, declared.leftOutWhy[m])
fmt.Printf("%s: %s left out — a setting stored for it cannot compose with its definition; "+
"what the machine holds for it is kept and its containers are untouched. %s\n",
node, m, declared.leftOutWhy[m])
}
// And whom it serves nothing, because their identity overflows what the provision keeps (ADR
// 0225): the machine is sent everything else, and the consumer is named.
@@ -528,11 +512,6 @@ func reportLeftOut(node string, declared sendable) {
for _, u := range declared.unbound {
fmt.Printf("%s: %s\n", node, u)
}
// And every contribution its holder could not render, which the machine is sent without (novox/hq
// ADR 0255).
for _, u := range declared.unplaced {
fmt.Printf("%s: %s\n", node, u)
}
}
// busCredentialIssued refuses an own secret called `broker` whose bus account nobody issued.
@@ -886,13 +865,8 @@ func renderingFor(ctx context.Context, open *stores, node string,
if err != nil {
return catalogue.Rendering{}, inventory.Node{}, err
}
judgesRoot, err := engineJudgesRoot(ctx, inv, node)
if err != nil {
return catalogue.Rendering{}, inventory.Node{}, err
}
return catalogue.Rendering{
ReadsHealth: readsHealth,
JudgesRoot: judgesRoot,
BusMembership: memberships[node],
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Foreseen: foreseen, Ports: ports,
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
@@ -915,16 +889,6 @@ func engineReadsHealth(ctx context.Context, inv *inventory.Inventory, node strin
return had && stated.Contract >= link.ReadinessContract, nil
}
// engineJudgesRoot says whether a machine's node-engine judges a user's declared `root` (novox/hq ADR 0266),
// by its own newest statement, for the same reason as engineReadsHealth: an older engine parses strictly.
func engineJudgesRoot(ctx context.Context, inv *inventory.Inventory, node string) (bool, error) {
stated, had, err := inv.HealthOf(ctx, node)
if err != nil {
return false, err
}
return had && stated.Contract >= link.RootContract, nil
}
// zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR
// 0199): the zone settled from that node's settings, the node's private address, the port the
// answering listen is published on there.
@@ -1205,15 +1169,12 @@ func planCommand(ctx context.Context, args []string) error {
// checking it against the host's own parser, most usefully, which is the only way to know
// that what the control plane emits is what the host accepts.
asJSON := set.Bool("json", false, "print the declaration this node would be sent")
// What a push would change, against what the machine was last sent (novox/hq ADR 0217): read
// before sending, so a change that removes, moves or recreates something is seen first.
asDiff := set.Bool("diff", false, "what a push would change on this node, against what it was last sent")
positionals, err := parseAround(set, args)
if err != nil {
return err
}
if len(positionals) != 1 {
return errors.New("plan <node> [--files] [--json] [--diff]")
return errors.New("plan <node> [--files] [--json]")
}
args = positionals
open, err := openStores(ctx)
@@ -1230,21 +1191,6 @@ func planCommand(ctx context.Context, args []string) error {
fmt.Printf("%s is assigned nothing\n", args[0])
return nil
}
if *asDiff {
declared, err := declarationFor(ctx, open, args[0], plan, settings)
if err != nil {
return err
}
body, err := declared.Body()
if err != nil {
return err
}
// 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)
}
if *asJSON {
declared, err := declarationFor(ctx, open, args[0], plan, settings)
if err != nil {
@@ -1444,7 +1390,7 @@ func servedOnNode(ctx context.Context, inv *inventory.Inventory, node string,
}
serves := catalogue.ServedOn(m, provision, ports)
if len(serves) > 0 {
serves, err = catalogue.Settle(serves, catalogue.WithDefaults(m, layers))
serves, err = catalogue.Settle(serves, layers)
if err != nil {
return nil, err
}
-121
View File
@@ -1,121 +0,0 @@
package main
import (
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// novox/hq issue 296: a plan saved a few seconds ago that has stood in its tier for forty minutes says
// forty minutes, and LATE — the line counted from the last save, which every advance made.
func TestAPlansLineCountsFromItsTierNotFromItsLastSave(t *testing.T) {
now := time.Date(2026, 10, 7, 20, 30, 0, 0, time.UTC)
asked := now.Add(-40 * time.Minute)
p := inventory.Plan{ID: "plan-t", Repository: "novox/mesh-catalog", Commit: "3ad22356", State: inventory.PlanBuilding,
Created: now.Add(-40 * time.Minute), Updated: now.Add(-3 * time.Second), TierEntered: now.Add(-40 * time.Minute),
Tiers: [][]string{{"a"}}, Modules: map[string]*inventory.PlanModule{"a": {State: "asked", AskedAt: &asked}}}
line := planLine(p, now)
if !strings.Contains(line, "building for 40m0s") || !strings.Contains(line, "LATE") {
t.Fatalf("a plan in its tier for 40m, saved 3s ago, reads %q", line)
}
st := planStatuses([]inventory.Plan{p}, now, pauseView{}, nil)[0]
if !st.Late || !st.Since.Equal(p.TierEntered) {
t.Errorf("status --json says %+v", st)
}
if _, late := openPlans([]inventory.Plan{p}, now, pauseView{}, nil); late != 1 {
t.Errorf("status counted %d late", late)
}
// LATE is the stalled condition said on the line: the same start and the same bound, measured or not.
bounds := boundsOf([]inventory.Duration{{Subject: p.Repository, Took: 20 * time.Minute}})
if got := bounds.of(p.Repository); got != time.Hour {
t.Fatalf("a repository whose tiers took 20m is given %s", got)
}
if got := bounds.of("novox/other"); got != tierAtLeast {
t.Fatalf("a repository with nothing measured is given %s", got)
}
for _, in := range []time.Duration{40 * time.Minute, 70 * time.Minute} {
q := p
q.TierEntered = now.Add(-in)
bound := bounds.of(q.Repository)
late := strings.Contains(planLineWith(q, now, pauseView{}, bound), "LATE")
stalled := len(watchPlans(&signalFacts{now: now, plans: []planFacts{{id: q.ID, repository: q.Repository,
commit: q.Commit, tiers: 1, entered: inTierSince(q), bound: bound}}})) == 1
if late != stalled || late != (in > bound) {
t.Errorf("in its tier %s with a bound of %s: LATE %v, stalled %v", in, bound, late, stalled)
}
}
// A plan read without its tier's stamp counts from its last save, the only time it has.
old := p
old.TierEntered = time.Time{}
if line := planLine(old, now); !strings.Contains(line, "for 3s") || strings.Contains(line, "LATE") {
t.Errorf("a plan without its tier's stamp reads %q", line)
}
// A walk that waited for its delivery's word counts its wait from when it was opened, and its tier
// from the word.
waiting := p
waiting.Delivery = &inventory.PlanDelivery{Awaits: catalogue.DeliverySeat}
waiting.Modules = map[string]*inventory.PlanModule{}
if line := planLine(waiting, now); !strings.Contains(line, "for 40m0s") || strings.Contains(line, "LATE") {
t.Errorf("a walk waiting 40m reads %q", line)
}
if st := planStatuses([]inventory.Plan{waiting}, now, pauseView{}, nil)[0]; st.Late {
t.Errorf("a waiting walk is called late: %+v", st)
}
let := now.Add(-5 * time.Minute)
gone := p
gone.Delivery = &inventory.PlanDelivery{Awaits: catalogue.DeliverySeat, Go: &let}
if line := planLine(gone, now); !strings.Contains(line, "building for 5m0s") || strings.Contains(line, "LATE") {
t.Errorf("a walk let go 5m ago, opened 40m ago, reads %q", line)
}
}
// novox/hq issue 296: a plan whose tier stands still is not written again by every advance — its revision,
// its last save and its `plan-moved` stay where its last change left them.
func TestAPlanStandingStillIsNotSavedAgain(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
asked := asksRecorded(t)
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"},
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/app", Ref: "main",
BuiltFrom: "c0", Head: "c0"}); err != nil {
t.Fatal(err)
}
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "c1aaaaaaaa",
Paths: []string{"modules/app/index.ts"}, ModuleDirs: []string{"modules/app"}, ModuleDirsSaid: true}
if err := (following{open: open}).SourceMoved(ctx, m); err != nil {
t.Fatal(err)
}
advanceHeld(ctx, open)
recent, err := inv.RecentPlans(ctx, 1)
if err != nil || len(recent) != 1 || len(*asked) != 1 {
t.Fatalf("no plan building: %v %v, asked %v", recent, err, *asked)
}
before := recent[0]
if before.State != inventory.PlanBuilding || before.Modules["app"] == nil || before.Modules["app"].State != "asked" {
t.Fatalf("the plan is not waiting on its build: %+v", before)
}
for range 3 {
advanceHeld(ctx, open)
}
after, err := inv.PlanByID(ctx, before.ID)
if err != nil {
t.Fatal(err)
}
if after.Revision != before.Revision || !after.Updated.Equal(before.Updated) {
t.Fatalf("a plan that did not move was saved again: revision %d → %d, saved %s → %s",
before.Revision, after.Revision, before.Updated, after.Updated)
}
if len(*asked) != 1 {
t.Fatalf("advancing asked again: %v", *asked)
}
}
+7 -28
View File
@@ -9,6 +9,7 @@ import (
"strings"
"time"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
@@ -97,9 +98,11 @@ func buildSeatPause(ctx context.Context, inv *inventory.Inventory, plans []inven
if len(holders) == 0 {
return pauseView{}
}
// On the serving controller's own connection when this is it: a watchdog tick while a walk waits for a
// build dialled one every 30 seconds (novox/hq issue 327).
js, err := aBus()
address, err := broker.BusAddress()
if err != nil {
return pauseView{}
}
js, err := broker.Dial(address)
if err != nil {
fmt.Fprintf(os.Stderr, "could not reach the bus to read whether the build seat is paused: %v\n", err)
return pauseView{}
@@ -164,13 +167,12 @@ func retryRefusal(p inventory.Plan, plans []inventory.Plan) error {
case p.Open():
return fmt.Errorf("%s is still %s; nothing in it failed to retry — `rebuild <module>` asks one module again", p.ID, p.State)
}
if len(failedIn(p)) > 0 {
if q, found := newerOpenPlan(p, plans); found {
return fmt.Errorf("%s supersedes it: a newer merge of %s (%s at %s) is open, and retrying %s would build "+
"what that one replaced", q.ID, q.Repository, q.ID, short(q.Commit), p.ID)
}
return oneWalkAtATime(p, plans)
return nil
}
stopped := stoppedRollouts(p)
if len(stopped) == 0 {
@@ -194,17 +196,6 @@ func retryRefusal(p inventory.Plan, plans []inventory.Plan) error {
"the older build back", m, q.ID, q.State, q.Repository, short(q.Commit), p.ID)
}
}
return oneWalkAtATime(p, plans)
}
// oneWalkAtATime refuses a retry while another walk is open, started or waiting for its word (novox/hq ADR
// 0276): a walk retried beside it would be two walks at once.
func oneWalkAtATime(p inventory.Plan, plans []inventory.Plan) error {
for _, q := range plans {
if q.ID != p.ID && q.Open() && q.Release == nil {
return fmt.Errorf("%s is open (%s): one walk at a time — retry %s once it ended", q.ID, q.Named(), p.ID)
}
}
return nil
}
@@ -276,18 +267,6 @@ func retryPlan(ctx context.Context, open *stores, id string) (string, error) {
return "", err
}
plans = append(plans, recent...)
// **A failed walk whose earlier merges are walked alone is not retried** (novox/hq ADR 0276): the search for
// the merge that brought the failure answers them now, and a retried walk would name them twice.
if p.Delivery != nil && len(p.Delivery.Merges) > 0 {
kept, err := inv.MergesOf(ctx, p.ID)
if err != nil {
return "", err
}
if len(kept) < len(p.Delivery.Merges) {
return "", fmt.Errorf("%s's earlier merges are walked alone, to find which one brought its failure: "+
"those walks answer them; a newer merge, or `rebuild <module>`, builds again", p.ID)
}
}
if err := retryRefusal(p, plans); err != nil {
return "", err
}
-151
View File
@@ -1,151 +0,0 @@
package main
import (
"reflect"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
)
// **The planner over edges the store derives**, not edges written by hand: modules registered, builds
// recorded with what they stood on and which repositories they read, the relation answered by
// inventory.Dependencies (dependenciesOf over the records), and the merge planned by reachOfMerge — the
// path a real merge takes, short of the bus.
//
// **A shared repository moves only what a change's files are in the build source of** (novox/hq ADR 0267,
// issues 338 and 363): each build records the build source it said, and a merge is mapped onto those of the
// newest builds. A build that said none (P below, as every build before ADR 0267) is read as before: P moves
// on any merge to the repository it packages, though through no edge. The rows tagged 338 held the opposite
// until ADR 0267 was built.
func TestASharedRepositoryIsPlannedFromTheRecordedBuildSources(t *testing.T) {
inv := inventory.ForTest(t)
ctx := t.Context()
asked := time.Now().Add(-time.Hour)
sourcesOf := map[string][]inventory.BuildSource{}
register := func(m catalogue.Manifest, repository, path string, against []string, read []inventory.ReadRepository) {
t.Helper()
if err := inv.RegisterModule(ctx, m, inventory.Source{Repository: repository, Seat: "git", Path: path,
Ref: "main", BuiltFrom: "old", Asked: asked}); err != nil {
t.Fatal(err)
}
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-" + m.Module, Repository: repository, Ref: "main",
Module: m.Module, Commit: "old", On: "builder", Path: path, Against: against, Read: read, Asked: asked,
Sources: sourcesOf[m.Module]}); err != nil {
t.Fatal(err)
}
}
controllerRead := []inventory.ReadRepository{{Repository: "novox/mesh-controller", Ref: "main"}}
agent := catalogue.Manifest{Module: "build-agent", Version: "1",
Claims: []catalogue.Claim{{Name: "node-build-agent", Scope: catalogue.ScopeNode}}}
// The shape of issue 338, each build saying its build source (ADR 0267).
gomod := []string{"go.mod", "go.sum"}
sourcesOf["mesh-controller"] = []inventory.BuildSource{{Paths: append([]string{"module.json", "cmd/mesh-controller/",
"internal/conditions/", "internal/broker/"}, gomod...)}}
sourcesOf["build-agent"] = []inventory.BuildSource{
{Paths: []string{"modules/build-agent/Dockerfile", "modules/build-agent/module.json"}},
{Repository: "novox/mesh-controller", Ref: "main", Paths: append([]string{"cmd/mesh-builder/", "internal/broker/"}, gomod...)}}
sourcesOf["route-proxy"] = []inventory.BuildSource{
{Paths: []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}},
{Repository: "novox/mesh-controller", Ref: "main", Paths: append([]string{"examples/route-proxy/", "internal/broker/"}, gomod...)}}
register(catalogue.Manifest{Module: "mesh-controller", Version: "1"}, "novox/mesh-controller", "", nil, nil)
register(agent, "novox/mesh-catalog", "modules/build-agent", nil, controllerRead)
register(catalogue.Manifest{Module: "route-proxy", Version: "1"}, "novox/mesh-catalog", "modules/route-proxy", nil, controllerRead)
register(catalogue.Manifest{Module: "gitea", Version: "1"}, "novox/mesh-catalog", "modules/gitea", nil, nil)
// A, B and C in one repository; D built against A's artifact, E declaring B, P packaging the repository.
for _, n := range []string{"a", "b", "c"} {
register(catalogue.Manifest{Module: n, Version: "1"}, "novox/one", "modules/"+n, nil, nil)
}
register(catalogue.Manifest{Module: "d", Version: "1"}, "novox/two", "d",
[]string{catalogue.ArtifactStoreScheme + "a/runtime@sha256:" + strings.Repeat("0", 64)}, nil)
register(catalogue.Manifest{Module: "e", Version: "1", Build: &catalogue.Build{
On: []catalogue.BuildsOn{{Arg: "BASE", Module: "b", Artifact: "runtime"}}}}, "novox/two", "e", nil, nil)
register(catalogue.Manifest{Module: "p", Version: "1"}, "novox/two", "p", nil,
[]inventory.ReadRepository{{Repository: "novox/one", Ref: "main"}})
entries, err := inv.Catalogued(ctx)
if err != nil {
t.Fatal(err)
}
read, err := inv.ReadRepositories(ctx)
if err != nil {
t.Fatal(err)
}
edges, err := inv.Dependencies(ctx)
if err != nil {
t.Fatal(err)
}
// The edges the hand-written rows of TestASharedRepositoryMovesWhatPackagesItAsItDoesToday use are
// the ones derived here.
var shared []inventory.Edge
in338 := map[string]bool{"mesh-controller": true, "build-agent": true, "route-proxy": true, "gitea": true}
for _, e := range edges {
if in338[e.From] && in338[e.To] {
shared = append(shared, e)
}
}
if !reflect.DeepEqual(shared, sharedRepositoryEdges) {
t.Errorf("derived %v\nthe hand-written rows use %v", shared, sharedRepositoryEdges)
}
// Each kind derived from its record: built against (stands-on), build.on (declared); a read draws none.
for _, e := range edges {
if e.Kind == inventory.EdgePackages {
t.Errorf("a packages edge was drawn (ADR 0267 rule 4): %v", e)
}
}
for _, want := range []inventory.Edge{
dep("d", inventory.EdgeStandsOn, "a"),
dep("e", inventory.EdgeDeclared, "b"),
dep("d", inventory.EdgeBuiltBy, "build-agent"),
} {
found := false
for _, e := range edges {
found = found || e == want
}
if !found {
t.Errorf("no %s %s %s derived: %v", want.From, want.Kind, want.To, edges)
}
}
for _, c := range []struct {
what, repo string
paths []string
want string
issue338 bool
}{
{"A and B changed, C untouched: D after A, E after B; P, which said no build source, reads all", "one",
[]string{"modules/a/x.go", "modules/b/x.go"}, "a,b,p | d,e", false},
{"C alone: C, and P, read whole as before; P after nothing", "one",
[]string{"modules/c/x.go"}, "c,p", false},
{"a README of the repository P packages: P, read whole as before", "one",
[]string{"README.md"}, "p", false},
{"the dependent's repository: D alone", "two", []string{"d/main.go"}, "d", false},
{"a README of the controller's repository: no module", "mesh-controller",
[]string{"README.md"}, "", true},
{"the controller's own command: the controller alone", "mesh-controller",
[]string{"cmd/mesh-controller/main.go"}, "mesh-controller", true},
{"a package only the controller builds from: the controller alone", "mesh-controller",
[]string{"internal/conditions/condition.go"}, "mesh-controller", true},
{"the route proxy's program: the route proxy alone", "mesh-controller",
[]string{"examples/route-proxy/main.go"}, "route-proxy", true},
{"a package all three build from: all three", "mesh-controller",
[]string{"internal/broker/broker.go"}, "mesh-controller | build-agent | route-proxy", false},
{"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
[]string{"modules/route-proxy/module.json"}, "route-proxy", false},
{"the build agent's directory: it alone, nothing it builds", "mesh-catalog",
[]string{"modules/build-agent/module.json"}, "build-agent", false},
} {
_, got := planMerge(t, c.repo, c.paths, entries, read, edges)
if got != c.want {
tag := ""
if c.issue338 {
tag = " (issue 338, flipped by ADR 0267)"
}
t.Errorf("%s: planned %q, wanted %q%s", c.what, got, c.want, tag)
}
}
}
-680
View File
@@ -1,680 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"math/rand/v2"
"sort"
"strings"
"testing"
"time"
snapshot "github.com/novox/mesh-controller/internal/facts"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// The delivery planner's rules, as recorded (novox/hq ADR 0162 §1, ADR 0238 §3), held by one table and
// one property over reachOfMerge — the planner's one answer to "what does this merge move, and in which
// order". A row that fails here on main is a planner that breaks a recorded rule: the row stays, the
// expectation is not bent to the code.
//
// The kinds of edge, as the code reads them (release_plan.go):
//
// kind widens the plan orders the tiers
// stands-on yes yes, after its base is built
// declared yes yes, after its base is built
// packages no no — retired by novox/hq ADR 0267; one recorded before is read and ignored
// built-by no yes, after the build machine — except for what the build machine stands
// on, and for the controller whose worker it binds
// worker-of no yes, the build seat's holder after the controller (hq issue 206)
const (
repoOne = "http://forge.internal:20000/novox/one.git"
repoTwo = "http://forge.internal:20000/novox/two.git"
)
// dep is one edge of the catalogue's relation: from depends on to, in the way kind says.
func dep(from, kind, to string) inventory.Edge {
return inventory.Edge{From: from, To: to, Kind: kind}
}
// tiered is a plan's tiers as one line: a tier's modules by comma, tiers by " | ". Empty for no plan.
func tiered(tiers [][]string) string {
var out []string
for _, t := range tiers {
out = append(out, strings.Join(t, ","))
}
return strings.Join(out, " | ")
}
// planMerge is what reachOfMerge plans for a merge of these files into a repository's main: its tiers as
// one line, and the files no build reads. It also holds the plan to its own shape: every module it builds
// is in exactly one tier.
func planMerge(t *testing.T, repo string, paths []string, entries []inventory.Entry,
read map[string][]inventory.ReadRepository, edges []inventory.Edge) (mergeReach, string) {
t.Helper()
m := link.SourceMoved{Owner: "novox", Repo: repo, Base: "main", Commit: "head", Paths: paths}
r := reachOfMerge(m, entries, read, edges)
seen := map[string]int{}
for _, tier := range r.Plan.Tiers {
for _, name := range tier {
seen[name]++
}
}
for name := range r.Plan.Modules {
if seen[name] != 1 {
t.Errorf("%s/%v: %s is in %d tiers of %v", repo, paths, name, seen[name], r.Plan.Tiers)
}
}
if len(seen) != len(r.Plan.Modules) {
t.Errorf("%s/%v: the tiers %v hold modules the plan does not (%d)", repo, paths, r.Plan.Tiers, len(r.Plan.Modules))
}
return r, tiered(r.Plan.Tiers)
}
// **A merge moves the modules whose directory it changed, and everything built on them; nothing else.**
// Each row is a catalogue (modules in directories of one or two repositories), its dependencies with
// their kinds, the files one commit changed, and the exact plan: the moved set and its tier order.
func TestAPlanIsWhatTheChangeTouchedAndWhatIsBuiltOnIt(t *testing.T) {
in := func(repo, dir string, names ...string) []inventory.Entry {
var out []inventory.Entry
for _, n := range names {
d := dir + "/" + n
if dir == "" {
d = n
}
out = append(out, fromRepo(n, repo, d))
}
return out
}
// Modules a to f, x and z in novox/one under modules/; g in novox/two at g/.
entries := append(in(repoOne, "modules", "a", "b", "c", "d", "e", "f", "x", "z"), in(repoTwo, "", "g")...)
const (
standsOn = inventory.EdgeStandsOn
declared = inventory.EdgeDeclared
packages = inventory.EdgePackages
builtBy = inventory.EdgeBuiltBy
workerOf = inventory.EdgeWorkerOf
)
// The two real cycles the kinds resolve themselves (ADR 0162 §1, hq issue 206).
runtime := append(in(repoOne, "modules", "runtime", "builder"), fromRepo("controller", repoTwo, ""))
for _, c := range []struct {
what string
entries []inventory.Entry
edges []inventory.Edge
repo string
paths []string
want string // the tiers, " | " between them
unread string
cycle bool
}{
// The operator's case: two modules changed, a third beside them in the same repository untouched,
// each changed one with a dependent.
{what: "A and B changed, C untouched beside them, D on A and E on B",
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("e", declared, "b")},
repo: "one", paths: []string{"modules/a/main.go", "modules/b/module.json"}, want: "a,b | d,e"},
{what: "only A's directory: A and what stands on it",
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("e", declared, "b")},
repo: "one", paths: []string{"modules/a/main.go"}, want: "a | d"},
{what: "only C's directory: C alone, nothing is built on it",
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("e", declared, "b")},
repo: "one", paths: []string{"modules/c/Dockerfile"}, want: "c"},
{what: "only the dependent changed: its base does not move",
edges: []inventory.Edge{dep("d", standsOn, "a")},
repo: "one", paths: []string{"modules/d/main.go"}, want: "d"},
{what: "transitive: F on D on A, A changed",
edges: []inventory.Edge{dep("f", standsOn, "d"), dep("d", standsOn, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a | d | f"},
{what: "transitive across kinds stops at a packages edge: F declared on D, D packages A (ADR 0267)",
edges: []inventory.Edge{dep("f", declared, "d"), dep("d", packages, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
// 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")},
repo: "one", paths: []string{"modules/a/x"}, want: "a | x"},
{what: "stands-on orders", edges: []inventory.Edge{dep("x", standsOn, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
{what: "declared (build.on) widens", edges: []inventory.Edge{dep("x", declared, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a | x"},
{what: "declared orders", edges: []inventory.Edge{dep("x", declared, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
{what: "packages, recorded before ADR 0267, widens nothing", edges: []inventory.Edge{dep("x", packages, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
{what: "packages does not order", edges: []inventory.Edge{dep("x", packages, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a,x"},
{what: "built-by never widens", edges: []inventory.Edge{dep("x", builtBy, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
{what: "built-by orders", edges: []inventory.Edge{dep("x", builtBy, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
{what: "worker-of never widens", edges: []inventory.Edge{dep("x", workerOf, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
{what: "worker-of orders", edges: []inventory.Edge{dep("x", workerOf, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
{what: "a change to the base alone does not move what it builds", edges: []inventory.Edge{dep("x", builtBy, "a"),
dep("d", builtBy, "a"), dep("e", standsOn, "a")},
repo: "one", paths: []string{"modules/a/module.json"}, want: "a | e"},
// The cycles the kinds resolve: the build machine stands on the runtime image the runtime image is
// built by; the build seat's holder follows the controller that is built by it.
{what: "the build machine's base comes first, built by the build machine that runs", entries: runtime,
edges: []inventory.Edge{dep("runtime", builtBy, "builder"), dep("builder", standsOn, "runtime")},
repo: "one", paths: []string{"modules/runtime/Dockerfile", "modules/builder/main.go"}, want: "runtime | builder"},
{what: "the runtime image alone takes the build machine on it along", entries: runtime,
edges: []inventory.Edge{dep("runtime", builtBy, "builder"), dep("builder", standsOn, "runtime")},
repo: "one", paths: []string{"modules/runtime/Dockerfile"}, want: "runtime | builder"},
{what: "the build machine alone moves alone", entries: runtime,
edges: []inventory.Edge{dep("runtime", builtBy, "builder"), dep("builder", standsOn, "runtime")},
repo: "one", paths: []string{"modules/builder/main.go"}, want: "builder"},
{what: "the build seat's holder follows the controller it binds the worker of", entries: runtime,
edges: []inventory.Edge{dep("controller", builtBy, "builder"), dep("builder", workerOf, "controller")},
repo: "two", paths: []string{"cmd/main.go"}, want: "controller"},
// Two repositories.
{what: "a dependent in another repository follows its base",
edges: []inventory.Edge{dep("g", standsOn, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a | g"},
{what: "a directory of the same name in another repository is not this one's",
edges: []inventory.Edge{dep("g", standsOn, "a")},
repo: "two", paths: []string{"modules/a/x"}, want: "", unread: "modules/a/x"},
{what: "the dependent's own repository moves the dependent alone",
edges: []inventory.Edge{dep("g", standsOn, "a")},
repo: "two", paths: []string{"g/main.go"}, want: "g"},
// A diamond.
{what: "a diamond, one side changed", edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", standsOn, "b")},
repo: "one", paths: []string{"modules/a/x"}, want: "a | d"},
{what: "a diamond, both sides changed", edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", standsOn, "b")},
repo: "one", paths: []string{"modules/a/x", "modules/b/x"}, want: "a,b | d"},
{what: "a diamond on one base", edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", declared, "b"),
dep("a", standsOn, "z"), dep("b", standsOn, "z")},
repo: "one", paths: []string{"modules/z/x"}, want: "z | a,b | d"},
{what: "a diamond of mixed kinds orders on the ordering side only",
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", packages, "b")},
repo: "one", paths: []string{"modules/b/x"}, want: "b"},
// 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"),
dep("f", standsOn, "c")},
repo: "one", paths: []string{"modules/a/x", "modules/c/x"}, want: "c | f | a,b", cycle: true},
// Files no build reads.
{what: "a README at the root of a repository whose modules all live below it",
edges: []inventory.Edge{dep("d", standsOn, "a")},
repo: "one", paths: []string{"README.md"}, want: "", unread: "README.md"},
{what: "a directory no module lives in", edges: []inventory.Edge{dep("d", standsOn, "a")},
repo: "one", paths: []string{"modules/lib/x.go", "modules/README.md"}, want: "",
unread: "modules/lib/x.go,modules/README.md"},
{what: "a module's directory beside a root file", edges: []inventory.Edge{dep("d", standsOn, "a")},
repo: "one", paths: []string{"merge-check.sh", "modules/a/x"}, want: "a | d", unread: "merge-check.sh"},
{what: "a directory whose name begins with a module's", edges: []inventory.Edge{dep("d", standsOn, "a")},
repo: "one", paths: []string{"modules/ab/x"}, want: "", unread: "modules/ab/x"},
} {
e := entries
if c.entries != nil {
e = c.entries
}
r, got := planMerge(t, c.repo, c.paths, e, nil, c.edges)
if got != c.want {
t.Errorf("%s: planned %q, wanted %q", c.what, got, c.want)
}
if u := strings.Join(r.Unread, ","); u != c.unread {
t.Errorf("%s: unread %q, wanted %q", c.what, u, c.unread)
}
// A packages edge in the last tier is no cycle; hasCycle said one on main at 8170fc5.
if hasCycle(r.Plan.Tiers, c.edges) != c.cycle {
t.Errorf("%s: a cycle said %v, wanted %v (%v)", c.what, !c.cycle, c.cycle, r.Plan.Tiers)
}
}
}
// **A shared repository moves only what a change's files are in the build source of** (novox/hq ADR 0267,
// issue 338, issue 363). The controller is built from its repository's root as a Go bundle; the route proxy
// and the build seat's holder build images whose context is that repository and which name the package they
// compile. Each newest trunk build said its build source — the import closure of its program — and a merge
// is mapped onto those. The rows tagged 338 held the opposite until ADR 0267 was built: every merge to the
// controller's repository planned all three, in three tiers.
//
// The build sources are this repository's own programs as GoBuildSource reads them (held to that by
// TestThisRepositorysProgramsHaveBuildSourcesOfTheirOwn in internal/builder), cut to what the rows need.
func TestASharedRepositoryMovesOnlyWhatItsBuildSourceHolds(t *testing.T) {
const catalogueRepo = "http://forge.internal:20000/novox/mesh-catalog.git"
const controllerRepo = "http://forge.internal:20000/novox/mesh-controller.git"
entries := []inventory.Entry{
fromRepo("mesh-controller", controllerRepo, ""),
fromRepo("build-agent", catalogueRepo, "modules/build-agent"),
fromRepo("route-proxy", catalogueRepo, "modules/route-proxy"),
fromRepo("gitea", catalogueRepo, "modules/gitea"),
}
gomod := []string{"go.mod", "go.sum", "vendor/modules.txt"}
with := func(paths ...string) []string { return append(append([]string{}, gomod...), paths...) }
read := map[string][]inventory.ReadRepository{
"mesh-controller": {{Own: true, Paths: with("module.json", "cmd/mesh-controller/", "internal/conditions/",
"internal/broker/", "internal/builder/", "internal/inventory/", "internal/inventory/migrations/**",
"vendor/github.com/nats-io/nats.go/")}},
"build-agent": {
{Repository: "novox/mesh-controller", Ref: "main", Paths: with("cmd/mesh-builder/", "internal/broker/",
"internal/builder/", "internal/inventory/", "internal/inventory/migrations/**", "vendor/github.com/nats-io/nats.go/")},
{Own: true, Paths: []string{"modules/build-agent/Dockerfile", "modules/build-agent/module.json"}},
},
"route-proxy": {
{Repository: "novox/mesh-controller", Ref: "main", Paths: with("examples/route-proxy/", "internal/broker/",
"vendor/github.com/nats-io/nats.go/")},
{Own: true, Paths: []string{"modules/route-proxy/Dockerfile", "modules/route-proxy/module.json"}},
},
}
// With no build source said — before each module's first trunk build under ADR 0267, or while an
// earlier merge's build of it is pending — a module is read as before.
unsaid := map[string][]inventory.ReadRepository{
"build-agent": {{Repository: "novox/mesh-controller", Ref: "main"}},
"route-proxy": {{Repository: "novox/mesh-controller", Ref: "main"}},
}
edges := sharedRepositoryEdges
for _, c := range []struct {
what, repo string
paths []string
read map[string][]inventory.ReadRepository
want string
unread string
}{
// The operator's acceptance: a merge of the controller's own code plans the controller alone.
{"the controller's own command: the controller alone (338)", "mesh-controller",
[]string{"cmd/mesh-controller/main.go"}, read, "mesh-controller", ""},
{"a package only the controller builds from: the controller alone (338)", "mesh-controller",
[]string{"internal/conditions/condition.go", "internal/conditions/bus.go"}, read, "mesh-controller", ""},
{"a test beside the controller's command: nothing is built from it", "mesh-controller",
[]string{"cmd/mesh-controller/main_test.go"}, read, "", "cmd/mesh-controller/main_test.go"},
{"a README of the controller's repository: no module (338)", "mesh-controller",
[]string{"README.md"}, read, "", "README.md"},
{"the route proxy's program alone: the route proxy alone (338)", "mesh-controller",
[]string{"examples/route-proxy/main.go"}, read, "route-proxy", ""},
{"the build seat's program alone: its holder alone", "mesh-controller",
[]string{"cmd/mesh-builder/main.go"}, read, "build-agent", ""},
{"a package the build seat's program and the controller build from: both", "mesh-controller",
[]string{"internal/builder/builder.go"}, read, "mesh-controller | build-agent", ""},
{"a migration the controller and the build seat's program embed: both", "mesh-controller",
[]string{"internal/inventory/migrations/0088-a-build-says-its-build-source.sql"}, read,
"mesh-controller | build-agent", ""},
// A package all three build from: all three; the build seat's holder after the controller whose worker
// it binds (worker-of, issue 206), the proxy after the holder that builds it (built-by).
{"a package all three build from: all three", "mesh-controller",
[]string{"internal/broker/broker.go"}, read, "mesh-controller | build-agent | route-proxy", ""},
{"a vendored package all three build from: all three", "mesh-controller",
[]string{"vendor/github.com/nats-io/nats.go/nats.go"}, read, "mesh-controller | build-agent | route-proxy", ""},
{"go.sum: all three", "mesh-controller",
[]string{"go.sum"}, read, "mesh-controller | build-agent | route-proxy", ""},
{"a file added to the proxy's package: the proxy", "mesh-controller",
[]string{"examples/route-proxy/new.go"}, read, "route-proxy", ""},
// Before any build source is said: as before.
{"no build source said: a README moves all three, as before", "mesh-controller",
[]string{"README.md"}, unsaid, "mesh-controller | build-agent | route-proxy", ""},
// In the catalogue, where they live, each by its own build source.
{"the route proxy's recipe: it alone", "mesh-catalog",
[]string{"modules/route-proxy/Dockerfile"}, read, "route-proxy", ""},
{"the route proxy's manifest: it alone", "mesh-catalog",
[]string{"modules/route-proxy/module.json"}, read, "route-proxy", ""},
{"the route proxy's README: nothing", "mesh-catalog",
[]string{"modules/route-proxy/README.md"}, read, "", "modules/route-proxy/README.md"},
{"the build agent's manifest: it alone, nothing it builds", "mesh-catalog",
[]string{"modules/build-agent/module.json"}, read, "build-agent", ""},
{"another module of the catalogue: neither", "mesh-catalog",
[]string{"modules/gitea/index.ts"}, read, "gitea", ""},
} {
r, got := planMerge(t, c.repo, c.paths, entries, c.read, edges)
if got != c.want {
t.Errorf("%s: planned %q, wanted %q", c.what, got, c.want)
}
if u := strings.Join(r.Unread, ","); u != c.unread {
t.Errorf("%s: unread %q, wanted %q", c.what, u, c.unread)
}
}
// Files not all said: everything the repository builds, as before.
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main", Commit: "head",
Paths: []string{"README.md"}, PathsTruncated: true}
if got := tiered(reachOfMerge(m, entries, read, edges).Plan.Tiers); got != "mesh-controller | build-agent | route-proxy" {
t.Errorf("a merge whose files were not all said: planned %q, wanted all three", got)
}
}
// **A module whose recorded build source a plan has overtaken is read whole** (novox/hq ADR 0267): a merge
// that added an import to the route proxy is planned; before a build of it works — still building, failed,
// or its plan closed before reaching it — a merge changing only the newly imported package must still move
// the proxy, since the build source its last build said does not hold that package.
func TestAModuleAPlanOvertookIsReadWhole(t *testing.T) {
built := time.Date(2026, 10, 10, 1, 0, 0, 0, time.UTC)
read := map[string][]inventory.ReadRepository{
"route-proxy": {
{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"examples/route-proxy/", "go.mod"}, Built: built},
{Own: true, Paths: []string{"modules/route-proxy/module.json"}, Built: built},
},
"mesh-controller": {{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/"}, Built: built}},
}
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main", Paths: []string{"internal/newly/imported.go"}}
if readsFrom(read["route-proxy"], m) {
t.Fatal("the said build source holds the new package: the fixture is wrong")
}
proxy := func(state string) map[string]*inventory.PlanModule {
return map[string]*inventory.PlanModule{"route-proxy": {State: state}, "gitea": {State: "built"}}
}
for _, c := range []struct {
what string
plans []inventory.Plan
whole bool
}{
{"no plan", nil, false},
{"a plan still building it", []inventory.Plan{{State: inventory.PlanBuilding, Created: built.Add(-time.Hour),
Modules: proxy("building")}}, true},
{"a plan made after its build that failed before building it", []inventory.Plan{{State: "failed",
Created: built.Add(time.Minute), Modules: proxy("waiting")}}, true},
{"a plan made after its build that built it", []inventory.Plan{{State: inventory.PlanDone,
Created: built.Add(time.Minute), Modules: proxy("built")}}, false},
{"a plan closed before its build", []inventory.Plan{{State: "failed", Created: built.Add(-time.Hour),
Modules: proxy("waiting")}}, false},
} {
view := planningView(read, c.plans)
if readsFrom(view["route-proxy"], m) != c.whole {
t.Errorf("%s: read whole %v, wanted %v", c.what, !c.whole, c.whole)
}
if (ownSource(view["route-proxy"]) == nil) != c.whole {
t.Errorf("%s: its own build source kept %v", c.what, ownSource(view["route-proxy"]) != nil)
}
if ownSource(view["mesh-controller"]) == nil {
t.Errorf("%s: a module no plan holds lost its build source", c.what)
}
}
}
// **A missed merge that moves only a module packaging the repository is acted on** (novox/hq ADR 0267,
// issue 266): the catch-up asks wouldMove, which counts it; once a plan or build of it is made after the
// merge, the merge is history for it, and the catch-up leaves it.
func TestAMissedMergeMovingOnlyAPackagingModuleIsActedOnOnce(t *testing.T) {
merged := time.Date(2026, 10, 10, 1, 0, 0, 0, time.UTC)
entries := []inventory.Entry{
fromRepo("mesh-controller", "http://forge.internal:20000/novox/mesh-controller.git", ""),
fromRepo("route-proxy", "http://forge.internal:20000/novox/mesh-catalog.git", "modules/route-proxy"),
}
read := map[string][]inventory.ReadRepository{
"mesh-controller": {{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/"}, Built: merged.Add(-time.Hour)}},
"route-proxy": {{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"examples/route-proxy/"},
Built: merged.Add(-time.Hour), Looked: merged.Add(-time.Hour)}},
}
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main", Commit: "c1",
MergedAt: merged.Format(time.RFC3339), Paths: []string{"examples/route-proxy/main.go"}}
if got := wouldMove(m, entries, planningView(read, nil)); len(got) != 1 || got[0].Manifest.Module != "route-proxy" {
t.Fatalf("a missed merge of the proxy's program would move %v", got)
}
// Acted on at once: the plan answering this very merge is a look, however close the clocks.
atOnce := []inventory.Plan{{State: inventory.PlanBuilding, Commit: "c1", Created: merged.Add(2 * time.Second),
Modules: map[string]*inventory.PlanModule{"route-proxy": {State: "building"}}}}
if got := wouldMove(m, entries, planningView(read, atOnce)); len(got) != 0 {
t.Fatalf("a merge whose own plan holds the proxy would move %v again", got)
}
acted := []inventory.Plan{{State: inventory.PlanBuilding, Created: merged.Add(2 * time.Minute),
Modules: map[string]*inventory.PlanModule{"route-proxy": {State: "building"}}}}
if got := wouldMove(m, entries, planningView(read, acted)); len(got) != 0 {
t.Fatalf("a merge acted on for the proxy would move %v again", got)
}
// A plan that closed without building it looked at nothing: the merge is still news for it.
closed := []inventory.Plan{{State: "failed", Created: merged.Add(2 * time.Minute),
Modules: map[string]*inventory.PlanModule{"route-proxy": {State: "waiting"}}}}
if got := wouldMove(m, entries, planningView(read, closed)); len(got) != 1 {
t.Fatalf("a plan that never built the proxy hid the merge from it: %v", got)
}
// A look just before the merge, on clocks a little apart, is no look after it.
skewed := []inventory.Plan{{State: inventory.PlanBuilding, Created: merged.Add(30 * time.Second),
Modules: map[string]*inventory.PlanModule{"route-proxy": {State: "building"}}}}
if got := wouldMove(m, entries, planningView(read, skewed)); len(got) != 1 {
t.Fatalf("a look within the clocks' margin made the merge history: %v", got)
}
}
// sharedRepositoryEdges is what dependenciesOf derives for the catalogue of the test above, sorted as it
// sorts them: no packages edge (novox/hq ADR 0267 rule 4).
var sharedRepositoryEdges = []inventory.Edge{
dep("build-agent", inventory.EdgeWorkerOf, "mesh-controller"),
dep("gitea", inventory.EdgeBuiltBy, "build-agent"),
dep("mesh-controller", inventory.EdgeBuiltBy, "build-agent"),
dep("route-proxy", inventory.EdgeBuiltBy, "build-agent"),
}
// **The planner's invariant, over random catalogues.** For any catalogue whose dependencies form no cycle
// and any set of changed files in one repository:
//
// - the plan is exactly the modules of that repository whose directory holds a changed file (every file,
// for a module built from the root), and everything reachable from them along stands-on and declared —
// never along packages (novox/hq ADR 0267), built-by or worker-of;
// - every stands-on, declared, built-by and worker-of edge with both ends in the plan has the module
// depended on in an earlier tier;
// - no cycle is said.
//
// Seeded, so a failure is replayed by its seed and case.
func TestAPlanIsTheTouchedModulesAndWhatIsReachableAlongTheWideningEdges(t *testing.T) {
kinds := []string{inventory.EdgeStandsOn, inventory.EdgeDeclared, inventory.EdgePackages,
inventory.EdgeBuiltBy, inventory.EdgeWorkerOf}
widens := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true}
orders := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true,
inventory.EdgeBuiltBy: true, inventory.EdgeWorkerOf: true}
// Directory names drawn from one pool, so two repositories hold directories of the same name, and one
// is a prefix of another.
dirs := []string{"a", "ab", "b", "c", "lib/x", "lib/y", "modules/a", "modules/a/sub"}
files := []string{"README.md", "merge-check.sh", "lib/z.go", "docs/x.md", "modules/README.md"}
falseCycles, firstFalseCycle := 0, ""
for _, seed := range []uint64{1, 2, 3, 0x338, 0x162} {
rng := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
for n := 0; n < 100; n++ {
repos := 1 + rng.IntN(3)
repoName := func(i int) string { return fmt.Sprintf("r%d", i) }
count := 1 + rng.IntN(12)
var entries []inventory.Entry
repoOf, dirOf := map[string]int{}, map[string]string{}
for i := 0; i < count; i++ {
name := fmt.Sprintf("m%02d", i)
repo := rng.IntN(repos)
dir := dirs[rng.IntN(len(dirs))]
if rng.IntN(12) == 0 {
dir = "" // built from the repository's root
}
repoOf[name], dirOf[name] = repo, dir
entries = append(entries, fromRepo(name, "http://forge.internal:20000/novox/"+repoName(repo)+".git", dir))
}
// A graph with no cycle: a module depends only on modules made before it.
var edges []inventory.Edge
for i := 1; i < count; i++ {
for j := 0; j < i; j++ {
if rng.IntN(4) == 0 {
edges = append(edges, dep(fmt.Sprintf("m%02d", i), kinds[rng.IntN(len(kinds))], fmt.Sprintf("m%02d", j)))
}
}
}
merged := rng.IntN(repos)
var paths []string
for k := 1 + rng.IntN(4); k > 0; k-- {
if rng.IntN(3) == 0 {
paths = append(paths, files[rng.IntN(len(files))])
} else {
paths = append(paths, dirs[rng.IntN(len(dirs))]+"/f.go")
}
}
// What the rules say.
want := map[string]bool{}
for _, e := range entries {
name := e.Manifest.Module
if repoOf[name] != merged {
continue
}
for _, p := range paths {
if d := dirOf[name]; d == "" || p == d || strings.HasPrefix(p, d+"/") {
want[name] = true
}
}
}
for grew := true; grew; {
grew = false
for _, e := range edges {
if widens[e.Kind] && want[e.To] && !want[e.From] {
want[e.From], grew = true, true
}
}
}
r, _ := planMerge(t, repoName(merged), paths, entries, nil, edges)
got := map[string]bool{}
tierOf := map[string]int{}
for i, tier := range r.Plan.Tiers {
for _, name := range tier {
got[name], tierOf[name] = true, i
}
}
replay := func() string {
return fmt.Sprintf("seed %#x case %d: repository %s, files %v\n modules %v\n edges %v\n tiers %v",
seed, n, repoName(merged), paths, describe(entries), edges, r.Plan.Tiers)
}
if !sameSet(got, want) {
t.Fatalf("planned %v, wanted %v\n%s", keys(got), keys(want), replay())
}
for _, e := range edges {
if orders[e.Kind] && got[e.From] && got[e.To] && tierOf[e.To] >= tierOf[e.From] {
t.Fatalf("%s %s %s, and %s is in tier %d, not before %s's %d\n%s", e.From, e.Kind, e.To,
e.To, tierOf[e.To], e.From, tierOf[e.From], replay())
}
}
if hasCycle(r.Plan.Tiers, edges) {
falseCycles++
if firstFalseCycle == "" {
firstFalseCycle = replay()
}
}
}
}
// Said once, after the other invariants have run over every case, so it hides none of them (hasCycle
// counted a packages edge on main at 8170fc5: 19 of these 500 cases).
if falseCycles > 0 {
t.Errorf("a cycle said of a graph with none in %d of 500 cases; "+
"the first:\n%s", falseCycles, firstFalseCycle)
}
}
func sameSet(a, b map[string]bool) bool {
if len(a) != len(b) {
return false
}
for k := range a {
if !b[k] {
return false
}
}
return true
}
func keys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func describe(entries []inventory.Entry) []string {
var out []string
for _, e := range entries {
out = append(out, fmt.Sprintf("%s@%s:%q", e.Manifest.Module,
strings.TrimSuffix(strings.TrimPrefix(e.Source.Repository, "http://forge.internal:20000/novox/"), ".git"),
e.Source.Path))
}
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)
}
}
}
-378
View File
@@ -1,378 +0,0 @@
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
}
@@ -1,265 +0,0 @@
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())
}
}
+72 -172
View File
@@ -252,10 +252,6 @@ func askEveryResolver(ctx context.Context, resolvers map[string]string, places [
v.wrong[0], andMore(len(v.wrong)-1))
} else {
// Nothing but silence: held for the next run, which raises it if the resolver is still silent.
// Refused on every try too: a few hundred milliseconds of refusals is a resolver restarting as
// well as one that stopped, and the two looks a finding needs are this run and the next
// (novox/hq issue 348). The machine's own node-engine is the fast detector: its names check
// raised the control node's resolver within a minute on 2026-10-09.
o.Confirm = true
o.Summary = fmt.Sprintf("the mesh's resolver on %s does not answer: %d of the %d question(s) about the "+
"machines' names went unanswered, each asked %d times — the first, %s", node, len(v.unanswered), v.asked,
@@ -423,9 +419,8 @@ func askResolver(ctx context.Context, at, name string, kind dnsmessage.Type) ([]
return addresses, answer.RCode, nil
}
// probeHolders is D3: every seat with a verb its holder must serve has, on every machine that holds it
// and is heard from, a holder answering the bus's discovery for that seat. A seat whose verbs are all
// still optional is not asked about (holdingNeedsAnAnswer). A machine past its heartbeat's bound is
// probeHolders is D3: every seat that serves verbs has, on every machine that holds it and is heard
// from, a holder answering the bus's discovery for that seat. A machine past its heartbeat's bound is
// S1's, and is not asked about here.
func probeHolders(ctx context.Context, d *doctor) ([]conditions.Observation, error) {
entries, err := d.open.inventory.Catalogued(ctx)
@@ -441,7 +436,44 @@ func probeHolders(ctx context.Context, d *doctor) ([]conditions.Observation, err
if err != nil {
return nil, err
}
expected := holdersToHear(catalogue.SeatsWithAProtocol(), recorded, entries, heard, delivered, time.Now())
now := time.Now()
expected := map[string]map[string]bool{} // seat → machine
add := func(seat, node string) {
if expected[seat] == nil {
expected[seat] = map[string]bool{}
}
expected[seat][node] = true
}
for _, s := range catalogue.SeatsWithAProtocol() {
if len(s.Serves) == 0 || s.Name == catalogue.ControllerSeatName {
continue // a seat with no verb has nothing to answer with; this controller is answering now
}
onRecord := false
for _, h := range recorded {
if h.Claim == s.Name && heard[h.Node] {
add(s.Name, h.Node)
onRecord = true
}
}
if onRecord && s.Scope == catalogue.ScopeMesh {
continue
}
for _, e := range entries {
for _, c := range e.Manifest.Claims {
if c.Name != s.Name {
continue
}
for _, node := range e.On {
// Assigned is not there yet: a holder is expected once its machine was sent it and
// had time to report, never between `assign` and `push` (novox/hq issue 275).
if heard[node] && (s.Scope == catalogue.ScopeNode || !onRecord) &&
delivered[node].settled(e.Manifest.Module, now) {
add(s.Name, node)
}
}
}
}
}
if len(expected) == 0 {
return nil, nil
}
@@ -477,11 +509,6 @@ func probeHolders(ctx context.Context, d *doctor) ([]conditions.Observation, err
}
}
}
return silentHolders(expected, answering), nil
}
// silentHolders is every expected holder the discovery did not hear, as D3's observations.
func silentHolders(expected, answering map[string]map[string]bool) []conditions.Observation {
var out []conditions.Observation
for seat, nodes := range expected {
for node := range nodes {
@@ -494,71 +521,7 @@ func silentHolders(expected, answering map[string]map[string]bool) []conditions.
Said: fmt.Sprintf("no answer for %s from %s to the bus's discovery", seat, node)})
}
}
return sortedFound(out)
}
// holdersToHear is D3's expectation: seat → every machine whose holder of it must answer the bus's
// discovery. A seat is listed only if holding it requires serving a verb (holdingNeedsAnAnswer); a holder
// is expected on a machine that is heard from and that holds it on record or — for a node seat, or a mesh
// seat nobody holds on record — was sent the module that claims it and had time to report.
func holdersToHear(seats []catalogue.Seat, recorded []catalogue.Held, entries []inventory.Entry,
heard map[string]bool, delivered map[string]lastSend, now time.Time) map[string]map[string]bool {
expected := map[string]map[string]bool{} // seat → machine
add := func(seat, node string) {
if expected[seat] == nil {
expected[seat] = map[string]bool{}
}
expected[seat][node] = true
}
for _, s := range seats {
if s.Name == catalogue.ControllerSeatName {
continue // this controller is answering now
}
if !holdingNeedsAnAnswer(s) {
continue // nothing its holder must serve, so nothing its silence would say (novox/hq issue 299)
}
onRecord := false
for _, h := range recorded {
if h.Claim == s.Name && heard[h.Node] {
add(s.Name, h.Node)
onRecord = true
}
}
if onRecord && s.Scope == catalogue.ScopeMesh {
continue
}
for _, e := range entries {
for _, c := range e.Manifest.Claims {
if c.Name != s.Name {
continue
}
for _, node := range e.On {
// Assigned is not there yet: a holder is expected once its machine was sent it and
// had time to report, never between `assign` and `push` (novox/hq issue 275).
if heard[node] && (s.Scope == catalogue.ScopeNode || !onRecord) &&
delivered[node].settled(e.Manifest.Module, now) {
add(s.Name, node)
}
}
}
}
}
return expected
}
// holdingNeedsAnAnswer says whether a seat's holder is judged silent when the bus's discovery hears
// nothing from it: only when the seat has a verb its holder must serve. **Silence is judged on required
// verbs alone** (novox/hq issue 299). An optional verb is one the seat's holders are still catching up
// to (Verb.Optional; design 33 §7, novox/hq ADR 0246): a holder that does not serve it yet holds the seat
// rightly, and a holder serving nothing because every verb is still optional is exactly that holder, not
// a silent one. A seat with no verb at all has nothing to answer with either.
func holdingNeedsAnAnswer(s catalogue.Seat) bool {
for _, v := range s.Serves {
if !v.Optional {
return true
}
}
return false
return sortedFound(out), nil
}
// reportGrace is how long a machine is given, after it was sent a declaration, to apply it and report
@@ -641,8 +604,31 @@ const (
// discoverHolders asks the bus's discovery who serves what, and answers seat → machine for every
// endpoint a seat's verb is served on.
func discoverHolders(ctx context.Context, conn *nats.Conn) (map[string]map[string]bool, error) {
inbox := conn.NewRespInbox()
sub, err := conn.SubscribeSync(inbox)
if err != nil {
return nil, err
}
defer func() { _ = sub.Unsubscribe() }()
if err := conn.PublishRequest("$SRV.INFO", inbox, nil); err != nil {
return nil, fmt.Errorf("asking the bus who serves what: %w", err)
}
out := map[string]map[string]bool{}
err := discoverServices(ctx, conn, func(info micro.Info) {
deadline := time.Now().Add(discoveryPatience)
for time.Now().Before(deadline) {
wait, cancel := context.WithTimeout(ctx, discoveryQuiet)
msg, err := sub.NextMsgWithContext(wait)
cancel()
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
break
}
var info micro.Info
if json.Unmarshal(msg.Data, &info) != nil {
continue
}
for _, e := range info.Endpoints {
seat, node := e.Metadata["seat"], e.Metadata["node"]
if seat == "" {
@@ -661,69 +647,8 @@ func discoverHolders(ctx context.Context, conn *nats.Conn) (map[string]map[strin
out[info.Name] = map[string]bool{}
}
out[info.Name][info.ID] = true
})
return out, err
}
// discoverSeatVerbs asks the bus's discovery the same, one level finer: seat → verb → machine, for every
// seat verb answered (an endpoint's `tool` is its verb). A seat held where a verb is withheld (novox/hq ADR
// 0268) shows the seat and not that verb.
func discoverSeatVerbs(ctx context.Context, conn *nats.Conn) (map[string]map[string]map[string]bool, error) {
out := map[string]map[string]map[string]bool{}
err := discoverServices(ctx, conn, func(info micro.Info) {
for _, e := range info.Endpoints {
seat, verb, node := e.Metadata["seat"], e.Metadata["tool"], e.Metadata["node"]
if seat == "" || verb == "" {
continue
}
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
return out, nil
}
// probeArchives is D4: every archive the mesh keeps is held by its manifest in the artifact store.
@@ -917,16 +842,6 @@ func streamDiffers(want broker.Stream, have nats.StreamConfig) string {
if perSubject != 0 && have.MaxMsgsPerSubject != perSubject {
differs = append(differs, fmt.Sprintf("keeps %d per subject, defined %d", have.MaxMsgsPerSubject, perSubject))
}
if want.MaxBytes > 0 && have.MaxBytes != want.MaxBytes {
differs = append(differs, fmt.Sprintf("holds up to %d bytes, defined %d", have.MaxBytes, want.MaxBytes))
}
if want.DuplicatesSeconds > 0 && have.Duplicates != time.Duration(want.DuplicatesSeconds)*time.Second {
differs = append(differs, fmt.Sprintf("keeps one of a message id for %s, defined %s", have.Duplicates,
time.Duration(want.DuplicatesSeconds)*time.Second))
}
if want.DiscardNew && have.Discard != nats.DiscardNew {
differs = append(differs, "drops what it holds when full, defined to refuse what comes next")
}
return strings.Join(differs, "; ")
}
@@ -1082,7 +997,12 @@ func probeCoreBuilds(ctx context.Context, d *doctor) ([]conditions.Observation,
return nil, err
}
hostVersions := deliveredVersions(shelf[hostModule])
rolling := rollingModules(plans)
rolling := map[string]bool{}
for _, p := range plans {
for m := range p.Modules {
rolling[m] = true
}
}
nodes, err := inv.Nodes(ctx)
if err != nil {
return nil, err
@@ -1140,26 +1060,6 @@ func probeCoreBuilds(ctx context.Context, d *doctor) ([]conditions.Observation,
return out, nil
}
// rollingModules is every module an open plan is rolling out: those it keeps a record of, and those its
// tiers name. **A release keeps no record per module** — its walk is per machine, its modules only in its
// tier — so reading the records alone, D10 said "no plan is rolling them out" about the node-engine while
// a release walked it, and the gate on that release's first machine waited on what its own send causes
// (novox/hq issue 348). Pure.
func rollingModules(plans []inventory.Plan) map[string]bool {
rolling := map[string]bool{}
for _, p := range plans {
for m := range p.Modules {
rolling[m] = true
}
for _, tier := range p.Tiers {
for _, m := range tier {
rolling[m] = true
}
}
}
return rolling
}
// deliveredVersions are the versions a module's registered build is delivered as: the last element
// of every resource path under a `versions/` directory, which registration filled from the artifact's
// digest (catalogue `${version}`). The node-engine names itself by that directory.
-793
View File
@@ -1,793 +0,0 @@
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
}
}
-746
View File
@@ -1,746 +0,0 @@
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
}
-192
View File
@@ -1,192 +0,0 @@
package main
import (
"context"
"fmt"
"sort"
"strings"
"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"
)
// A provider down is said once, at the provider (novox/hq ADR 0240 rule 5, to-be 48 §6, Phase C).
//
// With twelve consumers of the database provision and thirty-six of a route, one provider down would be
// twelve conditions for one fault and twelve gates failed for something none of them did. So a consumer's
// check names, in `needs`, the provision it exercises; while **that provision's provider for this
// consumer** — the one the controller composed the consumer against: its recorded binding (ADR 0232), or
// the provider its credential for the provision is from — is unhealthy on the record, what the check finds
// is held under the provider's condition: listed there as waiting on it, raised as nothing of its own, and
// the consumer's gate waits rather than fails. The provider's condition is urgent while consumers wait.
//
// **Only what the check finds is held.** A consumer that is down or restarting is its own, whatever its
// provider does; so is anything a check that names no provision finds, and anything found while the
// provider is healthy. A machine-level fault is never pinned on a module (issue 281), and this does not
// change that.
// holding is what one reading of the record needs to say who waits on whom: every machine's newest
// statement, the open conditions, and the catalogue — read once, asked many times.
type holding struct {
ctx context.Context
inv *inventory.Inventory
healths map[string]inventory.NodeHealth
open []conditions.Condition
shelf map[string]catalogue.Manifest
// providers memoises providerFor by machine, consumer and provision.
providers map[string]providerLookup
}
type providerLookup struct {
chosen catalogue.Chosen
ok bool
}
// readHolding reads what the hold is judged from.
func readHolding(ctx context.Context, inv *inventory.Inventory, open []conditions.Condition) (*holding, error) {
healths, err := inv.Healths(ctx)
if err != nil {
return nil, err
}
return &holding{ctx: ctx, inv: inv, healths: healths, open: open, providers: map[string]providerLookup{}}, nil
}
// heldFinding says a resource's state is a finding of its declared check that names a provision: what
// may be held. Down and restarting are liveness, the resource's own.
func heldFinding(r inventory.ResourceHealth) bool {
return r.State == link.StateUnhealthy && r.Check != "" && r.Needs != "" &&
r.Reason != "down" && r.Reason != "restarting"
}
// providerFor is the provider composed for a consumer's provision: its recorded binding, else the
// machine its credential for the provision comes from and the module there that provides it.
func (h *holding) providerFor(machine, consumer, provision string) (catalogue.Chosen, bool) {
key := machine + "\x00" + consumer + "\x00" + provision
if p, known := h.providers[key]; known {
return p.chosen, p.ok
}
chosen, ok := h.lookUpProvider(machine, consumer, provision)
h.providers[key] = providerLookup{chosen, ok}
return chosen, ok
}
func (h *holding) lookUpProvider(machine, consumer, provision string) (catalogue.Chosen, bool) {
if bound, err := h.inv.BindingsFor(h.ctx, machine); err == nil {
if c, ok := bound[consumer][provision]; ok && c.Node != "" && c.Module != "" {
return c, true
}
}
secrets, err := h.inv.SecretsOf(h.ctx, machine, consumer)
if err != nil {
return catalogue.Chosen{}, false
}
for _, s := range secrets {
if s.Name != provision || s.Provider == "" {
continue
}
if h.shelf == nil {
if h.shelf, err = h.inv.Catalogue(h.ctx); err != nil {
return catalogue.Chosen{}, false
}
}
assigned, err := h.inv.Assigned(h.ctx, s.Provider)
if err != nil {
return catalogue.Chosen{}, false
}
for _, module := range assigned {
for _, offer := range h.shelf[module].Offers() {
if offer == provision {
return catalogue.Chosen{Node: s.Provider, Module: module}, true
}
}
}
}
return catalogue.Chosen{}, false
}
// unhealthy says a provider is unhealthy on the record: its condition is open, or its machine's newest
// statement says a resource of it is unhealthy.
func (h *holding) unhealthy(p catalogue.Chosen) bool {
key := moduleUnhealthyKey(p.Module, p.Node)
for _, c := range h.open {
if c.Key == key {
return true
}
}
for _, r := range h.healths[p.Node].Resources {
if r.Module == p.Module && r.State == link.StateUnhealthy {
return true
}
}
return false
}
// heldUnder is the provider a consumer's unhealthy resources wait on: when every one of them is a finding
// of a check naming a provision whose provider for this consumer is unhealthy on the record. False when any
// is the consumer's own.
func (h *holding) heldUnder(machine, module string, rs []inventory.ResourceHealth) (catalogue.Chosen, bool) {
var on catalogue.Chosen
for _, r := range rs {
if r.State != link.StateUnhealthy {
continue
}
if !heldFinding(r) {
return catalogue.Chosen{}, false
}
p, ok := h.providerFor(machine, module, r.Needs)
if !ok || (p.Node == machine && p.Module == module) || !h.unhealthy(p) {
return catalogue.Chosen{}, false
}
on = p
}
return on, on.Module != ""
}
// waitersOn is every consumer held under a provider, as "<module> on <machine>", sorted.
func (h *holding) waitersOn(p catalogue.Chosen) []string {
var out []string
for machine, nh := range h.healths {
byModule := map[string][]inventory.ResourceHealth{}
for _, r := range nh.Resources {
if r.Module != "" && r.State == link.StateUnhealthy {
byModule[r.Module] = append(byModule[r.Module], r)
}
}
for module, rs := range byModule {
if on, held := h.heldUnder(machine, module, rs); held && on == p {
out = append(out, module+" on "+machine)
}
}
}
sort.Strings(out)
return out
}
// heldModules is, for one machine's statement, each module whose finding is held, with the provider.
func (h *holding) heldModules(machine string) map[string]catalogue.Chosen {
out := map[string]catalogue.Chosen{}
byModule := map[string][]inventory.ResourceHealth{}
for _, r := range h.healths[machine].Resources {
if r.Module != "" && r.State == link.StateUnhealthy {
byModule[r.Module] = append(byModule[r.Module], r)
}
}
for module, rs := range byModule {
if on, held := h.heldUnder(machine, module, rs); held {
out[module] = on
}
}
return out
}
// moduleUnhealthyKey is a module's health condition's key on a machine.
func moduleUnhealthyKey(module, machine string) string {
return conditions.ScopeModule + "." + module + "." + machine + ".unhealthy"
}
// waitingWords is the provider's evidence that consumers wait on it.
func waitingWords(waiters []string) string {
return fmt.Sprintf("waiting on it — %s", strings.Join(waiters, ", "))
}
-172
View File
@@ -1,172 +0,0 @@
package main
import (
"context"
"slices"
"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"
)
// A provider down is said once, at the provider (novox/hq ADR 0240 rule 5, "how it is checked"): one
// unhealthy database provider and three consumers failing their checks that need it — one condition, at the
// provider, urgent, the consumers listed as waiting; their gates wait, not fail; once the provider is
// healthy, a consumer still failing is its own. A consumer failing while its provider is healthy is raised
// on its own from the start.
func TestOneProviderDownAndThreeConsumersFailingAreOneCondition(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
k := conditionsFrom
register(t, open, catalogue.Manifest{Module: "db", Version: "1",
Provides: []catalogue.Offer{{Name: "postgres-database", Scope: catalogue.ScopeMesh}}})
consumers := []string{"shop", "wiki", "crm"}
for _, c := range consumers {
register(t, open, catalogue.Manifest{Module: c, Version: "1", Requires: []string{"postgres-database"}})
}
if _, err := inv.Assign(ctx, "anchor", "db"); err != nil {
t.Fatal(err)
}
machineOf := map[string]string{"shop": "laptop", "wiki": "laptop", "crm": "anchor"}
for _, c := range consumers {
if _, err := inv.Assign(ctx, machineOf[c], c); err != nil {
t.Fatal(err)
}
if err := inv.RecordBindings(ctx, machineOf[c], []inventory.Binding{{Machine: machineOf[c], Consumer: c,
Provision: "postgres-database", Provider: catalogue.Chosen{Node: "anchor", Module: "db"}}}); err != nil {
t.Fatal(err)
}
}
at := h0
say := func(machine string, rs ...link.ResourceHealth) {
t.Helper()
at = at.Add(time.Second)
for i := range rs {
rs[i].Since = at
}
if err := stateHealth(ctx, inv, k, machine, link.Health{Contract: link.ReadinessContract, At: at, Resources: rs}, at); err != nil {
t.Fatal(err)
}
}
dbDown := link.ResourceHealth{Module: "db", Resource: "db.server", Kind: "container", Target: "db", State: link.StateUnhealthy,
Reason: "its command: the database refuses connections", Check: "exec"}
dbUp := dbDown
dbUp.State, dbUp.Reason = link.StateHealthy, ""
failing := func(module string) link.ResourceHealth {
return link.ResourceHealth{Module: module, Resource: module + ".web", Kind: "container", Target: module,
State: link.StateUnhealthy, Reason: "http /health on web: answered 500", Check: "http", Needs: "postgres-database"}
}
fine := func(module string) link.ResourceHealth {
r := failing(module)
r.State, r.Reason = link.StateHealthy, ""
return r
}
openKeys := func() []conditions.Condition {
t.Helper()
list, err := k.Open(ctx)
if err != nil {
t.Fatal(err)
}
return list
}
// Two looks of the provider down and its consumers failing, in either order on each machine.
for look := 0; look < 2; look++ {
say("laptop", failing("shop"), failing("wiki"))
say("anchor", dbDown, failing("crm"))
}
raised := openKeys()
if len(raised) != 1 || raised[0].Key != "module.db.anchor.unhealthy" {
var keys []string
for _, c := range raised {
keys = append(keys, c.Key)
}
t.Fatalf("one provider down and three consumers failing raised %v; want the provider's alone", keys)
}
c := raised[0]
if c.Severity != conditions.Urgent {
t.Errorf("consumers wait on the provider and its condition is %s", c.Severity)
}
said := c.Evidence[0].Said
for _, w := range []string{"shop on laptop", "wiki on laptop", "crm on anchor"} {
if !strings.Contains(said, w) {
t.Errorf("the provider's condition does not list %s as waiting on it: %s", w, said)
}
}
// Their gates wait, not fail: the judging is neither a pass nor a fault, past the bound too.
f, err := gatherGateFacts(ctx, open, "")
if err != nil {
t.Fatal(err)
}
f.open, f.openErr, f.judged = raised, nil, false
for _, m := range consumers {
h, why := moduleHealthWord(m, machineOf[m], h0, f)
if h != healthWaiting || !strings.Contains(why, "waits on db on anchor") {
t.Errorf("%s's gate: %v %q; want it waiting on its provider", m, h, why)
}
}
// And a whole judging past the bound puts nothing back: it waits.
long := h0.Add(-time.Hour)
for _, m := range []string{"anchor", "laptop"} {
f.reports[m] = inventory.Reported{Node: m, Outcome: inventory.OutcomeApplied, At: &f.now, Current: true}
}
gatherWas := gatherGateFacts
defer func() { gatherGateFacts = gatherWas }()
gatherGateFacts = func(context.Context, *stores, string) (gateFacts, error) { return f, nil }
g := &inventory.PlanGate{Machines: []string{"laptop"}, Since: &long}
verdict, err := judgeMoves(ctx, open, g, []judged{{module: "shop", node: "laptop"}}, time.Now())
if err != nil || verdict != "" || !strings.Contains(g.Last, "waits on db on anchor") {
t.Fatalf("a held consumer's gate past its bound: verdict %q (%v), last %q; want it waiting", verdict, err, g.Last)
}
// The provider healthy again: a consumer still failing is now its own, at once (its streak stood).
say("anchor", dbUp, fine("crm"))
say("laptop", failing("shop"), fine("wiki"))
var keys []string
for _, c := range openKeys() {
keys = append(keys, c.Key)
}
if !slices.Equal(keys, []string{"module.shop.laptop.unhealthy"}) {
t.Fatalf("after the provider recovered: %v; want shop's own and nothing else", keys)
}
}
// A consumer failing while its provider is healthy is raised on its own.
func TestAConsumerFailingBesideAHealthyProviderIsItsOwn(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
register(t, open, catalogue.Manifest{Module: "db", Version: "1",
Provides: []catalogue.Offer{{Name: "postgres-database", Scope: catalogue.ScopeMesh}}})
register(t, open, catalogue.Manifest{Module: "shop", Version: "1", Requires: []string{"postgres-database"}})
for _, a := range [][2]string{{"anchor", "db"}, {"laptop", "shop"}} {
if _, err := inv.Assign(ctx, a[0], a[1]); err != nil {
t.Fatal(err)
}
}
if err := inv.RecordBindings(ctx, "laptop", []inventory.Binding{{Machine: "laptop", Consumer: "shop",
Provision: "postgres-database", Provider: catalogue.Chosen{Node: "anchor", Module: "db"}}}); err != nil {
t.Fatal(err)
}
healthy := link.ResourceHealth{Module: "db", Resource: "db.server", Kind: "container", Target: "db", State: link.StateHealthy, Check: "exec"}
shop := link.ResourceHealth{Module: "shop", Resource: "shop.web", Kind: "container", Target: "shop", State: link.StateUnhealthy,
Reason: "http /health on web: answered 500", Check: "http", Needs: "postgres-database"}
for i := 1; i <= 2; i++ {
at := h0.Add(time.Duration(i) * time.Minute)
_ = stateHealth(ctx, inv, conditionsFrom, "anchor", link.Health{Contract: 2, At: at, Resources: []link.ResourceHealth{healthy}}, at)
if err := stateHealth(ctx, inv, conditionsFrom, "laptop", link.Health{Contract: 2, At: at, Resources: []link.ResourceHealth{shop}}, at); err != nil {
t.Fatal(err)
}
}
list, _ := conditionsFrom.Open(ctx)
if len(list) != 1 || list[0].Key != "module.shop.laptop.unhealthy" {
t.Fatalf("a consumer failing beside a healthy provider raised %v", list)
}
}
+8 -153
View File
@@ -65,8 +65,6 @@ func connectLink(ctx context.Context, inv *inventory.Inventory, enroller link.En
}
func serve(ctx context.Context) (err error) {
// Nothing this process does, or starts, is the operator at the terminal (novox/hq ADR 0266).
markServed()
// The one process whose log is read over time, so the one that says each change to a node's
// unmet seat dependencies once (novox/hq ADR 0207).
logUnheldChanges = true
@@ -76,21 +74,6 @@ func serve(ctx context.Context) (err error) {
}
defer open.Close()
inv := open.inventory
// **What this build reads of the store's schema, on record** (novox/hq issue 352): the highest migration
// it carries, by its version, so a gate that would put this build back later knows it reads the store
// as it is then. A build that does not know its version records nothing, and is never put back.
if build := runningBuild(); build != "" {
if reach, err := schemaReach(); err != nil {
fmt.Printf("what this build reads of the store's schema is not recorded: %v\n", err)
} else if err := inv.RecordSchemaReach(ctx, build, reach); err != nil {
fmt.Printf("what this build (%s) reads of the store's schema is not recorded: %v\n", build, err)
} else {
fmt.Printf("this build (%s) reads the store's schema up to migration %04d; recorded\n", build, reach)
}
} else {
fmt.Printf("this process was not told which build it is (%s), so what it reads of the store's schema is not "+
"recorded, and a gate will never put it back\n", RunningBuildVar)
}
ident, err := openIdentity(ctx)
if err != nil {
@@ -182,13 +165,6 @@ func serve(ctx context.Context) (err error) {
// Open plans move on a timer as well as on outcomes (novox/hq ADR 0162): a tier waiting for
// machines to report moves when they have, and a plan left by a replaced controller resumes.
go planTicker(ctx, open)
// And the merge window (novox/hq ADR 0276): a batch is cut into its walk when the window closes, which no
// merge or outcome says.
go batchCutter(ctx, open)
// And the pending assignments settled on a tick of their own (novox/hq ADR 0261): made once their module
// is registered, ended with why when its build will not register it, raised and cleared as conditions.
// Never by a read.
go settlingPending(ctx, open)
// The durations the core's bounds are set from are kept a month (novox/hq to-be 45 Phase 0).
go forgettingOldDurations(ctx, inv)
// And what the catalogue decided a build meant. The builder's own result is already handled
@@ -239,10 +215,6 @@ func serve(ctx context.Context) (err error) {
}
// The hand-act log is counted for `status` on this connection rather than a new one a minute.
handActConn = bus.Conn
// And everything else this controller does on the bus for a moment (novox/hq issue 327).
servingBus.Store(server.JetStream())
// No longer serving: nothing is lent, and dead-letters says it is not read here (novox/hq issue 330).
defer servingBus.Store(nil)
// And says when it replaced a value given by hand (novox/hq ADR 0228).
givenEvents = bus
// And a pull request's merge check, asked when the forge announces its head and said when judged
@@ -284,12 +256,6 @@ func serve(ctx context.Context) (err error) {
return err
}
defer stopServing()
// And the operator's command on every node, asked through each node's engine (novox/hq ADR 0272).
stopCLI, err := bus.ServeCLI(answerMeshCLI(open.inventory), log.New(os.Stdout, "", log.LstdFlags))
if err != nil {
return err
}
defer stopCLI()
// And says so on the bus (novox/hq ADR 0197): what it serves, as the NATS services protocol asks.
stopAnnouncing, err := bus.Announce(seatAnnouncement(handlers), log.New(os.Stdout, "", log.LstdFlags))
if err != nil {
@@ -393,31 +359,14 @@ func pushCommand(ctx context.Context, args []string) error {
// A push by hand is a repair, and says why (novox/hq to-be 45 §7): required through the seat,
// recorded when given at a shell — see handacts.go for why a shell is not refused.
why := addHandActFlags(set)
// Every machine at once is said, not defaulted to (novox/hq ADR 0217): a bare `push` sent the
// whole mesh on 2026-10-05 when its usage was wanted.
all := set.Bool("all", false, "every machine")
// A running module's data moving is sent only when said, per module (novox/hq ADR 0217).
move := set.String("move", "", "modules whose data may move with this push, comma-separated")
positionals, err := parseAround(set, args)
if err != nil {
return err
}
args = positionals
if len(args) > 1 {
return errors.New("push <node> | push --behind | push --all — one machine, the ones behind, or all of them")
return errors.New("push [<node>] [--behind] — one node, or all of them")
}
if len(args) == 0 && !*behind && !*all {
return errors.New("push names a machine, or says --behind (the ones not running what they " +
"should) or --all (every machine) — a push to the whole mesh is not the default (novox/hq ADR 0217)")
}
if *all && (*behind || len(args) == 1) {
return errors.New("push --all is every machine; it takes no machine and no --behind")
}
movable := map[string]bool{}
for _, m := range splitModules(*move) {
movable[m] = true
}
var heldBack []string
if len(args) == 1 && *behind {
// Naming a machine and asking for the ones that need it are two different requests, and
// guessing which was meant would sometimes push to a machine somebody did not name.
@@ -428,12 +377,6 @@ func pushCommand(ctx context.Context, args []string) error {
if *behind {
recorded = append(recorded, "--behind")
}
if *all {
recorded = append(recorded, "--all")
}
if *move != "" {
recorded = append(recorded, "--move", *move)
}
why.record(ctx, "push", recorded)
open, err := openStores(ctx)
if err != nil {
@@ -644,28 +587,19 @@ func pushCommand(ctx context.Context, args []string) error {
})
defer release()
// A machine whose declaration would move a running module's data is held, and only it (novox/hq
// ADR 0217): said, with the command that sends it, and left out of everything below.
toSend, err := holdingBack(ctx, inv, sending, movable, &heldBack)
if err != nil {
return err
}
// **What it recreates, said before it is sent** (novox/hq ADR 0245): a person's push moves every
// module whose build changed, and recreates what changed in it — a gated send said so, a push did not.
sayWhatAPushRecreates(ctx, open, toSend, os.Stdout)
// Each machine's memberships first, then the declarations (novox/hq issue 249, ADR 0160): a push
// is the one most operators run, and on 2026-10-01 it was the one path that issued none.
bus := overTheBus{open: open, server: server, signer: ident}
sentDigest, err := deliver(ctx, bus, holder, toSend)
sentDigest, err := deliver(ctx, bus, holder, sending)
if err != nil {
return err
}
release()
told := make([]string, 0, len(toSend))
for _, r := range toSend {
told := make([]string, 0, len(sending))
for _, r := range sending {
told = append(told, r.node)
}
fmt.Printf("\n%d node(s) told: %s\n", len(toSend), strings.Join(told, ", "))
fmt.Printf("\n%d node(s) told: %s\n", len(sending), strings.Join(told, ", "))
reportUnheldPushed(os.Stdout, len(args) == 1, asked, unheld)
// **A named push leaves the mesh consistent, not just the machine it named** (novox/hq
@@ -687,12 +621,7 @@ func pushCommand(ctx context.Context, args []string) error {
for n := range saidHeld {
handled[n] = true
}
// The cascade is a push too, and a machine in it whose data would move is held the same way
// (novox/hq ADR 0217).
keep := keeping(func(held context.Context, sending []readyNode) ([]readyNode, error) {
return holdingBack(held, inv, sending, movable, &heldBack)
})
refused, err := flushBehind(ctx, open, nodes, handled, composeForPush(open, gens), bus, holder, keep, os.Stdout)
refused, err := flushBehind(ctx, open, nodes, handled, composeForPush(open, gens), bus, holder, os.Stdout)
refusals = append(refusals, refused...)
if err != nil {
return err
@@ -707,11 +636,6 @@ func pushCommand(ctx context.Context, args []string) error {
return err
}
}
if len(heldBack) > 0 {
sort.Strings(heldBack)
return fmt.Errorf("held %s: a running module's data would move — see above; nothing was sent "+
"there (novox/hq ADR 0217)", strings.Join(heldBack, ", "))
}
return couldNotBeResolved(refusals, len(sending))
}
@@ -820,7 +744,7 @@ func composeEach(names []string, allot func(node string) (order, error),
// still sent. Their memberships go before their declarations, as every send's do (issue 249).
func sendRound(ctx context.Context, open *stores, names []string,
compose func(held context.Context, node string) (sendable, error),
d delivery, holder string, keep keeping) ([]string, error) {
d delivery, holder string) ([]string, error) {
held, release, err := holdNodes(ctx, open, names)
if err != nil {
return nil, err
@@ -829,46 +753,12 @@ func sendRound(ctx context.Context, open *stores, names []string,
sending, refused := composeEach(names, allotting(held, open.inventory), func(node string) (sendable, error) {
return compose(held, node)
})
if keep != nil {
if sending, err = keep(held, sending); err != nil {
return refused, err
}
}
if _, err := deliver(held, d, holder, sending); err != nil {
return refused, err
}
return refused, nil
}
// keeping is what a round of sends keeps of the machines it composed, before any is delivered: nil
// keeps them all. A push holds back the machines whose running modules' data would move (holdingBack).
type keeping func(held context.Context, sending []readyNode) ([]readyNode, error)
// holdingBack leaves out each machine whose declaration would move a running module's data, says so,
// and names it among those held back (novox/hq ADR 0217); the rest go on to be delivered, grants first
// (issue 249). A declaration's body is the same bytes however often it is read.
func holdingBack(ctx context.Context, inv *inventory.Inventory, sending []readyNode, movable map[string]bool,
heldBack *[]string) ([]readyNode, error) {
var out []readyNode
for _, s := range sending {
body, err := s.declared.Body()
if err != nil {
return nil, err
}
moves, err := heldMoves(ctx, inv, s.node, body, movable)
if err != nil {
return nil, err
}
if len(moves) > 0 {
sayDataHeld(os.Stdout, s.node, moves)
*heldBack = append(*heldBack, s.node)
continue
}
out = append(out, s)
}
return out, nil
}
// delivery is the two acts of sending machines what they should be, apart, so the order between
// them is one function's and can be read and tested there (novox/hq issue 249).
type delivery interface {
@@ -1192,13 +1082,6 @@ func sendToEach(ctx context.Context, open *stores, names []string) ([]string, er
return nil, err
}
// **A recorded build moves only by a person's push** (novox/hq issue 295, ADR 0245): this send — a
// plan's, a release plan's, a rollback's, a healer's, a rotation's — composes every recorded module at
// the build its machine runs. The bus step is a person's word for the bus alone.
if ctx, err = sendKeeps(ctx, inv); err != nil {
return nil, err
}
// Held from composing to sending (novox/hq ADR 0100); a caller that holds them already —
// converge, which flips the node and then sends it — is not made to wait on itself.
ctx, release, err := holdNodes(ctx, open, names)
@@ -1268,14 +1151,11 @@ func issueMemberships(ctx context.Context, open *stores, server *link.Server, se
if err != nil {
return err
}
where := broker.PlacementsOf(records, records.Interchangeable)
bus, ok := server.Bus().(link.OverNATS)
if !ok {
return nil
}
// Which machines are root-free now (novox/hq ADR 0259 §8): a channel's verified sender is composed for the
// router only from one, beside a router on one. Judged once per push, by the root-free verb's judgement.
records.RootFree = rootFreeNow(ctx, newRootReader(ctx, open.inventory, bus.Conn), records.Nodes, time.Now())
where := broker.PlacementsOf(records, records.Interchangeable)
// **Every declared state's bucket, before the memberships that name it** (novox/hq ADR 0201). The
// raise at start asserts them too, but a module registered and assigned since would otherwise have
// its bucket only after the control plane next restarts — found the first time a module declared
@@ -1583,16 +1463,6 @@ func recordSent(ctx context.Context, inv *inventory.Inventory, node string, body
if err := inv.RecordSentUnder(kept, record.ID, digest, builds, epoch); err != nil {
return "", err
}
// And what it was, summarised, so the next push can be compared with it (novox/hq ADR 0217).
// Never a reason to fail a send that is already away: a summary that cannot be kept means the
// next comparison has nothing to hold against, which is how every machine starts.
if summary, err := summarize(body); err == nil {
if raw, err := json.Marshal(summary); err == nil {
if err := inv.RecordSentSummary(kept, record.ID, raw); err != nil {
fmt.Fprintf(os.Stderr, "%s: what it was sent is not kept for comparison: %v\n", node, err)
}
}
}
return digest, nil
}
@@ -1616,18 +1486,3 @@ func reportUnheldPushed(w io.Writer, named bool, asked []string, unheld map[stri
fmt.Fprintf(w, "%s: %d unmet seat dependenc(ies) — see `status`\n", node, len(lines))
}
}
// schemaReach is the highest migration this build carries for the inventory's store (novox/hq issue 352).
func schemaReach() (int, error) {
migrations, err := inventory.Migrations()
if err != nil {
return 0, err
}
reach := 0
for _, m := range migrations {
if m.Number > reach {
reach = m.Number
}
}
return reach, nil
}

Some files were not shown because too many files have changed in this diff Show More