Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0559b80887 | ||
|
|
74d35600a6 | ||
|
|
d19c9ed5b7 | ||
|
|
799eec0a5a | ||
|
|
ad406e81b8 | ||
|
|
646c5e53db | ||
|
|
2190e2c664 | ||
|
|
0909d7b125 | ||
|
|
744b0b9162 | ||
|
|
8de4dc7951 | ||
|
|
2913c54c29 | ||
|
|
ef26d4cb0f | ||
|
|
d9a730307c | ||
|
|
9ca7952d5e | ||
|
|
58cb586c37 | ||
|
|
c3c56a69e2 | ||
|
|
63b87b6f7b | ||
|
|
997a4925b0 | ||
|
|
077ddf0eb8 | ||
|
|
c58516f819 | ||
|
|
54b04a7604 | ||
|
|
af025562c7 | ||
|
|
c544c2a17b | ||
|
|
5866b2db94 | ||
|
|
983bf65141 | ||
|
|
0e46b8302d | ||
|
|
4c375dafed | ||
|
|
e2a45b18ba | ||
|
|
5fa8e40667 | ||
|
|
b3fd360ddb | ||
|
|
9372e80cec | ||
|
|
c9be75b13e | ||
|
|
f5f315cc95 | ||
|
|
51c8c7ec52 | ||
|
|
63e85b25e6 | ||
|
|
93c6ca5432 | ||
|
|
14ab2ddd9b | ||
|
|
510c91f144 | ||
|
|
33dc85d850 | ||
|
|
ea1d3ed96d | ||
|
|
eca6390d6f | ||
|
|
5438d7ff75 | ||
|
|
eb7a158086 | ||
|
|
03dac87d3a | ||
|
|
0c47f48537 | ||
|
|
2e1a9abbf7 | ||
|
|
95e7a7120a | ||
|
|
d15eee61bb | ||
|
|
1b780eae3a | ||
|
|
a5e8baf6da | ||
|
|
e168b60159 | ||
|
|
d951f22c04 | ||
|
|
b1897a3498 | ||
|
|
926dbd7a97 | ||
|
|
db702312af | ||
|
|
d7fe5b609b | ||
|
|
b4dff64ae0 | ||
|
|
2b8a28adab | ||
|
|
20d4f1ae71 | ||
|
|
528951319c | ||
|
|
dcbbf4487a | ||
|
|
fcfbf7e69e |
@@ -51,6 +51,9 @@ func assignWith(ctx context.Context, open *stores, node string, opts assignOptio
|
|||||||
if len(modules) == 0 {
|
if len(modules) == 0 {
|
||||||
return "", fmt.Errorf("assign %s names no module", node)
|
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
|
// 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).
|
// be taken without ever having been previewed (novox/hq ADR 0100).
|
||||||
ctx, release, err := holdNodes(ctx, open, []string{node})
|
ctx, release, err := holdNodes(ctx, open, []string{node})
|
||||||
@@ -209,6 +212,9 @@ 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
|
// (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.
|
// 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) {
|
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 {
|
if len(modules) == 0 {
|
||||||
return "", fmt.Errorf("unassign %s names no module", node)
|
return "", fmt.Errorf("unassign %s names no module", node)
|
||||||
}
|
}
|
||||||
@@ -360,3 +366,17 @@ 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)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
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 engine's bound on one search (link.RootSearchBound, the
|
||||||
|
// engine's own value), counted from when this controller first saw it waiting, never from the engine's start.
|
||||||
|
const searchQuietFor = link.RootSearchBound
|
||||||
|
|
||||||
|
// 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 first search since the node-engine started is still running: not the
|
||||||
|
// urgent condition after every restart. The agent is still not confined — ADR 0259's router reads
|
||||||
|
// agentConfined, not this — and `node show` still says not judged. Loud again once the search fails,
|
||||||
|
// runs out its bound, or the statement goes stale.
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After every node-engine restart its search for setuid programs runs for up to its bound, and the agent account
|
||||||
|
// is not judged until it ends. DA does not raise that as urgent while the search is within its bound, counted from
|
||||||
|
// when this controller first saw it waiting — never from the engine's own "since", which a restart resets, so an
|
||||||
|
// engine restarted in a loop does not keep it quiet. The account is still not confined, and `node show` still
|
||||||
|
// says not judged; a search that failed, or a way to root found, is urgent at once.
|
||||||
|
func TestASearchStillRunningAfterARestartIsNotUrgent(t *testing.T) {
|
||||||
|
if searchQuietFor != rootsearch.Bound {
|
||||||
|
t.Fatalf("the quiet is %s and the node-engine's bound %s: they are one value", searchQuietFor, rootsearch.Bound)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,801 @@
|
|||||||
|
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. 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
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 not
|
||||||
|
// asked again until the condition's answers or the channels change.
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 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)
|
||||||
|
// 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
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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
|
||||||
|
for _, r := range all {
|
||||||
|
if r.State == askOpen && !r.Rehearsal {
|
||||||
|
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.
|
||||||
|
if a.routerRecord != nil {
|
||||||
|
for _, r := range byCondition {
|
||||||
|
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.Rehearsal {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 := 0
|
||||||
|
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) && r.Channels == channels {
|
||||||
|
if _, held := byCondition[key]; !held {
|
||||||
|
if !saidUnasked {
|
||||||
|
unasked, saidUnasked = append(unasked, c), true
|
||||||
|
}
|
||||||
|
if r.Warrant != nil && r.Warrant.Words != "" {
|
||||||
|
refusedWords = append(refusedWords, r.Warrant.Words)
|
||||||
|
}
|
||||||
|
continue // refused, and nothing it was refused for has changed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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: "Needs you: answer them from the mesh MCP server. 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 // a rehearsal is 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
|
||||||
|
switch {
|
||||||
|
case r.Rehearsal && act.Verb == rehearsalVerb:
|
||||||
|
// A rehearsal's answer performs nothing: it is recorded below as the operator's decision.
|
||||||
|
case act.Arguments["silence"] != "":
|
||||||
|
acted = a.silence(ctx, act.Arguments["silence"], conditions.MaxSilence, byWords(w), why)
|
||||||
|
default:
|
||||||
|
acted = a.call(ctx, act, args)
|
||||||
|
}
|
||||||
|
ended, outcome := a.now(), "done"
|
||||||
|
if acted != nil {
|
||||||
|
outcome = "failed: " + acted.Error()
|
||||||
|
}
|
||||||
|
r.Ended, r.Acted = ended, outcome
|
||||||
|
if _, err := a.store.Change(ctx, r.ID, func(x *asked) bool {
|
||||||
|
if x.Acted != "acting" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
x.Ended, x.Acted = ended, outcome
|
||||||
|
return true
|
||||||
|
}); err != nil {
|
||||||
|
a.logf("%s was acted on (%s), and how it ended could NOT be kept: %v", r.ID, outcome, err)
|
||||||
|
}
|
||||||
|
verbArgs := []string{act.Verb}
|
||||||
|
if act.Machine != "" {
|
||||||
|
verbArgs = append(verbArgs, "on "+act.Machine)
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(args))
|
||||||
|
for k := range args {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, k := range keys {
|
||||||
|
if k != "why" {
|
||||||
|
verbArgs = append(verbArgs, k+"="+args[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := a.record(ctx, link.HandAct{Verb: handActWarrant, Args: verbArgs, Why: why, By: byWords(w),
|
||||||
|
Cause: conditions.CauseOperatorAnswer, Condition: r.Condition, Via: viaWords(w), Ask: r.ID,
|
||||||
|
Proofs: w.Proofs, RequestedBy: r.Condition, Outcome: r.Acted}); err != nil {
|
||||||
|
a.logf("%s was done, and could NOT be recorded in the hand-act log: %v", why, err)
|
||||||
|
}
|
||||||
|
a.logf("%s: %s", why, r.Acted)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handActWarrant is the verb an act the operator chose on a warrant is recorded under: a person's decision,
|
||||||
|
// never a repair (handActVerbs).
|
||||||
|
const handActWarrant = "warrant"
|
||||||
|
|
||||||
|
// byWords is who chose, as the hand-act log says it: "the operator, as telegram identity 42".
|
||||||
|
func byWords(w asks.Warrant) string {
|
||||||
|
if w.By == nil {
|
||||||
|
return "the operator"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("the %s, as %s identity %s", w.By.Who, w.By.Kind, w.By.Identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// viaWords is the channel an answer came through: its module and kind, and how the sender was known.
|
||||||
|
func viaWords(w asks.Warrant) string {
|
||||||
|
if w.By == nil {
|
||||||
|
return w.Channel
|
||||||
|
}
|
||||||
|
via := w.Channel + " (" + w.By.Kind + ")"
|
||||||
|
if w.By.Verified != "" {
|
||||||
|
via += ", " + w.By.Verified
|
||||||
|
}
|
||||||
|
return via
|
||||||
|
}
|
||||||
|
|
||||||
|
// errNotGranted is an action whose verb the controller's grant does not name.
|
||||||
|
var errNotGranted = errors.New("the controller's grant does not name this verb")
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
"github.com/nats-io/nats.go/jetstream"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
"github.com/novox/mesh-controller/internal/testbus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// busAsker is an asker on a real bus's `asked` bucket, counting what it performs: two of them are two
|
||||||
|
// controllers sharing one record.
|
||||||
|
type busAskerRig struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
called int
|
||||||
|
acts int
|
||||||
|
open []conditions.Condition
|
||||||
|
sent [][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rig *busAskerRig) asker(t *testing.T, conn *nats.Conn, now time.Time) *asker {
|
||||||
|
return &asker{
|
||||||
|
open: func(context.Context) ([]conditions.Condition, error) {
|
||||||
|
rig.mu.Lock()
|
||||||
|
defer rig.mu.Unlock()
|
||||||
|
return rig.open, nil
|
||||||
|
},
|
||||||
|
silence: func(context.Context, string, time.Duration, string, string) error { return nil },
|
||||||
|
store: busAsked{conn: conn},
|
||||||
|
publish: func(_ context.Context, subject string, body []byte, _ string) error {
|
||||||
|
rig.mu.Lock()
|
||||||
|
defer rig.mu.Unlock()
|
||||||
|
if subject == asks.AskSubject(askerName) {
|
||||||
|
rig.sent = append(rig.sent, body)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
call: func(context.Context, conditions.Action, map[string]string) error {
|
||||||
|
time.Sleep(20 * time.Millisecond) // long enough for the other delivery to arrive meanwhile
|
||||||
|
rig.mu.Lock()
|
||||||
|
defer rig.mu.Unlock()
|
||||||
|
rig.called++
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
record: func(context.Context, link.HandAct) error {
|
||||||
|
rig.mu.Lock()
|
||||||
|
defer rig.mu.Unlock()
|
||||||
|
rig.acts++
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
now: func() time.Time { return now },
|
||||||
|
logf: t.Logf,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func askedBus(t *testing.T) *nats.Conn {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := nats.Connect(testbus.URL(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(conn.Close)
|
||||||
|
js, err := jetstream.New(conn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: broker.AskedBucket}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// The review of 2026-10-09 (L7): two deliveries of one warrant, to two controllers at once, perform its act
|
||||||
|
// exactly once and record it once — the record's compare-and-set decides, never the warrant's message id.
|
||||||
|
func TestTwoAnswersAtOnceActOnce(t *testing.T) {
|
||||||
|
conn := askedBus(t)
|
||||||
|
now := time.Date(2026, 10, 9, 14, 0, 0, 0, time.UTC)
|
||||||
|
rig := &busAskerRig{open: []conditions.Condition{heldCondition()}}
|
||||||
|
first, second := rig.asker(t, conn, now), rig.asker(t, conn, now)
|
||||||
|
if err := first.reconcile(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rig.sent) != 1 {
|
||||||
|
t.Fatalf("asked %d times", len(rig.sent))
|
||||||
|
}
|
||||||
|
var q asks.Ask
|
||||||
|
_ = json.Unmarshal(rig.sent[0], &q)
|
||||||
|
release, _ := q.Option("release")
|
||||||
|
w := asks.Warrant{Ask: q.ID, Asker: askerName, About: q.About, Outcome: asks.OutcomeChosen, Option: release.ID,
|
||||||
|
Label: release.Label, Level: release.Level, Channel: "telegram", Proofs: []string{"P1"}, At: now,
|
||||||
|
AskDigest: q.Digest(), By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}
|
||||||
|
body, _ := json.Marshal(w)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, a := range []*asker{first, second, first, second} {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(a *asker) {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := a.Decided(context.Background(), body); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}(a)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if rig.called != 1 || rig.acts != 1 {
|
||||||
|
t.Fatalf("performed %d time(s), recorded %d time(s)", rig.called, rig.acts)
|
||||||
|
}
|
||||||
|
got, err := busAsked{conn: conn}.Get(context.Background(), q.ID)
|
||||||
|
if err != nil || got == nil || got.Acted != "done" {
|
||||||
|
t.Fatalf("kept as %+v (%v)", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The review of 2026-10-09 (L2): a write decided on a record read earlier never lands over one made since. A
|
||||||
|
// cancel read before the answer was acted on leaves the act's record as it is.
|
||||||
|
func TestAStaleCancelDoesNotWriteOverAnAct(t *testing.T) {
|
||||||
|
conn := askedBus(t)
|
||||||
|
now := time.Date(2026, 10, 9, 14, 0, 0, 0, time.UTC)
|
||||||
|
rig := &busAskerRig{open: []conditions.Condition{heldCondition()}}
|
||||||
|
a := rig.asker(t, conn, now)
|
||||||
|
if err := a.reconcile(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var q asks.Ask
|
||||||
|
_ = json.Unmarshal(rig.sent[0], &q)
|
||||||
|
stale, _ := busAsked{conn: conn}.Get(context.Background(), q.ID)
|
||||||
|
release, _ := q.Option("release")
|
||||||
|
w := asks.Warrant{Ask: q.ID, Asker: askerName, About: q.About, Outcome: asks.OutcomeChosen, Option: release.ID,
|
||||||
|
Label: release.Label, Level: release.Level, Channel: "telegram", At: now, AskDigest: q.Digest(),
|
||||||
|
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}
|
||||||
|
body, _ := json.Marshal(w)
|
||||||
|
if err := a.Decided(context.Background(), body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := a.cancel(context.Background(), *stale, "the condition ended"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := busAsked{conn: conn}.Get(context.Background(), q.ID)
|
||||||
|
if got.State != string(asks.OutcomeChosen) || got.Acted != "done" {
|
||||||
|
t.Errorf("a stale cancel wrote over the act: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,656 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"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 until its answers or the channels change.
|
||||||
|
func TestAnAskTheRouterRefusedWaitsUntilSomethingChanges(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()}
|
||||||
|
_ = r.a.reconcile(context.Background())
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
r.now = r.now.Add(askEvery)
|
||||||
|
_ = r.a.reconcile(context.Background())
|
||||||
|
}
|
||||||
|
if n := len(r.asksSent(t)); n != 1 {
|
||||||
|
t.Fatalf("asked again %d time(s) though nothing changed", n-1)
|
||||||
|
}
|
||||||
|
channels = "channel/telegram=telegram@anchor[choice,verified-sender]own:true"
|
||||||
|
_ = r.a.reconcile(context.Background())
|
||||||
|
if n := len(r.asksSent(t)); n != 2 {
|
||||||
|
t.Errorf("not asked again once the channels changed: %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
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"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
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),
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -451,6 +451,10 @@ func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wa
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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)
|
ident, err := openIdentity(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -500,8 +504,10 @@ func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wa
|
|||||||
// never before, so an ask that failed never reads as a build in flight. A dry run registers nothing, and
|
// never before, so an ask that failed never reads as a build in flight. A dry run registers nothing, and
|
||||||
// is not kept.
|
// is not kept.
|
||||||
keep := !dryRun && asker != ""
|
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,
|
asked := inventory.BuildRequest{ID: request.ID, Repository: source.Repository, Seat: source.Seat, Path: path,
|
||||||
Ref: ref, For: asker}
|
Ref: ref, For: asker, AtTerminal: startedAtTheTerminal()}
|
||||||
|
|
||||||
if wait == 0 {
|
if wait == 0 {
|
||||||
// Asked and not waited for (novox/hq issue 176): the outcome is the role's event, and the
|
// Asked and not waited for (novox/hq issue 176): the outcome is the role's event, and the
|
||||||
@@ -631,6 +637,21 @@ 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",
|
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)
|
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
|
// **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
|
// 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.
|
// --register` of one — is for checking, and is never a module's version; nothing could then send it.
|
||||||
@@ -678,6 +699,10 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
|
|||||||
}
|
}
|
||||||
return manifest, kept, err
|
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
|
// 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
|
// 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.
|
// worked and the module is registered.
|
||||||
@@ -719,6 +744,9 @@ func buildAndShow(ctx context.Context, source buildSource, path, ref string, wai
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := verbMayAsk(ctx, source, repository); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
ident, err := openIdentity(ctx)
|
ident, err := openIdentity(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"hash/fnv"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
failed := onTrunk("build-1791600000000000000", "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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,11 +19,11 @@ func TestABuildHeardIsRecordedAndRegistered(t *testing.T) {
|
|||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
manifest, _ := json.Marshal(map[string]any{"module": "shop", "version": "3"})
|
manifest, _ := json.Marshal(map[string]any{"module": "shop", "version": "3"})
|
||||||
m, _, err := takeIn(ctx, open.inventory, link.BuildResult{
|
m, _, err := takeIn(ctx, open.inventory, asTheOperator(t, open.inventory, link.BuildResult{
|
||||||
ID: "b-1", Repository: "http://forge.internal:20000/novox/shop.git", Path: "modules/shop",
|
ID: "b-1", Repository: "http://forge.internal:20000/novox/shop.git", Path: "modules/shop",
|
||||||
Ref: "main", On: "anchor", Commit: "abcdef0123", Manifest: manifest,
|
Ref: "main", On: "anchor", Commit: "abcdef0123", Manifest: manifest,
|
||||||
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/shop"},
|
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/shop"},
|
||||||
})
|
}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -78,7 +78,7 @@ func TestABuildAtACommitKeepsTheBranchTheModuleFollows(t *testing.T) {
|
|||||||
Path: "modules/unifi", Ref: ref, On: "anchor", Commit: commit, Manifest: manifest,
|
Path: "modules/unifi", Ref: ref, On: "anchor", Commit: commit, Manifest: manifest,
|
||||||
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}}
|
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}}
|
||||||
}
|
}
|
||||||
if _, _, err := takeIn(ctx, open.inventory, result("b-1", "main", "1111111aaaa")); err != nil {
|
if _, _, err := takeIn(ctx, open.inventory, asTheOperator(t, open.inventory, result("b-1", "main", "1111111aaaa"))); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, _, err := takeIn(ctx, open.inventory, result("b-2", "9c97a8a", "9c97a8a1d2c3")); err != nil {
|
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,
|
Path: "modules/postgres", Ref: "main", On: "anchor", Commit: "efff5415", Manifest: manifest,
|
||||||
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}}
|
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}}
|
||||||
}
|
}
|
||||||
if _, _, err := takeIn(ctx, open.inventory, result(newer, "4bcd5f73")); err != nil {
|
if _, _, err := takeIn(ctx, open.inventory, asTheOperator(t, open.inventory, result(newer, "4bcd5f73"))); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_, _, err := takeIn(ctx, open.inventory, result(older, "0ab07fa9"))
|
_, _, err := takeIn(ctx, open.inventory, result(older, "0ab07fa9"))
|
||||||
|
|||||||
@@ -53,9 +53,26 @@ func assertBusObjects(ctx context.Context, inv *inventory.Inventory, r broker.Ra
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// And the work queues of seats that name their caller or their kind, with each holder's worker
|
||||||
|
// (novox/hq ADR 0259 §3): an ask queues until the router takes it, a channel's work until that kind
|
||||||
|
// takes it.
|
||||||
|
trafficStreams, trafficWorkers, err := seatTrafficObjects(ctx, inv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
// Every one tried, and every failure named: one module's consumer the bus refuses is no reason
|
// Every one tried, and every failure named: one module's consumer the bus refuses is no reason
|
||||||
// the modules after it in the list hear nothing (novox/hq issue 208, where this runs on each send).
|
// the modules after it in the list hear nothing (novox/hq issue 208, where this runs on each send).
|
||||||
var failed []error
|
var failed []error
|
||||||
|
for _, s := range trafficStreams {
|
||||||
|
if err := r.EnsureStream(s); err != nil {
|
||||||
|
failed = append(failed, fmt.Errorf("the work queue %s: %w", s.Name, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range trafficWorkers {
|
||||||
|
if err := r.EnsureConsumer(c); err != nil {
|
||||||
|
failed = append(failed, fmt.Errorf("the worker %s on %s: %w", c.Name, c.Stream, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, c := range consumers {
|
for _, c := range consumers {
|
||||||
if err := r.EnsureConsumer(c.Consumer); err != nil {
|
if err := r.EnsureConsumer(c.Consumer); err != nil {
|
||||||
failed = append(failed, fmt.Errorf("how %s on %s hears what it consumes: %w", c.Module, c.Node, err))
|
failed = append(failed, fmt.Errorf("how %s on %s hears what it consumes: %w", c.Module, c.Node, err))
|
||||||
@@ -130,6 +147,37 @@ func moduleConsumers(ctx context.Context, inv *inventory.Inventory) ([]broker.Mo
|
|||||||
return broker.ConsumersOf(users), nil
|
return broker.ConsumersOf(users), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seatTrafficObjects is the work queues and workers of seats that name their caller or their kind, from
|
||||||
|
// the records the user list is composed from.
|
||||||
|
func seatTrafficObjects(ctx context.Context, inv *inventory.Inventory) ([]broker.Stream, []broker.Consumer, error) {
|
||||||
|
records, err := inv.BusRecords(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
users, err := broker.Users(records)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
streams, workers := broker.SeatTrafficObjects(users)
|
||||||
|
// And the queue of every such seat the catalogue declares, held or not: work queues from registration,
|
||||||
|
// so what is submitted before a holder is assigned waits for it (the correctness review of 2026-10-08).
|
||||||
|
declared, err := inv.DeclaredTrafficSeats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
have := map[string]bool{}
|
||||||
|
for _, s := range streams {
|
||||||
|
have[s.Name] = true
|
||||||
|
}
|
||||||
|
for _, s := range broker.TrafficQueues(declared) {
|
||||||
|
if !have[s.Name] {
|
||||||
|
streams = append(streams, s)
|
||||||
|
have[s.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return streams, workers, nil
|
||||||
|
}
|
||||||
|
|
||||||
// moduleConsumerCount is how many modules hear what they consume, for the raise's one line.
|
// moduleConsumerCount is how many modules hear what they consume, for the raise's one line.
|
||||||
func moduleConsumerCount(ctx context.Context, inv *inventory.Inventory) (int, error) {
|
func moduleConsumerCount(ctx context.Context, inv *inventory.Inventory) (int, error) {
|
||||||
consumers, err := moduleConsumers(ctx, inv)
|
consumers, err := moduleConsumers(ctx, inv)
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ func TestAPushAnswersBeforeItSends(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"push", map[string]any{"node": "anchor", "why": "w"}, true},
|
{"push", map[string]any{"node": "anchor", "why": "w"}, true},
|
||||||
{"push", map[string]any{"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},
|
{"command", map[string]any{"command": "builds"}, false},
|
||||||
{"status", map[string]any{}, false},
|
{"status", map[string]any{}, false},
|
||||||
{"assign", map[string]any{"node": "anchor", "module": "m"}, false},
|
{"assign", map[string]any{"node": "anchor", "module": "m"}, false},
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func keeperOn(ctx context.Context, conn *nats.Conn) (*conditions.Keeper, error)
|
|||||||
Say: func(format string, args ...any) { fmt.Fprintf(os.Stderr, format+"\n", args...) },
|
Say: func(format string, args ...any) { fmt.Fprintf(os.Stderr, format+"\n", args...) },
|
||||||
// What status leads with changed: composed again soon (a nudge outside the serving controller
|
// What status leads with changed: composed again soon (a nudge outside the serving controller
|
||||||
// does nothing).
|
// does nothing).
|
||||||
Changed: statusFrom.nudge,
|
Changed: func() { statusFrom.nudge(); askerFrom.nudge() },
|
||||||
// Written under the lease, carrying its epoch (novox/hq to-be 45 §6).
|
// Written under the lease, carrying its epoch (novox/hq to-be 45 §6).
|
||||||
Epoch: func() (uint64, error) { return theLease.epoch(context.WithoutCancel(ctx)) }}), nil
|
Epoch: func() (uint64, error) { return theLease.epoch(context.WithoutCancel(ctx)) }}), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,12 +33,14 @@ var deliveryOwnerWithin = 10 * time.Second
|
|||||||
|
|
||||||
// stalledLine is one delivery past its bound, as mesh-delivery's `stalled` says it.
|
// stalledLine is one delivery past its bound, as mesh-delivery's `stalled` says it.
|
||||||
type stalledLine struct {
|
type stalledLine struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
State string `json:"state"`
|
// Number is the pull request's, for a line of a head the forge never announced (novox/hq issue 347).
|
||||||
For string `json:"for"`
|
Number int `json:"number,omitempty"`
|
||||||
Bound string `json:"bound"`
|
State string `json:"state"`
|
||||||
H2 string `json:"h2"`
|
For string `json:"for"`
|
||||||
Says string `json:"says"`
|
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.
|
// operatorsOnly is whether the table leaves H2 nothing to do for the line: the state is the operator's.
|
||||||
|
|||||||
@@ -136,12 +136,13 @@ func TestTheDeliveryOwnerIsAskedOverTheBus(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, verb := range []string{"stalled", "close"} {
|
// And release and stop, which the operator's warrant chooses (novox/hq ADR 0259).
|
||||||
|
for _, verb := range []string{"stalled", "close", "release", "stop"} {
|
||||||
if !slices.Contains(granted.Publish, link.SeatToolSubject(catalogue.DeliverySeat, verb)) {
|
if !slices.Contains(granted.Publish, link.SeatToolSubject(catalogue.DeliverySeat, verb)) {
|
||||||
t.Errorf("the controller may not ask %s.%s", catalogue.DeliverySeat, verb)
|
t.Errorf("the controller may not ask %s.%s", catalogue.DeliverySeat, verb)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := askDeliveryOwner(t.Context(), nil, "stop", nil); err == nil || !strings.Contains(err.Error(), "grant") {
|
if _, err := askDeliveryOwner(t.Context(), nil, "retire-history", nil); err == nil || !strings.Contains(err.Error(), "grant") {
|
||||||
t.Fatalf("a verb the grant does not name was asked: %v", err)
|
t.Fatalf("a verb the grant does not name was asked: %v", err)
|
||||||
}
|
}
|
||||||
conn, err := nats.Connect(testbus.URL(t))
|
conn, err := nats.Connect(testbus.URL(t))
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
|
||||||
|
|
||||||
"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).
|
|
||||||
//
|
|
||||||
// **The value never passes through whoever asked for it.** An agent, or the operator at the mesh MCP
|
|
||||||
// server, calls `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.
|
|
||||||
//
|
|
||||||
// **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
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 err := d.declares(module, name); err != nil {
|
|
||||||
return "", fmt.Errorf("nobody was asked to type anything: %w", err)
|
|
||||||
}
|
|
||||||
public, private, err := secrets.Keypair()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("no key could be made to take the value: %w", err)
|
|
||||||
}
|
|
||||||
raw, err := d.ask(desk, map[string]any{
|
|
||||||
"prompt": name + " for " + module,
|
|
||||||
"message": fmt.Sprintf("The mesh asks for %s, the own secret of %s on %s. What you type is not shown, "+
|
|
||||||
"and is sealed before it leaves this machine. Type it only if you asked for this.", name, module, node),
|
|
||||||
"seal_to": public,
|
|
||||||
"timeout_seconds": deskPromptWithin,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
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:
|
|
||||||
return "", fmt.Errorf("%w: the prompt on %s was not answered within %d seconds", errNothingGiven, desk, deskPromptWithin)
|
|
||||||
case answer.Cancelled:
|
|
||||||
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) == "" {
|
|
||||||
return "", fmt.Errorf("%w: the prompt on %s was answered empty", errNothingGiven, desk)
|
|
||||||
}
|
|
||||||
untilStart, err := d.accept(value)
|
|
||||||
value = ""
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
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", name, module, node, desk),
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// giveAtDesk is `secret accept <node> <module> <name> --at-desk <machine>`: the desk path, on this
|
|
||||||
// controller's stores and bus.
|
|
||||||
func giveAtDesk(ctx context.Context, node, module, name, desk string) error {
|
|
||||||
open, err := openStores(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer open.Close()
|
|
||||||
d := deskGive{
|
|
||||||
declares: func(module, name string) error { return open.inventory.DeclaresOwnSecret(ctx, module, name) },
|
|
||||||
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
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
words, err := d.give(node, module, name, desk)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Println(words)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
|
||||||
"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 accept anchor telegram telegram-token --at-desk 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")
|
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -121,6 +121,16 @@ var probeRegistry = []probe{
|
|||||||
{ID: probeReconnectsID, Asserts: "no user of the bus had its connection dropped more than twelve times in the " +
|
{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,
|
"last hour: the bus module's nats_closed_connections", From: "issue 327", Kind: kindBusReconnects,
|
||||||
Phase: 1, run: probeReconnects},
|
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",
|
{ID: "DW", Asserts: "the watchdogs of the signals table ran within three of their intervals",
|
||||||
From: "ADR 0227 rule 6: the watchers are watched", Kind: "watchdogs-silent", Phase: 1, run: probeWatchdogs},
|
From: "ADR 0227 rule 6: the watchers are watched", Kind: "watchdogs-silent", Phase: 1, run: probeWatchdogs},
|
||||||
// The core's health definitions (novox/hq to-be 45 §8, ADR 0236): what a core component's new build is
|
// The core's health definitions (novox/hq to-be 45 §8, ADR 0236): what a core component's new build is
|
||||||
|
|||||||
@@ -269,7 +269,8 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
|
|||||||
engines := map[string]bool{}
|
engines := map[string]bool{}
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
m := snapshot.Machine{Name: scrub.Machine(n.Name), Length: len(n.Name), Adopted: n.Adopted,
|
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]}
|
AccountHome: scrub.Text(n.AccountHome), NodeEngine: n.HostVersion, PublicDomain: domains[n.Name],
|
||||||
|
AgentAccount: scrub.Account(n.AgentAccount), AgentAccountHome: scrub.Text(n.AgentAccountHome)}
|
||||||
switch n.Account {
|
switch n.Account {
|
||||||
case "", "root":
|
case "", "root":
|
||||||
m.Account = n.Account
|
m.Account = n.Account
|
||||||
|
|||||||
@@ -209,7 +209,9 @@ func judgeHealth(module, component string, m catalogue.Manifest, machine string,
|
|||||||
return healthNotYet, fmt.Sprintf("%s reported %q", machine, r.Outcome)
|
return healthNotYet, fmt.Sprintf("%s reported %q", machine, r.Outcome)
|
||||||
}
|
}
|
||||||
// **No new condition about it**: about the machine itself, or naming the module on that machine,
|
// **No new condition about it**: about the machine itself, or naming the module on that machine,
|
||||||
// raised since the judging began. The gate's own are not evidence about the build.
|
// raised since the judging began. The gate's own are not evidence about the build. A fault that was
|
||||||
|
// there at the send and reopened since is not new; one that had cleared before the send and came back
|
||||||
|
// after it is (OpenAt, novox/hq issue 348).
|
||||||
if f.judged {
|
if f.judged {
|
||||||
if f.openErr != nil {
|
if f.openErr != nil {
|
||||||
return healthNotYet, "what is wrong cannot be read, so whether the build made anything wrong is not known: " +
|
return healthNotYet, "what is wrong cannot be read, so whether the build made anything wrong is not known: " +
|
||||||
@@ -219,7 +221,7 @@ func judgeHealth(module, component string, m catalogue.Manifest, machine string,
|
|||||||
// A wait for a person's new login, or for a directory used as found to be handed over, is the module's
|
// A wait for a person's new login, or for a directory used as found to be handed over, is the module's
|
||||||
// reading, not a fault raised since the send: the gate reads it from the statement below (ADR 0254,
|
// reading, not a fault raised since the send: the gate reads it from the statement below (ADR 0254,
|
||||||
// novox/hq issue 339).
|
// novox/hq issue 339).
|
||||||
if c.Source == gateProbe || c.Raised.Before(since) || c.Kind == kindReloginNeeded || c.Kind == kindUsedAsFound {
|
if c.Source == gateProbe || c.OpenAt(since) || c.Kind == kindReloginNeeded || c.Kind == kindUsedAsFound {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
onIt := c.Subject.Machine == machine || slices.Contains(c.Subject.Also, machine) ||
|
onIt := c.Subject.Machine == machine || slices.Contains(c.Subject.Also, machine) ||
|
||||||
@@ -331,7 +333,7 @@ func aboutTheMachine(machine string, moved []string, since time.Time, f gateFact
|
|||||||
aboutIt := c.Subject.Scope == conditions.ScopeMachine && (c.Subject.ID == machine || c.Subject.Machine == machine ||
|
aboutIt := c.Subject.Scope == conditions.ScopeMachine && (c.Subject.ID == machine || c.Subject.Machine == machine ||
|
||||||
slices.Contains(c.Subject.Also, machine))
|
slices.Contains(c.Subject.Also, machine))
|
||||||
// A directory used as found waits for a person, whatever the send did (novox/hq issue 339).
|
// A directory used as found waits for a person, whatever the send did (novox/hq issue 339).
|
||||||
if !aboutIt || c.Source == gateProbe || c.Raised.Before(since) || c.Kind == kindUsedAsFound {
|
if !aboutIt || c.Source == gateProbe || c.OpenAt(since) || c.Kind == kindUsedAsFound {
|
||||||
kept = append(kept, c)
|
kept = append(kept, c)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,7 +221,8 @@ func TestABuildThatFailsItsGateIsRolledBackOnItsFirstMachineAndGoesNoFurther(t *
|
|||||||
t.Fatalf("sent again after the rollback: %v", g.sent)
|
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",
|
_, _, 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"})})
|
Commit: "c2", Manifest: mustJSON(t, catalogue.Manifest{Module: "app", Version: "c2"}),
|
||||||
|
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}})
|
||||||
if err == nil || !strings.Contains(err.Error(), "failed its gate") {
|
if err == nil || !strings.Contains(err.Error(), "failed its gate") {
|
||||||
t.Fatalf("the failed build was registered again: %v", err)
|
t.Fatalf("the failed build was registered again: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,11 +60,18 @@ var handActVerbs = []handActVerb{
|
|||||||
// a person's word (ADR 0242), which the push itself reads from what it carried (recorded_push.go).
|
// a person's word (ADR 0242), which the push itself reads from what it carried (recorded_push.go).
|
||||||
{Verb: "push", Decision: "a recorded build moves only by a person's push: that push is the word its " +
|
{Verb: "push", Decision: "a recorded build moves only by a person's push: that push is the word its " +
|
||||||
"upgrade policy asks for (ADR 0242)", DecidedWhen: pushedRecorded},
|
"upgrade policy asks for (ADR 0242)", DecidedWhen: pushedRecorded},
|
||||||
{Verb: "plans stop"},
|
// Stopping or starting a walk the operator chose on a warrant (novox/hq ADR 0259) is their decision.
|
||||||
|
{Verb: "plans stop", Decision: "the operator's answer to an ask is their decision, not a repair (ADR 0259)",
|
||||||
|
DecidedFor: []string{conditions.CauseOperatorAnswer}},
|
||||||
{Verb: "plans close"},
|
{Verb: "plans close"},
|
||||||
// A walk started by a person instead of its delivery's owner (novox/hq ADR 0239): the owner down, or
|
// A walk started by a person instead of its delivery's owner (novox/hq ADR 0239): the owner down, or
|
||||||
// not trusted with it — either is a repair the owner should have made.
|
// not trusted with it — either is a repair the owner should have made. Unless the operator chose it on
|
||||||
{Verb: "plans go"},
|
// a warrant (ADR 0259).
|
||||||
|
{Verb: "plans go", Decision: "the operator's answer to an ask is their decision, not a repair (ADR 0259)",
|
||||||
|
DecidedFor: []string{conditions.CauseOperatorAnswer}},
|
||||||
|
// An act the operator chose on a warrant (novox/hq ADR 0259): asked by the controller, answered on a
|
||||||
|
// channel that proved who answered, performed by the controller as itself.
|
||||||
|
{Verb: handActWarrant, Decision: "the operator chose it, answering what the controller asked (ADR 0259)"},
|
||||||
{Verb: "broker consumer-reset"},
|
{Verb: "broker consumer-reset"},
|
||||||
// Silencing the same condition twice says the condition, or what it watches, wants mending — unless
|
// Silencing the same condition twice says the condition, or what it watches, wants mending — unless
|
||||||
// it is the operator's answer on a notification: a decision to live with it (novox/hq ADR 0258).
|
// it is the operator's answer on a notification: a decision to live with it (novox/hq ADR 0258).
|
||||||
@@ -103,9 +110,6 @@ var handActVerbs = []handActVerb{
|
|||||||
// to that judgement. Several values rotate for one leak, and a leak that recurs is a defect of the
|
// to that judgement. Several values rotate for one leak, and a leak that recurs is a defect of the
|
||||||
// module that prints them, an issue against it, not a healer that rotates. A rotation for any other
|
// module that prints them, an issue against it, not a healer that rotates. A rotation for any other
|
||||||
// cause — a credential that stopped working — counts: a schedule or a healer could take it over.
|
// cause — a credential that stopped working — counts: a schedule or a healer could take it over.
|
||||||
// A value given at the desk (novox/hq ADR 0259 §10): an outside party's key, such as a bot token, which
|
|
||||||
// only a person can give. Their word, never a repair.
|
|
||||||
{Verb: "secret accept", Decision: "a value an outside party issued is given by a person, at their desk"},
|
|
||||||
{Verb: "secret rotate", Decision: "a value a person judged disclosed is replaced on their word",
|
{Verb: "secret rotate", Decision: "a value a person judged disclosed is replaced on their word",
|
||||||
DecidedFor: []string{causeLeakedInLogs}},
|
DecidedFor: []string{causeLeakedInLogs}},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,6 @@ func TestARepairByHandWithoutAReasonIsRefused(t *testing.T) {
|
|||||||
{"plans", map[string]any{"close": "plan-1"}},
|
{"plans", map[string]any{"close": "plan-1"}},
|
||||||
{"plans", map[string]any{"stop": "plan-1"}},
|
{"plans", map[string]any{"stop": "plan-1"}},
|
||||||
{"hand-act", map[string]any{"what": "restarted the proxy", "cause": "proxy-stuck"}},
|
{"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)
|
argv, err := argvFor(c.verb, c.args)
|
||||||
if c.verb == "plans" && err == nil {
|
if c.verb == "plans" && err == nil {
|
||||||
@@ -65,7 +61,6 @@ func TestARepairByHandCarriesItsReason(t *testing.T) {
|
|||||||
{"plans", map[string]any{"retry": "plan-1"}, "plans retry plan-1"},
|
{"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", 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"},
|
"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"},
|
{"command", map[string]any{"command": "plans plan-1"}, "plans plan-1"},
|
||||||
} {
|
} {
|
||||||
argv, err := argvFor(c.verb, c.args)
|
argv, err := argvFor(c.verb, c.args)
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/lease"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq issue 348: on 2026-10-09 the control node's resolver stopped answering on its private address
|
||||||
|
// at 10:57:57 UTC; the machine's network condition was raised at 10:58:45. The node-engine and the
|
||||||
|
// controller were sent at 10:59:34. The new node-engine's first statement judged the names once — unknown,
|
||||||
|
// "one look failed; a second decides" — and that statement cleared the condition, though its last evidence
|
||||||
|
// still said "connection refused". The next look raised it again at 11:00:23, after the send, and both
|
||||||
|
// builds failed their gate at 11:10 with "raised since it was sent" and were put back, for a fault that
|
||||||
|
// began before they were sent.
|
||||||
|
|
||||||
|
// namesRefused is the control node's names part as its node-engine said it in the outage: its own
|
||||||
|
// resolver, at its own address, refusing.
|
||||||
|
func namesRefused(state string, streak int) link.NetworkPart {
|
||||||
|
p := link.NetworkPart{Part: link.PartNames, State: state, Since: h0, Streak: streak}
|
||||||
|
if state == link.StateUnhealthy {
|
||||||
|
p.Reason = "1 of its 2 resolvers do not answer as the mesh's do"
|
||||||
|
p.Said = "10.77.0.1 — anchor.internal (IPv4): read udp 10.77.0.1:35244->10.77.0.1:53: read: connection refused"
|
||||||
|
p.Toward = []string{"10.77.0.1"}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func networkSaying(parts ...link.NetworkPart) *link.NetworkHealth {
|
||||||
|
state := link.StateHealthy
|
||||||
|
for _, p := range parts {
|
||||||
|
switch {
|
||||||
|
case p.State == link.StateUnhealthy:
|
||||||
|
state = link.StateUnhealthy
|
||||||
|
case p.State == link.StateUnknown && state == link.StateHealthy:
|
||||||
|
state = link.StateUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &link.NetworkHealth{State: state, Since: h0, Parts: parts}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReplay348 replays the statements of the outage: the condition raised before the send is not
|
||||||
|
// cleared by the restarted engine's first, undecided statement, and the gate does not count it against
|
||||||
|
// the builds sent after it began.
|
||||||
|
func TestReplay348(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
inv, k := open.inventory, conditionsFrom
|
||||||
|
say := func(at time.Time, n *link.NetworkHealth) {
|
||||||
|
t.Helper()
|
||||||
|
if err := stateHealth(ctx, inv, k, "anchor", link.Health{Contract: link.ReadinessContract, At: at, Network: n}, at); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
network := func() (conditions.Condition, bool) {
|
||||||
|
t.Helper()
|
||||||
|
list, err := k.Open(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, c := range list {
|
||||||
|
if c.Key == "machine.anchor.network" {
|
||||||
|
return c, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return conditions.Condition{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10:58:45 — the second failing look: raised.
|
||||||
|
say(h0, networkSaying(namesRefused(link.StateUnhealthy, 2)))
|
||||||
|
raised, ok := network()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the resolver refusing on the control node raised nothing")
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
sent := time.Now().UTC()
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
|
||||||
|
// 10:59:42 — the restarted engine's first statement: one look failed, a second decides.
|
||||||
|
say(h0.Add(time.Minute), networkSaying(namesRefused(link.StateUnknown, 1)))
|
||||||
|
if _, ok := network(); !ok {
|
||||||
|
t.Fatal("a statement that judged nothing yet cleared the condition: the restarted engine's first look " +
|
||||||
|
"said the fault was gone while it still refused")
|
||||||
|
}
|
||||||
|
// 11:00:23 — its second look: unhealthy again, the same raising.
|
||||||
|
say(h0.Add(2*time.Minute), networkSaying(namesRefused(link.StateUnhealthy, 2)))
|
||||||
|
again, ok := network()
|
||||||
|
if !ok || !again.Raised.Equal(raised.Raised) || again.Count != 1 {
|
||||||
|
t.Fatalf("the same raising was not kept: raised %s (first %s), count %d", again.Raised, raised.Raised, again.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gate on the control node, for a build sent after the fault began.
|
||||||
|
open2, err := k.Open(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f := gateFacts{judged: true, open: open2}
|
||||||
|
if w := aboutTheMachine("anchor", []string{"mesh-host"}, sent, f); w.whole != "" || len(w.on) != 0 {
|
||||||
|
t.Fatalf("a fault from before the send held the build: %+v", w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decided healthy: cleared.
|
||||||
|
say(h0.Add(3*time.Minute), networkSaying(link.NetworkPart{Part: link.PartNames, State: link.StateHealthy, Since: h0}))
|
||||||
|
if c, ok := network(); ok {
|
||||||
|
t.Fatalf("a statement that decides the names healthy left %s open", c.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fault there at the send, cleared and reopened after it, is not raised since the send; one that cleared
|
||||||
|
// before the send and came back after it is — a send that breaks a recovered machine fails its gate (review
|
||||||
|
// of mesh-controller PR 179, A2). Read through OpenAt, by both of the gate's readings.
|
||||||
|
func TestAFaultThatFlappedAfterTheSendIsNotTheSendsAndOneThatRecoveredBeforeItIs(t *testing.T) {
|
||||||
|
since := h0
|
||||||
|
network := func(first time.Time, gaps ...conditions.Gap) conditions.Condition {
|
||||||
|
raised := since.Add(time.Minute)
|
||||||
|
if len(gaps) > 0 {
|
||||||
|
raised = gaps[len(gaps)-1].Reopened
|
||||||
|
}
|
||||||
|
return conditions.Condition{Key: "machine.anchor.network", Kind: kindMachineNetwork,
|
||||||
|
Subject: conditions.Subject{Scope: conditions.ScopeMachine, ID: "anchor", Machine: "anchor"},
|
||||||
|
Summary: "anchor's network is not healthy", Source: sourceNetwork, First: first, Gaps: gaps, Raised: raised}
|
||||||
|
}
|
||||||
|
held := func(c conditions.Condition) bool {
|
||||||
|
return aboutTheMachine("anchor", []string{"mesh-controller"}, since, gateFacts{judged: true,
|
||||||
|
open: []conditions.Condition{c}}).whole != ""
|
||||||
|
}
|
||||||
|
// The day's case: raised before the send, cleared 8 s after it, reopened 49 s after it.
|
||||||
|
flapped := network(since.Add(-49*time.Second),
|
||||||
|
conditions.Gap{Cleared: since.Add(8 * time.Second), Reopened: since.Add(49 * time.Second)})
|
||||||
|
if held(flapped) {
|
||||||
|
t.Fatal("a fault there at the send, flapping after it, held the machine")
|
||||||
|
}
|
||||||
|
// Recovered before the send, broken again after it: the send's.
|
||||||
|
recovered := network(since.Add(-time.Hour),
|
||||||
|
conditions.Gap{Cleared: since.Add(-30 * time.Second), Reopened: since.Add(20 * time.Second)})
|
||||||
|
if !held(recovered) {
|
||||||
|
t.Fatal("a machine recovered at the send and broken after it passed the gate")
|
||||||
|
}
|
||||||
|
// An older gap, before the send, and the fault there at the send: not the send's.
|
||||||
|
twice := network(since.Add(-time.Hour),
|
||||||
|
conditions.Gap{Cleared: since.Add(-50 * time.Minute), Reopened: since.Add(-45 * time.Minute)},
|
||||||
|
conditions.Gap{Cleared: since.Add(10 * time.Second), Reopened: since.Add(30 * time.Second)})
|
||||||
|
if held(twice) {
|
||||||
|
t.Fatal("a fault there at the send, with an older gap, held the machine")
|
||||||
|
}
|
||||||
|
// Raised after the send, never cleared: the send's.
|
||||||
|
if !held(network(time.Time{})) {
|
||||||
|
t.Fatal("a fault raised after the send held nothing")
|
||||||
|
}
|
||||||
|
// And a module's own, through judgeHealth.
|
||||||
|
at := since.Add(2 * time.Minute)
|
||||||
|
g := gateFacts{judged: true, now: at, reports: map[string]inventory.Reported{"anchor": {Node: "anchor",
|
||||||
|
Outcome: inventory.OutcomeApplied, At: &at, Current: true}}, engines: map[string]string{},
|
||||||
|
served: map[string]served{}, rolledBack: map[string][]lease.Rollback{},
|
||||||
|
open: []conditions.Condition{{Key: "provider.app.anchor.x.failing", Subject: conditions.Subject{
|
||||||
|
Scope: conditions.ScopeProvider, ID: "app.anchor.x", Machine: "anchor"}, Summary: "failing",
|
||||||
|
First: since.Add(-time.Hour), Raised: since.Add(time.Minute),
|
||||||
|
Gaps: []conditions.Gap{{Cleared: since.Add(5 * time.Second), Reopened: since.Add(time.Minute)}}}}}
|
||||||
|
if _, why := judgeHealth("app", "", catalogue.Manifest{Module: "app"}, "anchor", since, g); strings.HasPrefix(why, "raised since it was sent") {
|
||||||
|
t.Fatalf("a module's own fault there at the send: %s", why)
|
||||||
|
}
|
||||||
|
g.open[0].Gaps[0].Cleared = since.Add(-5 * time.Second)
|
||||||
|
if _, why := judgeHealth("app", "", catalogue.Manifest{Module: "app"}, "anchor", since, g); !strings.HasPrefix(why, "raised since it was sent") {
|
||||||
|
t.Fatalf("a module's own fault, recovered at the send and back after it, was not counted: %s", why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only an undecided part holds a condition that names it; a condition about another part clears, and a
|
||||||
|
// statement unknown as a whole holds every part (review of PR 179, A4). Pure.
|
||||||
|
func TestAnUndecidedPartHoldsOnlyWhatNamesIt(t *testing.T) {
|
||||||
|
f := netFacts(map[string]*inventory.NetworkHealth{
|
||||||
|
"anchor": aNetwork(link.StateUnknown, inventory.NetworkPart{Part: link.PartNames, State: link.StateUnknown, Streak: 1},
|
||||||
|
inventory.NetworkPart{Part: link.PartRoute, State: link.StateHealthy}),
|
||||||
|
"laptop": aNetwork(link.StateHealthy, inventory.NetworkPart{Part: link.PartNames, State: link.StateHealthy}),
|
||||||
|
"spare": aNetwork(link.StateStarting, inventory.NetworkPart{Part: link.PartTunnel, State: link.StateHealthy}),
|
||||||
|
"other": aNetwork(link.StateStarting, inventory.NetworkPart{Part: link.PartTunnel, State: link.StateStarting}),
|
||||||
|
})
|
||||||
|
u := undecidedParts(f)
|
||||||
|
if !u["anchor"][link.PartNames] || u["anchor"][link.PartRoute] || u["laptop"] != nil || !u["spare"]["*"] ||
|
||||||
|
!u["other"][link.PartTunnel] || u["other"]["*"] {
|
||||||
|
t.Fatalf("undecided: %v", u)
|
||||||
|
}
|
||||||
|
about := func(machine, said string, also ...string) conditions.Condition {
|
||||||
|
return conditions.Condition{Subject: conditions.Subject{Scope: conditions.ScopeMachine, ID: machine,
|
||||||
|
Machine: machine, Also: also}, Evidence: []conditions.Evidence{{Said: said}}}
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
c conditions.Condition
|
||||||
|
held bool
|
||||||
|
}{
|
||||||
|
{about("anchor", "names since 2026-10-09 10:58:45 UTC: 10.77.0.1 — refused"), true},
|
||||||
|
{about("anchor", "route since 2026-10-09 10:58:45 UTC: no default route"), false},
|
||||||
|
{about("laptop", "names since 2026-10-09 10:58:45 UTC: refused"), false},
|
||||||
|
{about("spare", "route since …: no default route"), true},
|
||||||
|
{about("hub", "anchor: names: refused", "anchor"), true},
|
||||||
|
{about("hub", "anchor: tunnel: no handshake", "anchor"), false},
|
||||||
|
} {
|
||||||
|
if got := heldUndecided(c.c, u); got != c.held {
|
||||||
|
t.Errorf("%s %q held %v, want %v", c.c.Subject.Machine, c.c.Evidence[0].Said, got, c.held)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A release walks its modules without a record per module: D10 counts what its tier names as rolling,
|
||||||
|
// so the node-engine a release walks is not "behind, and no plan is rolling it out" on its first machine.
|
||||||
|
func TestAReleaseRollsOutWhatItsTierNames(t *testing.T) {
|
||||||
|
plans := []inventory.Plan{
|
||||||
|
{ID: "release-1", State: inventory.PlanRolling, Tiers: [][]string{{"mesh-host"}}, Modules: map[string]*inventory.PlanModule{}},
|
||||||
|
{ID: "plan-2", State: inventory.PlanRolling, Modules: map[string]*inventory.PlanModule{"letta": {}}},
|
||||||
|
}
|
||||||
|
got := rollingModules(plans)
|
||||||
|
if !got["mesh-host"] || !got["letta"] || len(got) != 2 {
|
||||||
|
t.Fatalf("rolling: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
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 349: on 2026-10-09 the plan of mesh-catalog at a082615b (the merge of a security fix to the
|
||||||
|
// forge's module) was "superseded at tier 0 by" the plan at 8ff8197a, the merge before it, which the
|
||||||
|
// catch-up acted on late; what the later plan had not built was folded into a plan at the commit before the
|
||||||
|
// fix. Plans of one branch are ordered by when the forge made their merges, and a merge older than an open
|
||||||
|
// plan of its branch is planned at that plan's commit.
|
||||||
|
|
||||||
|
// TestTwoMergesActedOnInReverseOrderBuildTheNewerCommit replays it: the later merge acted on first, the
|
||||||
|
// earlier one second (the catch-up). One plan is left open, at the later commit, and it builds both.
|
||||||
|
func TestTwoMergesActedOnInReverseOrderBuildTheNewerCommit(t *testing.T) {
|
||||||
|
open := aCatalogueMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
asksWithPaths(t)
|
||||||
|
for _, m := range []string{"gitea", "notes"} {
|
||||||
|
if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: m, Version: "1"},
|
||||||
|
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/" + m, Ref: "main",
|
||||||
|
BuiltFrom: "c0", Head: "c0"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
at := time.Now().UTC().Add(-20 * time.Minute).Truncate(time.Second)
|
||||||
|
fix := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "a082615bfix",
|
||||||
|
MergedAt: at.Add(2 * time.Minute).Format(time.RFC3339Nano),
|
||||||
|
Paths: []string{"modules/gitea/module.json"}, ModuleDirs: []string{"modules/gitea"}, ModuleDirsSaid: true}
|
||||||
|
before := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "8ff8197abefore",
|
||||||
|
MergedAt: at.Format(time.RFC3339Nano),
|
||||||
|
Paths: []string{"modules/notes/module.json"}, ModuleDirs: []string{"modules/notes"}, ModuleDirsSaid: true}
|
||||||
|
for _, m := range []link.SourceMoved{fix, before} {
|
||||||
|
if err := (following{open: open}).SourceMoved(ctx, m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plans, err := open.inventory.OpenPlans(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(plans) != 1 {
|
||||||
|
var said []string
|
||||||
|
for _, p := range plans {
|
||||||
|
said = append(said, p.ID+" "+p.Commit+" "+p.Note)
|
||||||
|
}
|
||||||
|
t.Fatalf("open plans: %s", strings.Join(said, "; "))
|
||||||
|
}
|
||||||
|
p := plans[0]
|
||||||
|
if p.Commit != fix.Commit {
|
||||||
|
t.Fatalf("the open plan builds %s, not the newer commit %s", p.Commit, fix.Commit)
|
||||||
|
}
|
||||||
|
for _, m := range []string{"gitea", "notes"} {
|
||||||
|
if _, has := p.Modules[m]; !has {
|
||||||
|
t.Fatalf("the open plan at the newer commit does not build %s: %v", m, p.Modules)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A late older merge after the newer plan is done (review of PR 179, A1): what it moved is built from the
|
||||||
|
// newer commit, and what the newer merge already looked at is not built again at the older one.
|
||||||
|
func TestALateMergeAfterTheNewerPlanEndedBuildsTheNewerCommit(t *testing.T) {
|
||||||
|
open := aCatalogueMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
asksWithPaths(t)
|
||||||
|
for _, m := range []string{"gitea", "notes"} {
|
||||||
|
if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: m, Version: "1"},
|
||||||
|
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/" + m, Ref: "main",
|
||||||
|
BuiltFrom: "c0", Head: "c0"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
at := time.Now().UTC().Add(-20 * time.Minute).Truncate(time.Second)
|
||||||
|
fix := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "a082615bfix",
|
||||||
|
MergedAt: at.Add(2*time.Minute + 500*time.Millisecond).Format(time.RFC3339Nano),
|
||||||
|
Paths: []string{"modules/gitea/module.json"}, ModuleDirs: []string{"modules/gitea"}, ModuleDirsSaid: true}
|
||||||
|
if err := (following{open: open}).SourceMoved(ctx, fix); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
plans, err := open.inventory.OpenPlans(ctx)
|
||||||
|
if err != nil || len(plans) != 1 {
|
||||||
|
t.Fatalf("%v %v", plans, err)
|
||||||
|
}
|
||||||
|
done := plans[0]
|
||||||
|
done.State = inventory.PlanDone
|
||||||
|
if err := open.inventory.SavePlan(ctx, &done); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, err := open.inventory.PlanByID(ctx, done.ID); err != nil || !got.Merged.Equal(at.Add(2*time.Minute+500*time.Millisecond)) {
|
||||||
|
t.Fatalf("the merge time kept is %s, not to the nanosecond (%v)", got.Merged, err)
|
||||||
|
}
|
||||||
|
before := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "8ff8197abefore",
|
||||||
|
MergedAt: at.Format(time.RFC3339Nano),
|
||||||
|
Paths: []string{"modules/notes/module.json", "modules/gitea/module.json"},
|
||||||
|
ModuleDirs: []string{"modules/notes", "modules/gitea"}, ModuleDirsSaid: true}
|
||||||
|
if err := (following{open: open}).SourceMoved(ctx, before); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
plans, err = open.inventory.OpenPlans(ctx)
|
||||||
|
if err != nil || len(plans) != 1 {
|
||||||
|
t.Fatalf("open plans after the late merge: %+v %v", plans, err)
|
||||||
|
}
|
||||||
|
p := plans[0]
|
||||||
|
if p.Commit != fix.Commit {
|
||||||
|
t.Fatalf("the late merge was planned at %s, not the newer commit %s", p.Commit, fix.Commit)
|
||||||
|
}
|
||||||
|
if _, has := p.Modules["notes"]; !has {
|
||||||
|
t.Fatalf("what only the late merge moved is not built: %v", p.Modules)
|
||||||
|
}
|
||||||
|
if _, has := p.Modules["gitea"]; has {
|
||||||
|
t.Fatalf("what the newer merge already built is built again: %v", p.Modules)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure: the branch's order is the merges', where both plans know it; and a later merge of the branch is
|
||||||
|
// found in any state, never a release's, another repository's or another branch's.
|
||||||
|
func TestTheBranchOrderIsTheMerges(t *testing.T) {
|
||||||
|
t0 := time.Date(2026, 10, 9, 10, 0, 0, 0, time.UTC)
|
||||||
|
newerMerge := inventory.Plan{ID: "plan-1", Repository: "novox/mesh-catalog", Branch: "main", Commit: "a082615b",
|
||||||
|
Merged: t0.Add(time.Minute), Created: t0.Add(2 * time.Minute), State: inventory.PlanRolling}
|
||||||
|
olderMerge := inventory.Plan{ID: "plan-2", Repository: "novox/mesh-catalog", Branch: "main", Commit: "8ff8197a",
|
||||||
|
Merged: t0, Created: t0.Add(10 * time.Minute), State: inventory.PlanBuilding}
|
||||||
|
if earlierOnTheBranch(newerMerge, olderMerge) || !earlierOnTheBranch(olderMerge, newerMerge) {
|
||||||
|
t.Fatal("ordered by when the plans were made, not by when the merges were")
|
||||||
|
}
|
||||||
|
if _, closed := supersededBy(olderMerge, []inventory.Plan{newerMerge}, func(string) bool { return true }); len(closed) != 0 {
|
||||||
|
t.Fatalf("the plan of an older merge superseded a newer one: %s", closed[0].Note)
|
||||||
|
}
|
||||||
|
unknown := newerMerge
|
||||||
|
unknown.Merged = time.Time{}
|
||||||
|
if !earlierOnTheBranch(unknown, olderMerge) {
|
||||||
|
t.Fatal("without a merge time the plans' own order does not stand")
|
||||||
|
}
|
||||||
|
same := olderMerge
|
||||||
|
same.Merged = newerMerge.Merged
|
||||||
|
if !earlierOnTheBranch(newerMerge, same) || earlierOnTheBranch(same, newerMerge) {
|
||||||
|
t.Fatal("one merge time: the plans' own order does not stand")
|
||||||
|
}
|
||||||
|
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "8ff8197a",
|
||||||
|
MergedAt: t0.Add(500 * time.Millisecond).Format(time.RFC3339Nano)}
|
||||||
|
newerMerge.State = inventory.PlanDone
|
||||||
|
if !laterOnTheBranch(m, newerMerge) {
|
||||||
|
t.Fatal("a later merge of the branch, its plan done, was not found")
|
||||||
|
}
|
||||||
|
for name, change := range map[string]func(p *inventory.Plan, m *link.SourceMoved){
|
||||||
|
"another branch": func(_ *inventory.Plan, m *link.SourceMoved) { m.Base = "release" },
|
||||||
|
"another repository": func(p *inventory.Plan, _ *link.SourceMoved) { p.Repository = "novox/mesh-controller" },
|
||||||
|
"a release": func(p *inventory.Plan, _ *link.SourceMoved) { p.Release = &inventory.PlanRelease{} },
|
||||||
|
"no merge time": func(p *inventory.Plan, _ *link.SourceMoved) { p.Merged = time.Time{} },
|
||||||
|
"the same commit": func(p *inventory.Plan, m *link.SourceMoved) { m.Commit = p.Commit },
|
||||||
|
"an older merge": func(p *inventory.Plan, _ *link.SourceMoved) { p.Merged = t0 },
|
||||||
|
"the same moment": func(p *inventory.Plan, _ *link.SourceMoved) { p.Merged = t0.Add(500 * time.Millisecond) },
|
||||||
|
"an unreadable time": func(_ *inventory.Plan, m *link.SourceMoved) { m.MergedAt = "yesterday" },
|
||||||
|
} {
|
||||||
|
p, mm := newerMerge, m
|
||||||
|
change(&p, &mm)
|
||||||
|
if laterOnTheBranch(mm, p) {
|
||||||
|
t.Errorf("%s was taken as a later merge of the branch", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// To the nanosecond: two merges within a second keep their order.
|
||||||
|
m.MergedAt = t0.Add(time.Minute + 200*time.Millisecond).Format(time.RFC3339Nano)
|
||||||
|
if !laterOnTheBranch(m, inventory.Plan{Repository: "novox/mesh-catalog", Branch: "main", Commit: "x",
|
||||||
|
Merged: t0.Add(time.Minute + 700*time.Millisecond)}) {
|
||||||
|
t.Fatal("merges within one second lost their order")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A merge time with a fraction of a second is kept whole in its plan, and two merges within one second keep
|
||||||
|
// their order through the plans and the lookup (review of PR 179).
|
||||||
|
func TestAMergeTimeKeepsItsFractionOfASecond(t *testing.T) {
|
||||||
|
at := "2026-10-09T10:57:52.123456789Z"
|
||||||
|
p := planOfMerge(link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "c1", MergedAt: at}, nil, nil)
|
||||||
|
want := time.Date(2026, 10, 9, 10, 57, 52, 123456789, time.UTC)
|
||||||
|
if !p.Merged.Equal(want) {
|
||||||
|
t.Fatalf("the plan's merge time is %s, not %s", p.Merged, want)
|
||||||
|
}
|
||||||
|
earlier := planOfMerge(link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "c0",
|
||||||
|
MergedAt: "2026-10-09T10:57:52.123456788Z"}, nil, nil)
|
||||||
|
earlier.Created = p.Created.Add(time.Second) // made after, merged before
|
||||||
|
if !earlierOnTheBranch(earlier, p) || earlierOnTheBranch(p, earlier) {
|
||||||
|
t.Fatal("two merges a nanosecond apart lost their order")
|
||||||
|
}
|
||||||
|
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "c0", MergedAt: "2026-10-09T10:57:52.123456788Z"}
|
||||||
|
if !laterOnTheBranch(m, p) {
|
||||||
|
t.Fatal("a merge a nanosecond later was not found as the later one")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,8 +98,9 @@ func judgeNetworks(ctx context.Context, inv *inventory.Inventory, k *conditions.
|
|||||||
problems = append(problems, err.Error())
|
problems = append(problems, err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
undecided := undecidedParts(f)
|
||||||
for _, c := range open {
|
for _, c := range open {
|
||||||
if !slices.Contains(networkKinds, c.Kind) || said[c.Key] {
|
if !slices.Contains(networkKinds, c.Kind) || said[c.Key] || heldUndecided(c, undecided) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
why := "no machine says it any more"
|
why := "no machine says it any more"
|
||||||
@@ -116,6 +117,59 @@ func judgeNetworks(ctx context.Context, inv *inventory.Inventory, k *conditions.
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// undecidedParts is, per machine, every part of its newest statement not yet judged: starting, or unknown
|
||||||
|
// — one look failed and a second decides (novox/hq issue 348). Such a part does not say its fault is gone.
|
||||||
|
// A statement whose parts are all decided but whose whole is unknown or starting holds every part. Pure.
|
||||||
|
//
|
||||||
|
// On 2026-10-09 the control node's resolver refused every question from 10:58 to 11:18 UTC. A build of
|
||||||
|
// the node-engine sent at 10:59:34 restarted it; its first statement judged the names once (unknown, "one
|
||||||
|
// look failed; a second decides"), and that statement cleared the control node's network condition while
|
||||||
|
// its last evidence still said "connection refused". The second look raised it again forty seconds later —
|
||||||
|
// after the send — and the gate failed the build for a fault from before it.
|
||||||
|
func undecidedParts(f networkFacts) map[string]map[string]bool {
|
||||||
|
out := map[string]map[string]bool{}
|
||||||
|
for m, h := range f.healths {
|
||||||
|
if h.Network == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := map[string]bool{}
|
||||||
|
for _, p := range h.Network.Parts {
|
||||||
|
if p.State == link.StateUnknown || p.State == link.StateStarting {
|
||||||
|
parts[p.Part] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(parts) == 0 && (h.Network.State == link.StateUnknown || h.Network.State == link.StateStarting) {
|
||||||
|
parts["*"] = true
|
||||||
|
}
|
||||||
|
if len(parts) > 0 {
|
||||||
|
out[m] = parts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// heldUndecided says an open network condition is kept rather than cleared: a part its newest evidence
|
||||||
|
// names is undecided in the newest statement of a machine it is about. A condition about other parts
|
||||||
|
// clears as before. Pure.
|
||||||
|
func heldUndecided(c conditions.Condition, undecided map[string]map[string]bool) bool {
|
||||||
|
said := ""
|
||||||
|
if len(c.Evidence) > 0 {
|
||||||
|
said = c.Evidence[0].Said
|
||||||
|
}
|
||||||
|
for _, m := range append([]string{c.Subject.Machine}, c.Subject.Also...) {
|
||||||
|
parts := undecided[m]
|
||||||
|
if parts["*"] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for part := range parts {
|
||||||
|
if strings.Contains(said, part+" since ") || strings.Contains(said, part+": ") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// pointed is one machine's failing part that points at another machine.
|
// pointed is one machine's failing part that points at another machine.
|
||||||
type pointed struct {
|
type pointed struct {
|
||||||
from string
|
from string
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ func run() error {
|
|||||||
return rotateCommand(ctx, args[1:])
|
return rotateCommand(ctx, args[1:])
|
||||||
case "ask":
|
case "ask":
|
||||||
return askCommand(ctx, args[1:])
|
return askCommand(ctx, args[1:])
|
||||||
|
case "rehearse":
|
||||||
|
return rehearseCommand(ctx, args[1:])
|
||||||
case "builds":
|
case "builds":
|
||||||
return buildsCommand(ctx, args[1:])
|
return buildsCommand(ctx, args[1:])
|
||||||
// The build queue, controlled by hand (novox/hq ADR 0219).
|
// The build queue, controlled by hand (novox/hq ADR 0219).
|
||||||
@@ -216,6 +218,10 @@ func run() error {
|
|||||||
func usage() {
|
func usage() {
|
||||||
fmt.Fprint(os.Stderr, `mesh-controller — the control plane
|
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
|
migrate bring each context's schema up to date
|
||||||
prepare the same, asked the way the mesh asks any module (ADR 0135)
|
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
|
node add <name> [--adopted] create a node record; --adopted: the machine is in use
|
||||||
|
|||||||
@@ -1013,6 +1013,11 @@ func raiseFromFacts(ctx context.Context, open *stores, f snapshot.Facts, shelf m
|
|||||||
return notes, err
|
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 m.PublicDomain != "" {
|
||||||
if err := inv.SetPublicDomain(ctx, m.Name, m.PublicDomain); err != nil {
|
if err := inv.SetPublicDomain(ctx, m.Name, m.PublicDomain); err != nil {
|
||||||
return notes, err
|
return notes, err
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
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: a command that reads one gets nothing, and fails saying so (ADR 0272 §5).
|
||||||
|
cmd.Stdin = nil
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,7 +73,7 @@ func stateHealth(ctx context.Context, inv *inventory.Inventory, k *conditions.Ke
|
|||||||
for _, r := range h.Resources {
|
for _, r := range h.Resources {
|
||||||
kept := inventory.ResourceHealth{Module: r.Module, Resource: r.Resource, Kind: r.Kind, Target: r.Target,
|
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,
|
State: r.State, Reason: r.Reason, Since: r.Since, Streak: r.Streak, Restarts: r.Restarts,
|
||||||
Check: r.Check, Needs: r.Needs, Account: r.Account}
|
Check: r.Check, Needs: r.Needs, Account: r.Account, Root: r.Root}
|
||||||
resources = append(resources, kept)
|
resources = append(resources, kept)
|
||||||
if r.State == link.StateUnhealthy && r.Module != "" {
|
if r.State == link.StateUnhealthy && r.Module != "" {
|
||||||
unhealthy[r.Module] = append(unhealthy[r.Module], kept)
|
unhealthy[r.Module] = append(unhealthy[r.Module], kept)
|
||||||
@@ -402,6 +402,7 @@ func moduleUnhealthyObservation(module, node string, rs []inventory.ResourceHeal
|
|||||||
Explanation: fmt.Sprintf("%s on %s is not healthy: %s. It clears as soon as it runs again.", module, node,
|
Explanation: fmt.Sprintf("%s on %s is not healthy: %s. It clears as soon as it runs again.", module, node,
|
||||||
namesWords(plain, 3)),
|
namesWords(plain, 3)),
|
||||||
Needs: needs,
|
Needs: needs,
|
||||||
|
Actions: moduleActions(node, rs),
|
||||||
Resolved: fmt.Sprintf("%s works again on %s", module, node)}
|
Resolved: fmt.Sprintf("%s works again on %s", module, node)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1107,7 +1107,7 @@ func refuseTerminalSettingsThroughAVerb(ctx context.Context, inv *inventory.Inve
|
|||||||
}
|
}
|
||||||
// A trusted mergeable file takes any key, so its module's whole layer is the terminal's (novox/hq issue 340).
|
// A trusted mergeable file takes any key, so its module's whole layer is the terminal's (novox/hq issue 340).
|
||||||
if files := catalogue.TrustedMergeable(shelf[module]); len(files) > 0 && !sameLayer(before, after) {
|
if files := catalogue.TrustedMergeable(shelf[module]); len(files) > 0 && !sameLayer(before, after) {
|
||||||
return fmt.Errorf("the settings of %s on %s are set at the controller's terminal only, never through a verb (this "+
|
return fmt.Errorf("the settings of %s on %s are set at the controller's terminal only (`mesh-cli` on the control-node), never through a verb (this "+
|
||||||
"line came through %q): %s merges whatever key a layer sets into a file root or a consumer trusts, so "+
|
"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 "+
|
"any key could point the module at a listener of the caller's, and whoever may call a verb includes "+
|
||||||
"agents (novox/hq issue 340; a file nothing trusts says \"trusted\": false). Nothing was changed",
|
"agents (novox/hq issue 340; a file nothing trusts says \"trusted\": false). Nothing was changed",
|
||||||
@@ -1119,7 +1119,7 @@ func refuseTerminalSettingsThroughAVerb(ctx context.Context, inv *inventory.Inve
|
|||||||
if string(was) == string(now) {
|
if string(was) == string(now) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return fmt.Errorf("%s of %s on %s is set at the controller's terminal only, never through a verb (this "+
|
return fmt.Errorf("%s of %s on %s is set at the controller's terminal only (`mesh-cli` on the control-node), never through a verb (this "+
|
||||||
"line came through %q): it says where root creates and owns a module's directories, which of "+
|
"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 "+
|
"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; "+
|
"root or a person's session obeys takes, and whoever may call a verb includes agents (novox/hq issue 339; "+
|
||||||
|
|||||||
@@ -100,6 +100,12 @@ func nodeCommand(ctx context.Context, args []string) error {
|
|||||||
"containers reaching outward. The machine reports which of its links face outside; see " +
|
"containers reaching outward. The machine reports which of its links face outside; see " +
|
||||||
"`node show <name>`")
|
"`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":
|
case "account":
|
||||||
// The operator's login on this machine (novox/hq to-be 29): what a home-scoped file is
|
// 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;
|
// owned by and which account `ssh <node>` uses. Reports with no argument; sets with one;
|
||||||
@@ -107,7 +113,7 @@ func nodeCommand(ctx context.Context, args []string) error {
|
|||||||
return nodeAccount(ctx, inv, args[1:])
|
return nodeAccount(ctx, inv, args[1:])
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("node has no %q; it has add, list, show, public-domain and account", args[0])
|
return fmt.Errorf("node has no %q; it has add, list, show, public-domain, account and agent-account", args[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +184,57 @@ func nodeAccount(ctx context.Context, inv *inventory.Inventory, positionals []st
|
|||||||
return nil
|
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; " +
|
const publicDomainUsage = "node public-domain <name> — what it is now; " +
|
||||||
"<name> <domain> to set it; <name> --clear to take it away"
|
"<name> <domain> to set it; <name> --clear to take it away"
|
||||||
|
|
||||||
@@ -592,6 +649,10 @@ func showNode(ctx context.Context, inv *inventory.Inventory, name string) error
|
|||||||
if err := showMode(ctx, inv, node); err != nil {
|
if err := showMode(ctx, inv, node); err != nil {
|
||||||
return err
|
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
|
// 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
|
// only when set: a machine that serves nothing to the outside has no domain, and saying so of
|
||||||
|
|||||||
@@ -110,6 +110,15 @@ var plainWordings = map[string]func(conditions.Observation) words{
|
|||||||
"reaches it. It keeps running what it has.", m),
|
"reaches it. It keeps running what it has.", m),
|
||||||
Resolved: m + " can get new instructions again"}
|
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 {
|
"own-address-banned": worded(func(o conditions.Observation) words {
|
||||||
m := machineOr(o, "a machine")
|
m := machineOr(o, "a machine")
|
||||||
return words{Headline: m + " has banned the mesh",
|
return words{Headline: m + " has banned the mesh",
|
||||||
@@ -671,6 +680,8 @@ func walkWaitingWords(w waitFacts, in time.Duration, severity conditions.Severit
|
|||||||
}
|
}
|
||||||
|
|
||||||
// waitingNeeds is what the operator does about a walk waiting past its urgent bound: nothing before it.
|
// waitingNeeds is what the operator does about a walk waiting past its urgent bound: nothing before it.
|
||||||
|
// Start and Stop are also asked of the operator (novox/hq ADR 0259); the condition's own words keep saying
|
||||||
|
// where they are given without a channel, and the ask's text drops that (askText).
|
||||||
func waitingNeeds(severity conditions.Severity) string {
|
func waitingNeeds(severity conditions.Severity) string {
|
||||||
if severity == conditions.Urgent {
|
if severity == conditions.Urgent {
|
||||||
return "start it, or stop it, " + FromMeshMCPServer
|
return "start it, or stop it, " + FromMeshMCPServer
|
||||||
@@ -678,6 +689,20 @@ func waitingNeeds(severity conditions.Severity) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// waitingActions are the answers to a walk waiting past its urgent bound: start it, or stop it — the plan's
|
||||||
|
// own verbs, approved by the operator (novox/hq ADR 0259). None before the bound.
|
||||||
|
func waitingActions(plan string, severity conditions.Severity) []conditions.Action {
|
||||||
|
if severity != conditions.Urgent || plan == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []conditions.Action{
|
||||||
|
{Label: "Start", Verb: "mesh-controller.plans", Level: conditions.LevelApprove,
|
||||||
|
Arguments: map[string]string{"go": plan, "why": "", "cause": conditions.CauseOperatorAnswer}},
|
||||||
|
{Label: "Stop", Verb: "mesh-controller.plans", Level: conditions.LevelApprove,
|
||||||
|
Arguments: map[string]string{"stop": plan, "why": "", "cause": conditions.CauseOperatorAnswer}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// moduleNeeds is what the operator can do about a module unhealthy on a machine: log in again where its
|
// moduleNeeds is what the operator can do about a module unhealthy on a machine: log in again where its
|
||||||
// account's groups wait for it (ADR 0252), restart a failed service, or nothing where the mesh restarts it.
|
// account's groups wait for it (ADR 0252), restart a failed service, or nothing where the mesh restarts it.
|
||||||
// No answer is offered for a restart: a desk click performs only an acknowledgement (ADR 0258).
|
// No answer is offered for a restart: a desk click performs only an acknowledgement (ADR 0258).
|
||||||
@@ -692,11 +717,35 @@ func moduleNeeds(node string, rs []inventory.ResourceHealth) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if unit != "" {
|
if unit != "" {
|
||||||
|
// Also asked of the operator (moduleActions); the ask's text drops where (askText).
|
||||||
return fmt.Sprintf("restart its service %s on %s %s", unit, node, FromMeshMCPServer)
|
return fmt.Sprintf("restart its service %s on %s %s", unit, node, FromMeshMCPServer)
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// moduleActions are the answers to a module unhealthy on a machine: restart its failed service there,
|
||||||
|
// approved by the operator (novox/hq ADR 0259) — none when the mesh restarts it, or a new login is what it
|
||||||
|
// waits for.
|
||||||
|
func moduleActions(node string, rs []inventory.ResourceHealth) []conditions.Action {
|
||||||
|
for _, r := range rs {
|
||||||
|
if strings.Contains(r.Reason, "relogin needed") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range rs {
|
||||||
|
if r.Kind != link.KindUnit || r.Target == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scope := "system"
|
||||||
|
if r.Account != "" {
|
||||||
|
scope = "user"
|
||||||
|
}
|
||||||
|
return []conditions.Action{{Label: "Restart", Verb: "node-service-manager.restart", Machine: node,
|
||||||
|
Level: conditions.LevelApprove, Arguments: map[string]string{"unit": r.Target, "scope": scope}}}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// FromMeshMCPServer ends what the operator needs when no notification can do it (ADR 0258), naming the mesh MCP
|
// FromMeshMCPServer ends what the operator needs when no notification can do it (ADR 0258), naming the mesh MCP
|
||||||
// server (the glossary's word; "console" is retired): the answer is not an
|
// server (the glossary's word; "console" is retired): the answer is not an
|
||||||
// acknowledgement, so it is given where the operator is known to be the one asking, until answers are
|
// acknowledgement, so it is given where the operator is known to be the one asking, until answers are
|
||||||
@@ -739,6 +788,25 @@ func stalledWords(l stalledLine, o conditions.Observation) (headline, explanatio
|
|||||||
actions []conditions.Action) {
|
actions []conditions.Action) {
|
||||||
repository, _, _ := strings.Cut(l.ID, "@")
|
repository, _, _ := strings.Cut(l.ID, "@")
|
||||||
name := repoName(repository)
|
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
|
held := l.State
|
||||||
if held == "" {
|
if held == "" {
|
||||||
held = "held"
|
held = "held"
|
||||||
@@ -748,15 +816,19 @@ func stalledWords(l stalledLine, o conditions.Observation) (headline, explanatio
|
|||||||
long = "for " + humanDuration(d)
|
long = "for " + humanDuration(d)
|
||||||
}
|
}
|
||||||
if o.Resolver == conditions.ResolverOperator {
|
if o.Resolver == conditions.ResolverOperator {
|
||||||
// Words only: releasing or stopping a delivery is not an acknowledgement, so no desk click
|
// Asked of the operator, approved on a channel that proves who answered (novox/hq ADR 0259); the
|
||||||
// performs it (ADR 0258).
|
// router says where each can be answered, so the words do not.
|
||||||
|
release := conditions.Action{Label: "Release", Verb: "mesh-delivery.release", Level: conditions.LevelApprove,
|
||||||
|
Arguments: map[string]string{"id": l.ID, "why": ""}}
|
||||||
|
stop := conditions.Action{Label: "Stop", Verb: "mesh-delivery.stop", Level: conditions.LevelApprove,
|
||||||
|
Arguments: map[string]string{"id": l.ID, "why": ""}}
|
||||||
switch held {
|
switch held {
|
||||||
case "held":
|
case "held":
|
||||||
needs = "release it, or stop it, " + FromMeshMCPServer
|
needs, actions = "release it, or stop it, "+FromMeshMCPServer, []conditions.Action{release, stop}
|
||||||
case "ready", "checked":
|
case "ready", "checked":
|
||||||
needs = "merge its pull request, or close it."
|
needs = "merge its pull request, or close it."
|
||||||
default:
|
default:
|
||||||
needs = "stop it " + FromMeshMCPServer
|
needs, actions = "stop it "+FromMeshMCPServer, []conditions.Action{stop}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("Delivery of %s %s %s", name, held, long),
|
return fmt.Sprintf("Delivery of %s %s %s", name, held, long),
|
||||||
|
|||||||
@@ -65,13 +65,21 @@ func TestADeliveryWaitingNeedsNothingUntilItsBoundThenOffersStartAndStop(t *test
|
|||||||
t.Errorf("the summary lost the way on for whoever looks closer: %q", got[0].Summary)
|
t.Errorf("the summary lost the way on for whoever looks closer: %q", got[0].Summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Past four hours it is urgent, and offers the controller's own answers.
|
// Past four hours it is urgent, and asks the operator to start or stop it (novox/hq ADR 0259): the plan's
|
||||||
|
// own verbs, approved, which the controller performs on the warrant. The router says where to answer.
|
||||||
f.waits[0].since = now.Add(-5 * time.Hour)
|
f.waits[0].since = now.Add(-5 * time.Hour)
|
||||||
got = watchWaits(f)
|
got = watchWaits(f)
|
||||||
plainExample(t, got[0], "openrazer delivery waiting to start",
|
plainExample(t, got[0], "openrazer delivery waiting to start",
|
||||||
"Needs you: start it, or stop it, from the mesh MCP server; this notification cannot do it. The change to openrazer is merged and built, and mesh-delivery (the "+
|
"Needs you: start it, or stop it, from the mesh MCP server; this notification cannot do it. The change to openrazer is merged and built, and mesh-delivery (the "+
|
||||||
"module that decides when a delivery goes out) has not let it start for 5 hours, so mesh-delivery may "+
|
"module that decides when a delivery goes out) has not let it start for 5 hours, so mesh-delivery may "+
|
||||||
"be stuck.")
|
"be stuck.", "Start", "Stop")
|
||||||
|
for i, want := range []string{"go", "stop"} {
|
||||||
|
a := got[0].Actions[i]
|
||||||
|
if a.Verb != "mesh-controller.plans" || a.Arguments[want] != "plan-1791454185265004861" ||
|
||||||
|
a.Level != conditions.LevelApprove || a.Arguments["cause"] != conditions.CauseOperatorAnswer {
|
||||||
|
t.Errorf("%s: %+v", a.Label, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Many modules are counted, not listed in the headline.
|
// Many modules are counted, not listed in the headline.
|
||||||
f.waits[0].modules = []string{"a", "b", "c", "d"}
|
f.waits[0].modules = []string{"a", "b", "c", "d"}
|
||||||
@@ -82,16 +90,20 @@ func TestADeliveryWaitingNeedsNothingUntilItsBoundThenOffersStartAndStop(t *test
|
|||||||
}
|
}
|
||||||
|
|
||||||
// **A module unhealthy**: "openrazer on g14 is not healthy: its unit openrazer-daemon.service failed in the
|
// **A module unhealthy**: "openrazer on g14 is not healthy: its unit openrazer-daemon.service failed in the
|
||||||
// account's own service manager (exit-code)". Restarting is not an acknowledgement, so it is said in words
|
// account's own service manager (exit-code)". Restarting is not an acknowledgement: it is asked of the
|
||||||
// and offered as no answer (ADR 0258).
|
// operator at the approve level (novox/hq ADR 0259), so a desk click never performs it (ADR 0258).
|
||||||
func TestAModuleUnhealthyAsksForARestartInWords(t *testing.T) {
|
func TestAModuleUnhealthyAsksForARestartInWords(t *testing.T) {
|
||||||
o := moduleUnhealthyObservation("openrazer", "g14", []inventory.ResourceHealth{{Kind: link.KindUnit,
|
o := moduleUnhealthyObservation("openrazer", "g14", []inventory.ResourceHealth{{Kind: link.KindUnit,
|
||||||
Resource: "openrazer-daemon", Target: "openrazer-daemon.service",
|
Resource: "openrazer-daemon", Target: "openrazer-daemon.service", Account: "jochen",
|
||||||
Reason: "failed in the account's own service manager (exit-code)", Since: time.Now()}})
|
Reason: "failed in the account's own service manager (exit-code)", Since: time.Now()}})
|
||||||
plainExample(t, o, "openrazer not working on g14",
|
plainExample(t, o, "openrazer not working on g14",
|
||||||
"Needs you: restart its service openrazer-daemon on g14 from the mesh MCP server; this notification cannot do it. "+
|
"Needs you: restart its service openrazer-daemon on g14 from the mesh MCP server; this notification cannot do it. "+
|
||||||
"openrazer on g14 is not healthy: its service openrazer-daemon stopped with an error. It clears as soon "+
|
"openrazer on g14 is not healthy: its service openrazer-daemon stopped with an error. It clears as soon "+
|
||||||
"as it runs again.")
|
"as it runs again.", "Restart")
|
||||||
|
if a := o.Actions[0]; a.Verb != "node-service-manager.restart" || a.Machine != "g14" || a.Level != conditions.LevelApprove ||
|
||||||
|
a.Arguments["unit"] != "openrazer-daemon.service" || a.Arguments["scope"] != "user" {
|
||||||
|
t.Errorf("restart: %+v", a)
|
||||||
|
}
|
||||||
// An account waiting for a new login (ADR 0252) asks for the login, held to the plain rule.
|
// An account waiting for a new login (ADR 0252) asks for the login, held to the plain rule.
|
||||||
o = moduleUnhealthyObservation("openrazer", "g14", []inventory.ResourceHealth{{Kind: "account",
|
o = moduleUnhealthyObservation("openrazer", "g14", []inventory.ResourceHealth{{Kind: "account",
|
||||||
Resource: "operator-in-group", Target: "jochen", Reason: "relogin needed: the account is in the group"}})
|
Resource: "operator-in-group", Target: "jochen", Reason: "relogin needed: the account is in the group"}})
|
||||||
@@ -154,7 +166,12 @@ func TestADeliveryHeldAsksForReleaseOrStopInWords(t *testing.T) {
|
|||||||
Bound: "24h0m0s", H2: "none: the state is the operator's", Says: "it waits for the operator"}})
|
Bound: "24h0m0s", H2: "none: the state is the operator's", Says: "it waits for the operator"}})
|
||||||
plainExample(t, got[0], "Delivery of hq held for 36 hours",
|
plainExample(t, got[0], "Delivery of hq held for 36 hours",
|
||||||
"Needs you: release it, or stop it, from the mesh MCP server; this notification cannot do it. A delivery of hq has been held for 36 hours, past its limit.",
|
"Needs you: release it, or stop it, from the mesh MCP server; this notification cannot do it. A delivery of hq has been held for 36 hours, past its limit.",
|
||||||
)
|
"Release", "Stop")
|
||||||
|
for i, verb := range []string{"mesh-delivery.release", "mesh-delivery.stop"} {
|
||||||
|
if a := got[0].Actions[i]; a.Verb != verb || a.Arguments["id"] != "novox/hq@055550802096" || a.Level != conditions.LevelApprove {
|
||||||
|
t.Errorf("%+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// **Every kind the controller raises has plain words**, and its words are plain for a subject of every
|
// **Every kind the controller raises has plain words**, and its words are plain for a subject of every
|
||||||
@@ -309,3 +326,24 @@ func TestANeedNoNotificationAnswersNamesTheMeshMCPServer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
check("updates held", releaseHeldWords([]string{"openrazer"}, []string{"g14"}))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -136,7 +136,8 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
|||||||
resolved, err := catalogue.Resolve(shelf, assigned,
|
resolved, err := catalogue.Resolve(shelf, assigned,
|
||||||
catalogue.Node{Name: nodeName, Site: site, Capabilities: capabilities,
|
catalogue.Node{Name: nodeName, Site: site, Capabilities: capabilities,
|
||||||
At: onNetwork[nodeName], PublicDomain: publicDomain,
|
At: onNetwork[nodeName], PublicDomain: publicDomain,
|
||||||
Account: who.Account, AccountHome: who.AccountHome}, world)
|
Account: who.Account, AccountHome: who.AccountHome,
|
||||||
|
AgentAccount: who.AgentAccount, AgentAccountHome: who.AgentAccountHome}, world)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// The node's own set does not compose. Marked, because this is the only failure here that
|
// 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.
|
// a mesh-wide gatherer may pass over — see notResolvable.
|
||||||
@@ -885,8 +886,13 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Rendering{}, inventory.Node{}, err
|
return catalogue.Rendering{}, inventory.Node{}, err
|
||||||
}
|
}
|
||||||
|
judgesRoot, err := engineJudgesRoot(ctx, inv, node)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Rendering{}, inventory.Node{}, err
|
||||||
|
}
|
||||||
return catalogue.Rendering{
|
return catalogue.Rendering{
|
||||||
ReadsHealth: readsHealth,
|
ReadsHealth: readsHealth,
|
||||||
|
JudgesRoot: judgesRoot,
|
||||||
BusMembership: memberships[node],
|
BusMembership: memberships[node],
|
||||||
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Foreseen: foreseen, Ports: ports,
|
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Foreseen: foreseen, Ports: ports,
|
||||||
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
|
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
|
||||||
@@ -909,6 +915,16 @@ func engineReadsHealth(ctx context.Context, inv *inventory.Inventory, node strin
|
|||||||
return had && stated.Contract >= link.ReadinessContract, nil
|
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
|
// 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
|
// 0199): the zone settled from that node's settings, the node's private address, the port the
|
||||||
// answering listen is published on there.
|
// answering listen is published on there.
|
||||||
@@ -1223,6 +1239,10 @@ func planCommand(ctx context.Context, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
return writePlanDiff(ctx, open.inventory, args[0], body)
|
||||||
}
|
}
|
||||||
if *asJSON {
|
if *asJSON {
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
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);
|
||||||
|
// 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 first setuid search runs,
|
||||||
|
// within its bound (ADR 0266's quiet window).
|
||||||
|
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 first setuid search still runs, within its bound: 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 first 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 first setuid
|
||||||
|
// search, within its bound (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 first setuid search
|
||||||
|
// runs, within its bound. The root-free verb never reads it, so the machine still answers not free.
|
||||||
|
r.quiet = func(ctx context.Context, node string, now time.Time) bool {
|
||||||
|
n, err := inv.NodeByName(ctx, node)
|
||||||
|
if err != nil || n.AgentAccount == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
h, had, err := inv.HealthOf(ctx, node)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
quiet, err := searchStillRunning(ctx, inv, node, n.AgentAccount, h, had, now)
|
||||||
|
return err == nil && quiet
|
||||||
|
}
|
||||||
|
return rootObservations(ctx, r, trustedMachines(r.entries), time.Now()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rootObservations judges the machines and says each that fails.
|
||||||
|
func rootObservations(ctx context.Context, r *rootReader, trusted map[string][]string, now time.Time) []conditions.Observation {
|
||||||
|
var machines []string
|
||||||
|
for m := range trusted {
|
||||||
|
machines = append(machines, m)
|
||||||
|
}
|
||||||
|
sort.Strings(machines)
|
||||||
|
var out []conditions.Observation
|
||||||
|
for _, v := range judgeRootFree(ctx, r, machines, now) {
|
||||||
|
if !v.Free && !v.Quiet {
|
||||||
|
out = append(out, agentRootObservation(v, trusted[v.Machine]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// rootClock is the clock the root-free verb judges by.
|
||||||
|
var rootClock = time.Now
|
||||||
|
|
||||||
|
// rootFreeAnswer is the root-free verb: the named machines, each judged now by the serving controller. Only it
|
||||||
|
// answers: a process that is not serving says so, and a caller reads that as no machine free.
|
||||||
|
func rootFreeAnswer(ctx context.Context, machines string, now time.Time) (any, error) {
|
||||||
|
d := doctorFrom
|
||||||
|
if d == nil || d.open == nil || d.open.inventory == nil {
|
||||||
|
return nil, fmt.Errorf("this controller is not serving, so it judges no machine root-free: ask again, and " +
|
||||||
|
"the serving controller answers")
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, m := range strings.Split(machines, ",") {
|
||||||
|
if m = strings.TrimSpace(m); m != "" && !slices.Contains(names, m) {
|
||||||
|
names = append(names, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
return nil, fmt.Errorf("root-free judges the machines named, and none was")
|
||||||
|
}
|
||||||
|
var conn *nats.Conn
|
||||||
|
if d.js != nil {
|
||||||
|
conn = d.js.Conn()
|
||||||
|
}
|
||||||
|
return map[string]any{"machines": judgeRootFree(ctx, newRootReader(ctx, d.open.inventory, conn), names, now)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rootFreeNow is the machines judged root-free, for composing a push's memberships: only those that pass.
|
||||||
|
func rootFreeNow(ctx context.Context, r *rootReader, machines []string, now time.Time) map[string]bool {
|
||||||
|
free := map[string]bool{}
|
||||||
|
for _, v := range judgeRootFree(ctx, r, machines, now) {
|
||||||
|
if v.Free {
|
||||||
|
free[v.Machine] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return free
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
var rootNow = time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// A machine is root-free only on a positive measure of each thing (the review of 2026-10-09, H2/H3): every
|
||||||
|
// read succeeded, an agent account is named and judged confined by the node-engine, execute is not served.
|
||||||
|
func TestRootFreeIsAPositiveMeasureAndNothingElse(t *testing.T) {
|
||||||
|
pass := rootFacts{Machine: "anchor", AgentNamed: true, Confined: true,
|
||||||
|
ConfinedWhy: "the agent account agents cannot become root without a person (judged 2026-10-09 12:00)"}
|
||||||
|
if v := judgeRoot(pass, rootNow); !v.Free || v.Machine != "anchor" || !v.Judged.Equal(rootNow) {
|
||||||
|
t.Fatalf("the one pass: %+v", v)
|
||||||
|
}
|
||||||
|
fails := map[string]func(*rootFacts){
|
||||||
|
"a read failed": func(f *rootFacts) { f.Unread = []string{"the store did not answer"} },
|
||||||
|
"no agent account named": func(f *rootFacts) { f.AgentNamed, f.Confined = false, false },
|
||||||
|
"not confined": func(f *rootFacts) { f.Confined = false },
|
||||||
|
"nothing said of it": func(f *rootFacts) { f.ConfinedWhy = "" },
|
||||||
|
"execute served": func(f *rootFacts) { f.Execute, f.ExecuteWhy = true, "the bus hears execute answered there" },
|
||||||
|
"confined, but agent read": func(f *rootFacts) { f.Unread, f.ConfinedWhy = []string{"x"}, "" },
|
||||||
|
}
|
||||||
|
for name, mutate := range fails {
|
||||||
|
f := pass
|
||||||
|
mutate(&f)
|
||||||
|
if v := judgeRoot(f, rootNow); v.Free || v.Why == "" {
|
||||||
|
t.Errorf("%s: judged %+v", name, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reader fails closed on every case the review named: the agent account read only where the coding-agent
|
||||||
|
// module runs (old :225), no account to measure taken for a pass (old :246), and a measure that did not answer
|
||||||
|
// taken for "not root" (old :114, :123).
|
||||||
|
func TestTheRootReaderFailsClosed(t *testing.T) {
|
||||||
|
shell := catalogue.Manifest{Module: "zsh", Claims: []catalogue.Claim{{Name: loginShellSeat, Serves: []string{"execute"}}},
|
||||||
|
Settings: map[string]catalogue.SettingDeclaration{"execute": {Default: "withhold"}}}
|
||||||
|
reader := func() *rootReader {
|
||||||
|
return &rootReader{
|
||||||
|
entries: []inventory.Entry{{Manifest: shell, On: []string{"anchor"}}},
|
||||||
|
heard: map[string]map[string]map[string]bool{},
|
||||||
|
confined: func(context.Context, string, time.Time) (bool, bool, string, error) {
|
||||||
|
return true, true, "the agent account agents cannot become root without a person", nil
|
||||||
|
},
|
||||||
|
settings: func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, nil },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
if v := judgeRootFree(ctx, reader(), []string{"anchor"}, rootNow)[0]; !v.Free {
|
||||||
|
t.Fatalf("the control: agents confined, execute withheld and unheard, everything read: %+v", v)
|
||||||
|
}
|
||||||
|
cases := map[string]func(*rootReader){
|
||||||
|
"no agent account named, no coding-agent module there": func(r *rootReader) {
|
||||||
|
r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) {
|
||||||
|
return false, false, "anchor names no agent account: agents run as the operator account (ops)", nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"the node-engine's verdict not read": func(r *rootReader) {
|
||||||
|
r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) {
|
||||||
|
return false, false, "", errors.New("the store did not answer")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"a stale verdict": func(r *rootReader) {
|
||||||
|
r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) {
|
||||||
|
return true, false, "the agent account agents is not judged: the machine's newest statement was heard at …", nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"the bus not asked": func(r *rootReader) { r.asked = errors.New("no bus") },
|
||||||
|
"the placements not read": func(r *rootReader) { r.read = errors.New("no store") },
|
||||||
|
"the holder's setting not read": func(r *rootReader) {
|
||||||
|
r.settings = func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, errors.New("no store") }
|
||||||
|
},
|
||||||
|
"execute heard on the bus": func(r *rootReader) {
|
||||||
|
r.heard = map[string]map[string]map[string]bool{loginShellSeat: {"execute": {"anchor": true}}}
|
||||||
|
},
|
||||||
|
"a holder that serves execute": func(r *rootReader) {
|
||||||
|
r.entries[0].Manifest.Settings = nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for name, mutate := range cases {
|
||||||
|
r := reader()
|
||||||
|
mutate(r)
|
||||||
|
if v := judgeRootFree(ctx, r, []string{"anchor"}, rootNow)[0]; v.Free {
|
||||||
|
t.Errorf("%s: judged free: %+v", name, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A machine with no login shell holder at all: still the bus must hear none.
|
||||||
|
r := reader()
|
||||||
|
r.entries = nil
|
||||||
|
r.heard = map[string]map[string]map[string]bool{loginShellSeat: {"execute": {"anchor": true}}}
|
||||||
|
if v := judgeRootFree(ctx, r, []string{"anchor"}, rootNow)[0]; v.Free {
|
||||||
|
t.Errorf("execute answered by a module nobody assigned: %+v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The probe says agent-root, by the same judgement, on each machine where the router or a module of its own
|
||||||
|
// account runs and that is not root-free; urgent and in plain words; nothing where every one is free.
|
||||||
|
func TestTheProbeSaysEachMachineThatIsNotRootFree(t *testing.T) {
|
||||||
|
entries := []inventory.Entry{
|
||||||
|
{Manifest: catalogue.Manifest{Module: "telegram", RunsAs: "telegram"}, On: []string{"anchor"}},
|
||||||
|
{Manifest: catalogue.Manifest{Module: "messenger", RunsAs: "messenger",
|
||||||
|
Claims: []catalogue.Claim{{Name: routerSeat}}}, On: []string{"anchor"}},
|
||||||
|
{Manifest: catalogue.Manifest{Module: "xorg", Claims: []catalogue.Claim{{Name: catalogue.DisplayServerSeat}}},
|
||||||
|
On: []string{"laptop"}},
|
||||||
|
}
|
||||||
|
trusted := trustedMachines(entries)
|
||||||
|
if len(trusted["anchor"]) != 2 || len(trusted["laptop"]) != 0 {
|
||||||
|
t.Fatalf("trusted %v", trusted)
|
||||||
|
}
|
||||||
|
r := &rootReader{entries: entries, heard: map[string]map[string]map[string]bool{},
|
||||||
|
confined: func(_ context.Context, node string, _ time.Time) (bool, bool, string, error) {
|
||||||
|
return false, false, node + " names no agent account: agents run as the operator account (ops)", nil
|
||||||
|
},
|
||||||
|
settings: func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, nil }}
|
||||||
|
got := rootObservations(context.Background(), r, trusted, rootNow)
|
||||||
|
if len(got) != 1 || got[0].Machine != "anchor" || got[0].Kind != kindRootNotFree || got[0].Severity != conditions.Urgent ||
|
||||||
|
!strings.Contains(got[0].Summary, "messenger, telegram") || !strings.Contains(got[0].Summary, "names no agent account") {
|
||||||
|
t.Fatalf("said %+v", got)
|
||||||
|
}
|
||||||
|
o := got[0]
|
||||||
|
if why, ok := conditions.PlainWords(conditions.Words{Headline: o.Headline, Explanation: o.Explanation,
|
||||||
|
Needs: o.Needs, Resolved: o.Resolved}, o.Machine); !ok {
|
||||||
|
t.Errorf("not plain: %s", why)
|
||||||
|
}
|
||||||
|
r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) {
|
||||||
|
return true, true, "confined", nil
|
||||||
|
}
|
||||||
|
if got := rootObservations(context.Background(), r, trusted, rootNow); len(got) != 0 {
|
||||||
|
t.Errorf("said of a free machine: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0268: the login shell counts only where its execute is served, and fails closed.
|
||||||
|
func TestTheLoginShellCountsOnlyWhereExecuteIsServed(t *testing.T) {
|
||||||
|
val := func(v any) *any { return &v }
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
claims bool
|
||||||
|
setting *any
|
||||||
|
heard, asked bool
|
||||||
|
served bool
|
||||||
|
saysInTheWhys string
|
||||||
|
}{
|
||||||
|
{"withheld by the setting and silent on the bus", true, val("withhold"), false, true, false, ""},
|
||||||
|
{"withheld by the setting, the machine not yet pushed", true, val("withhold"), true, true, true, "the bus hears"},
|
||||||
|
{"the setting serves", true, val("serve"), false, true, true, "setting there is serve"},
|
||||||
|
{"a wrong value withholds, as the holder does", true, val("Serve"), false, true, false, ""},
|
||||||
|
{"the setting withholds, the bus could not be asked", true, val("withhold"), false, false, true, "could not be asked"},
|
||||||
|
{"a holder with no such setting that claims execute", true, nil, false, true, true, "claims execute"},
|
||||||
|
{"a holder with no such setting that does not claim it, heard all the same", false, nil, true, true, true, "the bus hears"},
|
||||||
|
{"a holder with no such setting that does not claim it, silent", false, nil, false, true, false, ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
served, why := loginShellServed("zsh", c.claims, c.setting, c.heard, c.asked)
|
||||||
|
if served != c.served || (c.saysInTheWhys != "" && !strings.Contains(why, c.saysInTheWhys)) {
|
||||||
|
t.Errorf("%s: served %v (%q), want %v saying %q", c.name, served, why, c.served, c.saysInTheWhys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The root-free verb is the serving controller's alone, answered in its process and never as a command; a
|
||||||
|
// controller not serving answers an error, which the router reads as no machine free.
|
||||||
|
func TestRootFreeIsAnsweredOnlyByTheServingController(t *testing.T) {
|
||||||
|
was := doctorFrom
|
||||||
|
doctorFrom = nil
|
||||||
|
t.Cleanup(func() { doctorFrom = was })
|
||||||
|
if _, err := rootFreeAnswer(context.Background(), "anchor", rootNow); err == nil {
|
||||||
|
t.Error("a controller not serving judged a machine")
|
||||||
|
}
|
||||||
|
if !inProcess["root-free"] {
|
||||||
|
t.Error("root-free is not answered in the serving process")
|
||||||
|
}
|
||||||
|
if _, err := argvFor("root-free", map[string]any{"machines": "anchor"}); err == nil {
|
||||||
|
t.Error("root-free ran as a command")
|
||||||
|
}
|
||||||
|
// The router names its machines as a list (its contract with this verb); one text separated by commas is
|
||||||
|
// the same; anything else in the list is refused.
|
||||||
|
for _, given := range []any{[]any{"anchor", "relay"}, "anchor, relay"} {
|
||||||
|
a, err := readArguments("root-free", map[string]any{"machines": given})
|
||||||
|
if err != nil || a.given["machines"] != "anchor,relay" && a.given["machines"] != "anchor, relay" {
|
||||||
|
t.Errorf("root-free given %v read %v (%v)", given, a, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := readArguments("root-free", map[string]any{"machines": []any{"anchor", 7}}); err == nil {
|
||||||
|
t.Error("root-free took a number for a machine")
|
||||||
|
}
|
||||||
|
if _, err := readArguments("status", map[string]any{"machines": []any{"anchor"}}); err == nil {
|
||||||
|
t.Error("a verb that takes no list took one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The confirmation review of 2026-10-09: ADR 0266's quiet window (#175) keeps the self-check from raising
|
||||||
|
// agent-can-become-root while the node-engine's first setuid search runs. 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 first 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 first setuid search, within its bound — 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 first search runs: %+v", got)
|
||||||
|
}
|
||||||
|
if v := judgeRootFree(context.Background(), r, []string{"anchor"}, rootNow)[0]; v.Free || !v.Quiet {
|
||||||
|
t.Errorf("root-free while the first 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 its bound, 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 its bound was not said: %+v", got)
|
||||||
|
}
|
||||||
|
// One key per judgement: DA's is machine.<m>.agent-root, this one its own.
|
||||||
|
da := conditions.Observation{Scope: conditions.ScopeMachine, ID: "anchor", Token: "agent-root", Machine: "anchor"}
|
||||||
|
if got[0].Key() == da.Key() {
|
||||||
|
t.Errorf("D-root and DA share the key %s", da.Key())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -252,6 +252,10 @@ func askEveryResolver(ctx context.Context, resolvers map[string]string, places [
|
|||||||
v.wrong[0], andMore(len(v.wrong)-1))
|
v.wrong[0], andMore(len(v.wrong)-1))
|
||||||
} else {
|
} else {
|
||||||
// Nothing but silence: held for the next run, which raises it if the resolver is still silent.
|
// Nothing but silence: held for the next run, which raises it if the resolver is still silent.
|
||||||
|
// Refused on every try too: a few hundred milliseconds of refusals is a resolver restarting as
|
||||||
|
// well as one that stopped, and the two looks a finding needs are this run and the next
|
||||||
|
// (novox/hq issue 348). The machine's own node-engine is the fast detector: its names check
|
||||||
|
// raised the control node's resolver within a minute on 2026-10-09.
|
||||||
o.Confirm = true
|
o.Confirm = true
|
||||||
o.Summary = fmt.Sprintf("the mesh's resolver on %s does not answer: %d of the %d question(s) about the "+
|
o.Summary = fmt.Sprintf("the mesh's resolver on %s does not answer: %d of the %d question(s) about the "+
|
||||||
"machines' names went unanswered, each asked %d times — the first, %s", node, len(v.unanswered), v.asked,
|
"machines' names went unanswered, each asked %d times — the first, %s", node, len(v.unanswered), v.asked,
|
||||||
@@ -637,31 +641,8 @@ const (
|
|||||||
// discoverHolders asks the bus's discovery who serves what, and answers seat → machine for every
|
// discoverHolders asks the bus's discovery who serves what, and answers seat → machine for every
|
||||||
// endpoint a seat's verb is served on.
|
// endpoint a seat's verb is served on.
|
||||||
func discoverHolders(ctx context.Context, conn *nats.Conn) (map[string]map[string]bool, error) {
|
func discoverHolders(ctx context.Context, conn *nats.Conn) (map[string]map[string]bool, error) {
|
||||||
inbox := conn.NewRespInbox()
|
|
||||||
sub, err := conn.SubscribeSync(inbox)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer func() { _ = sub.Unsubscribe() }()
|
|
||||||
if err := conn.PublishRequest("$SRV.INFO", inbox, nil); err != nil {
|
|
||||||
return nil, fmt.Errorf("asking the bus who serves what: %w", err)
|
|
||||||
}
|
|
||||||
out := map[string]map[string]bool{}
|
out := map[string]map[string]bool{}
|
||||||
deadline := time.Now().Add(discoveryPatience)
|
err := discoverServices(ctx, conn, func(info micro.Info) {
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
wait, cancel := context.WithTimeout(ctx, discoveryQuiet)
|
|
||||||
msg, err := sub.NextMsgWithContext(wait)
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
var info micro.Info
|
|
||||||
if json.Unmarshal(msg.Data, &info) != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, e := range info.Endpoints {
|
for _, e := range info.Endpoints {
|
||||||
seat, node := e.Metadata["seat"], e.Metadata["node"]
|
seat, node := e.Metadata["seat"], e.Metadata["node"]
|
||||||
if seat == "" {
|
if seat == "" {
|
||||||
@@ -680,8 +661,69 @@ func discoverHolders(ctx context.Context, conn *nats.Conn) (map[string]map[strin
|
|||||||
out[info.Name] = map[string]bool{}
|
out[info.Name] = map[string]bool{}
|
||||||
}
|
}
|
||||||
out[info.Name][info.ID] = true
|
out[info.Name][info.ID] = true
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoverSeatVerbs asks the bus's discovery the same, one level finer: seat → verb → machine, for every
|
||||||
|
// seat verb answered (an endpoint's `tool` is its verb). A seat held where a verb is withheld (novox/hq ADR
|
||||||
|
// 0268) shows the seat and not that verb.
|
||||||
|
func discoverSeatVerbs(ctx context.Context, conn *nats.Conn) (map[string]map[string]map[string]bool, error) {
|
||||||
|
out := map[string]map[string]map[string]bool{}
|
||||||
|
err := discoverServices(ctx, conn, func(info micro.Info) {
|
||||||
|
for _, e := range info.Endpoints {
|
||||||
|
seat, verb, node := e.Metadata["seat"], e.Metadata["tool"], e.Metadata["node"]
|
||||||
|
if seat == "" || verb == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
return out, nil
|
inbox := conn.NewRespInbox()
|
||||||
|
sub, err := conn.SubscribeSync(inbox)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = sub.Unsubscribe() }()
|
||||||
|
if err := conn.PublishRequest("$SRV.INFO", inbox, nil); err != nil {
|
||||||
|
return fmt.Errorf("asking the bus who serves what: %w", err)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(discoveryPatience)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
wait, cancel := context.WithTimeout(ctx, discoveryQuiet)
|
||||||
|
msg, err := sub.NextMsgWithContext(wait)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var info micro.Info
|
||||||
|
if json.Unmarshal(msg.Data, &info) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
visit(info)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// probeArchives is D4: every archive the mesh keeps is held by its manifest in the artifact store.
|
// probeArchives is D4: every archive the mesh keeps is held by its manifest in the artifact store.
|
||||||
@@ -1040,12 +1082,7 @@ func probeCoreBuilds(ctx context.Context, d *doctor) ([]conditions.Observation,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
hostVersions := deliveredVersions(shelf[hostModule])
|
hostVersions := deliveredVersions(shelf[hostModule])
|
||||||
rolling := map[string]bool{}
|
rolling := rollingModules(plans)
|
||||||
for _, p := range plans {
|
|
||||||
for m := range p.Modules {
|
|
||||||
rolling[m] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nodes, err := inv.Nodes(ctx)
|
nodes, err := inv.Nodes(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1103,6 +1140,26 @@ func probeCoreBuilds(ctx context.Context, d *doctor) ([]conditions.Observation,
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rollingModules is every module an open plan is rolling out: those it keeps a record of, and those its
|
||||||
|
// tiers name. **A release keeps no record per module** — its walk is per machine, its modules only in its
|
||||||
|
// tier — so reading the records alone, D10 said "no plan is rolling them out" about the node-engine while
|
||||||
|
// a release walked it, and the gate on that release's first machine waited on what its own send causes
|
||||||
|
// (novox/hq issue 348). Pure.
|
||||||
|
func rollingModules(plans []inventory.Plan) map[string]bool {
|
||||||
|
rolling := map[string]bool{}
|
||||||
|
for _, p := range plans {
|
||||||
|
for m := range p.Modules {
|
||||||
|
rolling[m] = true
|
||||||
|
}
|
||||||
|
for _, tier := range p.Tiers {
|
||||||
|
for _, m := range tier {
|
||||||
|
rolling[m] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rolling
|
||||||
|
}
|
||||||
|
|
||||||
// deliveredVersions are the versions a module's registered build is delivered as: the last element
|
// deliveredVersions are the versions a module's registered build is delivered as: the last element
|
||||||
// of every resource path under a `versions/` directory, which registration filled from the artifact's
|
// of every resource path under a `versions/` directory, which registration filled from the artifact's
|
||||||
// digest (catalogue `${version}`). The node-engine names itself by that directory.
|
// digest (catalogue `${version}`). The node-engine names itself by that directory.
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ func connectLink(ctx context.Context, inv *inventory.Inventory, enroller link.En
|
|||||||
}
|
}
|
||||||
|
|
||||||
func serve(ctx context.Context) (err error) {
|
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
|
// 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).
|
// unmet seat dependencies once (novox/hq ADR 0207).
|
||||||
logUnheldChanges = true
|
logUnheldChanges = true
|
||||||
@@ -264,6 +266,12 @@ func serve(ctx context.Context) (err error) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer stopServing()
|
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.
|
// 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))
|
stopAnnouncing, err := bus.Announce(seatAnnouncement(handlers), log.New(os.Stdout, "", log.LstdFlags))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1242,11 +1250,14 @@ func issueMemberships(ctx context.Context, open *stores, server *link.Server, se
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
where := broker.PlacementsOf(records, records.Interchangeable)
|
|
||||||
bus, ok := server.Bus().(link.OverNATS)
|
bus, ok := server.Bus().(link.OverNATS)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// Which machines are root-free now (novox/hq ADR 0259 §8): a channel's verified sender is composed for the
|
||||||
|
// router only from one, beside a router on one. Judged once per push, by the root-free verb's judgement.
|
||||||
|
records.RootFree = rootFreeNow(ctx, newRootReader(ctx, open.inventory, bus.Conn), records.Nodes, time.Now())
|
||||||
|
where := broker.PlacementsOf(records, records.Interchangeable)
|
||||||
// **Every declared state's bucket, before the memberships that name it** (novox/hq ADR 0201). The
|
// **Every declared state's bucket, before the memberships that name it** (novox/hq ADR 0201). The
|
||||||
// raise at start asserts them too, but a module registered and assigned since would otherwise have
|
// raise at start asserts them too, but a module registered and assigned since would otherwise have
|
||||||
// its bucket only after the control plane next restarts — found the first time a module declared
|
// its bucket only after the control plane next restarts — found the first time a module declared
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func TestAPushSaysWhatItRecreates(t *testing.T) {
|
|||||||
aContainerBuild(t, "postgres", "c1111111", "", start.Add(time.Second),
|
aContainerBuild(t, "postgres", "c1111111", "", start.Add(time.Second),
|
||||||
map[string][2]string{"server": {image("e"), ""}}),
|
map[string][2]string{"server": {image("e"), ""}}),
|
||||||
} {
|
} {
|
||||||
if _, _, err := takeIn(ctx, inv, b); err != nil {
|
if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, b)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func TestARecordedBuildIsCarriedOnlyByAPersonsPush(t *testing.T) {
|
|||||||
aContainerBuild(t, "mailu", "c1111111", "", start.Add(time.Second),
|
aContainerBuild(t, "mailu", "c1111111", "", start.Add(time.Second),
|
||||||
map[string][2]string{"smtp": {mailImage, ""}, "imap": {mailImage, ""}}),
|
map[string][2]string{"smtp": {mailImage, ""}, "imap": {mailImage, ""}}),
|
||||||
} {
|
} {
|
||||||
if _, _, err := takeIn(ctx, inv, b); err != nil {
|
if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, b)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The rehearsal of the operator's answers — not a drill, which in the glossary is something broken on purpose (novox/hq ADR 0259, the live acceptance after rollout): an ask the
|
||||||
|
// operator starts at the controller's terminal, answered on the phone, whose approval changes nothing and is
|
||||||
|
// recorded as a person's decision like any other.
|
||||||
|
//
|
||||||
|
// mesh-controller rehearse [--for 15m]
|
||||||
|
//
|
||||||
|
// It asks with two answers, Approve and Decline, each bound to the rehearsal's own act and **both at the level
|
||||||
|
// approve** (the review of 2026-10-09, M1: an acknowledgement never shares an ask with an approval), so only a
|
||||||
|
// channel that proves who answered carries either — the rehearsal is of exactly that. The serving controller acts on the warrant
|
||||||
|
// as on any other: it claims the ask once, checks the act is the one bound, performs nothing, and records the
|
||||||
|
// hand-act `warrant` with who answered, through which channel, and the proofs. `hand-acts` then shows it.
|
||||||
|
//
|
||||||
|
// **The terminal's alone** (startedAtTheTerminal): a command a verb runs, an ordinary mesh-cli line and anything the
|
||||||
|
// serving controller started are refused, so no agent starts a rehearsal — a
|
||||||
|
// rehearsal is a question the operator expects, and one an agent could start would teach them to approve what they
|
||||||
|
// did not ask for.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rehearsalVerb is the act a rehearsal's answers bind: nothing is called.
|
||||||
|
const rehearsalVerb = "rehearsal"
|
||||||
|
|
||||||
|
// rehearsalActions are the rehearsal's two answers.
|
||||||
|
func rehearsalActions() []conditions.Action {
|
||||||
|
return []conditions.Action{
|
||||||
|
{Label: "Approve", Verb: rehearsalVerb, Level: conditions.LevelApprove, Arguments: map[string]string{"rehearsal": "approve"}},
|
||||||
|
{Label: "Decline", Verb: rehearsalVerb, Level: conditions.LevelApprove, Arguments: map[string]string{"rehearsal": "decline"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rehearsalAsk is the rehearsal's ask, as the router is sent it.
|
||||||
|
func rehearsalAsk(id string, now time.Time, lasts time.Duration) (asks.Ask, map[string]int) {
|
||||||
|
q := asks.Ask{ID: id, Headline: "Rehearsal: approve this test question?", Who: asks.Operator,
|
||||||
|
Explanation: "Needs you: approve or decline. You started this rehearsal at the controller's terminal. Approving " +
|
||||||
|
"changes nothing on the mesh; it is recorded as your decision, so you can check the record.",
|
||||||
|
OnExpiry: "nothing is done", Expires: now.Add(lasts), About: "rehearsal." + id}
|
||||||
|
options := map[string]int{}
|
||||||
|
for i, act := range rehearsalActions() {
|
||||||
|
binds, _ := asks.ActDigest(boundAct(act))
|
||||||
|
oid := optionID(act.Label)
|
||||||
|
options[oid] = i
|
||||||
|
q.Options = append(q.Options, asks.Option{ID: oid, Label: act.Label, Does: doesRehearsal(act), Level: asks.Level(act.Level),
|
||||||
|
Binds: binds})
|
||||||
|
}
|
||||||
|
return q, options
|
||||||
|
}
|
||||||
|
|
||||||
|
func doesRehearsal(act conditions.Action) string {
|
||||||
|
if act.Arguments["rehearsal"] == "approve" {
|
||||||
|
return "nothing changes; your approval is recorded"
|
||||||
|
}
|
||||||
|
return "nothing changes; your answer is recorded"
|
||||||
|
}
|
||||||
|
|
||||||
|
func rehearseCommand(ctx context.Context, args []string) error {
|
||||||
|
// The terminal as main judges it (startedAtTheTerminal): not a verb, not the serving controller or anything it
|
||||||
|
// started, and a mesh-cli line only when it is the control-node's operator's (novox/hq ADR 0272 §4).
|
||||||
|
if !startedAtTheTerminal() {
|
||||||
|
return errors.New("rehearse is the controller's terminal's alone: a verb, a mesh-cli line from anybody but " +
|
||||||
|
"the control-node's operator, or a process the serving controller started may not start one, so no agent " +
|
||||||
|
"asks the operator a question they did not start (novox/hq ADR 0259)")
|
||||||
|
}
|
||||||
|
set := flag.NewFlagSet("rehearse", flag.ContinueOnError)
|
||||||
|
lasts := set.Duration("for", 15*time.Minute, "how long the question waits for an answer")
|
||||||
|
if err := set.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *lasts < time.Minute || *lasts > askApproveFor {
|
||||||
|
return fmt.Errorf("a rehearsal waits between a minute and %s", askApproveFor)
|
||||||
|
}
|
||||||
|
js, err := aBus()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer js.Close()
|
||||||
|
now := time.Now()
|
||||||
|
id := newAskID()
|
||||||
|
q, options := rehearsalAsk(id, now, *lasts)
|
||||||
|
if err := q.Check(now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
store := busAsked{conn: js.Conn()}
|
||||||
|
// Kept before it is published, as the asker keeps every ask, so a warrant always finds it.
|
||||||
|
if err := store.Create(ctx, asked{ID: id, Condition: q.About, Ask: q, Actions: rehearsalActions(), Options: options,
|
||||||
|
State: askOpen, Opened: now, Rehearsal: true}); err != nil {
|
||||||
|
return fmt.Errorf("the rehearsal could not be kept in the controller's asks: %w", err)
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(q)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := js.Context().Publish(asks.AskSubject(askerName), body, nats.MsgId("ask."+id), nats.Context(ctx)); err != nil {
|
||||||
|
return fmt.Errorf("the rehearsal could not be asked: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("rehearsal %s asked: answer it on your phone before %s. Then `mesh-controller hand-acts` shows the "+
|
||||||
|
"answer as a warrant, with who answered, through which channel and the proofs; nothing else changes.\n",
|
||||||
|
id, q.Expires.Local().Format("15:04"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A rehearsal (the live acceptance of novox/hq ADR 0259): its approval is a warrant like any other — claimed once,
|
||||||
|
// its act checked against what the option bound, recorded as the operator's decision with who, how and the
|
||||||
|
// proofs — and it performs nothing. The reconciling of conditions leaves it open.
|
||||||
|
func TestARehearsalsApprovalIsRecordedAndPerformsNothing(t *testing.T) {
|
||||||
|
r := newAskerRig(t)
|
||||||
|
q, options := rehearsalAsk("crehearsal", r.now, askerRehearsalFor)
|
||||||
|
if err := q.Check(r.now); err != nil {
|
||||||
|
t.Fatalf("the rehearsal's ask is refused: %v", err)
|
||||||
|
}
|
||||||
|
r.store["crehearsal"] = asked{ID: "crehearsal", Condition: q.About, Ask: q, Actions: rehearsalActions(), Options: options,
|
||||||
|
State: askOpen, Opened: r.now, Rehearsal: true}
|
||||||
|
if err := r.a.reconcile(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := r.store["crehearsal"]; got.State != askOpen {
|
||||||
|
t.Fatalf("the reconciling of conditions ended the rehearsal: %+v", got)
|
||||||
|
}
|
||||||
|
approve, _ := q.Option("approve")
|
||||||
|
w := asks.Warrant{Ask: "crehearsal", Asker: "mesh-controller", Outcome: asks.OutcomeChosen, Option: approve.ID,
|
||||||
|
Label: approve.Label, Level: approve.Level, Channel: "telegram", Proofs: []string{"P1"}, At: r.now,
|
||||||
|
AskDigest: q.Digest(), By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}
|
||||||
|
answerWith(t, r, w)
|
||||||
|
answerWith(t, r, w) // heard again
|
||||||
|
if len(r.called)+len(r.silenced) != 0 {
|
||||||
|
t.Errorf("a rehearsal performed something: %v %v", r.called, r.silenced)
|
||||||
|
}
|
||||||
|
if len(r.acts) != 1 {
|
||||||
|
t.Fatalf("hand-acts %+v", r.acts)
|
||||||
|
}
|
||||||
|
act := r.acts[0]
|
||||||
|
if act.Verb != handActWarrant || act.By != "the operator, as telegram identity 42" || act.Ask != "crehearsal" ||
|
||||||
|
strings.Join(act.Args, " ") != "rehearsal rehearsal=approve" || act.Outcome != "done" || strings.Join(act.Proofs, ",") != "P1" {
|
||||||
|
t.Errorf("the rehearsal's record: %+v", act)
|
||||||
|
}
|
||||||
|
if !personsDecision(act) {
|
||||||
|
t.Error("a rehearsal's answer counts as a repair")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the terminal starts a rehearsal: a verb's process is refused before anything is asked.
|
||||||
|
func TestARehearsalIsTheTerminalsAlone(t *testing.T) {
|
||||||
|
t.Setenv(verbVar, "mesh-controller.command")
|
||||||
|
if err := rehearseCommand(context.Background(), nil); err == nil || !strings.Contains(err.Error(), "terminal") {
|
||||||
|
t.Fatalf("a verb started a rehearsal: %v", err)
|
||||||
|
}
|
||||||
|
// Nor a mesh-cli line from anybody but the control-node's operator (hq ADR 0272 §4): run without a verb,
|
||||||
|
// naming its caller, and without the terminal's mark — and nor anything the serving controller started.
|
||||||
|
for name, env := range map[string]map[string]string{
|
||||||
|
"an ordinary mesh-cli line": {verbVar: "", link.CallerVar: "laptop/agent"},
|
||||||
|
"a process the serving controller ran": {verbVar: "", servedVar: "1"},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
for k, v := range env {
|
||||||
|
t.Setenv(k, v)
|
||||||
|
}
|
||||||
|
if err := rehearseCommand(context.Background(), nil); err == nil || !strings.Contains(err.Error(), "terminal") {
|
||||||
|
t.Fatalf("%s started a rehearsal: %v", name, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// askerRehearsalFor is how long the test's rehearsal waits.
|
||||||
|
const askerRehearsalFor = 15 * time.Minute
|
||||||
@@ -188,11 +188,13 @@ func planOfMerge(m link.SourceMoved, moved []string, edges []inventory.Edge) inv
|
|||||||
for _, name := range set {
|
for _, name := range set {
|
||||||
modules[name] = &inventory.PlanModule{}
|
modules[name] = &inventory.PlanModule{}
|
||||||
}
|
}
|
||||||
|
merged, _ := time.Parse(time.RFC3339Nano, m.MergedAt)
|
||||||
return inventory.Plan{
|
return inventory.Plan{
|
||||||
ID: fmt.Sprintf("plan-%d", time.Now().UnixNano()),
|
ID: fmt.Sprintf("plan-%d", time.Now().UnixNano()),
|
||||||
Repository: m.Owner + "/" + m.Repo,
|
Repository: m.Owner + "/" + m.Repo,
|
||||||
Branch: m.Base,
|
Branch: m.Base,
|
||||||
Commit: m.Commit,
|
Commit: m.Commit,
|
||||||
|
Merged: merged.UTC(),
|
||||||
Created: time.Now().UTC(),
|
Created: time.Now().UTC(),
|
||||||
State: inventory.PlanBuilding,
|
State: inventory.PlanBuilding,
|
||||||
Tiers: tiers,
|
Tiers: tiers,
|
||||||
@@ -220,12 +222,20 @@ func planOfMerge(m link.SourceMoved, moved []string, edges []inventory.Edge) inv
|
|||||||
//
|
//
|
||||||
// A plan with no branch recorded is from before branches were kept, and is superseded by the next
|
// A plan with no branch recorded is from before branches were kept, and is superseded by the next
|
||||||
// plan of its repository: what it had not built is folded in, so nothing is lost by it.
|
// plan of its repository: what it had not built is folded in, so nothing is lost by it.
|
||||||
|
//
|
||||||
|
// **Newer is the branch's order, not the plans'** (novox/hq issue 349). A merge the bus did not hand
|
||||||
|
// over is acted on late, by the catch-up, so its plan is made after the plan of a merge that came after
|
||||||
|
// it — and that later-made plan of the earlier commit superseded the later merge's, and built what it
|
||||||
|
// folded in from the commit before the later merge: twice on 2026-10-09, once a security fix. So where
|
||||||
|
// both plans know when their merge was made, that decides; only where one does not do the plans' own
|
||||||
|
// times. A merge older than the newest planned merge of its branch never reaches here at its own commit:
|
||||||
|
// it is planned at that merge's commit, which contains it (laterOnTheBranch).
|
||||||
func supersededBy(newer inventory.Plan, open []inventory.Plan, rollsOut func(string) bool) ([]string, []inventory.Plan) {
|
func supersededBy(newer inventory.Plan, open []inventory.Plan, rollsOut func(string) bool) ([]string, []inventory.Plan) {
|
||||||
folded := map[string]bool{}
|
folded := map[string]bool{}
|
||||||
var closed []inventory.Plan
|
var closed []inventory.Plan
|
||||||
for _, old := range open {
|
for _, old := range open {
|
||||||
if old.ID == newer.ID || !old.Open() || !strings.EqualFold(old.Repository, newer.Repository) ||
|
if old.ID == newer.ID || !old.Open() || !strings.EqualFold(old.Repository, newer.Repository) ||
|
||||||
(old.Branch != "" && old.Branch != newer.Branch) || !old.Created.Before(newer.Created) {
|
(old.Branch != "" && old.Branch != newer.Branch) || !earlierOnTheBranch(old, newer) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var took []string
|
var took []string
|
||||||
@@ -254,6 +264,30 @@ func supersededBy(newer inventory.Plan, open []inventory.Plan, rollsOut func(str
|
|||||||
return out, closed
|
return out, closed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// earlierOnTheBranch says plan a answers a merge made before b's: by when the forge made each merge where
|
||||||
|
// both are known, else by when each plan was made. A merge's own plan made again at the same commit
|
||||||
|
// (the same merge time) is ordered by when it was made. Pure.
|
||||||
|
func earlierOnTheBranch(a, b inventory.Plan) bool {
|
||||||
|
if !a.Merged.IsZero() && !b.Merged.IsZero() && !a.Merged.Equal(b.Merged) {
|
||||||
|
return a.Merged.Before(b.Merged)
|
||||||
|
}
|
||||||
|
return a.Created.Before(b.Created)
|
||||||
|
}
|
||||||
|
|
||||||
|
// laterOnTheBranch says the plan newest answers a merge into m's branch made after m: then newest's commit
|
||||||
|
// contains m's change, and m is planned there, so the newest commit of the branch is what is built (novox/hq
|
||||||
|
// issue 349). newest is the newest merge's plan of the branch in any state: a later plan already done
|
||||||
|
// built what it moved at its commit, and m planned at its own would build m's dependents back at the older
|
||||||
|
// one. Pure.
|
||||||
|
func laterOnTheBranch(m link.SourceMoved, newest inventory.Plan) bool {
|
||||||
|
merged, err := time.Parse(time.RFC3339Nano, m.MergedAt)
|
||||||
|
if err != nil || newest.Release != nil || newest.Commit == m.Commit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(newest.Repository, m.Owner+"/"+m.Repo) && newest.Branch == m.Base &&
|
||||||
|
newest.Merged.After(merged)
|
||||||
|
}
|
||||||
|
|
||||||
// gates is what the next tier needs running from this one: a module of the tier that a later
|
// gates is what the next tier needs running from this one: a module of the tier that a later
|
||||||
// tier is built by — the runtime dependency — and whose policy rolls it out, must be applied by
|
// tier is built by — the runtime dependency — and whose policy rolls it out, must be applied by
|
||||||
// the machines running it before the next tier is asked. A base an image stands on need only be
|
// the machines running it before the next tier is asked. A base an image stands on need only be
|
||||||
|
|||||||
@@ -539,8 +539,8 @@ func TestReplay301APersonsPushOfAHeldRecordedBuildIsNoRepair(t *testing.T) {
|
|||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
start := time.Now().Add(-time.Hour)
|
start := time.Now().Add(-time.Hour)
|
||||||
image := "registry.invalid:5000/resolver/server@sha256:" + strings.Repeat("e", 64)
|
image := "registry.invalid:5000/resolver/server@sha256:" + strings.Repeat("e", 64)
|
||||||
if _, _, err := takeIn(ctx, inv, aContainerBuild(t, "resolver", "c1111111", catalogue.PolicyRecord, start,
|
if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, aContainerBuild(t, "resolver", "c1111111", catalogue.PolicyRecord, start,
|
||||||
map[string][2]string{"server": {image, ""}})); err != nil {
|
map[string][2]string{"server": {image, ""}}))); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, node := range []string{"anchor", "laptop"} {
|
for _, node := range []string{"anchor", "laptop"} {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func TestARebuildOfAnUnchangedSourceKeepsItsArtifacts(t *testing.T) {
|
|||||||
// Another module's merge rebuilt it: a new commit, a new image digest, the same source.
|
// Another module's merge rebuilt it: a new commit, a new image digest, the same source.
|
||||||
anImageBuild(t, "app", "", "c2bbbbbb", strings.Repeat("b", 64), "src1:same", start.Add(time.Minute)),
|
anImageBuild(t, "app", "", "c2bbbbbb", strings.Repeat("b", 64), "src1:same", start.Add(time.Minute)),
|
||||||
} {
|
} {
|
||||||
if _, _, err := takeIn(ctx, inv, b); err != nil {
|
if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, b)); err != nil {
|
||||||
t.Fatalf("build %d: %v", i, err)
|
t.Fatalf("build %d: %v", i, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ func TestABusRebuiltFromAnUnchangedSourceDemandsNoBusStep(t *testing.T) {
|
|||||||
b.Manifest = manifest
|
b.Manifest = manifest
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
if _, _, err := takeIn(ctx, inv, bus("", "n1111111", strings.Repeat("a", 64), "src1:bus", start)); err != nil {
|
if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, bus("", "n1111111", strings.Repeat("a", 64), "src1:bus", start))); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := inv.Assign(ctx, "anchor", "nats"); err != nil {
|
if _, err := inv.Assign(ctx, "anchor", "nats"); err != nil {
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ import (
|
|||||||
// shape fails the suite, naming its source. The keeper would say such a summary in words at run time;
|
// shape fails the suite, naming its source. The keeper would say such a summary in words at run time;
|
||||||
// this is where the producer is made to say it rightly in the first place.
|
// this is where the producer is made to say it rightly in the first place.
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
|
// The process a mesh-cli test runs as a command line: it says the verb and the caller it was given, and
|
||||||
|
// ends (meshcli_test.go).
|
||||||
|
if os.Getenv(echoEnvironment) != "" {
|
||||||
|
fmt.Printf("verb=%q caller=%q terminal=%v\n", os.Getenv("MESH_VERB"), os.Getenv("MESH_CALLER"), startedAtTheTerminal())
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
conditions.Unsayable = func(o conditions.Observation, field string, r outward.Refusal) {
|
conditions.Unsayable = func(o conditions.Observation, field string, r outward.Refusal) {
|
||||||
unsaid.note(o, field, r)
|
unsaid.note(o, field, r)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,12 @@ func handOver(ctx context.Context, seatName, to string, adding bool) error {
|
|||||||
if !ok || nodeName == "" || module == "" {
|
if !ok || nodeName == "" || module == "" {
|
||||||
return fmt.Errorf("the new holder is named <node>/<module>, not %q", to)
|
return fmt.Errorf("the new holder is named <node>/<module>, not %q", to)
|
||||||
}
|
}
|
||||||
|
// A kinded bench is held once per kind, by the claims themselves (novox/hq ADR 0234 §2, ADR 0259): the
|
||||||
|
// record of who holds a seat has no kind, so a handover would name one holder for every kind.
|
||||||
|
if catalogue.KindedBenches[seatName] {
|
||||||
|
return fmt.Errorf("%s is a kinded bench: each kind is held by the module claiming it, and is not handed "+
|
||||||
|
"over by `seat` — assign the module that claims the kind, or unassign the one that does", seatName)
|
||||||
|
}
|
||||||
open, err := openStores(ctx)
|
open, err := openStores(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
@@ -62,3 +64,13 @@ func TestAClaimOutsideTheSetIsShownNotHidden(t *testing.T) {
|
|||||||
t.Fatalf("a claim outside the set was not shown: %+v", outside)
|
t.Fatalf("a claim outside the set was not shown: %+v", outside)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A kinded bench is not handed over by `seat`: each kind is held by its claim (novox/hq ADR 0259).
|
||||||
|
func TestAKindedBenchIsNotHandedOver(t *testing.T) {
|
||||||
|
for _, bench := range []string{"channel", "intake"} {
|
||||||
|
err := handOver(context.Background(), bench, "anchor/telegram", false)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "is a kinded bench") {
|
||||||
|
t.Errorf("%s: %v", bench, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
argv, err := a.commandLine()
|
argv, err := a.commandLine()
|
||||||
|
if err == nil {
|
||||||
|
err = terminalOnly(argv)
|
||||||
|
}
|
||||||
if len(a.misread) > 0 {
|
if len(a.misread) > 0 {
|
||||||
// The table and the command line disagree: the verb reads an argument no caller can see
|
// The table and the command line disagree: the verb reads an argument no caller can see
|
||||||
// in its schema, so no caller could ever pass it.
|
// in its schema, so no caller could ever pass it.
|
||||||
@@ -111,6 +114,14 @@ func declaredArguments(v catalogue.Verb) (names []string, switches map[string]bo
|
|||||||
return names, switches
|
return names, switches
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isList says a verb's argument is declared a list of text (catalogue's listed): given as a JSON array, it is
|
||||||
|
// read as its items joined by commas, as the same argument given as one text would be.
|
||||||
|
func isList(v catalogue.Verb, name string) bool {
|
||||||
|
props, _ := v.Input["properties"].(map[string]any)
|
||||||
|
p, _ := props[name].(map[string]any)
|
||||||
|
return p != nil && p["type"] == "array"
|
||||||
|
}
|
||||||
|
|
||||||
// readArguments refuses what the verb does not take, before anything is composed.
|
// readArguments refuses what the verb does not take, before anything is composed.
|
||||||
func readArguments(verb string, args map[string]any) (*verbArguments, error) {
|
func readArguments(verb string, args map[string]any) (*verbArguments, error) {
|
||||||
v, known := controllerVerb(verb)
|
v, known := controllerVerb(verb)
|
||||||
@@ -147,6 +158,19 @@ func readArguments(verb string, args map[string]any) (*verbArguments, error) {
|
|||||||
return nil, fmt.Errorf("%s: %q is text, not true or false", verb, k)
|
return nil, fmt.Errorf("%s: %q is text, not true or false", verb, k)
|
||||||
}
|
}
|
||||||
value = fmt.Sprint(x)
|
value = fmt.Sprint(x)
|
||||||
|
case []any:
|
||||||
|
if !isList(v, k) {
|
||||||
|
return nil, fmt.Errorf("%s: %q is text, and was given a list", verb, k)
|
||||||
|
}
|
||||||
|
items := make([]string, 0, len(x))
|
||||||
|
for _, item := range x {
|
||||||
|
text, ok := item.(string)
|
||||||
|
if !ok || strings.TrimSpace(text) == "" || strings.Contains(text, ",") {
|
||||||
|
return nil, fmt.Errorf("%s: %q is a list of names, and holds %v", verb, k, item)
|
||||||
|
}
|
||||||
|
items = append(items, strings.TrimSpace(text))
|
||||||
|
}
|
||||||
|
value = strings.Join(items, ",")
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("%s: %q is text, and was given %T", verb, k, x)
|
return nil, fmt.Errorf("%s: %q is text, and was given %T", verb, k, x)
|
||||||
}
|
}
|
||||||
@@ -226,6 +250,35 @@ func quoteAll(xs []string) string {
|
|||||||
return strings.Join(q, ", ")
|
return strings.Join(q, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refusedAsTheGenericCommand is what the generic `command` verb refuses of a command line, and so what every
|
||||||
|
// line asked through a verb's route refuses: the `command` verb's, and an ordinary line from mesh-cli (novox/hq ADR
|
||||||
|
// 0272 §4). One function, so the two routes cannot drift apart.
|
||||||
|
func refusedAsTheGenericCommand(argv []string) error {
|
||||||
|
if len(argv) == 0 {
|
||||||
|
return errors.New("command names no command")
|
||||||
|
}
|
||||||
|
// A layer is written through the settings verb, never the generic one (novox/hq issue 339): the
|
||||||
|
// settings verb is where what a verb may not set is refused, and one route is one set of words.
|
||||||
|
// The command refuses places and accesses through any verb as well; this says so before it runs.
|
||||||
|
if argv[0] == "settings" && slices.ContainsFunc(argv[1:], func(w string) bool { return w == "set" || w == "clear" }) {
|
||||||
|
return &heldAtTheTerminal{msg: "settings are set and cleared through the settings verb, not the " +
|
||||||
|
"generic command; and places and accesses only at the controller's terminal (novox/hq issue 339). " +
|
||||||
|
"Nothing was done"}
|
||||||
|
}
|
||||||
|
// The generic verb only reads (novox/hq ADR 0266): what writes has a named verb that composes its own
|
||||||
|
// line, or is the operator's at the controller's terminal.
|
||||||
|
if err := commandReads(argv); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The generic verb is no way round the hand-act log (novox/hq to-be 45 §7): a repair through
|
||||||
|
// it says why, as it would through its own verb.
|
||||||
|
if repair := repairingCommand(argv); repair != "" && !slices.ContainsFunc(argv, isWhyFlag) {
|
||||||
|
return fmt.Errorf("%s is a repair done by hand, and says why: add --why <text> to the command "+
|
||||||
|
"line (recorded in the hand-act log). Nothing was done", repair)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// commandLine composes the command. Every argument it reads is one it uses: a branch that reads an
|
// commandLine composes the command. Every argument it reads is one it uses: a branch that reads an
|
||||||
// argument and then drops it would pass it over, which is what the check after it exists to refuse.
|
// argument and then drops it would pass it over, which is what the check after it exists to refuse.
|
||||||
func (a *verbArguments) commandLine() ([]string, error) {
|
func (a *verbArguments) commandLine() ([]string, error) {
|
||||||
@@ -242,28 +295,16 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(argv) == 0 {
|
if err := refusedAsTheGenericCommand(argv); err != nil {
|
||||||
return nil, errors.New("command names no command")
|
return nil, err
|
||||||
}
|
|
||||||
// A layer is written through the settings verb, never the generic one (novox/hq issue 339): the
|
|
||||||
// settings verb is where what a verb may not set is refused, and one route is one set of words.
|
|
||||||
// The command refuses places and accesses through any verb as well; this says so before it runs.
|
|
||||||
if argv[0] == "settings" && slices.ContainsFunc(argv[1:], func(w string) bool { return w == "set" || w == "clear" }) {
|
|
||||||
return nil, errors.New("settings are set and cleared through the settings verb, not the generic " +
|
|
||||||
"command; and places and accesses only at the controller's terminal (novox/hq issue 339). " +
|
|
||||||
"Nothing was done")
|
|
||||||
}
|
|
||||||
// The generic verb is no way round the hand-act log (novox/hq to-be 45 §7): a repair through
|
|
||||||
// it says why, as it would through its own verb.
|
|
||||||
if repair := repairingCommand(argv); repair != "" && !slices.ContainsFunc(argv, isWhyFlag) {
|
|
||||||
return nil, fmt.Errorf("%s is a repair done by hand, and says why: add --why <text> to the command "+
|
|
||||||
"line (recorded in the hand-act log). Nothing was done", repair)
|
|
||||||
}
|
}
|
||||||
return argv, nil
|
return argv, nil
|
||||||
case "tools":
|
case "tools":
|
||||||
return nil, errors.New("tools is answered from the records, not by a command")
|
return nil, errors.New("tools is answered from the records, not by a command")
|
||||||
case "dead-letters":
|
case "dead-letters":
|
||||||
return nil, errors.New("dead-letters is answered by the serving controller, on its own connection, not by a command")
|
return nil, errors.New("dead-letters is answered by the serving controller, on its own connection, not by a command")
|
||||||
|
case "root-free":
|
||||||
|
return nil, errors.New("root-free is judged by the serving controller, on its own connection, not by a command")
|
||||||
case "status":
|
case "status":
|
||||||
return []string{"status", "--json"}, nil
|
return []string{"status", "--json"}, nil
|
||||||
case "nodes":
|
case "nodes":
|
||||||
@@ -721,11 +762,6 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
|||||||
argv = append(argv, "--probe", p)
|
argv = append(argv, "--probe", p)
|
||||||
}
|
}
|
||||||
return append(argv, "--json"), nil
|
return append(argv, "--json"), nil
|
||||||
case "give":
|
|
||||||
if err := need("node", "module", "secret", "at"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return []string{"secret", "accept", str("node"), str("module"), str("secret"), "--at-desk", str("at")}, nil
|
|
||||||
case "rotate":
|
case "rotate":
|
||||||
if p := str("provision"); p != "" {
|
if p := str("provision"); p != "" {
|
||||||
argv := []string{"rotate", p}
|
argv := []string{"rotate", p}
|
||||||
@@ -916,6 +952,30 @@ func isWhyFlag(word string) bool {
|
|||||||
return word == "--why" || word == "-why" || strings.HasPrefix(word, "--why=") || strings.HasPrefix(word, "-why=")
|
return word == "--why" || word == "-why" || strings.HasPrefix(word, "--why=") || strings.HasPrefix(word, "-why=")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// commandEnvironment is the environment of a command line this controller runs for someone: its own — the
|
||||||
|
// stores' credentials, the bus, the broker, everything a command run from a shell beside it would have, because it
|
||||||
|
// is that — with who asked, and how it came.
|
||||||
|
//
|
||||||
|
// **terminal** is said, never inferred (novox/hq ADR 0272): only a line mesh-cli asked as the controller's terminal
|
||||||
|
// passes true. Its line has no `MESH_VERB`, not the served mark ADR 0266 puts on everything the serving controller
|
||||||
|
// starts, and the terminal's mark (cliTerminalVar), so startedAtTheTerminal reads yes. Every other line names its
|
||||||
|
// verb, and is stripped of the terminal's mark whatever this process's environment holds.
|
||||||
|
func commandEnvironment(caller, verb string, terminal bool) []string {
|
||||||
|
env := make([]string, 0, len(os.Environ())+3)
|
||||||
|
for _, kv := range os.Environ() {
|
||||||
|
if strings.HasPrefix(kv, verbVar+"=") || strings.HasPrefix(kv, link.CallerVar+"=") ||
|
||||||
|
strings.HasPrefix(kv, cliTerminalVar+"=") || (terminal && strings.HasPrefix(kv, servedVar+"=")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
env = append(env, kv)
|
||||||
|
}
|
||||||
|
env = append(env, link.CallerVar+"="+caller)
|
||||||
|
if terminal {
|
||||||
|
return append(env, cliTerminalVar+"=1")
|
||||||
|
}
|
||||||
|
return append(env, verbVar+"="+verb)
|
||||||
|
}
|
||||||
|
|
||||||
// runVerb runs this binary with the given command line and gathers what it said.
|
// runVerb runs this binary with the given command line and gathers what it said.
|
||||||
//
|
//
|
||||||
// **This binary is the image this process runs, never the file at the path it started from**
|
// **This binary is the image this process runs, never the file at the path it started from**
|
||||||
@@ -929,21 +989,17 @@ func runVerb(ctx context.Context, argv []string) (verbAnswer, error) {
|
|||||||
return verbAnswer{}, fmt.Errorf("%w: this controller is stopping and runs no new command", link.ErrHandingOver)
|
return verbAnswer{}, fmt.Errorf("%w: this controller is stopping and runs no new command", link.ErrHandingOver)
|
||||||
}
|
}
|
||||||
cmd := selfCommand(ctx, argv)
|
cmd := selfCommand(ctx, argv)
|
||||||
// The same environment: the stores' credentials, the bus, the broker — everything a command run
|
|
||||||
// from a shell in this container would have, because it is that.
|
|
||||||
cmd.Env = os.Environ()
|
|
||||||
// And who asked, so an act it does by hand is recorded as theirs (novox/hq to-be 45 §7).
|
// And who asked, so an act it does by hand is recorded as theirs (novox/hq to-be 45 §7).
|
||||||
caller := link.CallerIn(ctx)
|
caller := link.CallerIn(ctx)
|
||||||
if caller == "" {
|
if caller == "" {
|
||||||
caller = "a seat call whose caller the bus did not name"
|
caller = "a seat call whose caller the bus did not name"
|
||||||
}
|
}
|
||||||
cmd.Env = append(cmd.Env, link.CallerVar+"="+caller+", through the "+catalogue.ControllerSeatName+" seat")
|
|
||||||
// And which verb, so the connection it dials says so in the bus's list (novox/hq issue 327).
|
// And which verb, so the connection it dials says so in the bus's list (novox/hq issue 327).
|
||||||
verb, _ := ctx.Value(verbKey{}).(string)
|
verb, _ := ctx.Value(verbKey{}).(string)
|
||||||
if verb == "" {
|
if verb == "" {
|
||||||
verb = argv[0]
|
verb = argv[0]
|
||||||
}
|
}
|
||||||
cmd.Env = append(cmd.Env, verbVar+"="+verb)
|
cmd.Env = commandEnvironment(caller+", through the "+catalogue.ControllerSeatName+" seat", verb, false)
|
||||||
// Two buffers, one answer. What the command *says* is both streams, in the order a person at
|
// Two buffers, one answer. What the command *says* is both streams, in the order a person at
|
||||||
// a shell would read them; what it *answers as data* is standard output alone — `status --json`
|
// a shell would read them; what it *answers as data* is standard output alone — `status --json`
|
||||||
// prints its warnings beside the document, and a JSON parsed from the two together parsed
|
// prints its warnings beside the document, and a JSON parsed from the two together parsed
|
||||||
@@ -1059,6 +1115,9 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) {
|
|||||||
if verb == "dead-letters" {
|
if verb == "dead-letters" {
|
||||||
return deadLettersAnswer(ctx, a)
|
return deadLettersAnswer(ctx, a)
|
||||||
}
|
}
|
||||||
|
if verb == "root-free" {
|
||||||
|
return rootFreeAnswer(ctx, a.given["machines"], rootClock())
|
||||||
|
}
|
||||||
if verb == "doctor" {
|
if verb == "doctor" {
|
||||||
// From the serving controller, which runs the self-check and hears the signals
|
// From the serving controller, which runs the self-check and hears the signals
|
||||||
// (novox/hq to-be 45 §4): the last verdict at once, or a run now.
|
// (novox/hq to-be 45 §4): the last verdict at once, or a run now.
|
||||||
@@ -1076,7 +1135,8 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, err := argvFor(verb, sampleArguments(v)); err != nil {
|
var policy *heldAtTheTerminal
|
||||||
|
if _, err := argvFor(verb, sampleArguments(v)); err != nil && !errors.As(err, &policy) {
|
||||||
// **A row ahead of this binary is not a reason to go silent.**
|
// **A row ahead of this binary is not a reason to go silent.**
|
||||||
//
|
//
|
||||||
// The row is the store's and a control plane follows it (novox/hq ADR 0154), so a verb
|
// The row is the store's and a control plane follows it (novox/hq ADR 0154), so a verb
|
||||||
@@ -1148,7 +1208,10 @@ func actsOnAPlan(args map[string]any) bool {
|
|||||||
var inProcess = map[string]bool{"tools": true, "calls": true, "doctor": true,
|
var inProcess = map[string]bool{"tools": true, "calls": true, "doctor": true,
|
||||||
// What a consumer gave up on, read and changed on the serving controller's own connection (novox/hq
|
// What a consumer gave up on, read and changed on the serving controller's own connection (novox/hq
|
||||||
// issue 330).
|
// issue 330).
|
||||||
"dead-letters": true}
|
"dead-letters": true,
|
||||||
|
// Whether a machine is root-free, judged live on the serving controller's store and connection (novox/hq ADR
|
||||||
|
// 0259 §8): the router asks it before an approval.
|
||||||
|
"root-free": true}
|
||||||
|
|
||||||
// answersFirst is a command line whose caller is answered before it runs: a push, by its verb or
|
// answersFirst is a command line whose caller is answered before it runs: a push, by its verb or
|
||||||
// through `command`. A push sends the machine holding the bus first when its user list changed, the
|
// through `command`. A push sends the machine holding the bus first when its user list changed, the
|
||||||
@@ -1164,7 +1227,7 @@ func answersFirst(argv []string) bool {
|
|||||||
// the reason said beside it.
|
// the reason said beside it.
|
||||||
func callsAnswer(log *link.CallLog, id string) (any, error) {
|
func callsAnswer(log *link.CallLog, id string) (any, error) {
|
||||||
if id != "" {
|
if id != "" {
|
||||||
c, ok, err := log.Get(id)
|
c, ok, err := log.Shown(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("call %s is not in this controller's memory, and the calls kept on the "+
|
return nil, fmt.Errorf("call %s is not in this controller's memory, and the calls kept on the "+
|
||||||
"bus could not be read: %w", id, err)
|
"bus could not be read: %w", id, err)
|
||||||
@@ -1337,3 +1400,131 @@ func seatAnnouncement(handlers map[string]link.ToolHandler) micro.Info {
|
|||||||
Endpoints: endpoints,
|
Endpoints: endpoints,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nodeReads are the `node` subcommands a verb may run: the ones that only read.
|
||||||
|
var nodeReads = map[string]bool{"list": true, "show": true}
|
||||||
|
|
||||||
|
// flagsOnly says a command line's rest names no subcommand: empty, or beginning with a flag. For a command
|
||||||
|
// with no subcommands every word is a flag, its value or a name it reads.
|
||||||
|
func flagsOnly(rest []string) bool { return len(rest) == 0 || strings.HasPrefix(rest[0], "-") }
|
||||||
|
|
||||||
|
// subIn says the rest begins with one of these subcommands.
|
||||||
|
func subIn(rest []string, subs ...string) bool {
|
||||||
|
return len(rest) > 0 && slices.Contains(subs, rest[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// commandReadForms are the command lines the generic `command` verb may run (novox/hq ADR 0266): **an allow
|
||||||
|
// list of the ones that only read**, judged command by command. Anything else — every command that writes a
|
||||||
|
// record, sends, builds, issues an account or a token, sets a key, accepts, rotates, recovers or exports a
|
||||||
|
// secret — is refused, and a command added later is refused until it is judged a read. Writing has its named
|
||||||
|
// verbs, which compose their own lines and are judged by terminalOnly; the rest is the operator's at the
|
||||||
|
// controller's terminal.
|
||||||
|
var commandReadForms = map[string]func(rest []string) bool{
|
||||||
|
"status": flagsOnly, "version": flagsOnly, "help": flagsOnly, "seats": flagsOnly, "healers": flagsOnly,
|
||||||
|
"hand-acts": flagsOnly, "durations": flagsOnly, "collection": flagsOnly, "images": flagsOnly,
|
||||||
|
"artifacts": flagsOnly, "data": flagsOnly, "builds": flagsOnly, "queue": flagsOnly,
|
||||||
|
// `plan <node>` previews a node's declaration; it sends nothing.
|
||||||
|
"plan": func([]string) bool { return true },
|
||||||
|
// `plans` lists and `plans <id>` shows one; `plans stop|close|go` acts.
|
||||||
|
// Judged on every word, not the first: a flag before the subcommand (`plans --json go <id>`) still acts.
|
||||||
|
"plans": func(r []string) bool {
|
||||||
|
return !slices.ContainsFunc(r, func(w string) bool { return slices.Contains(plansActs, w) })
|
||||||
|
},
|
||||||
|
// `doctor` answers the last run, `probes` and `signals` describe; `doctor run` runs.
|
||||||
|
"doctor": func(r []string) bool { return flagsOnly(r) || subIn(r, "probes", "signals") },
|
||||||
|
"conditions": func(r []string) bool { return flagsOnly(r) || subIn(r, "list", "show", "history") },
|
||||||
|
"node": func(r []string) bool { return subIn(r, "list", "show") },
|
||||||
|
"module": func(r []string) bool { return subIn(r, "list") },
|
||||||
|
"settings": func(r []string) bool { return subIn(r, "show", "preferences") },
|
||||||
|
"retire": func(r []string) bool { return subIn(r, "list") },
|
||||||
|
"cleanup": func(r []string) bool { return subIn(r, "list") },
|
||||||
|
"delivery": func(r []string) bool { return subIn(r, "plan", "walks") },
|
||||||
|
// `bus` alone says the bus's step; `bus upgrade` takes one.
|
||||||
|
"bus": func(r []string) bool { return len(r) == 0 },
|
||||||
|
// `mirrors` lists; --record and --confirm keep a mirror.
|
||||||
|
"mirrors": func(r []string) bool {
|
||||||
|
return flagsOnly(r) && !slices.ContainsFunc(r, func(w string) bool {
|
||||||
|
return w == "--record" || w == "-record" || strings.HasPrefix(w, "--record=") || strings.HasPrefix(w, "-record=") ||
|
||||||
|
w == "--confirm" || w == "-confirm" || strings.HasPrefix(w, "--confirm=")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// plansActs are the `plans` subcommands that act on a walk; no other word of a plans line is one of them.
|
||||||
|
var plansActs = []string{"go", "stop", "close", "retry"}
|
||||||
|
|
||||||
|
// heldAtTheTerminal is a refusal of policy (novox/hq ADR 0266): the verb is known and served, and this line is
|
||||||
|
// the operator's at the controller's terminal. Never read as a verb this binary is behind on.
|
||||||
|
type heldAtTheTerminal struct{ msg string }
|
||||||
|
|
||||||
|
func (e *heldAtTheTerminal) Error() string { return e.msg }
|
||||||
|
|
||||||
|
func terminalRefusal(format string, args ...any) error {
|
||||||
|
return &heldAtTheTerminal{fmt.Sprintf(format, args...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// commandReads refuses a line the generic verb may not run, saying what it may.
|
||||||
|
func commandReads(argv []string) error {
|
||||||
|
if read, ok := commandReadForms[argv[0]]; ok && read(argv[1:]) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return terminalRefusal("%q is not a reading command, and the generic command verb only reads (novox/hq ADR 0266): "+
|
||||||
|
"whoever may call a verb includes agents, and a line that writes, issues, sets a key or reveals a secret "+
|
||||||
|
"would be theirs to run. Use the named verb for it, or run it at the controller's terminal. The verb may "+
|
||||||
|
"run: %s. Nothing was done", strings.Join(argv, " "), commandReadNames())
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandReadNames() string {
|
||||||
|
names := make([]string, 0, len(commandReadForms))
|
||||||
|
for n := range commandReadForms {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return strings.Join(names, ", ") + " (each in its reading forms)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// terminalOnlyCommands are the commands no verb runs, whatever composed them (novox/hq ADR 0266): they set
|
||||||
|
// the operator's key, issue a credential or a token that is answered to the caller, or accept, recover or
|
||||||
|
// export a secret. Their answers or effects hand whoever calls them what the runtime's account holds.
|
||||||
|
var terminalOnlyCommands = map[string]string{
|
||||||
|
"operator": "the operator's key and credential",
|
||||||
|
"identity": "the mesh's identity keys",
|
||||||
|
"token": "a token a machine joins with, answered to the caller",
|
||||||
|
"broker": "the bus's accounts",
|
||||||
|
"api": "the controller's API keys",
|
||||||
|
"licence": "the licences' secrets",
|
||||||
|
}
|
||||||
|
|
||||||
|
// terminalOnly refuses, through any verb, a command that is the operator's at the controller's terminal
|
||||||
|
// alone (novox/hq ADR 0266). **Every `node` subcommand that is not a read**: `node account` and
|
||||||
|
// `node agent-account` above all. Whoever may call a verb includes agents, and an agent that named itself
|
||||||
|
// the operator account, or cleared the agent account, would have the next send grant it root through the
|
||||||
|
// sudo module's rule. An allow list, so a subcommand added later is refused until it is judged a read.
|
||||||
|
func terminalOnly(argv []string) error {
|
||||||
|
if len(argv) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if what, kept := terminalOnlyCommands[argv[0]]; kept {
|
||||||
|
return terminalRefusal("%s is run at the controller's terminal only, never through a verb: it holds %s, and "+
|
||||||
|
"whoever may call a verb includes agents (novox/hq ADR 0266). Nothing was done", argv[0], what)
|
||||||
|
}
|
||||||
|
// Of a secret's commands only rotation, which seals the new value to the machine that uses it.
|
||||||
|
if argv[0] == "secret" && (len(argv) < 2 || argv[1] != "rotate") {
|
||||||
|
return terminalRefusal("secret %s is run at the controller's terminal only, never through a verb: accepting, "+
|
||||||
|
"recovering or exporting a secret hands it to whoever asks, and that includes agents (novox/hq ADR "+
|
||||||
|
"0266). Nothing was done", strings.Join(argv[1:], " "))
|
||||||
|
}
|
||||||
|
if argv[0] != "node" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(argv) > 1 && nodeReads[argv[1]] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sub := "node"
|
||||||
|
if len(argv) > 1 {
|
||||||
|
sub += " " + argv[1]
|
||||||
|
}
|
||||||
|
return terminalRefusal("%s is run at the controller's terminal only, never through a verb: a node's accounts "+
|
||||||
|
"decide who may become root on it (novox/hq ADR 0266). A verb may run node list and node show. "+
|
||||||
|
"Nothing was done", sub)
|
||||||
|
}
|
||||||
|
|||||||
@@ -271,18 +271,10 @@ var accountedFlags = map[string]map[string]string{
|
|||||||
"builds": {"n": "=limit"},
|
"builds": {"n": "=limit"},
|
||||||
"plans": {"n": "=limit", "what-if": "=repository"},
|
"plans": {"n": "=limit", "what-if": "=repository"},
|
||||||
// The machine's tunnel key, named as the verb's other arguments are (novox/hq ADR 0169).
|
// The machine's tunnel key, named as the verb's other arguments are (novox/hq ADR 0169).
|
||||||
"token issue": {"overlay-key": "=overlay_key"},
|
|
||||||
"durations": {
|
"durations": {
|
||||||
"json": "set by the verb: the answer is data",
|
"json": "set by the verb: the answer is data",
|
||||||
"all": "withheld: every measurement of a fortnight is more than a call should carry; `command` reaches it",
|
"all": "withheld: every measurement of a fortnight is more than a call should carry; `command` reaches it",
|
||||||
},
|
},
|
||||||
// The desk path of `secret accept` (novox/hq ADR 0259 §10): a value is never an argument of a call.
|
|
||||||
"secret accept": {
|
|
||||||
"at-desk": "=at",
|
|
||||||
"from": "withheld: a file of the control node's is read at a shell, never named by a call",
|
|
||||||
"provider": "withheld: a pair credential's value is given at a shell; give takes a module's own secret",
|
|
||||||
"local": "withheld: it goes with --provider",
|
|
||||||
},
|
|
||||||
"hand-acts": {"json": "set by the verb: the answer is data"},
|
"hand-acts": {"json": "set by the verb: the answer is data"},
|
||||||
"conditions": {"json": "set by the verb: the answer is data"},
|
"conditions": {"json": "set by the verb: the answer is data"},
|
||||||
"retire": {"json": "set by the verb: the answer is data"},
|
"retire": {"json": "set by the verb: the answer is data"},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -108,11 +109,14 @@ func TestRotateTakesAProvisionOrAnOwnSecret(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// `token` is `token issue` at a shell, with the machine's tunnel key (novox/hq ADR 0169).
|
// `token` answers a joining token to its caller, and whoever may call a verb includes agents: it is the
|
||||||
func TestTokenIssuesForAMachineAndItsTunnelKey(t *testing.T) {
|
// controller's terminal's alone (novox/hq ADR 0266), refused through the verb whatever it is given.
|
||||||
argv, err := argvFor("token", map[string]any{"new": "laptop", "overlay_key": "k", "for": "2h"})
|
func TestTokenIsRefusedThroughAVerb(t *testing.T) {
|
||||||
if err != nil || strings.Join(argv, " ") != "token issue --new laptop --overlay-key k --for 2h" {
|
for _, args := range []map[string]any{{"new": "laptop", "overlay_key": "k", "for": "2h"}, {"node": "ace"}} {
|
||||||
t.Fatalf("token: %v %v", argv, err)
|
argv, err := argvFor("token", args)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "controller's terminal only") {
|
||||||
|
t.Fatalf("token %v: %v %v", args, argv, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,20 +241,20 @@ func TestAJSONVerbsAnswerIsItsStandardOutput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// `command` is the generic verb: the command line as given, split as a shell would, nothing added —
|
// `command` is the generic verb: the command line as given, split as a shell would, nothing added
|
||||||
// so an operator's `node account g14 jochen` is one call through the console rather than a shell on
|
// (novox/hq ADR 0154, ADR 0175). It once carried an operator's `node account g14 jochen` too; a node's
|
||||||
// the control node (novox/hq ADR 0154, ADR 0175).
|
// accounts are the controller's terminal's alone since ADR 0266 (TestNoVerbSetsANodesAccounts).
|
||||||
func TestCommandRunsTheLineAsGiven(t *testing.T) {
|
func TestCommandRunsTheLineAsGiven(t *testing.T) {
|
||||||
argv, err := argvFor("command", map[string]any{"command": "node account g14 jochen"})
|
argv, err := argvFor("command", map[string]any{"command": "node show g14"})
|
||||||
if err != nil || strings.Join(argv, " ") != "node account g14 jochen" {
|
if err != nil || strings.Join(argv, " ") != "node show g14" {
|
||||||
t.Fatalf("a plain line: %v %v", argv, err)
|
t.Fatalf("a plain line: %v %v", argv, err)
|
||||||
}
|
}
|
||||||
argv, err = argvFor("command", map[string]any{"command": `settings show dnsmasq '{"a": "b c"}' --node ace`})
|
argv, err = argvFor("command", map[string]any{"command": `settings show dnsmasq '{"a": "b c"}' --node ace`})
|
||||||
if err != nil || len(argv) != 6 || argv[3] != `{"a": "b c"}` {
|
if err != nil || len(argv) != 6 || argv[3] != `{"a": "b c"}` {
|
||||||
t.Fatalf("a quoted word stays one word: %q %v", argv, err)
|
t.Fatalf("a quoted word stays one word: %q %v", argv, err)
|
||||||
}
|
}
|
||||||
argv, err = argvFor("command", map[string]any{"command": `node add "the box" --adopted`})
|
argv, err = argvFor("command", map[string]any{"command": `plan "the box" --json`})
|
||||||
if err != nil || len(argv) != 4 || argv[2] != "the box" {
|
if err != nil || len(argv) != 3 || argv[1] != "the box" {
|
||||||
t.Fatalf("double quotes group: %q %v", argv, err)
|
t.Fatalf("double quotes group: %q %v", argv, err)
|
||||||
}
|
}
|
||||||
if _, err := argvFor("command", map[string]any{"command": " "}); err == nil {
|
if _, err := argvFor("command", map[string]any{"command": " "}); err == nil {
|
||||||
@@ -355,3 +359,59 @@ func TestTheControllerAnnouncesTheVerbsItServes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The generic verb only reads (novox/hq ADR 0266): an allow list of reading forms, and every line that
|
||||||
|
// writes, issues, sets a key or reveals a secret refused — the chain a review found ran through it: set the
|
||||||
|
// operator's key to one the caller holds, rotate secrets sealed to it, open them.
|
||||||
|
func TestTheCommandVerbOnlyReads(t *testing.T) {
|
||||||
|
for _, line := range []string{
|
||||||
|
"operator key set --replace k", "operator issue", "secret accept a b", "secret rotate a b c",
|
||||||
|
"secret recover a", "secret export a", "token issue --new x", "identity show", "broker users",
|
||||||
|
"api key", "licence show", "push anchor --why w", "assign novox m", "settings set m {}",
|
||||||
|
"settings clear m", "module add f", "module check /etc", "module forget m", "module issue m --node a",
|
||||||
|
"seat rename a b", "plans close p --why w", "plans go p", "plans retry p", "plans stop p",
|
||||||
|
"plans --json go p", "plans -n 3 close p", "plans --what-if r retry p", "doctor run", "conditions silence c --why w",
|
||||||
|
"retire approve x", "cleanup delete x", "delivery check", "delivery go x", "bus upgrade",
|
||||||
|
"mirrors --record x", "mirrors --confirm", "hand-act record x --why y --cause z", "serve", "migrate",
|
||||||
|
"prepare", "declare x", "overlay x", "facts", "merge-gate", "check-here", "build x", "rebuild x",
|
||||||
|
"cancel x", "kill x", "clear x", "replay x", "pause", "resume", "pin a b", "unpin a", "take x",
|
||||||
|
"converge", "adopt x", "rollout x", "upgrade x", "collect", "board", "builder", "ask x", "frobnicate",
|
||||||
|
} {
|
||||||
|
argv, err := argvFor("command", map[string]any{"command": line})
|
||||||
|
var policy *heldAtTheTerminal
|
||||||
|
if err == nil || !errors.As(err, &policy) {
|
||||||
|
t.Errorf("%q ran as %v (%v); the generic verb only reads", line, argv, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, line := range []string{
|
||||||
|
"status --json", "version", "seats --json", "healers", "hand-acts --days 3", "durations", "collection",
|
||||||
|
"images", "artifacts --collected", "data --machine a", "builds --log b", "queue", "plan ace --diff",
|
||||||
|
"plans", "plans plan-1", "doctor", "doctor probes", "doctor signals", "conditions", "conditions list",
|
||||||
|
"conditions show c", "conditions history", "node list", "node show ace", "module list",
|
||||||
|
"settings show m", "settings preferences", "retire list", "cleanup list", "delivery plan --repository r",
|
||||||
|
"delivery walks", "bus", "mirrors --json",
|
||||||
|
} {
|
||||||
|
if _, err := argvFor("command", map[string]any{"command": line}); err != nil {
|
||||||
|
t.Errorf("%q, a read, was refused: %v", line, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What hands a caller a key, a credential or a secret is refused whichever verb composed it.
|
||||||
|
func TestNoVerbSetsTheOperatorsKeyOrRevealsASecret(t *testing.T) {
|
||||||
|
for _, argv := range [][]string{
|
||||||
|
{"operator", "key", "set"}, {"operator", "issue"}, {"identity"}, {"token", "issue"}, {"broker", "users"},
|
||||||
|
{"api"}, {"licence"}, {"secret", "export", "x"}, {"secret", "recover", "x"}, {"secret", "accept", "x"}, {"secret"},
|
||||||
|
} {
|
||||||
|
if err := terminalOnly(argv); err == nil {
|
||||||
|
t.Errorf("%v passed", argv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := terminalOnly([]string{"secret", "rotate", "n", "m", "s"}); err != nil {
|
||||||
|
t.Errorf("rotating seals to the machine that uses the secret, and stays a verb's: %v", err)
|
||||||
|
}
|
||||||
|
// A policy refusal is not a verb this binary is behind on: every verb is still served.
|
||||||
|
if _, behind, err := seatToolHandlers(); err != nil || len(behind) != 0 {
|
||||||
|
t.Fatalf("behind %v: %v", behind, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,9 +55,6 @@ func secretCommand(ctx context.Context, args []string) error {
|
|||||||
provider := set.String("provider", "",
|
provider := set.String("provider", "",
|
||||||
"the node providing <name>: the value becomes the PAIR credential between <module> on <node> "+
|
"the node providing <name>: the value becomes the PAIR credential between <module> on <node> "+
|
||||||
"and that provider, sealed to both — the vault's operator-delivered secret (ADR 0092)")
|
"and that provider, sealed to both — the vault's operator-delivered secret (ADR 0092)")
|
||||||
desk := set.String("at-desk", "",
|
|
||||||
"ask the operator for the value in a prompt that does not show it, on this machine's desk; the "+
|
|
||||||
"answer comes back sealed to this call alone (novox/hq ADR 0259 §10)")
|
|
||||||
local := set.String("local", "",
|
local := set.String("local", "",
|
||||||
"with --provider: the name the credential goes by inside <module>, where its manifest keeps "+
|
"with --provider: the name the credential goes by inside <module>, where its manifest keeps "+
|
||||||
"several for <name> (ADR 0094)")
|
"several for <name> (ADR 0094)")
|
||||||
@@ -68,12 +65,6 @@ func secretCommand(ctx context.Context, args []string) error {
|
|||||||
return errors.New(secretUsage)
|
return errors.New(secretUsage)
|
||||||
}
|
}
|
||||||
node, module, name := rest[0], rest[1], rest[2]
|
node, module, name := rest[0], rest[1], rest[2]
|
||||||
if *desk != "" {
|
|
||||||
if *from != "" || *provider != "" {
|
|
||||||
return errors.New("--at-desk gives a module's own secret, and takes neither --from nor --provider")
|
|
||||||
}
|
|
||||||
return giveAtDesk(ctx, node, module, name, *desk)
|
|
||||||
}
|
|
||||||
|
|
||||||
value, err := valueFor(node, module, name, *from)
|
value, err := valueFor(node, module, name, *from)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -127,7 +118,7 @@ func secretCommand(ctx context.Context, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const secretUsage = "secret rotate <node> <module> <name> [--why <text> [--cause <word>]]\n" +
|
const secretUsage = "secret rotate <node> <module> <name> [--why <text> [--cause <word>]]\n" +
|
||||||
"secret accept <node> <module> <name> [--from <file> | --at-desk <machine>] [--provider <node> [--local <name>]]\n" +
|
"secret accept <node> <module> <name> [--from <file>] [--provider <node> [--local <name>]]\n" +
|
||||||
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
|
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
|
||||||
"secret export [--out <file>]"
|
"secret export [--out <file>]"
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const (
|
|||||||
|
|
||||||
// callBounds are the verbs that may run longer than callDefault, and how long (S7).
|
// callBounds are the verbs that may run longer than callDefault, and how long (S7).
|
||||||
var callBounds = map[string]time.Duration{
|
var callBounds = map[string]time.Duration{
|
||||||
"push": 30 * time.Minute, "rotate": 30 * time.Minute, "give": 5 * time.Minute, "assign": 15 * time.Minute,
|
"push": 30 * time.Minute, "rotate": 30 * time.Minute, "assign": 15 * time.Minute,
|
||||||
"unassign": 15 * time.Minute, "command": 30 * time.Minute, "doctor": 3 * time.Minute,
|
"unassign": 15 * time.Minute, "command": 30 * time.Minute, "doctor": 3 * time.Minute,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,6 +378,7 @@ func watchWaits(f *signalFacts) []conditions.Observation {
|
|||||||
Headline: deliveryName(w.modules, w.repository) + " waiting to start",
|
Headline: deliveryName(w.modules, w.repository) + " waiting to start",
|
||||||
Explanation: walkWaitingWords(w, in, severity),
|
Explanation: walkWaitingWords(w, in, severity),
|
||||||
Needs: waitingNeeds(severity),
|
Needs: waitingNeeds(severity),
|
||||||
|
Actions: waitingActions(w.id, severity),
|
||||||
Resolved: deliveryName(w.modules, w.repository) + " no longer waiting"})
|
Resolved: deliveryName(w.modules, w.repository) + " no longer waiting"})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -318,6 +318,21 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
|||||||
return notNow(err)
|
return notNow(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **A merge older than the newest planned merge of its branch is planned at that merge's commit**
|
||||||
|
// (novox/hq issue 349): the catch-up acts on a merge the bus did not hand over after the merges that
|
||||||
|
// came after it, and planned at its own commit it built what a later merge had fixed — the forge's
|
||||||
|
// security fix, on 2026-10-09 — from the commit before it, dependents and all. The later commit contains
|
||||||
|
// this merge's change. Planned there, with that merge's time, a module the later merge already looked at
|
||||||
|
// reads as history and is not built again; the rest are built from the newest commit.
|
||||||
|
newest, known, err := inv.NewestMergeOf(ctx, m.Owner+"/"+m.Repo, m.Base)
|
||||||
|
if err != nil {
|
||||||
|
return notNow(err)
|
||||||
|
}
|
||||||
|
if known && laterOnTheBranch(m, newest) {
|
||||||
|
fmt.Printf(" %s/%s %.8s was merged before %.8s (%s, %s): what it moved is built from %.8s, which "+
|
||||||
|
"contains it\n", m.Owner, m.Repo, m.Commit, newest.Commit, newest.ID, newest.State, newest.Commit)
|
||||||
|
m.Commit, m.MergedAt = newest.Commit, newest.Merged.Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
from, packaging, already := mergeCandidates(m, entries, read)
|
from, packaging, already := mergeCandidates(m, entries, read)
|
||||||
if len(from) == 0 && len(packaging) == 0 {
|
if len(from) == 0 && len(packaging) == 0 {
|
||||||
// "Already built from it" and "nothing reads it" are different facts, and reading the first
|
// "Already built from it" and "nothing reads it" are different facts, and reading the first
|
||||||
|
|||||||
@@ -700,6 +700,9 @@ func watchTheMesh(ctx context.Context, open *stores, server *link.Server, bus li
|
|||||||
// under the lease and the brake, every act said.
|
// under the lease and the brake, every act said.
|
||||||
healers := newHealing(open, keeper, bus, server.JetStream())
|
healers := newHealing(open, keeper, bus, server.JetStream())
|
||||||
go healers.keep(watching)
|
go healers.keep(watching)
|
||||||
|
// And the asker (novox/hq ADR 0259): what needs the operator and names its answers is asked of them,
|
||||||
|
// and the answer chosen is performed on its warrant.
|
||||||
|
startAsking(watching, open, server, bus.Conn, keeper)
|
||||||
go forgettingOldHeals(watching, open.inventory)
|
go forgettingOldHeals(watching, open.inventory)
|
||||||
fmt.Printf("watching the mesh: %d signal(s) every %s, %d probe(s) every %s; what is wrong is kept in %s "+
|
fmt.Printf("watching the mesh: %d signal(s) every %s, %d probe(s) every %s; what is wrong is kept in %s "+
|
||||||
"and said as %s events\n", len(watchedRows()), watchEvery, len(runnableProbes()), doctorEvery,
|
"and said as %s events\n", len(watchedRows()), watchEvery, len(runnableProbes()), doctorEvery,
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ module github.com/novox/mesh-controller
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.10-0.20261009115850-16984aafc689
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
|
github.com/nats-io/nats-server/v2 v2.11.17
|
||||||
github.com/nats-io/nats.go v1.54.0
|
github.com/nats-io/nats.go v1.54.0
|
||||||
github.com/novox/mesh-host v0.0.0
|
github.com/novox/mesh-host v0.0.0
|
||||||
golang.org/x/crypto v0.57.0
|
golang.org/x/crypto v0.57.0
|
||||||
@@ -19,10 +21,8 @@ require (
|
|||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
github.com/minio/highwayhash v1.0.4 // indirect
|
github.com/minio/highwayhash v1.0.4 // indirect
|
||||||
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
||||||
github.com/nats-io/nats-server/v2 v2.11.17 // indirect
|
|
||||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
|
||||||
golang.org/x/sync v0.23.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/sys v0.48.0 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
golang.org/x/text v0.42.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
@@ -35,4 +35,4 @@ require (
|
|||||||
// committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and
|
// committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and
|
||||||
// fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without
|
// fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without
|
||||||
// `go mod vendor` fails loudly, at once, everywhere.
|
// `go mod vendor` fails loudly, at once, everywhere.
|
||||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac
|
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d
|
||||||
|
|||||||
@@ -4,6 +4,22 @@ git.novox.be/novox/mesh-host v0.0.0-20261007120832-bdd44154ccac h1:KvnKtJ2rWeIE/
|
|||||||
git.novox.be/novox/mesh-host v0.0.0-20261007120832-bdd44154ccac/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
git.novox.be/novox/mesh-host v0.0.0-20261007120832-bdd44154ccac/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
||||||
git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac h1:yLtFS0pDCCqIE9Zx8hgXEFG9fUWzf8L9WQoKV+Amk1E=
|
git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac h1:yLtFS0pDCCqIE9Zx8hgXEFG9fUWzf8L9WQoKV+Amk1E=
|
||||||
git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
||||||
|
git.novox.be/novox/mesh-host v0.0.0-20261009081005-b28d7bbcbff4 h1:f4rBnKSemuN0Z9dTtRJMigIGfEs6ltFPOILJGHGab74=
|
||||||
|
git.novox.be/novox/mesh-host v0.0.0-20261009081005-b28d7bbcbff4/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
|
||||||
|
git.novox.be/novox/mesh-host v0.0.0-20261009101157-2673e7a2c95e h1:H7eVqDILL6e9cMbWSLHTbCqu9ZxDOmyeQhUmWl9QBV0=
|
||||||
|
git.novox.be/novox/mesh-host v0.0.0-20261009101157-2673e7a2c95e/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
|
||||||
|
git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d h1:IrmJ+lz21n+eSqKrmXREtR/7raUCBJ+fZvs+BNhuXVI=
|
||||||
|
git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008145004-62367ce15ad6 h1:JT7xM1bnLNInW7/oImV2OlXTrcQ4/GSM0Y8tAb+AhmY=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008145004-62367ce15ad6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008162031-55090da7e08f h1:BNvyWq899GwP7F3sY4ACieB5a5fnFAq+sJ9lP6HQ5qI=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008162031-55090da7e08f/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8 h1:soqhLNpEXThdq6PdiPy6ExxjJ+yjhh1N1n9E3j1CtrM=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009095928-76902998cd39 h1:WHW6CgbuTxP7M+qRBOgzsiG9vT49xdkZ/rarc9/vKMA=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009095928-76902998cd39/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.10-0.20261009115850-16984aafc689 h1:Ti2P9nwders7YQ/hq3X/dPo+CXMj5pdUcfA5P/c12CU=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.10-0.20261009115850-16984aafc689/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package broker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq ADR 0259 §6: the controller asks the operator through the router's seat as any user of it, under
|
||||||
|
// its own name, hears its own warrants, reads its own record, and calls the verbs a warrant chooses.
|
||||||
|
func TestTheControllerAsksUnderItsOwnNameAndCallsTheVerbsAWarrantChooses(t *testing.T) {
|
||||||
|
records := Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": {
|
||||||
|
{Module: "messenger", Holds: []Seat{operatorChannel()}},
|
||||||
|
}}}
|
||||||
|
users, err := Users(records)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := perms(t, users[0])
|
||||||
|
for _, s := range []string{
|
||||||
|
"mesh.seat.operator-channel.accept.ask.mesh-controller",
|
||||||
|
"mesh.seat.operator-channel.accept.cancel.mesh-controller",
|
||||||
|
"$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.mesh-controller.c1",
|
||||||
|
"mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stop",
|
||||||
|
"mesh.seat.node-service-manager.tool.restart.g14", "mesh.seat.mesh-controller.tool.plans",
|
||||||
|
} {
|
||||||
|
if !allowed(got.Publish, s) {
|
||||||
|
t.Errorf("the controller may not publish %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range []string{
|
||||||
|
"mesh.seat.operator-channel.accept.ask.mesh-delivery",
|
||||||
|
"mesh.seat.operator-channel.event.decided.mesh-controller",
|
||||||
|
// (A direct get of another asker's record is not refused here: the controller holds the whole
|
||||||
|
// JetStream API, as the only writer of stream definitions.)
|
||||||
|
"mesh.seat.node-service-manager.tool.stop.g14",
|
||||||
|
} {
|
||||||
|
if allowed(got.Publish, s) {
|
||||||
|
t.Errorf("the controller may publish %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowed(got.Subscribe, DecidedSubject) || allowed(got.Subscribe, "mesh.seat.operator-channel.event.decided.mesh-delivery") {
|
||||||
|
t.Error("the controller does not hear exactly its own warrants")
|
||||||
|
}
|
||||||
|
// Its events consumer carries them, so a controller that was away hears what was decided meanwhile.
|
||||||
|
if !slices.Contains(ControllerFollows, DecidedSubject) {
|
||||||
|
t.Error("the controller does not follow its warrants")
|
||||||
|
}
|
||||||
|
// Without a holder of the seat it is granted no ask at all.
|
||||||
|
alone, _ := Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{}})
|
||||||
|
if allowed(perms(t, alone[0]).Publish, "mesh.seat.operator-channel.accept.ask.mesh-controller") {
|
||||||
|
t.Error("asked a seat nobody holds")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,8 +34,15 @@ var (
|
|||||||
// LeaseBucket holds the controller's lease (to-be 45 §6): one key, `holder`, which the instance
|
// LeaseBucket holds the controller's lease (to-be 45 §6): one key, `holder`, which the instance
|
||||||
// allowed to act writes by compare-and-set and renews; its revision when taken is the epoch.
|
// allowed to act writes by compare-and-set and renews; its revision when taken is the epoch.
|
||||||
LeaseBucket = BucketName(ControllerSeat, "lease")
|
LeaseBucket = BucketName(ControllerSeat, "lease")
|
||||||
|
// AskedBucket keeps what the controller asked the operator about its conditions (novox/hq ADR 0259):
|
||||||
|
// each ask by its id, its options and the actions they stand for, how it ended and whether the
|
||||||
|
// controller acted on its warrant — so a restart neither asks twice nor acts twice.
|
||||||
|
AskedBucket = BucketName(ControllerSeat, "asked")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AskedKeptFor is how long an ask is kept after it was made: a month, as the router keeps its own.
|
||||||
|
const AskedKeptFor = 30 * 24 * time.Hour
|
||||||
|
|
||||||
// LeaseTTL is how long the lease's key lives unrenewed (to-be 45 §6): fifteen seconds, renewed
|
// LeaseTTL is how long the lease's key lives unrenewed (to-be 45 §6): fifteen seconds, renewed
|
||||||
// every five. The bucket's age, so the bus forgets a holder that stopped renewing.
|
// every five. The bucket's age, so the bus forgets a holder that stopped renewing.
|
||||||
const LeaseTTL = 15 * time.Second
|
const LeaseTTL = 15 * time.Second
|
||||||
@@ -59,12 +66,12 @@ const (
|
|||||||
// IsControllerBucket says a bucket is the controller's own, not a module's state nothing declares.
|
// IsControllerBucket says a bucket is the controller's own, not a module's state nothing declares.
|
||||||
func IsControllerBucket(bucket string) bool {
|
func IsControllerBucket(bucket string) bool {
|
||||||
return bucket == CallsBucket || bucket == HandActsBucket || bucket == ConditionsBucket ||
|
return bucket == CallsBucket || bucket == HandActsBucket || bucket == ConditionsBucket ||
|
||||||
bucket == ConditionHistoryBucket || bucket == LeaseBucket
|
bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket
|
||||||
}
|
}
|
||||||
|
|
||||||
// ControllerBuckets are the controller's own buckets, in the order they are asserted.
|
// ControllerBuckets are the controller's own buckets, in the order they are asserted.
|
||||||
func ControllerBuckets() []string {
|
func ControllerBuckets() []string {
|
||||||
return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket}
|
return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ControllerBucketsAsserter is what raising the controller's buckets needs of a connection.
|
// ControllerBucketsAsserter is what raising the controller's buckets needs of a connection.
|
||||||
@@ -149,6 +156,18 @@ func (j *JetStream) EnsureControllerBuckets() error {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("asserting bucket %s: %w", ConditionHistoryBucket, err)
|
return fmt.Errorf("asserting bucket %s: %w", ConditionHistoryBucket, err)
|
||||||
}
|
}
|
||||||
|
if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
|
||||||
|
Bucket: AskedBucket,
|
||||||
|
Description: "what the controller asked the operator about its conditions, and what came of each (novox/hq " +
|
||||||
|
"ADR 0259): written by the controller alone; an ask acted on is acted on once",
|
||||||
|
History: 1,
|
||||||
|
TTL: AskedKeptFor,
|
||||||
|
MaxValueSize: 32 << 10,
|
||||||
|
MaxBytes: 32 << 20,
|
||||||
|
Storage: jetstream.FileStorage,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("asserting bucket %s: %w", AskedBucket, err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
package broker
|
package broker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go/jetstream"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/testbus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// **The controller may write every bucket it writes** (novox/hq to-be 45 §1, issue 269). Writing a
|
// **The controller may write every bucket it writes** (novox/hq to-be 45 §1, issue 269). Writing a
|
||||||
@@ -59,3 +65,34 @@ func TestTheWatchedSignalsMayBeSaidAndHeard(t *testing.T) {
|
|||||||
t.Error("the controller may not ask who answers, or hears every API call")
|
t.Error("the controller may not ask who answers, or hears every API call")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The controller's record of what it asked the operator is bounded (correctness review of 2026-10-08): one
|
||||||
|
// value a key, a month's age, and a size it cannot outgrow.
|
||||||
|
func TestWhatTheControllerAskedIsBounded(t *testing.T) {
|
||||||
|
js, err := Dial(testbus.URL(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer js.Close()
|
||||||
|
if err := js.EnsureControllerBuckets(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
kv, err := jetstream.New(js.Conn())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bucket, err := kv.KeyValue(ctx, AskedBucket)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
status, err := bucket.Status(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info := status.(*jetstream.KeyValueBucketStatus).StreamInfo()
|
||||||
|
if status.History() != 1 || status.TTL() != AskedKeptFor || info.Config.MaxBytes <= 0 || info.Config.MaxBytes > 64<<20 {
|
||||||
|
t.Errorf("history %d, age %s, bytes %d", status.History(), status.TTL(), info.Config.MaxBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -126,7 +126,9 @@ func ConsumerFor(p Principal) (Consumer, bool) {
|
|||||||
// A module that reacts to anything — a module's events or a role's (novox/hq ADR 0121). Watching
|
// A module that reacts to anything — a module's events or a role's (novox/hq ADR 0121). Watching
|
||||||
// a role was missing here, so the one module that does it got no consumer at all: it started,
|
// a role was missing here, so the one module that does it got no consumer at all: it started,
|
||||||
// connected, and its graph stayed empty with nothing anywhere reporting why.
|
// connected, and its graph stayed empty with nothing anywhere reporting why.
|
||||||
if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0) {
|
// And one that hears its own answers on a seat it uses (novox/hq ADR 0259 §3): an asker's warrants.
|
||||||
|
hearsItsOwn := len(SeatTrafficOf(p.Module, nil, p.Uses, nil).Subscribe) > 0
|
||||||
|
if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0 && !hearsItsOwn) {
|
||||||
return Consumer{}, false
|
return Consumer{}, false
|
||||||
}
|
}
|
||||||
perms, err := PermissionsFor(p)
|
perms, err := PermissionsFor(p)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package broker
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -50,6 +51,12 @@ type Membership struct {
|
|||||||
// and refuses, with the reason, what is not on it — the bus enforces only the union over every
|
// and refuses, with the reason, what is not on it — the bus enforces only the union over every
|
||||||
// module on the machine.
|
// module on the machine.
|
||||||
State []StateIssued `json:"state,omitempty"`
|
State []StateIssued `json:"state,omitempty"`
|
||||||
|
// SeatTraffic is what this module's code may submit, say, hear, take, ask, answer and read on seats
|
||||||
|
// that name their caller or their kind (novox/hq ADR 0259 §3). The runtime carrying the module
|
||||||
|
// publishes, takes and answers for it only what is listed here: the bus enforces only the union
|
||||||
|
// over every module on the machine, so one module's code reaching another's name or kind through
|
||||||
|
// the runtime is the runtime's to refuse.
|
||||||
|
SeatTraffic *SeatTraffic `json:"seat-traffic,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Served is one address a tool is answered on.
|
// Served is one address a tool is answered on.
|
||||||
@@ -78,6 +85,8 @@ type Placements struct {
|
|||||||
// Interchangeable is each module whose definition says its instances are the same anywhere,
|
// Interchangeable is each module whose definition says its instances are the same anywhere,
|
||||||
// so the module's plain subject is issued to all of them in one queue.
|
// so the module's plain subject is issued to all of them in one queue.
|
||||||
Interchangeable map[string]bool
|
Interchangeable map[string]bool
|
||||||
|
// Kinds is every kind held of a kinded bench, by whom and with what capabilities (ADR 0259 §5).
|
||||||
|
Kinds []KindHeld
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnswersForTheModule says whether an instance of a module on one machine is issued the module's
|
// AnswersForTheModule says whether an instance of a module on one machine is issued the module's
|
||||||
@@ -104,11 +113,28 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
|
|||||||
m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}", Queue: "serve." + d.Module})
|
m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}", Queue: "serve." + d.Module})
|
||||||
}
|
}
|
||||||
for _, s := range d.Holds {
|
for _, s := range d.Holds {
|
||||||
|
if servedOnlyByTheController(s) {
|
||||||
|
continue // answered by the serving controller alone, never through a membership
|
||||||
|
}
|
||||||
for _, verb := range s.Serves {
|
for _, verb := range s.Serves {
|
||||||
m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)})
|
m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.State = stateIssuedFor(d, node)
|
m.State = stateIssuedFor(d, node)
|
||||||
|
t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches)
|
||||||
|
for _, s := range append(append([]Seat{}, d.Uses...), d.Watches...) {
|
||||||
|
if !s.Kinded {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, k := range where.Kinds {
|
||||||
|
if k.Seat == s.Name && !kindListed(t.Kinds, k) {
|
||||||
|
t.Kinds = append(t.Kinds, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(t.Publish)+len(t.Subscribe)+len(t.Answers)+len(t.Workers)+len(t.Records)+len(t.Kinds) > 0 {
|
||||||
|
m.SeatTraffic = &t
|
||||||
|
}
|
||||||
if len(d.Invokes) > 0 {
|
if len(d.Invokes) > 0 {
|
||||||
m.Reaches = map[string][]string{}
|
m.Reaches = map[string][]string{}
|
||||||
for _, t := range d.Invokes {
|
for _, t := range d.Invokes {
|
||||||
@@ -145,5 +171,67 @@ func PlacementsOf(r Records, interchangeable map[string]bool) Placements {
|
|||||||
for _, nodes := range p.Nodes {
|
for _, nodes := range p.Nodes {
|
||||||
sort.Strings(nodes)
|
sort.Strings(nodes)
|
||||||
}
|
}
|
||||||
|
// Where the router runs: a verified sender is believed only while its machine is root-free too. A router
|
||||||
|
// placed nowhere, or on more than one machine, frees nothing.
|
||||||
|
var routerNodes []string
|
||||||
|
for node, declared := range r.Assigned {
|
||||||
|
for _, d := range declared {
|
||||||
|
for _, s := range d.Holds {
|
||||||
|
if s.Name == routerSeat && !slices.Contains(routerNodes, node) {
|
||||||
|
routerNodes = append(routerNodes, node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
routerFree := len(routerNodes) == 1 && r.RootFree[routerNodes[0]]
|
||||||
|
for node, declared := range r.Assigned {
|
||||||
|
for _, d := range declared {
|
||||||
|
for _, s := range d.Holds {
|
||||||
|
if s.Kinded && s.Kind != "" {
|
||||||
|
p.Kinds = append(p.Kinds, KindHeld{Seat: s.Name, Kind: s.Kind, Module: d.Module, Node: node,
|
||||||
|
Capabilities: placedCapabilities(s.Capabilities, d.RunsAs, routerFree && r.RootFree[node])})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(p.Kinds, func(i, j int) bool {
|
||||||
|
a, b := p.Kinds[i], p.Kinds[j]
|
||||||
|
if a.Seat != b.Seat {
|
||||||
|
return a.Seat < b.Seat
|
||||||
|
}
|
||||||
|
if a.Kind != b.Kind {
|
||||||
|
return a.Kind < b.Kind
|
||||||
|
}
|
||||||
|
return a.Node < b.Node
|
||||||
|
})
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// routerSeat is the seat the router of asks holds (novox/hq ADR 0259 §3).
|
||||||
|
const routerSeat = "operator-channel"
|
||||||
|
|
||||||
|
// placedCapabilities is what a kind's claim promises, as far as its placement lets the router believe it
|
||||||
|
// (novox/hq ADR 0259 §8): `verified-sender` only from a holder that runs as an account of its own, on a bus
|
||||||
|
// account of its own — never one the machine's runtime carries as the operator's account — and only while
|
||||||
|
// its machine and the router's were root-free when composed (rootFree; the review of 2026-10-09, H3). The
|
||||||
|
// membership carrying it is composed at a push, so it can outlive a pass that later fails: the router asks
|
||||||
|
// the controller's root-free verb again before it honours an approval, and that is the check that holds.
|
||||||
|
func placedCapabilities(declared []string, runsAs string, rootFree bool) []string {
|
||||||
|
var out []string
|
||||||
|
for _, c := range declared {
|
||||||
|
if c == "verified-sender" && (runsAs == "" || !rootFree) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func kindListed(list []KindHeld, k KindHeld) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if x.Seat == k.Seat && x.Kind == k.Kind && x.Module == k.Module && x.Node == k.Node {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
+92
-11
@@ -63,6 +63,23 @@ type Seat struct {
|
|||||||
Emits []string
|
Emits []string
|
||||||
Serves []string
|
Serves []string
|
||||||
Versions []string // protocol versions served beside the current one; empty for v1 only
|
Versions []string // protocol versions served beside the current one; empty for v1 only
|
||||||
|
|
||||||
|
// Kinded says the seat is a kinded bench (novox/hq ADR 0234 §2, ADR 0259 §3): each holder claims one
|
||||||
|
// kind, and its verbs' subjects carry it. Kind is the kind this principal's claim names, for a seat it
|
||||||
|
// holds.
|
||||||
|
Kinded bool
|
||||||
|
Kind string
|
||||||
|
// ByCaller are the accepts and emits whose last token names the calling module (ADR 0259 §3).
|
||||||
|
ByCaller []string
|
||||||
|
// Proofs are the seat's proof verbs: core request and reply, never on a stream (ADR 0259 §3).
|
||||||
|
Proofs []string
|
||||||
|
// Records are the holder's buckets, by their full name, each user reads under its own name.
|
||||||
|
Records []string
|
||||||
|
// Capabilities are what this principal's claim of a kinded bench promises (ADR 0234 §2).
|
||||||
|
Capabilities []string
|
||||||
|
// DeclaredBy is the module that declares the seat. On a kinded bench it alone submits work to a kind
|
||||||
|
// and answers its proofs (novox/hq ADR 0259 §8): the router, not any user or watcher of the bench.
|
||||||
|
DeclaredBy string
|
||||||
}
|
}
|
||||||
|
|
||||||
// A Principal is one user of the bus. Its permissions are derived from what it declares and
|
// A Principal is one user of the bus. Its permissions are derived from what it declares and
|
||||||
@@ -166,15 +183,20 @@ var VerbsTheSelfCheckAsks = []SeatVerb{{Seat: "node-intrusion-prevention", Verb:
|
|||||||
// the controller's grant that acts, and only through the step a person starts.
|
// the controller's grant that acts, and only through the step a person starts.
|
||||||
var VerbsTheBusStepAsks = []SeatVerb{{Seat: "node-backup", Verb: "now"}}
|
var VerbsTheBusStepAsks = []SeatVerb{{Seat: "node-backup", Verb: "now"}}
|
||||||
|
|
||||||
// VerbsTheControllerAsksForASecret are the seat verbs `give` calls (novox/hq ADR 0259 §10): the operator's
|
|
||||||
// desk opens a prompt that does not show what is typed, and answers it sealed to the controller's call.
|
|
||||||
var VerbsTheControllerAsksForASecret = []SeatVerb{{Seat: "node-launcher", Verb: "secret"}}
|
|
||||||
|
|
||||||
// VerbsTheControllerAsksTheDeliveryOwner are the mesh-delivery seat's verbs the controller calls (novox/hq
|
// VerbsTheControllerAsksTheDeliveryOwner are the mesh-delivery seat's verbs the controller calls (novox/hq
|
||||||
// ADR 0239): its self-check reads `stalled`, and healer H2 takes the one transition the table allows
|
// ADR 0239): its self-check reads `stalled`, and healer H2 takes the one transition the table allows
|
||||||
// through `close`. A mesh seat's verb is flat: no machine in the subject.
|
// through `close`. A mesh seat's verb is flat: no machine in the subject.
|
||||||
|
//
|
||||||
|
// And, since novox/hq ADR 0259, `release` and `stop`: the controller asks the operator for them about a
|
||||||
|
// delivery held past its bound, and calls them on the operator's warrant, with its why.
|
||||||
var VerbsTheControllerAsksTheDeliveryOwner = []SeatVerb{{Seat: "mesh-delivery", Verb: "stalled"},
|
var VerbsTheControllerAsksTheDeliveryOwner = []SeatVerb{{Seat: "mesh-delivery", Verb: "stalled"},
|
||||||
{Seat: "mesh-delivery", Verb: "close"}}
|
{Seat: "mesh-delivery", Verb: "close"}, {Seat: "mesh-delivery", Verb: "release"}, {Seat: "mesh-delivery", Verb: "stop"}}
|
||||||
|
|
||||||
|
// VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant
|
||||||
|
// chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the
|
||||||
|
// controller's own `plans`. Named one by one; a node seat's on any machine, a mesh seat's flat.
|
||||||
|
var VerbsTheControllerActsOnAWarrant = []SeatVerb{{Seat: "node-service-manager", Verb: "restart"},
|
||||||
|
{Seat: ControllerSeat, Verb: "plans"}}
|
||||||
|
|
||||||
// perMachineEvents are a node-scoped seat's events about the holder itself, whose last token is the
|
// perMachineEvents are a node-scoped seat's events about the holder itself, whose last token is the
|
||||||
// holder's machine (novox/hq ADR 0219): `paused.<node>`, the build agent saying whether it takes work.
|
// holder's machine (novox/hq ADR 0219): `paused.<node>`, the build agent saying whether it takes work.
|
||||||
@@ -370,14 +392,24 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
for _, v := range VerbsTheBusStepAsks {
|
for _, v := range VerbsTheBusStepAsks {
|
||||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
|
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
|
||||||
}
|
}
|
||||||
// And the operator's desk, for a secret given there (ADR 0259 §10).
|
|
||||||
for _, v := range VerbsTheControllerAsksForASecret {
|
|
||||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
|
|
||||||
}
|
|
||||||
// And the delivery's owner, a mesh seat, asked on its flat subjects (ADR 0239).
|
// And the delivery's owner, a mesh seat, asked on its flat subjects (ADR 0239).
|
||||||
for _, v := range VerbsTheControllerAsksTheDeliveryOwner {
|
for _, v := range VerbsTheControllerAsksTheDeliveryOwner {
|
||||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb)
|
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb)
|
||||||
}
|
}
|
||||||
|
// And the verbs a warrant chooses (novox/hq ADR 0259): a node seat's on any machine, its own flat.
|
||||||
|
for _, v := range VerbsTheControllerActsOnAWarrant {
|
||||||
|
if v.Seat == ControllerSeat {
|
||||||
|
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
|
||||||
|
}
|
||||||
|
// And asking the operator (novox/hq ADR 0259): an ask and its cancel under its own name, its warrants
|
||||||
|
// heard under its own name, the record of its asks read under its own name — as any user of the seat,
|
||||||
|
// derived the same way, from the seat its holder declares.
|
||||||
|
tp, ts := SeatTrafficOf(ControllerSeat, nil, p.Uses, nil).grants()
|
||||||
|
pub = append(pub, tp...)
|
||||||
|
sub = append(sub, ts...)
|
||||||
// And asks who answers (novox/hq to-be 45 §4, D3): the self-check finds every seat's holder by
|
// And asks who answers (novox/hq to-be 45 §4, D3): the self-check finds every seat's holder by
|
||||||
// the same discovery the console reads. The question only; the answers come to its own inbox.
|
// the same discovery the console reads. The question only; the answers come to its own inbox.
|
||||||
pub = append(pub, "$SRV.INFO")
|
pub = append(pub, "$SRV.INFO")
|
||||||
@@ -538,6 +570,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
// 2b. Events of a role it watches, under the seat's own namespace. Subscribe only: watching a
|
// 2b. Events of a role it watches, under the seat's own namespace. Subscribe only: watching a
|
||||||
// role is hearing what it announced, not taking part in it.
|
// role is hearing what it announced, not taking part in it.
|
||||||
for _, w := range p.Watches {
|
for _, w := range p.Watches {
|
||||||
|
if w.Kinded {
|
||||||
|
continue // composed by SeatTrafficOf below
|
||||||
|
}
|
||||||
for _, e := range w.Emits {
|
for _, e := range w.Emits {
|
||||||
sub = append(sub, seatSubject(w, "event", e))
|
sub = append(sub, seatSubject(w, "event", e))
|
||||||
}
|
}
|
||||||
@@ -554,8 +589,19 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
"$JS.API.CONSUMER.INFO."+consumerStream(p)+"."+consumerDurable(p),
|
"$JS.API.CONSUMER.INFO."+consumerStream(p)+"."+consumerDurable(p),
|
||||||
"$JS.API.CONSUMER.MSG.NEXT."+consumerStream(p)+"."+consumerDurable(p))
|
"$JS.API.CONSUMER.MSG.NEXT."+consumerStream(p)+"."+consumerDurable(p))
|
||||||
|
|
||||||
// 3. Seats it holds: full participation.
|
// 3. Seats it holds: full participation — but the controller's own seat, whose verbs only the serving
|
||||||
|
// controller answers, on its own connection (servedOnlyByTheController).
|
||||||
for _, s := range p.Holds {
|
for _, s := range p.Holds {
|
||||||
|
if servedOnlyByTheController(s) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.isNewTraffic() {
|
||||||
|
// Composed by SeatTrafficOf below, worker and all; only its tools are served here.
|
||||||
|
for _, t := range s.Serves {
|
||||||
|
sub = append(sub, seatToolSubject(s, t, p.Node))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
// Taking work from the role's queue: the worker consumer every holder shares (asked
|
// Taking work from the role's queue: the worker consumer every holder shares (asked
|
||||||
// about, pulled from, acknowledged), on the seat's own stream (novox/hq ADR 0190). A
|
// about, pulled from, acknowledged), on the seat's own stream (novox/hq ADR 0190). A
|
||||||
// holder pulls — asks the consumer for its next message, answered on its own inbox —
|
// holder pulls — asks the consumer for its next message, answered on its own inbox —
|
||||||
@@ -598,7 +644,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
// seat's inbound subject and watch other modules' traffic, nor publish its outbound
|
// seat's inbound subject and watch other modules' traffic, nor publish its outbound
|
||||||
// events and lie about outcomes (design 29 §2).
|
// events and lie about outcomes (design 29 §2).
|
||||||
for _, s := range p.Uses {
|
for _, s := range p.Uses {
|
||||||
for _, a := range s.Accepts {
|
for _, a := range plainVerbs(s, s.Accepts) {
|
||||||
pub = append(pub, seatSubject(s, "accept", a))
|
pub = append(pub, seatSubject(s, "accept", a))
|
||||||
}
|
}
|
||||||
for _, t := range s.Serves {
|
for _, t := range s.Serves {
|
||||||
@@ -611,6 +657,12 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
pub = append(pub, stateGrants(stateAccess{Module: p.Module, Node: p.Node, Keeps: p.State,
|
pub = append(pub, stateGrants(stateAccess{Module: p.Module, Node: p.Node, Keeps: p.State,
|
||||||
PerMachine: p.PerMachine, Reads: p.Reads, KeyedReads: p.KeyedReads})...)
|
PerMachine: p.PerMachine, Reads: p.Reads, KeyedReads: p.KeyedReads})...)
|
||||||
|
|
||||||
|
// 6. Its traffic on seats that name their caller or their kind, ask proofs or keep records
|
||||||
|
// (novox/hq ADR 0259 §3).
|
||||||
|
tp, ts := SeatTrafficOf(p.Module, p.Holds, p.Uses, p.Watches).grants()
|
||||||
|
pub = append(pub, tp...)
|
||||||
|
sub = append(sub, ts...)
|
||||||
|
|
||||||
case KindNodeTools:
|
case KindNodeTools:
|
||||||
// **One process serves what every module on the machine would have served for itself**
|
// **One process serves what every module on the machine would have served for itself**
|
||||||
// (novox/hq ADR 0175). Each carried module's whole tool namespace — the same grant that
|
// (novox/hq ADR 0175). Each carried module's whole tool namespace — the same grant that
|
||||||
@@ -636,6 +688,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
pub = append(pub, own+".event."+e)
|
pub = append(pub, own+".event."+e)
|
||||||
}
|
}
|
||||||
for _, s := range d.Holds {
|
for _, s := range d.Holds {
|
||||||
|
if servedOnlyByTheController(s) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
for _, t := range s.Serves {
|
for _, t := range s.Serves {
|
||||||
sub = append(sub, seatToolSubject(s, t, p.Node))
|
sub = append(sub, seatToolSubject(s, t, p.Node))
|
||||||
}
|
}
|
||||||
@@ -688,6 +743,25 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
pub = append(pub, stateGrants(stateAccess{Module: d.Module, Node: p.Node, Keeps: stateNames(d.State),
|
pub = append(pub, stateGrants(stateAccess{Module: d.Module, Node: p.Node, Keeps: stateNames(d.State),
|
||||||
PerMachine: perMachineNames(d.State), Reads: d.Reads, KeyedReads: d.KeyedReads})...)
|
PerMachine: perMachineNames(d.State), Reads: d.Reads, KeyedReads: d.KeyedReads})...)
|
||||||
}
|
}
|
||||||
|
// **Never the traffic of a trusted holder** (novox/hq ADR 0259 §8): the machine's runtime runs as the
|
||||||
|
// operator's account, which every agent runs as, so a module saying warrants or speaking for a kind
|
||||||
|
// that proves its sender is never composed into it — refused here, naming it, whatever registration
|
||||||
|
// let through.
|
||||||
|
for _, d := range p.Carries {
|
||||||
|
if why := trustedTraffic(d); why != "" {
|
||||||
|
return Permissions{}, fmt.Errorf("%s on %s is carried by the machine's runtime, and %s: it runs "+
|
||||||
|
"as an account of its own, never the runtime's (novox/hq ADR 0259)", d.Module, p.Node, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// **And the seat traffic of the modules it carries** (novox/hq ADR 0259 §3): a bundle reaches the
|
||||||
|
// bus only through its runtime, so the runtime is granted the union. That one module's code does
|
||||||
|
// not publish under another's name or kind through it is the runtime's to keep, from the seat
|
||||||
|
// traffic each module's membership lists.
|
||||||
|
for _, d := range p.Carries {
|
||||||
|
tp, ts := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches).grants()
|
||||||
|
pub = append(pub, tp...)
|
||||||
|
sub = append(sub, ts...)
|
||||||
|
}
|
||||||
sub = unique(sub)
|
sub = unique(sub)
|
||||||
pub = unique(pub)
|
pub = unique(pub)
|
||||||
}
|
}
|
||||||
@@ -751,6 +825,13 @@ func seatSubject(s Seat, kind, verb string) string {
|
|||||||
// seat carries the node it is asked of, because a flat subject would reach every machine's holder
|
// seat carries the node it is asked of, because a flat subject would reach every machine's holder
|
||||||
// and the queue group would silently pick a winner (novox/hq ADR 0132, design 33 §4). A holder
|
// and the queue group would silently pick a winner (novox/hq ADR 0132, design 33 §4). A holder
|
||||||
// subscribes its own node's; a user publishes any node's (`*`) and names the machine in the subject.
|
// subscribes its own node's; a user publishes any node's (`*`) and names the machine in the subject.
|
||||||
|
// servedOnlyByTheController says a seat's verbs are answered by the serving controller alone, on its own
|
||||||
|
// connection (the KindController grant), never by a module claiming the seat or a runtime carrying it: the
|
||||||
|
// controller's own seat. Its verbs decide what the mesh is — and `root-free` decides whether the router believes
|
||||||
|
// a verified sender (novox/hq ADR 0259 §8) — so a machine's runtime, whose credential an agent on that machine
|
||||||
|
// may hold, answering one would be an agent answering it (the confirmation review of 2026-10-09).
|
||||||
|
func servedOnlyByTheController(s Seat) bool { return s.Name == ControllerSeat }
|
||||||
|
|
||||||
func seatToolSubject(s Seat, verb, node string) string {
|
func seatToolSubject(s Seat, verb, node string) string {
|
||||||
base := seatSubject(s, "tool", verb)
|
base := seatSubject(s, "tool", verb)
|
||||||
if s.Scope == "node" && node != "" {
|
if s.Scope == "node" && node != "" {
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
package broker
|
||||||
|
|
||||||
|
import "sort"
|
||||||
|
|
||||||
|
// What a module may say and take on the seats it holds, uses and watches, beyond tools (novox/hq ADR
|
||||||
|
// 0259 §3, to-be 46 §10).
|
||||||
|
//
|
||||||
|
// Three rules are added to the ones a seat always had, each a subject whose last token names who may
|
||||||
|
// publish it, granted to that publisher alone — the way a node seat's event about a machine carries the
|
||||||
|
// machine (ADR 0219):
|
||||||
|
//
|
||||||
|
// - **A verb named by its caller** (`by-caller`). A user of the seat submits that accept, and hears that
|
||||||
|
// event, under its own module's name and no other: `accept.ask.<module>`, `event.decided.<module>`. The
|
||||||
|
// holder takes every caller's accept and says the event to any caller. So an ask's asker is a fact the
|
||||||
|
// server enforces, and a warrant reaches only the asker it is for.
|
||||||
|
// - **A kinded bench** (ADR 0234 §2). A holder claims one kind and takes its own kind's accepts, says its
|
||||||
|
// own kind's events and asks its own kind's proofs, and nothing of another kind; a user submits to any
|
||||||
|
// kind. Each kind has its own worker on the seat's queue, so a holder that is away keeps its work and
|
||||||
|
// holds up no other kind.
|
||||||
|
// - **A proof** (`proofs`): core request and reply on `mesh.seat.<seat>.proof.<verb>.<kind>`. No stream's
|
||||||
|
// subjects cover it, so what travels there — a code typed by the operator — is never persisted. A kinded
|
||||||
|
// holder asks with its own kind; the modules that watch the seat answer.
|
||||||
|
//
|
||||||
|
// And one read: **a holder's records**, a bucket the seat names, read by each user under its own name only
|
||||||
|
// (`$KV.<bucket>.<module>.>`), so an asker reads the state of its own asks and no other asker's.
|
||||||
|
|
||||||
|
// Worker is one durable consumer a holder pulls a seat's work from.
|
||||||
|
type Worker struct {
|
||||||
|
Stream string
|
||||||
|
Consumer string
|
||||||
|
Filter string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeatTraffic is one module's seat traffic beyond tools. Publish and Subscribe are subject patterns, in the
|
||||||
|
// server's wildcards; the runtime that carries the module checks a bundle's request against them, since
|
||||||
|
// the runtime's own principal holds the union of every module it carries.
|
||||||
|
type SeatTraffic struct {
|
||||||
|
// Publish is what it submits (accepts of seats it uses), says (events of seats it holds) and asks
|
||||||
|
// (proofs of seats it holds a kind of).
|
||||||
|
Publish []string `json:"publish,omitempty"`
|
||||||
|
// Subscribe is what it hears: events of seats it uses that are named by caller, events of seats it
|
||||||
|
// watches, and accepts of seats it holds.
|
||||||
|
Subscribe []string `json:"subscribe,omitempty"`
|
||||||
|
// Answers is the proof subjects it answers, as a watcher of a kinded seat.
|
||||||
|
Answers []string `json:"answers,omitempty"`
|
||||||
|
// Workers are the work queues it takes from, as a holder.
|
||||||
|
Workers []Worker `json:"workers,omitempty"`
|
||||||
|
// Records is the direct-get subjects of the records it reads under its own name.
|
||||||
|
Records []string `json:"records,omitempty"`
|
||||||
|
// Kinds are the holders of every kinded bench it uses or watches, with what each promises: the one
|
||||||
|
// account of which channel is which, and what it can carry, that the router judges an answer by. The
|
||||||
|
// controller's, from the claims, never a channel's word (novox/hq ADR 0259 §5).
|
||||||
|
Kinds []KindHeld `json:"kinds,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KindHeld is one kind of a kinded bench and who holds it.
|
||||||
|
type KindHeld struct {
|
||||||
|
Seat string `json:"seat"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Module string `json:"module"`
|
||||||
|
Node string `json:"node"`
|
||||||
|
Capabilities []string `json:"capabilities,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KindedBenches are the seats that may be kinded (ADR 0234 §2): making another is a decision, recorded.
|
||||||
|
var KindedBenches = map[string]bool{"channel": true, "intake": true}
|
||||||
|
|
||||||
|
// WorkerName is the worker a seat's holders pull from: one for the seat, or one per kind on a kinded bench.
|
||||||
|
func WorkerName(seat, kind string) string {
|
||||||
|
if kind == "" {
|
||||||
|
return "SEAT_" + upperSnake(seat) + "_worker"
|
||||||
|
}
|
||||||
|
return "SEAT_" + upperSnake(seat) + "_" + upperSnake(kind) + "_worker"
|
||||||
|
}
|
||||||
|
|
||||||
|
func namesVerb(list []string, s string) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if x == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeatTrafficOf derives one module's seat traffic from the seats it holds, uses and watches. Only seats
|
||||||
|
// carrying one of the rules above are read: every other seat is composed as it always was.
|
||||||
|
func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic {
|
||||||
|
var t SeatTraffic
|
||||||
|
for _, s := range holds {
|
||||||
|
if !s.isNewTraffic() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kind := ""
|
||||||
|
if s.Kinded {
|
||||||
|
kind = s.Kind
|
||||||
|
if kind == "" || !safeSubject.MatchString(kind) {
|
||||||
|
// A kinded claim without a usable kind is refused at registration; here it is granted
|
||||||
|
// nothing, which is the same answer at the last place it could be asked.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(s.Accepts) > 0 {
|
||||||
|
stream := seatStreamName(s.Name)
|
||||||
|
filter := "mesh.seat." + s.Name + ".accept.>"
|
||||||
|
if kind != "" {
|
||||||
|
filter = "mesh.seat." + s.Name + ".accept.*." + kind
|
||||||
|
}
|
||||||
|
t.Workers = append(t.Workers, Worker{Stream: stream, Consumer: WorkerName(s.Name, kind), Filter: filter})
|
||||||
|
}
|
||||||
|
for _, a := range s.Accepts {
|
||||||
|
switch {
|
||||||
|
case kind != "":
|
||||||
|
t.Subscribe = append(t.Subscribe, seatSubject(s, "accept", a+"."+kind))
|
||||||
|
case namesVerb(s.ByCaller, a):
|
||||||
|
t.Subscribe = append(t.Subscribe, seatSubject(s, "accept", a+".*"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, e := range s.Emits {
|
||||||
|
switch {
|
||||||
|
case kind != "":
|
||||||
|
t.Publish = append(t.Publish, seatSubject(s, "event", e+"."+kind))
|
||||||
|
case namesVerb(s.ByCaller, e):
|
||||||
|
t.Publish = append(t.Publish, seatSubject(s, "event", e+".*"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kind != "" {
|
||||||
|
for _, v := range s.Proofs {
|
||||||
|
t.Publish = append(t.Publish, seatSubject(s, "proof", v+"."+kind))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range uses {
|
||||||
|
if !s.isNewTraffic() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, a := range s.Accepts {
|
||||||
|
switch {
|
||||||
|
case namesVerb(s.ByCaller, a):
|
||||||
|
t.Publish = append(t.Publish, seatSubject(s, "accept", a+"."+module))
|
||||||
|
case s.Kinded && module == s.DeclaredBy:
|
||||||
|
// Work for a kind is put on its queue by the bench's own router, and by no other user.
|
||||||
|
t.Publish = append(t.Publish, seatSubject(s, "accept", a+".*"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, e := range s.Emits {
|
||||||
|
if namesVerb(s.ByCaller, e) {
|
||||||
|
t.Subscribe = append(t.Subscribe, seatSubject(s, "event", e+"."+module))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, b := range s.Records {
|
||||||
|
if !safeSubject.MatchString(b) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Records = append(t.Records, "$JS.API.DIRECT.GET.KV_"+b+".$KV."+b+"."+module+".>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, w := range watches {
|
||||||
|
if w.Kinded {
|
||||||
|
for _, e := range w.Emits {
|
||||||
|
t.Subscribe = append(t.Subscribe, seatSubject(w, "event", e+".*"))
|
||||||
|
}
|
||||||
|
if module == w.DeclaredBy {
|
||||||
|
// A code is answered by the bench's own router, and by no other watcher.
|
||||||
|
for _, v := range w.Proofs {
|
||||||
|
t.Answers = append(t.Answers, seatSubject(w, "proof", v+".*"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Publish = unique(t.Publish)
|
||||||
|
t.Subscribe = unique(t.Subscribe)
|
||||||
|
t.Answers = unique(t.Answers)
|
||||||
|
t.Records = unique(t.Records)
|
||||||
|
sort.Slice(t.Workers, func(i, j int) bool { return t.Workers[i].Consumer < t.Workers[j].Consumer })
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// grants is the bus permissions seat traffic needs: the subjects themselves, and the JetStream API a
|
||||||
|
// worker is pulled and acknowledged through and a record is read through.
|
||||||
|
func (t SeatTraffic) grants() (pub, sub []string) {
|
||||||
|
pub = append(pub, t.Publish...)
|
||||||
|
sub = append(sub, t.Subscribe...)
|
||||||
|
sub = append(sub, t.Answers...)
|
||||||
|
for _, w := range t.Workers {
|
||||||
|
pub = append(pub,
|
||||||
|
"$JS.API.CONSUMER.INFO."+w.Stream+"."+w.Consumer,
|
||||||
|
"$JS.API.CONSUMER.MSG.NEXT."+w.Stream+"."+w.Consumer,
|
||||||
|
"$JS.ACK."+w.Stream+"."+w.Consumer+".>")
|
||||||
|
}
|
||||||
|
pub = append(pub, t.Records...)
|
||||||
|
return pub, sub
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNewTraffic says whether a seat carries any of the rules above, so a seat that carries none is
|
||||||
|
// composed exactly as before them.
|
||||||
|
func (s Seat) isNewTraffic() bool {
|
||||||
|
return s.Kinded || len(s.ByCaller) > 0 || len(s.Proofs) > 0 || len(s.Records) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// plainVerbs is a seat's accepts or emits with those the rules above compose taken out: a verb named by
|
||||||
|
// its caller and every verb of a kinded bench are composed by SeatTrafficOf and nowhere else.
|
||||||
|
func plainVerbs(s Seat, verbs []string) []string {
|
||||||
|
if s.Kinded {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, v := range verbs {
|
||||||
|
if !namesVerb(s.ByCaller, v) {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeatTrafficObjects is the work queues and workers the seat traffic of every composed user implies
|
||||||
|
// (novox/hq ADR 0259 §3): a queue for each seat a holder takes work from, and each holder's worker on it —
|
||||||
|
// one per kind on a kinded bench, filtered to that kind, so the kinds never take each other's work. Only
|
||||||
|
// seats carrying the rules above; the mesh's own seats' queues are RaiseSeats'.
|
||||||
|
func SeatTrafficObjects(users []Principal) ([]Stream, []Consumer) {
|
||||||
|
streams := map[string]Stream{}
|
||||||
|
consumers := map[string]Consumer{}
|
||||||
|
add := func(module string, holds []Seat) {
|
||||||
|
for _, w := range SeatTrafficOf(module, holds, nil, nil).Workers {
|
||||||
|
seat := ""
|
||||||
|
for _, s := range holds {
|
||||||
|
if seatStreamName(s.Name) == w.Stream {
|
||||||
|
seat = s.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
streams[w.Stream] = Stream{
|
||||||
|
Name: w.Stream,
|
||||||
|
Subjects: []string{"mesh.seat." + seat + ".accept.>"},
|
||||||
|
Retention: RetentionWorkQueue,
|
||||||
|
MaxAge: 7 * 24 * 60 * 60,
|
||||||
|
Why: "work submitted to the " + seat + " seat; its holders take it, each kind its own, and it queues while nobody does",
|
||||||
|
}
|
||||||
|
consumers[w.Consumer] = Consumer{
|
||||||
|
Name: w.Consumer,
|
||||||
|
Stream: w.Stream,
|
||||||
|
Filters: []string{w.Filter},
|
||||||
|
AckWaitSeconds: 60,
|
||||||
|
// No bound on redelivery: a channel away for a day keeps its work, offered again later and
|
||||||
|
// later by its holder's runtime (novox/hq ADR 0259; the correctness review of 2026-10-08).
|
||||||
|
MaxDeliver: 0,
|
||||||
|
Why: module + " holds " + seat + "; it pulls one ask at a time and acknowledges once it has " +
|
||||||
|
"recorded it, so a crash redelivers rather than loses",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range users {
|
||||||
|
switch p.Kind {
|
||||||
|
case KindModule:
|
||||||
|
add(p.Module, p.Holds)
|
||||||
|
case KindNodeTools:
|
||||||
|
for _, d := range p.Carries {
|
||||||
|
add(d.Module, d.Holds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var ss []Stream
|
||||||
|
for _, s := range streams {
|
||||||
|
ss = append(ss, s)
|
||||||
|
}
|
||||||
|
sort.Slice(ss, func(i, j int) bool { return ss[i].Name < ss[j].Name })
|
||||||
|
var cs []Consumer
|
||||||
|
for _, c := range consumers {
|
||||||
|
cs = append(cs, c)
|
||||||
|
}
|
||||||
|
sort.Slice(cs, func(i, j int) bool { return cs[i].Name < cs[j].Name })
|
||||||
|
return ss, cs
|
||||||
|
}
|
||||||
|
|
||||||
|
// trustedTraffic is why a module's seat traffic is the trusted holder's (novox/hq ADR 0259 §8), or "": it
|
||||||
|
// says a seat's event to one caller each (a warrant), or holds a kind of a kinded bench that proves its sender.
|
||||||
|
func trustedTraffic(d Declared) string {
|
||||||
|
for _, s := range d.Holds {
|
||||||
|
for _, e := range s.Emits {
|
||||||
|
if namesVerb(s.ByCaller, e) {
|
||||||
|
return "it says " + s.Name + "'s " + e + " to one caller each"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Kinded && namesVerb(s.Capabilities, "verified-sender") {
|
||||||
|
return "it holds " + s.Name + " of kind " + s.Kind + ", which proves its sender"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrafficQueues is the work queue of every seat naming its caller or its kind that accepts work, held or not
|
||||||
|
// (novox/hq ADR 0259 §3): what is submitted before a holder is assigned waits for it.
|
||||||
|
func TrafficQueues(seats []Seat) []Stream {
|
||||||
|
var out []Stream
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, s := range seats {
|
||||||
|
if len(s.Accepts) == 0 || !s.isNewTraffic() || seen[s.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[s.Name] = true
|
||||||
|
out = append(out, Stream{Name: seatStreamName(s.Name), Subjects: []string{"mesh.seat." + s.Name + ".accept.>"},
|
||||||
|
Retention: RetentionWorkQueue, MaxAge: 7 * 24 * 60 * 60,
|
||||||
|
Why: "work submitted to the " + s.Name + " seat; its holders take it, each kind its own, and it queues while nobody does"})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
package broker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The seats of novox/hq ADR 0259 §3, as the messenger declares them.
|
||||||
|
func operatorChannel() Seat {
|
||||||
|
return Seat{Name: "operator-channel", Scope: "mesh", Accepts: []string{"ask", "cancel"},
|
||||||
|
Emits: []string{"decided"}, Serves: []string{"open", "history", "notify"},
|
||||||
|
ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"messenger_asks"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func channelSeat(kind string) Seat {
|
||||||
|
return Seat{Name: "channel", Scope: "mesh", Accepts: []string{"show", "edit", "send"}, Kinded: true, Kind: kind,
|
||||||
|
DeclaredBy: "messenger"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func intakeSeat(kind string) Seat {
|
||||||
|
return Seat{Name: "intake", Scope: "mesh", Emits: []string{"choice", "link"}, Proofs: []string{"code"},
|
||||||
|
Kinded: true, Kind: kind, DeclaredBy: "messenger"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowed(patterns []string, subject string) bool {
|
||||||
|
for _, p := range patterns {
|
||||||
|
if subjectMatches(p, subject) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func perms(t *testing.T, p Principal) Permissions {
|
||||||
|
t.Helper()
|
||||||
|
got, err := PermissionsFor(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAskerAsksAndHearsUnderItsOwnNameOnly(t *testing.T) {
|
||||||
|
asker := Principal{Kind: KindModule, Node: "anchor", Module: "mesh-delivery", Uses: []Seat{operatorChannel()}}
|
||||||
|
got := perms(t, asker)
|
||||||
|
for _, s := range []string{
|
||||||
|
"mesh.seat.operator-channel.accept.ask.mesh-delivery",
|
||||||
|
"mesh.seat.operator-channel.accept.cancel.mesh-delivery",
|
||||||
|
"$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.mesh-delivery.a1",
|
||||||
|
} {
|
||||||
|
if !allowed(got.Publish, s) {
|
||||||
|
t.Errorf("an asker may not publish %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range []string{
|
||||||
|
"mesh.seat.operator-channel.accept.ask.mesh-controller",
|
||||||
|
"mesh.seat.operator-channel.accept.ask.*",
|
||||||
|
"mesh.seat.operator-channel.event.decided.mesh-delivery",
|
||||||
|
"$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.mesh-controller.a1",
|
||||||
|
"$KV.messenger_asks.mesh-delivery.a1",
|
||||||
|
} {
|
||||||
|
if allowed(got.Publish, s) {
|
||||||
|
t.Errorf("an asker may publish %s, which is not its own to submit", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowed(got.Subscribe, "mesh.seat.operator-channel.event.decided.mesh-delivery") {
|
||||||
|
t.Error("an asker does not hear its own warrants")
|
||||||
|
}
|
||||||
|
if allowed(got.Subscribe, "mesh.seat.operator-channel.event.decided.mesh-controller") {
|
||||||
|
t.Error("an asker hears another asker's warrants")
|
||||||
|
}
|
||||||
|
// And its own consumer carries its warrants, so a restart catches up.
|
||||||
|
c, ok := ConsumerFor(asker)
|
||||||
|
if !ok || !allowed(c.Filters, "mesh.seat.operator-channel.event.decided.mesh-delivery") {
|
||||||
|
t.Errorf("the asker's consumer does not carry its warrants: %v", c.Filters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlyTheHolderPublishesAWarrant(t *testing.T) {
|
||||||
|
users, err := Users(Records{
|
||||||
|
Nodes: []string{"anchor"},
|
||||||
|
Assigned: map[string][]Declared{"anchor": {
|
||||||
|
{Module: "messenger", Holds: []Seat{operatorChannel()}, Uses: []Seat{channelSeat("")},
|
||||||
|
Watches: []Seat{{Name: "intake", Emits: []string{"choice", "link"}, Kinded: true, Proofs: []string{"code"}, DeclaredBy: "messenger"}}},
|
||||||
|
{Module: "mesh-delivery", Uses: []Seat{operatorChannel()}},
|
||||||
|
{Module: "telegram", Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, u := range users {
|
||||||
|
got := perms(t, u)
|
||||||
|
says := allowed(got.Publish, "mesh.seat.operator-channel.event.decided.mesh-delivery")
|
||||||
|
if says != (u.Module == "messenger") {
|
||||||
|
t.Errorf("%s %s publish a warrant", u.Username(), map[bool]string{true: "may", false: "may not"}[says])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheHolderTakesEveryCallersAskThroughItsWorker(t *testing.T) {
|
||||||
|
got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "messenger", Holds: []Seat{operatorChannel()}})
|
||||||
|
if !allowed(got.Subscribe, "mesh.seat.operator-channel.accept.ask.mesh-delivery") {
|
||||||
|
t.Error("the router does not take an ask")
|
||||||
|
}
|
||||||
|
for _, s := range []string{
|
||||||
|
"$JS.API.CONSUMER.MSG.NEXT.SEAT_OPERATOR_CHANNEL.SEAT_OPERATOR_CHANNEL_worker",
|
||||||
|
"$JS.ACK.SEAT_OPERATOR_CHANNEL.SEAT_OPERATOR_CHANNEL_worker.x",
|
||||||
|
"mesh.seat.operator-channel.event.decided.mesh-controller",
|
||||||
|
} {
|
||||||
|
if !allowed(got.Publish, s) {
|
||||||
|
t.Errorf("the router may not publish %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allowed(got.Publish, "mesh.seat.operator-channel.accept.ask.messenger") {
|
||||||
|
t.Error("the holder may ask its own seat without using it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAKindedHolderReachesItsOwnKindAndNoOther(t *testing.T) {
|
||||||
|
got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "telegram",
|
||||||
|
Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}})
|
||||||
|
for _, s := range []string{
|
||||||
|
"mesh.seat.intake.event.choice.telegram", "mesh.seat.intake.event.link.telegram",
|
||||||
|
"mesh.seat.intake.proof.code.telegram",
|
||||||
|
"$JS.API.CONSUMER.MSG.NEXT.SEAT_CHANNEL.SEAT_CHANNEL_TELEGRAM_worker",
|
||||||
|
} {
|
||||||
|
if !allowed(got.Publish, s) {
|
||||||
|
t.Errorf("telegram may not publish %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range []string{
|
||||||
|
"mesh.seat.intake.event.choice.desktop", "mesh.seat.intake.proof.code.desktop",
|
||||||
|
"mesh.seat.channel.accept.show.telegram",
|
||||||
|
"$JS.API.CONSUMER.MSG.NEXT.SEAT_CHANNEL.SEAT_CHANNEL_DESKTOP_worker",
|
||||||
|
"mesh.seat.operator-channel.event.decided.mesh-delivery",
|
||||||
|
} {
|
||||||
|
if allowed(got.Publish, s) {
|
||||||
|
t.Errorf("telegram may publish %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowed(got.Subscribe, "mesh.seat.channel.accept.show.telegram") ||
|
||||||
|
allowed(got.Subscribe, "mesh.seat.channel.accept.show.desktop") {
|
||||||
|
t.Error("telegram does not take exactly its own kind's work")
|
||||||
|
}
|
||||||
|
if allowed(got.Subscribe, "mesh.seat.intake.proof.code.telegram") {
|
||||||
|
t.Error("a channel answers its own proofs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheWatcherAnswersProofsAndHearsEveryKind(t *testing.T) {
|
||||||
|
got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "messenger",
|
||||||
|
Uses: []Seat{channelSeat("")},
|
||||||
|
Watches: []Seat{{Name: "intake", Emits: []string{"choice"}, Kinded: true, Proofs: []string{"code"}, DeclaredBy: "messenger"}}})
|
||||||
|
for _, s := range []string{"mesh.seat.intake.event.choice.telegram", "mesh.seat.intake.proof.code.desktop"} {
|
||||||
|
if !allowed(got.Subscribe, s) {
|
||||||
|
t.Errorf("the router does not hear %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowed(got.Publish, "mesh.seat.channel.accept.show.telegram") {
|
||||||
|
t.Error("the router cannot send a channel its work")
|
||||||
|
}
|
||||||
|
if allowed(got.Publish, "mesh.seat.intake.event.choice.telegram") || allowed(got.Publish, "mesh.seat.intake.proof.code.telegram") {
|
||||||
|
t.Error("the router may say a channel's answer or proof")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoStreamKeepsAProof(t *testing.T) {
|
||||||
|
users, _ := Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": {
|
||||||
|
{Module: "telegram", Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}},
|
||||||
|
{Module: "messenger", Holds: []Seat{operatorChannel()}},
|
||||||
|
}}})
|
||||||
|
streams, _ := SeatTrafficObjects(users)
|
||||||
|
streams = append(streams, MeshStreams()...)
|
||||||
|
for _, s := range streams {
|
||||||
|
for _, subject := range s.Subjects {
|
||||||
|
if subjectMatches(subject, "mesh.seat.intake.proof.code.telegram") {
|
||||||
|
t.Errorf("%s keeps a proof (%s)", s.Name, subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEachKindHasAWorkerOfItsOwn(t *testing.T) {
|
||||||
|
users, _ := Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": {
|
||||||
|
{Module: "telegram", Holds: []Seat{channelSeat("telegram")}},
|
||||||
|
{Module: "desk-channel", Holds: []Seat{channelSeat("desktop")}},
|
||||||
|
{Module: "messenger", Holds: []Seat{operatorChannel()}},
|
||||||
|
}}})
|
||||||
|
streams, workers := SeatTrafficObjects(users)
|
||||||
|
names := map[string]string{}
|
||||||
|
for _, w := range workers {
|
||||||
|
names[w.Name] = strings.Join(w.Filters, ",")
|
||||||
|
}
|
||||||
|
want := map[string]string{
|
||||||
|
"SEAT_CHANNEL_TELEGRAM_worker": "mesh.seat.channel.accept.*.telegram",
|
||||||
|
"SEAT_CHANNEL_DESKTOP_worker": "mesh.seat.channel.accept.*.desktop",
|
||||||
|
"SEAT_OPERATOR_CHANNEL_worker": "mesh.seat.operator-channel.accept.>",
|
||||||
|
}
|
||||||
|
for n, f := range want {
|
||||||
|
if names[n] != f {
|
||||||
|
t.Errorf("worker %s filters %q, want %q", n, names[n], f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(streams) != 2 {
|
||||||
|
t.Errorf("want the queues of channel and operator-channel, got %v", streams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheRuntimeIsGrantedTheUnionAndTheMembershipEachModulesShare(t *testing.T) {
|
||||||
|
telegram := Declared{Module: "telegram", Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}}
|
||||||
|
desk := Declared{Module: "desk-channel", Holds: []Seat{channelSeat("desktop"), intakeSeat("desktop")}}
|
||||||
|
got := perms(t, Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule, Carries: []Declared{telegram, desk}})
|
||||||
|
for _, s := range []string{"mesh.seat.intake.event.choice.telegram", "mesh.seat.intake.event.choice.desktop"} {
|
||||||
|
if !allowed(got.Publish, s) {
|
||||||
|
t.Errorf("the runtime may not publish %s for a module it carries", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m := MembershipFor("anchor", telegram, Placements{})
|
||||||
|
if m.SeatTraffic == nil || !allowed(m.SeatTraffic.Publish, "mesh.seat.intake.event.choice.telegram") ||
|
||||||
|
allowed(m.SeatTraffic.Publish, "mesh.seat.intake.event.choice.desktop") {
|
||||||
|
t.Errorf("telegram's membership does not list exactly its own kind: %+v", m.SeatTraffic)
|
||||||
|
}
|
||||||
|
if plain := MembershipFor("anchor", Declared{Module: "plain"}, Placements{}); plain.SeatTraffic != nil {
|
||||||
|
t.Error("a module with no such seat is given seat traffic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASeatWithoutTheNewRulesIsComposedAsBefore(t *testing.T) {
|
||||||
|
old := Seat{Name: "node-build-agent", Scope: "node", Accepts: []string{"build"}, Emits: []string{"built"}}
|
||||||
|
got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "builder", Holds: []Seat{old}})
|
||||||
|
for _, s := range []string{"mesh.seat.node-build-agent.event.built",
|
||||||
|
"$JS.API.CONSUMER.MSG.NEXT.SEAT_NODE_BUILD_AGENT.SEAT_NODE_BUILD_AGENT_worker"} {
|
||||||
|
if !allowed(got.Publish, s) {
|
||||||
|
t.Errorf("an old seat's holder lost %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowed(got.Subscribe, "mesh.seat.node-build-agent.accept.build") {
|
||||||
|
t.Error("an old seat's holder lost its accept")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The router learns which channel is which, and what each promises, from the controller's membership:
|
||||||
|
// the claims, never a channel's word (ADR 0259 §5).
|
||||||
|
func TestTheRoutersMembershipNamesEveryKindAndItsCapabilities(t *testing.T) {
|
||||||
|
tg := channelSeat("telegram")
|
||||||
|
tg.Capabilities = []string{"choice", "verified-sender"}
|
||||||
|
desk := channelSeat("desktop")
|
||||||
|
desk.Capabilities = []string{"choice"}
|
||||||
|
router := Declared{Module: "messenger", Holds: []Seat{operatorChannel()}, Uses: []Seat{channelSeat("")}}
|
||||||
|
records := Records{Nodes: []string{"anchor", "laptop"}, Assigned: map[string][]Declared{
|
||||||
|
"anchor": {router, {Module: "telegram", Holds: []Seat{tg}, RunsAs: "telegram"}},
|
||||||
|
"laptop": {{Module: "desk-channel", Holds: []Seat{desk}}},
|
||||||
|
}, RootFree: map[string]bool{"anchor": true}}
|
||||||
|
where := PlacementsOf(records, nil)
|
||||||
|
m := MembershipFor("anchor", router, where)
|
||||||
|
if m.SeatTraffic == nil || len(m.SeatTraffic.Kinds) != 2 {
|
||||||
|
t.Fatalf("the router is not told the kinds: %+v", m.SeatTraffic)
|
||||||
|
}
|
||||||
|
byKind := map[string]KindHeld{}
|
||||||
|
for _, k := range m.SeatTraffic.Kinds {
|
||||||
|
byKind[k.Kind] = k
|
||||||
|
}
|
||||||
|
if k := byKind["telegram"]; k.Module != "telegram" || k.Node != "anchor" || !namesVerb(k.Capabilities, "verified-sender") {
|
||||||
|
t.Errorf("telegram is %+v", k)
|
||||||
|
}
|
||||||
|
if k := byKind["desktop"]; k.Module != "desk-channel" || namesVerb(k.Capabilities, "verified-sender") {
|
||||||
|
t.Errorf("the desk is %+v", k)
|
||||||
|
}
|
||||||
|
if other := MembershipFor("anchor", Declared{Module: "mesh-delivery", Uses: []Seat{operatorChannel()}}, where); other.SeatTraffic != nil && len(other.SeatTraffic.Kinds) > 0 {
|
||||||
|
t.Error("an asker is told the channels")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0259 §8: only the bench's own router answers its proofs and puts work on a kind's queue.
|
||||||
|
func TestOnlyTheBenchsRouterAnswersProofsAndSubmitsWork(t *testing.T) {
|
||||||
|
other := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "eavesdropper",
|
||||||
|
Uses: []Seat{channelSeat("")},
|
||||||
|
Watches: []Seat{{Name: "intake", Emits: []string{"choice"}, Kinded: true, Proofs: []string{"code"}, DeclaredBy: "messenger"}}})
|
||||||
|
if allowed(other.Subscribe, "mesh.seat.intake.proof.code.telegram") {
|
||||||
|
t.Error("a watcher that is not the router answers codes")
|
||||||
|
}
|
||||||
|
if allowed(other.Publish, "mesh.seat.channel.accept.show.telegram") {
|
||||||
|
t.Error("a user that is not the router puts work on a kind's queue")
|
||||||
|
}
|
||||||
|
if !allowed(other.Subscribe, "mesh.seat.intake.event.choice.telegram") {
|
||||||
|
t.Error("a watcher no longer hears the bench's events")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0259 §8: the machine's runtime runs as the operator's account; it never carries a module
|
||||||
|
// that says warrants or speaks for a kind proving its sender, and such a module has its own account.
|
||||||
|
func TestTheMachinesRuntimeNeverCarriesATrustedHolder(t *testing.T) {
|
||||||
|
tg := channelSeat("telegram")
|
||||||
|
tg.Capabilities = []string{"choice", "verified-sender"}
|
||||||
|
for name, d := range map[string]Declared{
|
||||||
|
"the router": {Module: "messenger", Holds: []Seat{operatorChannel()}},
|
||||||
|
"a verified channel": {Module: "telegram", Holds: []Seat{tg}},
|
||||||
|
} {
|
||||||
|
if _, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule,
|
||||||
|
Carries: []Declared{d}}); err == nil || !strings.Contains(err.Error(), "an account of its own") {
|
||||||
|
t.Errorf("%s was composed into the machine's runtime: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
desk := channelSeat("desktop")
|
||||||
|
desk.Capabilities = []string{"choice"}
|
||||||
|
if _, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule,
|
||||||
|
Carries: []Declared{{Module: "desk-channel", Holds: []Seat{desk}}}}); err != nil {
|
||||||
|
t.Errorf("a channel proving nothing was refused: %v", err)
|
||||||
|
}
|
||||||
|
users, err := Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": {
|
||||||
|
{Module: RuntimeModule}, {Module: "telegram", Holds: []Seat{tg}, RunsAs: "telegram"},
|
||||||
|
{Module: "messenger", Holds: []Seat{operatorChannel()}, RunsAs: "messenger"},
|
||||||
|
}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, u := range users {
|
||||||
|
if u.Kind == KindNodeTools {
|
||||||
|
for _, d := range u.Carries {
|
||||||
|
if d.RunsAs != "" {
|
||||||
|
t.Errorf("the machine's runtime carries %s", d.Module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := PermissionsFor(u); err != nil {
|
||||||
|
t.Errorf("the runtime could not be composed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0259 §8: verified-sender reaches the router only from a holder of its own account, on a machine
|
||||||
|
// root-free when composed, with the router's own machine root-free too (the review of 2026-10-09, H3).
|
||||||
|
func TestVerifiedSenderIsBelievedOnlyFromAHolderOfItsOwnAccount(t *testing.T) {
|
||||||
|
if got := placedCapabilities([]string{"choice", "verified-sender"}, "", true); namesVerb(got, "verified-sender") {
|
||||||
|
t.Errorf("a carried holder keeps verified-sender: %v", got)
|
||||||
|
}
|
||||||
|
if got := placedCapabilities([]string{"choice", "verified-sender"}, "telegram", true); !namesVerb(got, "verified-sender") {
|
||||||
|
t.Errorf("a holder of its own account on a root-free machine lost verified-sender: %v", got)
|
||||||
|
}
|
||||||
|
if got := placedCapabilities([]string{"choice", "verified-sender"}, "telegram", false); namesVerb(got, "verified-sender") ||
|
||||||
|
!namesVerb(got, "choice") {
|
||||||
|
t.Errorf("a holder on a machine not root-free keeps verified-sender, or lost the rest: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The kinds the router is told carry verified-sender only while the channel's machine and the router's are
|
||||||
|
// both root-free as composed; no record of a pass is no pass, and neither is a router placed nowhere.
|
||||||
|
func TestVerifiedSenderNeedsTheChannelsAndTheRoutersMachinesRootFree(t *testing.T) {
|
||||||
|
tg := channelSeat("telegram")
|
||||||
|
tg.Capabilities = []string{"choice", "verified-sender"}
|
||||||
|
router := Declared{Module: "messenger", Holds: []Seat{operatorChannel()}, RunsAs: "messenger"}
|
||||||
|
telegram := Declared{Module: "telegram", Holds: []Seat{tg}, RunsAs: "telegram"}
|
||||||
|
verified := func(r Records) bool {
|
||||||
|
for _, k := range PlacementsOf(r, nil).Kinds {
|
||||||
|
if k.Kind == "telegram" {
|
||||||
|
return namesVerb(k.Capabilities, "verified-sender")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("telegram not placed")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
same := func(free map[string]bool) Records {
|
||||||
|
return Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": {router, telegram}}, RootFree: free}
|
||||||
|
}
|
||||||
|
apart := func(free map[string]bool) Records {
|
||||||
|
return Records{Nodes: []string{"anchor", "relay"},
|
||||||
|
Assigned: map[string][]Declared{"anchor": {router}, "relay": {telegram}}, RootFree: free}
|
||||||
|
}
|
||||||
|
if !verified(same(map[string]bool{"anchor": true})) {
|
||||||
|
t.Error("both on one root-free machine: verified-sender withheld")
|
||||||
|
}
|
||||||
|
if verified(same(nil)) {
|
||||||
|
t.Error("no record of a pass, and verified-sender kept")
|
||||||
|
}
|
||||||
|
if verified(apart(map[string]bool{"relay": true})) {
|
||||||
|
t.Error("the router's machine not root-free, and verified-sender kept")
|
||||||
|
}
|
||||||
|
if verified(apart(map[string]bool{"anchor": true})) {
|
||||||
|
t.Error("the channel's machine not root-free, and verified-sender kept")
|
||||||
|
}
|
||||||
|
if !verified(apart(map[string]bool{"anchor": true, "relay": true})) {
|
||||||
|
t.Error("both machines root-free: verified-sender withheld")
|
||||||
|
}
|
||||||
|
noRouter := Records{Nodes: []string{"relay"}, Assigned: map[string][]Declared{"relay": {telegram}},
|
||||||
|
RootFree: map[string]bool{"relay": true}}
|
||||||
|
if verified(noRouter) {
|
||||||
|
t.Error("no router placed, and verified-sender kept")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -363,8 +363,19 @@ var ControllerFollows = []string{
|
|||||||
// seat to check before it merges — every machine of the facts snapshot composed with the change.
|
// seat to check before it merges — every machine of the facts snapshot composed with the change.
|
||||||
// Appended, because the index is a name.
|
// Appended, because the index is a name.
|
||||||
moduleEventSubject("gitea", "pull.updated"),
|
moduleEventSubject("gitea", "pull.updated"),
|
||||||
|
// **The operator's answers to what the controller asked** (novox/hq ADR 0259): the router's warrant, or
|
||||||
|
// the end of an ask without one, said to the controller alone under its own name. On the stream, so a
|
||||||
|
// controller that was away hears what was decided meanwhile. Appended, because the index is a name.
|
||||||
|
DecidedSubject,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AsksSeat is the seat an ask is made on and its warrant heard from (novox/hq ADR 0259): the router's.
|
||||||
|
const AsksSeat = "operator-channel"
|
||||||
|
|
||||||
|
// DecidedSubject is where the router says the controller's warrants: the seat's event named by the
|
||||||
|
// controller as its caller.
|
||||||
|
var DecidedSubject = seatEventSubject(AsksSeat, "decided."+ControllerSeat)
|
||||||
|
|
||||||
// The provider standing events, by their local names. Written here as well as in the catalogue
|
// The provider standing events, by their local names. Written here as well as in the catalogue
|
||||||
// (catalogue.ProvisionerEvents), which this package cannot import; a test keeps them agreeing.
|
// (catalogue.ProvisionerEvents), which this package cannot import; a test keeps them agreeing.
|
||||||
const (
|
const (
|
||||||
|
|||||||
+2
-2
@@ -24,8 +24,8 @@ accounts {
|
|||||||
jetstream: enabled
|
jetstream: enabled
|
||||||
users = [
|
users = [
|
||||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*"] }
|
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-service-manager.tool.restart.*"] }
|
||||||
subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built"] }
|
subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] }
|
||||||
allow_responses: { max: 1, ttl: "1m" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
|
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ type Declared struct {
|
|||||||
// Checks are the module's own tools its health asks, each `<module>.<tool>` (novox/hq ADR 0240, to-be
|
// Checks are the module's own tools its health asks, each `<module>.<tool>` (novox/hq ADR 0240, to-be
|
||||||
// 48 §3): the machine's node-engine asks them of its own node tools, and is granted that and no more.
|
// 48 §3): the machine's node-engine asks them of its own node tools, and is granted that and no more.
|
||||||
Checks []string
|
Checks []string
|
||||||
|
// RunsAs is the account the module runs as in a runtime of its own (novox/hq ADR 0259 §8): it is never
|
||||||
|
// carried by the machine's runtime, and reaches the bus on its own account.
|
||||||
|
RunsAs string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Records is what composing a user list needs to know about the mesh, and nothing more.
|
// Records is what composing a user list needs to know about the mesh, and nothing more.
|
||||||
@@ -67,6 +70,10 @@ type Records struct {
|
|||||||
// Interchangeable is each module whose definition says its instances are the same anywhere
|
// Interchangeable is each module whose definition says its instances are the same anywhere
|
||||||
// (ADR 0160), which decides whether the module's plain subject is issued to every instance.
|
// (ADR 0160), which decides whether the module's plain subject is issued to every instance.
|
||||||
Interchangeable map[string]bool
|
Interchangeable map[string]bool
|
||||||
|
// RootFree is each machine judged root-free when this was composed (novox/hq ADR 0259 §8): it names an
|
||||||
|
// account agents run as, judged unable to become root by its node-engine, and serves no login shell
|
||||||
|
// execute. A machine absent is not free: no record of a pass is no pass.
|
||||||
|
RootFree map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Users is every user the composed file should contain, in the order it will be written.
|
// Users is every user the composed file should contain, in the order it will be written.
|
||||||
@@ -75,7 +82,7 @@ type Records struct {
|
|||||||
// is a mesh that cannot be told anything, and there is no state of the records in which that is
|
// is a mesh that cannot be told anything, and there is no state of the records in which that is
|
||||||
// correct.
|
// correct.
|
||||||
func Users(r Records) ([]Principal, error) {
|
func Users(r Records) ([]Principal, error) {
|
||||||
out := []Principal{{Kind: KindController}}
|
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r)}}
|
||||||
|
|
||||||
for _, node := range sortedCopy(r.Nodes) {
|
for _, node := range sortedCopy(r.Nodes) {
|
||||||
witness := false
|
witness := false
|
||||||
@@ -120,10 +127,13 @@ func Users(r Records) ([]Principal, error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if runtimeHere {
|
if runtimeHere {
|
||||||
out = append(out, Principal{
|
var carried []Declared
|
||||||
Kind: KindNodeTools, Node: node, Module: RuntimeModule,
|
for _, d := range r.Assigned[node] {
|
||||||
Carries: append([]Declared(nil), r.Assigned[node]...),
|
if d.RunsAs == "" {
|
||||||
})
|
carried = append(carried, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, Principal{Kind: KindNodeTools, Node: node, Module: RuntimeModule, Carries: carried})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, node := range sortedCopy(r.Enrolling) {
|
for _, node := range sortedCopy(r.Enrolling) {
|
||||||
@@ -189,3 +199,21 @@ func sortedNames(in map[string][]string) []string {
|
|||||||
|
|
||||||
// controllerModule is the controller's module: the machine assigned it witnesses its upgrades.
|
// controllerModule is the controller's module: the machine assigned it witnesses its upgrades.
|
||||||
const controllerModule = "mesh-controller"
|
const controllerModule = "mesh-controller"
|
||||||
|
|
||||||
|
// asksSeatOf is the seat an ask is made on, as its holder declares it (novox/hq ADR 0259): the controller
|
||||||
|
// asks the operator through it like any other user, and is granted what its declaration names for a caller.
|
||||||
|
// None while nothing holds it.
|
||||||
|
func asksSeatOf(r Records) []Seat {
|
||||||
|
for _, node := range sortedCopy(r.Nodes) {
|
||||||
|
for _, d := range r.Assigned[node] {
|
||||||
|
for _, s := range d.Holds {
|
||||||
|
if s.Name == AsksSeat && namesVerb(s.ByCaller, "ask") {
|
||||||
|
seat := s
|
||||||
|
seat.Kind, seat.Capabilities = "", nil
|
||||||
|
return []Seat{seat}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,8 +87,10 @@ var WritersTable = []WriterRow{
|
|||||||
{State: "a machine's applied state and its report", Writer: "the node-engine's apply queue",
|
{State: "a machine's applied state and its report", Writer: "the node-engine's apply queue",
|
||||||
KeptIn: "the machine; the report on the bus", Others: "the reconcile and a delivery enqueue, never apply",
|
KeptIn: "the machine; the report on the bus", Others: "the reconcile and a delivery enqueue, never apply",
|
||||||
// And its health statement between reports (novox/hq ADR 0240): the same writer stating the same
|
// And its health statement between reports (novox/hq ADR 0240): the same writer stating the same
|
||||||
// machine, inside the grant it already had (`mesh.control.<its own>.>`).
|
// machine, inside the grant it already had (`mesh.control.<its own>.>`). And a line mesh-cli was given
|
||||||
Subjects: []string{"mesh.control.*.report", "mesh.control.*.health"}, Writes: ownMachine},
|
// on the machine (novox/hq ADR 0272): the node is what the controller judges the terminal by, so that
|
||||||
|
// subject is this machine's engine's alone.
|
||||||
|
Subjects: []string{"mesh.control.*.report", "mesh.control.*.health", "mesh.control.*.cli"}, Writes: ownMachine},
|
||||||
{State: "the controller lease", Writer: "the controller instance holding it", KeptIn: "key-value " + LeaseBucket,
|
{State: "the controller lease", Writer: "the controller instance holding it", KeptIn: "key-value " + LeaseBucket,
|
||||||
Others: "a candidate waits", Subjects: kvOf(LeaseBucket), Writes: isController},
|
Others: "a candidate waits", Subjects: kvOf(LeaseBucket), Writes: isController},
|
||||||
{State: "plans and their tiers", Writer: "controller (lease holder), compare-and-set on the plan's revision",
|
{State: "plans and their tiers", Writer: "controller (lease holder), compare-and-set on the plan's revision",
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ func TestASecondWriterIsRefusedAtComposition(t *testing.T) {
|
|||||||
[]string{"mesh.control.>"}, "a machine's applied state and its report"},
|
[]string{"mesh.control.>"}, "a machine's applied state and its report"},
|
||||||
{"a machine publishing another's report", Principal{Kind: KindNode, Node: "one"},
|
{"a machine publishing another's report", Principal{Kind: KindNode, Node: "one"},
|
||||||
[]string{"mesh.control.two.report"}, "a machine's applied state and its report"},
|
[]string{"mesh.control.two.report"}, "a machine's applied state and its report"},
|
||||||
|
{"a machine asking the controller as another (mesh-cli, ADR 0272)", Principal{Kind: KindNode, Node: "one"},
|
||||||
|
[]string{"mesh.control.two.cli"}, "a machine's applied state and its report"},
|
||||||
|
{"a module asking the controller as a machine (mesh-cli, ADR 0272)", Principal{Kind: KindModule, Module: "shop"},
|
||||||
|
[]string{"mesh.control.one.cli"}, "a machine's applied state and its report"},
|
||||||
{"a machine publishing every machine's", Principal{Kind: KindNode, Node: "one"},
|
{"a machine publishing every machine's", Principal{Kind: KindNode, Node: "one"},
|
||||||
[]string{"mesh.control.*.>"}, "a machine's applied state and its report"},
|
[]string{"mesh.control.*.>"}, "a machine's applied state and its report"},
|
||||||
{"a module writing the lease", Principal{Kind: KindModule, Module: "shop"},
|
{"a module writing the lease", Principal{Kind: KindModule, Module: "shop"},
|
||||||
@@ -115,6 +119,7 @@ func TestASecondWriterIsRefusedAtComposition(t *testing.T) {
|
|||||||
publish []string
|
publish []string
|
||||||
}{
|
}{
|
||||||
{Principal{Kind: KindNode, Node: "one"}, []string{"mesh.control.one.>"}},
|
{Principal{Kind: KindNode, Node: "one"}, []string{"mesh.control.one.>"}},
|
||||||
|
{Principal{Kind: KindNode, Node: "one"}, []string{"mesh.control.one.cli"}},
|
||||||
{Principal{Kind: KindController}, []string{"mesh.node.>", "$JS.API.>", "$KV.mesh-controller_lease.>"}},
|
{Principal{Kind: KindController}, []string{"mesh.node.>", "$JS.API.>", "$KV.mesh-controller_lease.>"}},
|
||||||
{Principal{Kind: KindModule, Module: "gitea"}, []string{"mesh.mod.gitea.event.pull.merged"}},
|
{Principal{Kind: KindModule, Module: "gitea"}, []string{"mesh.mod.gitea.event.pull.merged"}},
|
||||||
{Principal{Kind: KindModule, Module: "shop"}, []string{"$JS.API.CONSUMER.CREATE.KV_shop_carts.>"}},
|
{Principal{Kind: KindModule, Module: "shop"}, []string{"$JS.API.CONSUMER.CREATE.KV_shop_carts.>"}},
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The account agents run as (novox/hq ADR 0266): a module names it as a machine fact — the agent account
|
||||||
|
// where the node names one, the operator's otherwise — and asks the node-engine to judge it never to become
|
||||||
|
// root only where it is the agents' own.
|
||||||
|
|
||||||
|
func TestTheAgentAccountFactFallsBackToTheOperatorAndIsNeverRootOnlyWhenItsOwn(t *testing.T) {
|
||||||
|
facts := machineFacts(Resolution{Node: "anchor", Account: "ops"}, nil, "")
|
||||||
|
if facts["agent-account"] != "ops" || facts["agent-home"] != "/home/ops" || facts["agent-root"] != "" {
|
||||||
|
t.Errorf("with no agent account named, agents run as the operator: %v", facts)
|
||||||
|
}
|
||||||
|
facts = machineFacts(Resolution{Node: "anchor", Account: "ops", AccountHome: "/srv/ops"}, nil, "")
|
||||||
|
if facts["agent-home"] != "/srv/ops" {
|
||||||
|
t.Errorf("the operator's stated home is the agent's home when they are one account: %v", facts)
|
||||||
|
}
|
||||||
|
facts = machineFacts(Resolution{Node: "anchor", Account: "ops", AgentAccount: "agent"}, nil, "")
|
||||||
|
if facts["agent-account"] != "agent" || facts["agent-home"] != "/home/agent" || facts["agent-root"] != RootNever {
|
||||||
|
t.Errorf("a named agent account is the agents', never root: %v", facts)
|
||||||
|
}
|
||||||
|
if facts["account"] != "ops" {
|
||||||
|
t.Errorf("the operator account is still the operator's: %v", facts)
|
||||||
|
}
|
||||||
|
facts = machineFacts(Resolution{Node: "anchor", AgentAccount: "agent", AgentAccountHome: "/var/lib/agent"}, nil, "")
|
||||||
|
if facts["agent-home"] != "/var/lib/agent" || facts["agent-root"] != RootNever {
|
||||||
|
t.Errorf("an agent account with a stated home on a machine with no operator: %v", facts)
|
||||||
|
}
|
||||||
|
if _, has := machineFacts(Resolution{Node: "anchor"}, nil, "")["agent-account"]; has {
|
||||||
|
t.Error("a machine with no account at all names an agent account")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The agent's module, in the shape the catalogue's declares it: the account, never root where it is its
|
||||||
|
// own; its directory under that home, owned by it.
|
||||||
|
const agentModule = `{"module": "agent", "version": "1", "resources": [
|
||||||
|
{"id": "account", "type": "user", "name": "${machine:agent-account}", "home": "${machine:agent-home}",
|
||||||
|
"root": "${machine:agent-root}"},
|
||||||
|
{"id": "home", "type": "directory", "path": "${machine:agent-home}/.agent", "mode": "0700",
|
||||||
|
"owner": "${machine:agent-account}"}
|
||||||
|
]}`
|
||||||
|
|
||||||
|
func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing.T) {
|
||||||
|
m, err := ParseManifest([]byte(agentModule))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
compose := func(r Resolution, with Rendering) (user, home map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
r.Node, r.Modules = "anchor", []Manifest{m}
|
||||||
|
out, err := r.Declaration(with)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return fileNamed(out, "agent.account"), fileNamed(out, "agent.home")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, home := compose(Resolution{Account: "ops", AgentAccount: "agent"}, Rendering{JudgesRoot: true})
|
||||||
|
if user["name"] != "agent" || user[RootField] != RootNever || user["home"] != "/home/agent" {
|
||||||
|
t.Errorf("an engine that judges root is sent the agent account never to become root: %v", user)
|
||||||
|
}
|
||||||
|
if home["path"] != "/home/agent/.agent" || home["owner"] != "agent" {
|
||||||
|
t.Errorf("the agent's directory is under its own home, its own: %v", home)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, _ = compose(Resolution{Account: "ops", AgentAccount: "agent", AgentAccountHome: "/srv/agent"}, Rendering{JudgesRoot: true})
|
||||||
|
if user["home"] != "/srv/agent" {
|
||||||
|
t.Errorf("an agent account named with a home of its own is made there: %v", user)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, _ = compose(Resolution{Account: "ops", AgentAccount: "agent"}, Rendering{})
|
||||||
|
if _, sent := user[RootField]; sent || user["name"] != "agent" {
|
||||||
|
t.Errorf("an older engine, which parses strictly, is sent root: %v", user)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, home = compose(Resolution{Account: "ops"}, Rendering{JudgesRoot: true})
|
||||||
|
if _, sent := user[RootField]; sent || user["name"] != "ops" {
|
||||||
|
t.Errorf("where agents run as the operator, root asserts nothing and is not sent: %v", user)
|
||||||
|
}
|
||||||
|
if home["path"] != "/home/ops/.agent" || home["owner"] != "ops" {
|
||||||
|
t.Errorf("with no agent account, the agent's directory is the operator's: %v", home)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheRuntimeIsToldTheAgentAccount(t *testing.T) {
|
||||||
|
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||||
|
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
|
||||||
|
envOf := func(r Resolution) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
r.Node, r.Modules = "anchor", []Manifest{aToolsModule(t, "nftables", "tools/index.js"), theRuntime(t)}
|
||||||
|
out, err := r.Declaration(with)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
||||||
|
if process == nil {
|
||||||
|
t.Fatal("no runtime process was composed")
|
||||||
|
}
|
||||||
|
return process["env"].(map[string]string)
|
||||||
|
}
|
||||||
|
env := envOf(Resolution{Account: "ops", AgentAccount: "agent"})
|
||||||
|
if env[RuntimeAgentAccount] != "agent" || env[RuntimeAgentHome] != "/home/agent" || env[RuntimeOperatorAccount] != "ops" {
|
||||||
|
t.Errorf("the runtime is not told whom agents run as: %v", env)
|
||||||
|
}
|
||||||
|
env = envOf(Resolution{Account: "ops"})
|
||||||
|
if env[RuntimeAgentAccount] != "ops" || env[RuntimeAgentHome] != "/home/ops" {
|
||||||
|
t.Errorf("with no agent account, agents run as the operator: %v", env)
|
||||||
|
}
|
||||||
|
env = envOf(Resolution{})
|
||||||
|
if _, set := env[RuntimeAgentAccount]; set {
|
||||||
|
t.Errorf("a machine with no account names an agent account: %v", env)
|
||||||
|
}
|
||||||
|
if problems := bundleEnvProblems("x", Artifact{Name: "b", Kind: ArtifactBundle, Loads: []string{"x"},
|
||||||
|
Env: map[string]string{RuntimeAgentAccount: "me"}}); len(problems) == 0 {
|
||||||
|
t.Error("a bundle may tell the runtime whom agents run as")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -399,7 +399,7 @@ func versionOf(digest string) string {
|
|||||||
// telling the runtime what it is, which is the mesh's to say (novox/hq ADR 0192).
|
// telling the runtime what it is, which is the mesh's to say (novox/hq ADR 0192).
|
||||||
var bundleEnvWords = map[string]bool{
|
var bundleEnvWords = map[string]bool{
|
||||||
RuntimeToolModules: true, RuntimeBrokerFile: true, RuntimeOperatorAccount: true,
|
RuntimeToolModules: true, RuntimeBrokerFile: true, RuntimeOperatorAccount: true,
|
||||||
RuntimeOperatorHome: true, RuntimeToolEnv: true,
|
RuntimeOperatorHome: true, RuntimeToolEnv: true, RuntimeAgentAccount: true, RuntimeAgentHome: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// bundleEnvProblems says what is wrong with what a bundle says it is given (novox/hq ADR 0192):
|
// bundleEnvProblems says what is wrong with what a bundle says it is given (novox/hq ADR 0192):
|
||||||
|
|||||||
@@ -241,6 +241,31 @@ type Rendering struct {
|
|||||||
// refuses a field it does not know, whole — so to it the field is not sent, and what it runs is
|
// refuses a field it does not know, whole — so to it the field is not sent, and what it runs is
|
||||||
// judged by liveness alone.
|
// judged by liveness alone.
|
||||||
ReadsHealth bool
|
ReadsHealth bool
|
||||||
|
|
||||||
|
// JudgesRoot says this machine's node-engine judges a user's declared `root` (novox/hq ADR 0266: its
|
||||||
|
// statement's contract is link.RootContract or later). To an older, strict engine the field is not
|
||||||
|
// sent, and the account it names is not judged — which the self-check says, as not judged.
|
||||||
|
JudgesRoot bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// RootField is a user resource's field saying the account must never become root without a person
|
||||||
|
// (novox/hq ADR 0266).
|
||||||
|
const RootField = "root"
|
||||||
|
|
||||||
|
// rootInto composes a user's `root` for the node-engine: taken away when it asserts nothing (empty — a
|
||||||
|
// machine where agents run as the operator) or when the engine is older than the field and parses
|
||||||
|
// strictly; kept as "never" otherwise.
|
||||||
|
func rootInto(resource map[string]any, with Rendering) {
|
||||||
|
if resource["type"] != "user" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value, has := resource[RootField]
|
||||||
|
if !has {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s, _ := value.(string); s == "" || !with.JudgesRoot {
|
||||||
|
delete(resource, RootField)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// machinePort is where a module's port lives on this machine, or the port itself when the mesh has
|
// machinePort is where a module's port lives on this machine, or the port itself when the mesh has
|
||||||
@@ -981,6 +1006,9 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
// How it is ready, in the node-engine's words: its endpoint as the port this machine
|
// How it is ready, in the node-engine's words: its endpoint as the port this machine
|
||||||
// published it on — or not sent at all to an engine older than the field (ADR 0240).
|
// published it on — or not sent at all to an engine older than the field (ADR 0240).
|
||||||
healthInto(copied, m, with)
|
healthInto(copied, m, with)
|
||||||
|
// And a user's `root` (novox/hq ADR 0266): sent only when it asserts something, to an engine
|
||||||
|
// that judges it.
|
||||||
|
rootInto(copied, with)
|
||||||
// The account's environment and every module's shell code, where this module holds the
|
// The account's environment and every module's shell code, where this module holds the
|
||||||
// seat that places them (novox/hq ADR 0203, ADR 0204). Gathered from every module on
|
// seat that places them (novox/hq ADR 0203, ADR 0204). Gathered from every module on
|
||||||
// the node, as the jails are, and **last of every placeholder pass**: shell code is a
|
// the node, as the jails are, and **last of every placeholder pass**: shell code is a
|
||||||
@@ -1073,9 +1101,23 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
}
|
}
|
||||||
owner[fmt.Sprint(process["id"])] = RuntimeModule
|
owner[fmt.Sprint(process["id"])] = RuntimeModule
|
||||||
out = append(out, process)
|
out = append(out, process)
|
||||||
// What each module's bundles are given is read as the account the runtime runs as.
|
// And a runtime of its own for each module of its own account, after it (novox/hq ADR 0259 §8).
|
||||||
|
owns, err := r.ownRuntimes(with)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, p := range owns {
|
||||||
|
id := fmt.Sprint(p["id"])
|
||||||
|
owner[id] = strings.TrimSuffix(id, "."+OwnRuntimeID())
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
// What each module's bundles are given is read as the account the runtime runs as — not what a
|
||||||
|
// module of its own account is given, which its own account reads.
|
||||||
words := map[string]map[string]string{}
|
words := map[string]map[string]string{}
|
||||||
for _, m := range r.Modules {
|
for _, m := range r.Modules {
|
||||||
|
if m.RunsAs != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
w, err := bundleWords(m, with)
|
w, err := bundleWords(m, with)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -78,18 +78,6 @@ func graphicalSessionSeats() []Seat {
|
|||||||
"description": "the lines to choose between, in order"},
|
"description": "the lines to choose between, in order"},
|
||||||
"prompt": map[string]any{"type": "string", "description": "what the menu asks (optional)"},
|
"prompt": map[string]any{"type": "string", "description": "what the menu asks (optional)"},
|
||||||
}}},
|
}}},
|
||||||
// A value the operator types and nobody sees (novox/hq ADR 0259 §10): a hidden prompt whose answer
|
|
||||||
// is sealed to the asker's key, so it is never plaintext on the bus or in any call's record.
|
|
||||||
// **Optional while its holders catch up** (ADR 0246): rofi serves it once this is live.
|
|
||||||
{Name: "secret", Optional: true, Description: "Ask the operator for a value in a prompt that " +
|
|
||||||
"does not show what is typed, and answer it sealed to the key the asker gives — never in " +
|
|
||||||
"the clear — or cancelled when the prompt was dismissed or not answered in time.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"prompt": "what the prompt asks",
|
|
||||||
"message": "a line saying who asks and for what (optional)",
|
|
||||||
"seal_to": "the asker's public sealing key: the answer is sealed to it",
|
|
||||||
"timeout_seconds": "give up after this long (optional)",
|
|
||||||
}, []string{"prompt", "seal_to"})},
|
|
||||||
}},
|
}},
|
||||||
{Name: NotifierSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
{Name: NotifierSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||||
{Name: "send", Description: "Show the operator a notification.",
|
{Name: "send", Description: "Show the operator a notification.",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func TestTheGraphicalSessionsSeatsAreTheMeshsOwnWithTheirVerbs(t *testing.T) {
|
|||||||
DisplayServerSeat: {"displays", "layout"},
|
DisplayServerSeat: {"displays", "layout"},
|
||||||
DisplaySessionSeat: {"reload", "workspaces", "windows"},
|
DisplaySessionSeat: {"reload", "workspaces", "windows"},
|
||||||
TerminalEmulatorSeat: {"open"},
|
TerminalEmulatorSeat: {"open"},
|
||||||
LauncherSeat: {"menu", "secret"},
|
LauncherSeat: {"menu"},
|
||||||
NotifierSeat: {"send", "history"},
|
NotifierSeat: {"send", "history"},
|
||||||
LockScreenSeat: {"lock"},
|
LockScreenSeat: {"lock"},
|
||||||
ClipboardSeat: {"history", "copy"},
|
ClipboardSeat: {"history", "copy"},
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The router of novox/hq ADR 0259: it declares the operator's seat and the two kinded benches.
|
||||||
|
func router() Manifest {
|
||||||
|
return Manifest{Module: "messenger", Tools: []string{"open", "history", "notify"},
|
||||||
|
State: []StateDeclaration{{Name: "asks"}}, RunsAs: "messenger", SecretsOwner: "messenger",
|
||||||
|
DefinesSeats: []SeatDeclaration{
|
||||||
|
{Name: "operator-channel", Scope: ScopeMesh, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"},
|
||||||
|
ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"asks"},
|
||||||
|
Serves: []Verb{{Name: "open"}, {Name: "history"}, {Name: "notify"}}},
|
||||||
|
{Name: "channel", Scope: ScopeMesh, Kinded: true, Accepts: []string{"show", "edit", "send"}},
|
||||||
|
{Name: "intake", Scope: ScopeMesh, Kinded: true, Emits: []string{"choice", "link"}, Proofs: []string{"code"}},
|
||||||
|
},
|
||||||
|
Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh}},
|
||||||
|
Uses: []string{"channel"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func aChannel(module, kind string) Manifest {
|
||||||
|
return Manifest{Module: module, Claims: []Claim{
|
||||||
|
{Name: "channel", Scope: ScopeMesh, Kind: kind}, {Name: "intake", Scope: ScopeMesh, Kind: kind}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTwoChannelsOfDifferentKindsHoldTheBenches(t *testing.T) {
|
||||||
|
shelf := Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"),
|
||||||
|
"desk-channel": aChannel("desk-channel", "desktop")}
|
||||||
|
if got := problemsFor(t, shelf); got != "" {
|
||||||
|
t.Fatalf("two kinds were refused: %s", got)
|
||||||
|
}
|
||||||
|
for _, m := range shelf {
|
||||||
|
if got := declaredSeatProblems(m); len(got) > 0 {
|
||||||
|
t.Fatalf("%s: %v", m.Module, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASecondClaimOfOneKindIsRefused(t *testing.T) {
|
||||||
|
got := problemsFor(t, Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"),
|
||||||
|
"telegram-two": aChannel("telegram-two", "telegram")})
|
||||||
|
if !strings.Contains(got, `of kind "telegram", which telegram already claims`) {
|
||||||
|
t.Fatalf("a second holder of one kind stood: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAKindedBenchNeedsAKindAndNoOtherSeatTakesOne(t *testing.T) {
|
||||||
|
got := problemsFor(t, Shelf{"messenger": router(), "nameless": aChannel("nameless", "")})
|
||||||
|
if !strings.Contains(got, "claims the kinded bench channel and names no kind") {
|
||||||
|
t.Fatalf("a claim without a kind stood: %s", got)
|
||||||
|
}
|
||||||
|
odd := Manifest{Module: "odd", Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh, Kind: "telegram"}}}
|
||||||
|
got = problemsFor(t, Shelf{"messenger": router(), "odd": odd})
|
||||||
|
if !strings.Contains(got, "only a kinded bench takes a kind") {
|
||||||
|
t.Fatalf("a kind on a seat that is not kinded stood: %s", got)
|
||||||
|
}
|
||||||
|
dotted := problemsFor(t, Shelf{"messenger": router(), "dotted": aChannel("dotted", "a.b")})
|
||||||
|
if !strings.Contains(dotted, "not a usable name") {
|
||||||
|
t.Fatalf("a kind that would widen a subject stood: %s", dotted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlyChannelAndIntakeAreKinded(t *testing.T) {
|
||||||
|
m := Manifest{Module: "x", DefinesSeats: []SeatDeclaration{{Name: "pager", Kinded: true, Accepts: []string{"page"}}}}
|
||||||
|
if got := strings.Join(declaredSeatProblems(m), "; "); !strings.Contains(got, "only channel and intake are kinded") {
|
||||||
|
t.Fatalf("another kinded bench was declared: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheNewRulesAreHeldToWhatTheSeatSays(t *testing.T) {
|
||||||
|
m := Manifest{Module: "x", DefinesSeats: []SeatDeclaration{{Name: "thing", Accepts: []string{"do"},
|
||||||
|
ByCaller: []string{"undo"}, Proofs: []string{"code"}, Records: []string{"nothing"}}}}
|
||||||
|
got := strings.Join(declaredSeatProblems(m), "; ")
|
||||||
|
for _, want := range []string{"names thing.undo by its caller, which the seat neither accepts nor emits",
|
||||||
|
"declares proofs on thing, which is not kinded", `read its records "nothing", which it keeps no state of`} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("not refused: %q in %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two kinds on one machine are two holders, and one kind on two machines is a second claimant.
|
||||||
|
func TestEachKindIsItsOwnHolderWhenResolved(t *testing.T) {
|
||||||
|
modules := []Manifest{aChannel("telegram", "telegram"), aChannel("desk-channel", "desktop")}
|
||||||
|
held, problems := checkClaims(modules, Node{Name: "anchor"}, nil, nil)
|
||||||
|
if len(problems) > 0 || len(held) != 4 {
|
||||||
|
t.Fatalf("two kinds on one machine: held %v, problems %v", held, problems)
|
||||||
|
}
|
||||||
|
_, problems = checkClaims([]Manifest{aChannel("telegram", "telegram")}, Node{Name: "home"}, held, nil)
|
||||||
|
if len(problems) == 0 {
|
||||||
|
t.Fatal("one kind held on two machines was not refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A channel's capabilities come from the fixed vocabulary, and only a kinded claim carries any.
|
||||||
|
func TestCapabilitiesAreTheVocabularysAndOnlyOnAKindedClaim(t *testing.T) {
|
||||||
|
good := aChannel("telegram", "telegram")
|
||||||
|
good.Claims[0].Capabilities = []string{"deliver", "choice", "verified-sender", "max-length:4096"}
|
||||||
|
good.RunsAs = "telegram"
|
||||||
|
if got := problemsFor(t, Shelf{"messenger": router(), "telegram": good}); got != "" {
|
||||||
|
t.Fatalf("the vocabulary was refused: %s", got)
|
||||||
|
}
|
||||||
|
bad := aChannel("telegram", "telegram")
|
||||||
|
bad.Claims[0].Capabilities = []string{"trusted", "max-length:lots"}
|
||||||
|
got := problemsFor(t, Shelf{"messenger": router(), "telegram": bad})
|
||||||
|
for _, w := range []string{`"trusted"`, `"max-length:lots"`} {
|
||||||
|
if !strings.Contains(got, w+", which channel-capabilities/1 does not have") {
|
||||||
|
t.Errorf("%s was not refused: %s", w, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
odd := Manifest{Module: "odd", Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh, Capabilities: []string{"deliver"}}}}
|
||||||
|
if got := problemsFor(t, Shelf{"messenger": router(), "odd": odd}); !strings.Contains(got, "only a kinded bench's claim carries them") {
|
||||||
|
t.Errorf("capabilities on a seat that is not kinded stood: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0259 §8: a module saying warrants, or speaking for a kind that proves its sender, runs as an
|
||||||
|
// account of its own — never carried by the machine's runtime, which runs as the operator's account.
|
||||||
|
func TestATrustedHolderMustRunAsAnAccountOfItsOwn(t *testing.T) {
|
||||||
|
r := router()
|
||||||
|
r.RunsAs = ""
|
||||||
|
if got := problemsFor(t, Shelf{"messenger": r}); !strings.Contains(got, "messenger must run as an account of its own") {
|
||||||
|
t.Errorf("a router on the machine's runtime stood: %s", got)
|
||||||
|
}
|
||||||
|
tg := aChannel("telegram", "telegram")
|
||||||
|
tg.Claims[0].Capabilities = []string{"choice", "verified-sender"}
|
||||||
|
if got := problemsFor(t, Shelf{"messenger": router(), "telegram": tg}); !strings.Contains(got, "telegram must run as an account of its own") {
|
||||||
|
t.Errorf("a verified channel on the machine's runtime stood: %s", got)
|
||||||
|
}
|
||||||
|
desk := aChannel("desk-channel", "desktop")
|
||||||
|
desk.Claims[0].Capabilities = []string{"choice"}
|
||||||
|
if got := problemsFor(t, Shelf{"messenger": router(), "desk-channel": desk}); got != "" {
|
||||||
|
t.Errorf("a channel proving nothing was held to it: %s", got)
|
||||||
|
}
|
||||||
|
// A kind that is `private` shows a link's code, which links an account as the operator: its holder is
|
||||||
|
// trusted with it, so it runs as its own account too (the confirmation review of 2026-10-09).
|
||||||
|
private := aChannel("desk-channel", "desktop")
|
||||||
|
private.Claims[0].Capabilities = []string{"choice", "private"}
|
||||||
|
if got := problemsFor(t, Shelf{"messenger": router(), "desk-channel": private}); !strings.Contains(got, "desk-channel must run as an account of its own") {
|
||||||
|
t.Errorf("a private channel on the machine's runtime stood: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunsAsIsAnAccountOfTheModulesOwn(t *testing.T) {
|
||||||
|
ok := Manifest{Module: "telegram", RunsAs: "telegram", SecretsOwner: "telegram",
|
||||||
|
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/telegram/broker"}},
|
||||||
|
Resources: []map[string]any{{"id": "account", "type": "user", "name": "telegram"}}}
|
||||||
|
if got := RunsAsProblems(ok); len(got) != 0 {
|
||||||
|
t.Fatalf("a sound runs-as was refused: %v", got)
|
||||||
|
}
|
||||||
|
for want, change := range map[string]func(*Manifest){
|
||||||
|
"never root": func(m *Manifest) { m.RunsAs, m.SecretsOwner = "root", "root" },
|
||||||
|
"not an account name": func(m *Manifest) { m.RunsAs = "${machine:account}" },
|
||||||
|
"which it does not make": func(m *Manifest) { m.Resources = nil },
|
||||||
|
"declares no own secret": func(m *Manifest) { m.OwnSecrets = nil },
|
||||||
|
"they are the account's own": func(m *Manifest) { m.SecretsOwner = "" },
|
||||||
|
} {
|
||||||
|
m := ok
|
||||||
|
m.Resources = append([]map[string]any(nil), ok.Resources...)
|
||||||
|
m.OwnSecrets = OwnSecrets{"broker": {Path: "/x"}}
|
||||||
|
change(&m)
|
||||||
|
if got := strings.Join(RunsAsProblems(m), "; "); !strings.Contains(got, want) {
|
||||||
|
t.Errorf("want %q, got %q", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,9 +78,47 @@ func machineFacts(r Resolution, names map[string]string, meshRange string) map[s
|
|||||||
out["account"] = r.Account
|
out["account"] = r.Account
|
||||||
out["account-home"] = accountHomeOf(r.Account, r.AccountHome)
|
out["account-home"] = accountHomeOf(r.Account, r.AccountHome)
|
||||||
}
|
}
|
||||||
|
// The account agents run as here, and whether it must never become root (novox/hq ADR 0266). The agent
|
||||||
|
// account where the node names one; the operator account otherwise, so a module writing the agent's
|
||||||
|
// home names one fact on every machine. `agent-root` is "never" only for an account of the agents' own:
|
||||||
|
// the user resource naming it then asks the node-engine to judge it, and on a machine where agents run
|
||||||
|
// as the operator it is empty, asserting nothing — the operator's account may become root there.
|
||||||
|
if agent, home := r.agentAccount(); agent != "" {
|
||||||
|
out["agent-account"] = agent
|
||||||
|
out["agent-home"] = home
|
||||||
|
out["agent-root"] = ""
|
||||||
|
if r.AgentAccount != "" {
|
||||||
|
out["agent-root"] = RootNever
|
||||||
|
}
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RootNever is what a user resource's `root` says of an account that must never become root without a
|
||||||
|
// person (novox/hq ADR 0266); the node-engine judges it.
|
||||||
|
const RootNever = "never"
|
||||||
|
|
||||||
|
// agentAccount is the account agents run as on this machine and its home: the agent account when the node
|
||||||
|
// names one (novox/hq ADR 0266), else the operator account; empty when neither is known.
|
||||||
|
func (r Resolution) agentAccount() (string, string) {
|
||||||
|
if r.AgentAccount != "" {
|
||||||
|
return r.AgentAccount, agentHomeOf(r.AgentAccount, r.AgentAccountHome)
|
||||||
|
}
|
||||||
|
if r.Account != "" {
|
||||||
|
return r.Account, accountHomeOf(r.Account, r.AccountHome)
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentHomeOf is where the agent account's home is: what was stored, or /home/<account>. Never /root: the
|
||||||
|
// agent account is never root.
|
||||||
|
func agentHomeOf(account, home string) string {
|
||||||
|
if home != "" {
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
return "/home/" + account
|
||||||
|
}
|
||||||
|
|
||||||
// accountHomeOf is where an account's home is: what was stored, or the derived default — /root for
|
// accountHomeOf is where an account's home is: what was stored, or the derived default — /root for
|
||||||
// root, /home/<account> otherwise. The one place the default is written, so a fact and the store
|
// root, /home/<account> otherwise. The one place the default is written, so a fact and the store
|
||||||
// cannot disagree about it.
|
// cannot disagree about it.
|
||||||
@@ -105,8 +143,12 @@ func machineInto(resource map[string]any, facts map[string]string, module string
|
|||||||
// (novox/hq to-be 29), the same reason its content names ${machine:address}. And the name a
|
// (novox/hq to-be 29), the same reason its content names ${machine:address}. And the name a
|
||||||
// `user` shape sets the login shell of, and the user a user-scoped unit or a process runs as:
|
// `user` shape sets the login shell of, and the user a user-scoped unit or a process runs as:
|
||||||
// the shell module makes the operator's account its holder's login shell, and the desktop's
|
// the shell module makes the operator's account its holder's login shell, and the desktop's
|
||||||
// watchers run as that account (novox/hq ADR 0176, ADR 0177) — neither can name the person.
|
// watchers run as that account (novox/hq ADR 0176, ADR 0177) — neither can name the person. And a
|
||||||
for _, field := range []string{"path", "owner", "content", "name", "user"} {
|
// user's `root`: the agent's module declares the account agents run as with ${machine:agent-root},
|
||||||
|
// "never" only where that account is the agents' own (novox/hq ADR 0266), and its `home`, so an account
|
||||||
|
// the operator named with a home of its own is made there (${machine:agent-home}); the node-engine reads a
|
||||||
|
// user's home only when it creates the account, so an existing one is never moved.
|
||||||
|
for _, field := range []string{"path", "owner", "content", "name", "user", "root", "home"} {
|
||||||
s, ok := resource[field].(string)
|
s, ok := resource[field].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ type Claim struct {
|
|||||||
// text/template over one piece — its fields and `module` — in the tool's own grammar (novox/hq ADR
|
// text/template over one piece — its fields and `module` — in the tool's own grammar (novox/hq ADR
|
||||||
// 0255). The data is the mesh's, the format the holder's, as a module's facts template is.
|
// 0255). The data is the mesh's, the format the holder's, as a module's facts template is.
|
||||||
Renders map[string]string `json:"renders,omitempty"`
|
Renders map[string]string `json:"renders,omitempty"`
|
||||||
|
// Kind is the kind this module holds a kinded bench as (novox/hq ADR 0234 §2, ADR 0259): `telegram`,
|
||||||
|
// `desktop`. Refused on any other seat, and a second claim of one kind is refused.
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
|
// Capabilities are what a channel of this kind promises, from the fixed vocabulary
|
||||||
|
// channel-capabilities/1 (novox/hq ADR 0234 §2): the router judges an answer by these, read from the
|
||||||
|
// controller's record of this claim and never from the channel.
|
||||||
|
Capabilities []string `json:"capabilities,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServesFor is what this claim offers a seat's protocol: the verbs it names, else the module's
|
// ServesFor is what this claim offers a seat's protocol: the verbs it names, else the module's
|
||||||
@@ -640,6 +647,13 @@ type Manifest struct {
|
|||||||
// cannot use.
|
// cannot use.
|
||||||
SecretsOwner string `json:"secrets-owner,omitempty"`
|
SecretsOwner string `json:"secrets-owner,omitempty"`
|
||||||
|
|
||||||
|
// RunsAs is the account this module's tools bundle runs as, in a runtime of its own on a bus account of
|
||||||
|
// its own (novox/hq ADR 0259 §8): never the machine's runtime, which runs as the operator's account and
|
||||||
|
// carries every module on the machine. The account is one the module makes (a `user` resource of that
|
||||||
|
// name), owns its secrets (`secrets-owner`), and is neither root nor the operator's. Required of a module
|
||||||
|
// that says a warrant, or speaks for a channel kind that proves its sender.
|
||||||
|
RunsAs string `json:"runs-as,omitempty"`
|
||||||
|
|
||||||
// Keeps is where this module wants every operator-sealed secret in the mesh written — the
|
// Keeps is where this module wants every operator-sealed secret in the mesh written — the
|
||||||
// vault's field, and so far nobody else's (novox/hq ADR 0085, amended).
|
// vault's field, and so far nobody else's (novox/hq ADR 0085, amended).
|
||||||
//
|
//
|
||||||
@@ -1620,6 +1634,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
// prefix. Whether a seat anybody names exists, and whether a holder answers for it, are
|
// prefix. Whether a seat anybody names exists, and whether a holder answers for it, are
|
||||||
// facts about the catalogue and are checked at registration (CatalogueProblems).
|
// facts about the catalogue and are checked at registration (CatalogueProblems).
|
||||||
problems = append(problems, declaredSeatProblems(m)...)
|
problems = append(problems, declaredSeatProblems(m)...)
|
||||||
|
problems = append(problems, RunsAsProblems(m)...)
|
||||||
if m.Computed != "" && len(m.Resources) > 0 {
|
if m.Computed != "" && len(m.Resources) > 0 {
|
||||||
// One or the other. A module that both ships files and has them computed would leave
|
// One or the other. A module that both ships files and has them computed would leave
|
||||||
// nobody able to say where a given file came from.
|
// nobody able to say where a given file came from.
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ type Node struct {
|
|||||||
// to-be 29). What a home-scoped file is owned by and what ${machine:account} resolves to.
|
// to-be 29). What a home-scoped file is owned by and what ${machine:account} resolves to.
|
||||||
Account string
|
Account string
|
||||||
AccountHome string
|
AccountHome string
|
||||||
|
// AgentAccount is the login agents run as here when it is not the operator's, AgentAccountHome its
|
||||||
|
// home when not derived (novox/hq ADR 0266). What ${machine:agent-account} resolves to; empty means
|
||||||
|
// agents run as the operator account.
|
||||||
|
AgentAccount string
|
||||||
|
AgentAccountHome string
|
||||||
}
|
}
|
||||||
|
|
||||||
// World is what the rest of the mesh already has.
|
// World is what the rest of the mesh already has.
|
||||||
@@ -115,6 +120,16 @@ type Held struct {
|
|||||||
Node string
|
Node string
|
||||||
Module string
|
Module string
|
||||||
Site string
|
Site string
|
||||||
|
// Kind is the kind a kinded bench is held as (novox/hq ADR 0234 §2): each kind is its own holder.
|
||||||
|
Kind string
|
||||||
|
}
|
||||||
|
|
||||||
|
// heldKey is what one holder holds: the seat, and its kind on a kinded bench.
|
||||||
|
func heldKey(claim, kind string) string {
|
||||||
|
if kind == "" {
|
||||||
|
return canonicalSeat(claim)
|
||||||
|
}
|
||||||
|
return canonicalSeat(claim) + "/" + kind
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolution is what a node should run, and why.
|
// Resolution is what a node should run, and why.
|
||||||
@@ -134,6 +149,10 @@ type Resolution struct {
|
|||||||
// here without a store lookup.
|
// here without a store lookup.
|
||||||
Account string
|
Account string
|
||||||
AccountHome string
|
AccountHome string
|
||||||
|
// AgentAccount and AgentAccountHome are the account agents run as here when it is not the
|
||||||
|
// operator's, and its home (novox/hq ADR 0266); empty when agents run as the operator account.
|
||||||
|
AgentAccount string
|
||||||
|
AgentAccountHome string
|
||||||
// Capabilities are the machine's, as its profile reported them, carried from the node so a
|
// Capabilities are the machine's, as its profile reported them, carried from the node so a
|
||||||
// contribution placed only where the machine has something (`if-capability`, novox/hq ADR 0255)
|
// contribution placed only where the machine has something (`if-capability`, novox/hq ADR 0255)
|
||||||
// is decided here without a store lookup.
|
// is decided here without a store lookup.
|
||||||
@@ -703,7 +722,8 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
}
|
}
|
||||||
|
|
||||||
resolution := Resolution{Node: node.Name, At: node.At, PublicDomain: node.PublicDomain,
|
resolution := Resolution{Node: node.Name, At: node.At, PublicDomain: node.PublicDomain,
|
||||||
Account: node.Account, AccountHome: node.AccountHome, Capabilities: node.Capabilities,
|
Account: node.Account, AccountHome: node.AccountHome, AgentAccount: node.AgentAccount,
|
||||||
|
AgentAccountHome: node.AgentAccountHome, Capabilities: node.Capabilities,
|
||||||
Because: because, Needs: needs, Unhostable: unhostable, Kept: kept}
|
Because: because, Needs: needs, Unhostable: unhostable, Kept: kept}
|
||||||
for _, n := range providersFirst(order, catalogue) {
|
for _, n := range providersFirst(order, catalogue) {
|
||||||
resolution.Modules = append(resolution.Modules, catalogue[n])
|
resolution.Modules = append(resolution.Modules, catalogue[n])
|
||||||
@@ -864,7 +884,7 @@ func checkClaims(modules []Manifest, node Node, elsewhere []Held, holdings []Hel
|
|||||||
// **One seat under either of its names** (novox/hq ADR 0122): a manifest registered before
|
// **One seat under either of its names** (novox/hq ADR 0122): a manifest registered before
|
||||||
// a rename claims the former name, and one written after it the current — two claimants of
|
// a rename claims the former name, and one written after it the current — two claimants of
|
||||||
// one seat, compared by the seat they resolve to and not by how each spelled it.
|
// one seat, compared by the seat they resolve to and not by how each spelled it.
|
||||||
seat := canonicalSeat(c.Name)
|
seat := heldKey(c.Name, c.Kind)
|
||||||
if other, taken := byScope[scope][seat]; taken {
|
if other, taken := byScope[scope][seat]; taken {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s and %s both claim %q, and only one thing may hold it per %s",
|
"%s and %s both claim %q, and only one thing may hold it per %s",
|
||||||
@@ -873,14 +893,14 @@ func checkClaims(modules []Manifest, node Node, elsewhere []Held, holdings []Hel
|
|||||||
}
|
}
|
||||||
byScope[scope][seat] = m.Module
|
byScope[scope][seat] = m.Module
|
||||||
held = append(held, Held{Claim: c.Name, Scope: scope, Node: node.Name,
|
held = append(held, Held{Claim: c.Name, Scope: scope, Node: node.Name,
|
||||||
Module: m.Module, Site: node.Site})
|
Module: m.Module, Site: node.Site, Kind: c.Kind})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// And against the rest of the mesh, for the scopes that reach past this machine.
|
// And against the rest of the mesh, for the scopes that reach past this machine.
|
||||||
for _, h := range held {
|
for _, h := range held {
|
||||||
for _, e := range elsewhere {
|
for _, e := range elsewhere {
|
||||||
if e.Node == node.Name || canonicalSeat(e.Claim) != canonicalSeat(h.Claim) || e.Scope != h.Scope {
|
if e.Node == node.Name || heldKey(e.Claim, e.Kind) != heldKey(h.Claim, h.Kind) || e.Scope != h.Scope {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch h.Scope {
|
switch h.Scope {
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
|
|||||||
"\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n",
|
"\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n",
|
||||||
// The private address and loopback, never a LAN's (novox/hq ADR 0194): a device that is not a
|
// The private address and loopback, never a LAN's (novox/hq ADR 0194): a device that is not a
|
||||||
// member cannot reach what the mesh's names point at.
|
// member cannot reach what the mesh's names point at.
|
||||||
"\nlisten-address=127.0.0.1\n", "\nlisten-address=${machine:address}\n", "\nbind-dynamic\n",
|
"\nlisten-address=127.0.0.1\n", "\nlisten-address=${machine:address}\n",
|
||||||
|
|
||||||
// No hosts file and no operator's files: the mesh's resolver answers every node (ADR 0199).
|
// No hosts file and no operator's files: the mesh's resolver answers every node (ADR 0199).
|
||||||
"\nno-hosts\n",
|
"\nno-hosts\n",
|
||||||
"\nconf-file=" + m.Facts["zones"].Path + "\n",
|
"\nconf-file=" + m.Facts["zones"].Path + "\n",
|
||||||
@@ -62,6 +63,14 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
|
|||||||
t.Errorf("the resolver's configuration lacks %q:\n%s", strings.TrimSpace(want), config)
|
t.Errorf("the resolver's configuration lacks %q:\n%s", strings.TrimSpace(want), config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Bound to its addresses, one way or the other. mesh-catalog PR 161 (novox/hq issue 348) moves it from
|
||||||
|
// bind-dynamic, which closed the private address's listener when a bridge teardown failed its re-read
|
||||||
|
// of the machine's addresses, to bind-interfaces. This test reads the catalogue beside it, which may be
|
||||||
|
// on either side of that merge, so it takes both; once the catalogue's main has it, bind-dynamic is
|
||||||
|
// refused here.
|
||||||
|
if !strings.Contains(config, "\nbind-interfaces\n") && !strings.Contains(config, "\nbind-dynamic\n") {
|
||||||
|
t.Errorf("the resolver's configuration binds neither by bind-interfaces nor by bind-dynamic:\n%s", config)
|
||||||
|
}
|
||||||
// By address and never by interface: dnsmasq admits a query by the interface it arrives on
|
// By address and never by interface: dnsmasq admits a query by the interface it arrives on
|
||||||
// when told one, and a container's query to the private address arrives on the runtime's
|
// when told one, and a container's query to the private address arrives on the runtime's
|
||||||
// bridge — `interface=mesh0` dropped every such query, silently (novox/hq issue 110).
|
// bridge — `interface=mesh0` dropped every such query, silently (novox/hq issue 110).
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ const (
|
|||||||
RuntimeBrokerFile = "MESH_BROKER_FILE"
|
RuntimeBrokerFile = "MESH_BROKER_FILE"
|
||||||
RuntimeOperatorAccount = "MESH_OPERATOR_ACCOUNT"
|
RuntimeOperatorAccount = "MESH_OPERATOR_ACCOUNT"
|
||||||
RuntimeOperatorHome = "MESH_OPERATOR_HOME"
|
RuntimeOperatorHome = "MESH_OPERATOR_HOME"
|
||||||
|
// RuntimeAgentAccount and RuntimeAgentHome are the account agents run as on the machine and its home
|
||||||
|
// (novox/hq ADR 0266): the agent account where the node names one, the operator account otherwise.
|
||||||
|
// The agent's module writes the agent's home from them; absent where neither account is known.
|
||||||
|
RuntimeAgentAccount = "MESH_AGENT_ACCOUNT"
|
||||||
|
RuntimeAgentHome = "MESH_AGENT_HOME"
|
||||||
// RuntimeToolEnv is every served module's composed environment, as JSON (novox/hq ADR 0192):
|
// RuntimeToolEnv is every served module's composed environment, as JSON (novox/hq ADR 0192):
|
||||||
// {"<module>": {"<word>": "<value>"}}. The runtime takes it at start, removes it from its own
|
// {"<module>": {"<word>": "<value>"}}. The runtime takes it at start, removes it from its own
|
||||||
// environment and hands each module's words to that module's bundles alone. In the unit, so a
|
// environment and hands each module's words to that module's bundles alone. In the unit, so a
|
||||||
@@ -165,6 +170,10 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
|
|||||||
if with.Adopted && m.Filtering != nil {
|
if with.Adopted && m.Filtering != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if m.RunsAs != "" {
|
||||||
|
// Served by a runtime of its own, on its own account (ownRuntimes): never the machine's.
|
||||||
|
continue
|
||||||
|
}
|
||||||
words, err := bundleWords(m, with)
|
words, err := bundleWords(m, with)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -215,6 +224,10 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
|
|||||||
env[RuntimeOperatorHome] = accountHomeOf(r.Account, r.AccountHome)
|
env[RuntimeOperatorHome] = accountHomeOf(r.Account, r.AccountHome)
|
||||||
process["user"] = r.Account
|
process["user"] = r.Account
|
||||||
}
|
}
|
||||||
|
if agent, home := r.agentAccount(); agent != "" {
|
||||||
|
env[RuntimeAgentAccount] = agent
|
||||||
|
env[RuntimeAgentHome] = home
|
||||||
|
}
|
||||||
// Routed through the artifact store as this network reaches it now, like everything the mesh
|
// Routed through the artifact store as this network reaches it now, like everything the mesh
|
||||||
// built; refused with the same words when there is no store to route through.
|
// built; refused with the same words when there is no store to route through.
|
||||||
if err := artifactsInto(process, RuntimeModule, with); err != nil {
|
if err := artifactsInto(process, RuntimeModule, with); err != nil {
|
||||||
@@ -223,6 +236,94 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
|
|||||||
return process, nil
|
return process, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OwnRuntimeID names the process a module of its own account is served by (novox/hq ADR 0259 §8).
|
||||||
|
func OwnRuntimeID() string { return "own-runtime" }
|
||||||
|
|
||||||
|
// ownRuntimes are the processes the modules of their own account are served by (novox/hq ADR 0259 §8): each
|
||||||
|
// the machine's runtime program — the same build, run from the same source — serving that one module alone,
|
||||||
|
// as the module's own account, on the module's own bus credential. Never the machine's runtime, which runs
|
||||||
|
// as the operator's account and carries every module on the machine.
|
||||||
|
func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) {
|
||||||
|
var own []Manifest
|
||||||
|
for _, m := range r.Modules {
|
||||||
|
if m.RunsAs != "" && !(with.Adopted && m.Filtering != nil) {
|
||||||
|
own = append(own, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(own) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var runtime *Manifest
|
||||||
|
for i := range r.Modules {
|
||||||
|
if r.Modules[i].Module == RuntimeModule {
|
||||||
|
runtime = &r.Modules[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if runtime == nil || len(runtime.Bundles) != 1 || runtime.Bundles[0].Binary == "" {
|
||||||
|
return nil, fmt.Errorf("%s runs as its own account in a runtime of its own, and %s is not here to run it "+
|
||||||
|
"from: assign %s to %s first (novox/hq ADR 0259)", own[0].Module, RuntimeModule, RuntimeModule, r.Node)
|
||||||
|
}
|
||||||
|
program := runtime.Bundles[0]
|
||||||
|
var out []map[string]any
|
||||||
|
for _, m := range own {
|
||||||
|
// Never the node's operator account, nor the account agents run as there (the review of 2026-10-09):
|
||||||
|
// either would hand what it holds back to the very accounts it is kept from.
|
||||||
|
switch {
|
||||||
|
case r.Account != "" && m.RunsAs == r.Account:
|
||||||
|
return nil, fmt.Errorf("%s runs as %s, the operator's account on %s: a module of its own account never "+
|
||||||
|
"runs as it (novox/hq ADR 0259 §8)", m.Module, m.RunsAs, r.Node)
|
||||||
|
case r.AgentAccount != "" && m.RunsAs == r.AgentAccount:
|
||||||
|
return nil, fmt.Errorf("%s runs as %s, the account agents run as on %s: a module of its own account "+
|
||||||
|
"never runs as it (novox/hq ADR 0259 §8)", m.Module, m.RunsAs, r.Node)
|
||||||
|
}
|
||||||
|
credential, declared := m.OwnSecrets["broker"]
|
||||||
|
if !declared {
|
||||||
|
return nil, fmt.Errorf("%s runs as its own account and declares no own secret broker", m.Module)
|
||||||
|
}
|
||||||
|
var served, restartOn []string
|
||||||
|
for _, b := range m.Bundles {
|
||||||
|
for _, load := range b.Loads {
|
||||||
|
if launcher, has := b.Launchers[load]; has {
|
||||||
|
load = launcher
|
||||||
|
}
|
||||||
|
served = append(served, m.Module+"="+BundlePath(m.Module, b.Name)+"/"+load)
|
||||||
|
}
|
||||||
|
if len(b.Loads) > 0 {
|
||||||
|
restartOn = append(restartOn, m.Module+"."+BundleID(b.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(served) == 0 {
|
||||||
|
return nil, fmt.Errorf("%s runs as its own account and its build produced no bundle to serve", m.Module)
|
||||||
|
}
|
||||||
|
sort.Strings(served)
|
||||||
|
restartOn = append(restartOn, m.Module+"."+NeedID("broker"))
|
||||||
|
sort.Strings(restartOn)
|
||||||
|
env := map[string]string{RuntimeToolModules: strings.Join(served, ","), RuntimeBrokerFile: credential.Path}
|
||||||
|
words, err := bundleWords(m, with)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(words) > 0 {
|
||||||
|
body, err := json.Marshal(map[string]map[string]string{m.Module: words})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
env[RuntimeToolEnv] = string(body)
|
||||||
|
}
|
||||||
|
process := map[string]any{
|
||||||
|
"id": m.Module + "." + OwnRuntimeID(), "type": "process", "name": m.Module + "-runtime",
|
||||||
|
"source": program.Source, "digest": program.Digest,
|
||||||
|
"run": []any{"./" + program.Binary}, "env": env, "restart-on": toAny(restartOn),
|
||||||
|
"user": m.RunsAs,
|
||||||
|
}
|
||||||
|
if err := artifactsInto(process, RuntimeModule, with); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, process)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func toAny(in []string) []any {
|
func toAny(in []string) []any {
|
||||||
out := make([]any, 0, len(in))
|
out := make([]any, 0, len(in))
|
||||||
for _, s := range in {
|
for _, s := range in {
|
||||||
|
|||||||
@@ -457,3 +457,75 @@ func TestAGoToolsBundleIsServedByItsBinary(t *testing.T) {
|
|||||||
t.Error("a Go bundle loading a file it does not contain was admitted")
|
t.Error("a Go bundle loading a file it does not contain was admitted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0259 §8: a module of its own account is served by a runtime of its own — the machine's
|
||||||
|
// runtime program, as that account, on that module's own credential — and never by the machine's runtime,
|
||||||
|
// which runs as the operator's account and is given none of its words.
|
||||||
|
func TestAModuleOfItsOwnAccountIsServedByARuntimeOfItsOwn(t *testing.T) {
|
||||||
|
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||||
|
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}, "telegram": {"broker": "own"}}}
|
||||||
|
goRuntime := Manifest{Module: RuntimeModule, Version: "1",
|
||||||
|
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}},
|
||||||
|
Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "go",
|
||||||
|
System: "arch", From: "cmd/node-tools"}}}}
|
||||||
|
goRuntime, err := goRuntime.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle,
|
||||||
|
Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
telegram := aToolsModule(t, "telegram", "tools/index.js")
|
||||||
|
telegram.RunsAs, telegram.SecretsOwner = "telegram", "telegram"
|
||||||
|
telegram.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/telegram/broker"}}
|
||||||
|
out, err := Resolution{Node: "anchor", Account: "ops",
|
||||||
|
Modules: []Manifest{aToolsModule(t, "nftables", "tools/index.js"), telegram, goRuntime}}.Declaration(with)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
machine := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
||||||
|
if served := machine["env"].(map[string]string)[RuntimeToolModules]; strings.Contains(served, "telegram") || !strings.Contains(served, "nftables") {
|
||||||
|
t.Errorf("the machine's runtime serves %q", served)
|
||||||
|
}
|
||||||
|
own := fileNamed(out, "telegram."+OwnRuntimeID())
|
||||||
|
if own == nil {
|
||||||
|
t.Fatalf("telegram has no runtime of its own: %v", ids(out))
|
||||||
|
}
|
||||||
|
env := own["env"].(map[string]string)
|
||||||
|
if own["user"] != "telegram" || fmt.Sprint(own["run"]) != "[./node-tools]" ||
|
||||||
|
env[RuntimeBrokerFile] != "/var/lib/telegram/broker" ||
|
||||||
|
env[RuntimeToolModules] != "telegram="+BundleRoot+"/telegram/tools/tools/index.js" {
|
||||||
|
t.Errorf("its own runtime: user %v run %v env %v", own["user"], own["run"], env)
|
||||||
|
}
|
||||||
|
if _, told := env[RuntimeOperatorAccount]; told {
|
||||||
|
t.Error("a runtime of a module's own account is told the operator's account")
|
||||||
|
}
|
||||||
|
// Without the machine's runtime to run it from, it is refused in words.
|
||||||
|
if _, err := (Resolution{Node: "anchor", Modules: []Manifest{telegram}}).ownRuntimes(with); err == nil {
|
||||||
|
t.Error("a module of its own account composed without a runtime program")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The review of 2026-10-09 (L4): a module of its own account never runs as the node's operator account, nor
|
||||||
|
// as the account agents run as there — either would hand what it holds back to the accounts it is kept from.
|
||||||
|
func TestAModuleOfItsOwnAccountIsRefusedTheOperatorsAndTheAgentsAccount(t *testing.T) {
|
||||||
|
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||||
|
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}, "telegram": {"broker": "own"}}}
|
||||||
|
goRuntime := Manifest{Module: RuntimeModule, Version: "1",
|
||||||
|
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}},
|
||||||
|
Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "go",
|
||||||
|
System: "arch", From: "cmd/node-tools"}}}}
|
||||||
|
goRuntime, err := goRuntime.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle,
|
||||||
|
Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, account := range []string{"ops", "agent"} {
|
||||||
|
telegram := aToolsModule(t, "telegram", "tools/index.js")
|
||||||
|
telegram.RunsAs, telegram.SecretsOwner = account, account
|
||||||
|
telegram.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/telegram/broker"}}
|
||||||
|
_, err := Resolution{Node: "anchor", Account: "ops", AgentAccount: "agent",
|
||||||
|
Modules: []Manifest{telegram, goRuntime}}.ownRuntimes(with)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), account) {
|
||||||
|
t.Errorf("telegram running as %s was composed: %v", account, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package catalogue
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -51,6 +52,35 @@ type SeatDeclaration struct {
|
|||||||
// owns its own, which is why a seat is also the answer for a module that needs retention
|
// owns its own, which is why a seat is also the answer for a module that needs retention
|
||||||
// its events cannot have.
|
// its events cannot have.
|
||||||
RetainSeconds int `json:"retain-seconds,omitempty"`
|
RetainSeconds int `json:"retain-seconds,omitempty"`
|
||||||
|
|
||||||
|
// Kinded makes the seat a kinded bench (novox/hq ADR 0234 §2, ADR 0259 §3): its holders are different
|
||||||
|
// modules, each claiming one kind, and each verb's subject carries the kind. Only the benches in
|
||||||
|
// KindedBenches may be kinded; making another is a decision, recorded.
|
||||||
|
Kinded bool `json:"kinded,omitempty"`
|
||||||
|
// ByCaller are accepts and emits whose subject's last token names the calling module (ADR 0259 §3): a
|
||||||
|
// user submits such an accept, and hears such an event, under its own name and no other.
|
||||||
|
ByCaller []string `json:"by-caller,omitempty"`
|
||||||
|
// Proofs are verbs carried as core request and reply, never on a stream: what travels on them (a code
|
||||||
|
// the operator typed) is never kept (ADR 0259 §3). On a kinded bench a holder asks with its own kind and
|
||||||
|
// the modules watching the seat answer.
|
||||||
|
Proofs []string `json:"proofs,omitempty"`
|
||||||
|
// Records are state buckets of the declaring module that each user reads under its own name — the
|
||||||
|
// keys `<user>.…` and no other (ADR 0259 §3).
|
||||||
|
Records []string `json:"records,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KindedBenches are the seats that may be kinded (novox/hq ADR 0234 §2): `channel` sends to the operator,
|
||||||
|
// `intake` takes what the operator answers. Another is a decision, recorded, as ADR 0223 asks of a bench.
|
||||||
|
var KindedBenches = map[string]bool{"channel": true, "intake": true}
|
||||||
|
|
||||||
|
// NamedByCaller says whether one of the seat's verbs is named by its caller.
|
||||||
|
func (s SeatDeclaration) NamedByCaller(verb string) bool {
|
||||||
|
for _, v := range s.ByCaller {
|
||||||
|
if v == verb {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// At is this declaration's scope, with the default applied. Mesh by default, because a seat
|
// At is this declaration's scope, with the default applied. Mesh by default, because a seat
|
||||||
@@ -132,6 +162,7 @@ func declaredSeatProblems(m Manifest) []string {
|
|||||||
"%s declares %s.%s, which is not a usable verb", m.Module, s.Name, v))
|
"%s declares %s.%s, which is not a usable verb", m.Module, s.Name, v))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
problems = append(problems, trafficProblems(m, s)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, u := range m.Uses {
|
for _, u := range m.Uses {
|
||||||
@@ -142,6 +173,56 @@ func declaredSeatProblems(m Manifest) []string {
|
|||||||
return problems
|
return problems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// trafficProblems is what one declaration of the rules of ADR 0259 §3 can be judged on alone.
|
||||||
|
func trafficProblems(m Manifest, s SeatDeclaration) []string {
|
||||||
|
var problems []string
|
||||||
|
if s.Kinded && !KindedBenches[s.Name] {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s declares %s as a kinded bench; only channel and intake are kinded, and another is a "+
|
||||||
|
"decision, recorded (novox/hq ADR 0234)", m.Module, s.Name))
|
||||||
|
}
|
||||||
|
if s.Kinded && len(s.ByCaller) > 0 {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s declares %s kinded and names verbs by their caller; a kinded bench's subjects carry the kind",
|
||||||
|
m.Module, s.Name))
|
||||||
|
}
|
||||||
|
for _, v := range s.ByCaller {
|
||||||
|
inAccepts, inEmits := false, false
|
||||||
|
for _, a := range s.Accepts {
|
||||||
|
inAccepts = inAccepts || a == v
|
||||||
|
}
|
||||||
|
for _, e := range s.Emits {
|
||||||
|
inEmits = inEmits || e == v
|
||||||
|
}
|
||||||
|
if !inAccepts && !inEmits {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s names %s.%s by its caller, which the seat neither accepts nor emits", m.Module, s.Name, v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range s.Proofs {
|
||||||
|
if !name.MatchString(v) || strings.Contains(v, ".") {
|
||||||
|
problems = append(problems, fmt.Sprintf("%s declares the proof %s.%s, which is not a usable verb",
|
||||||
|
m.Module, s.Name, v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(s.Proofs) > 0 && !s.Kinded {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s declares proofs on %s, which is not kinded; a proof is asked by a holder of a kind",
|
||||||
|
m.Module, s.Name))
|
||||||
|
}
|
||||||
|
for _, r := range s.Records {
|
||||||
|
kept := false
|
||||||
|
for _, st := range m.State {
|
||||||
|
kept = kept || st.Name == r
|
||||||
|
}
|
||||||
|
if !kept {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s says %s's users read its records %q, which it keeps no state of", m.Module, s.Name, r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
// A Shelf is every manifest the mesh has registered, by module name.
|
// A Shelf is every manifest the mesh has registered, by module name.
|
||||||
type Shelf map[string]Manifest
|
type Shelf map[string]Manifest
|
||||||
|
|
||||||
@@ -182,8 +263,19 @@ func CatalogueProblems(shelf Shelf) []string {
|
|||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Who claims each kind of a kinded bench, so a second claim of one kind is refused (ADR 0234 §2).
|
||||||
|
kindsTaken := map[string]string{}
|
||||||
|
|
||||||
for _, module := range shelfOrder(shelf) {
|
for _, module := range shelfOrder(shelf) {
|
||||||
m := shelf[module]
|
m := shelf[module]
|
||||||
|
for _, c := range m.Claims {
|
||||||
|
if c.Kind != "" {
|
||||||
|
if _, isModuleSeat := declared[c.Name]; !isModuleSeat {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s claims %s of kind %q, and only a kinded bench takes a kind", module, c.Name, c.Kind))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A `uses` naming nothing is where ADR 0110's guarantee lands under a derived set: the
|
// A `uses` naming nothing is where ADR 0110's guarantee lands under a derived set: the
|
||||||
// same refusal, at the same moment, from a set nobody maintains by hand.
|
// same refusal, at the same moment, from a set nobody maintains by hand.
|
||||||
@@ -214,6 +306,7 @@ func CatalogueProblems(shelf Shelf) []string {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
problems = append(problems, kindProblems(module, c, s, kindsTaken)...)
|
||||||
if c.At() != s.At() {
|
if c.At() != s.At() {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s claims %s at scope %q, and %s declares it at %s",
|
"%s claims %s at scope %q, and %s declares it at %s",
|
||||||
@@ -228,6 +321,16 @@ func CatalogueProblems(shelf Shelf) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// **A trusted holder runs as its own account** (novox/hq ADR 0259 §8): a module saying warrants, or
|
||||||
|
// speaking for a kind that proves its sender, is never carried by a machine's runtime.
|
||||||
|
for _, module := range shelfOrder(shelf) {
|
||||||
|
m := shelf[module]
|
||||||
|
if why := TrustedHolding(m, declared); why != "" && m.RunsAs == "" {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s must run as an account of its own (runs-as): %s, and the machine's runtime runs as the "+
|
||||||
|
"operator's account, which every agent runs as (novox/hq ADR 0259)", module, why))
|
||||||
|
}
|
||||||
|
}
|
||||||
// A read of a module's state that module does not keep (novox/hq ADR 0201) — said only where the
|
// A read of a module's state that module does not keep (novox/hq ADR 0201) — said only where the
|
||||||
// owner is on the shelf, as a consumer may be installed before its emitter.
|
// owner is on the shelf, as a consumer may be installed before its emitter.
|
||||||
var manifests []Manifest
|
var manifests []Manifest
|
||||||
@@ -239,6 +342,63 @@ func CatalogueProblems(shelf Shelf) []string {
|
|||||||
return problems
|
return problems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChannelCapabilities is the fixed vocabulary `channel-capabilities/1` (novox/hq ADR 0234 §2): a word
|
||||||
|
// outside it is refused. `max-length:<N>` takes a number.
|
||||||
|
var ChannelCapabilities = map[string]bool{
|
||||||
|
"deliver": true, "reaches-away": true, "loud": true, "silent": true, "edit": true,
|
||||||
|
"reaches-when-mesh-down": true, "private": true,
|
||||||
|
"choice": true, "reply": true, "threads": true, "operator-first": true,
|
||||||
|
"verified-sender": true, "exact-render": true, "code-factor": true, "key-factor": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxLength = regexp.MustCompile(`^max-length:[1-9][0-9]{0,6}$`)
|
||||||
|
|
||||||
|
// capabilityProblems are the words of a claim outside the vocabulary, and capabilities on a claim of a
|
||||||
|
// seat that is not kinded.
|
||||||
|
func capabilityProblems(module string, c Claim, kinded bool) []string {
|
||||||
|
if len(c.Capabilities) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !kinded {
|
||||||
|
return []string{fmt.Sprintf("%s claims %s with capabilities, and only a kinded bench's claim carries them",
|
||||||
|
module, c.Name)}
|
||||||
|
}
|
||||||
|
var problems []string
|
||||||
|
for _, w := range c.Capabilities {
|
||||||
|
if !ChannelCapabilities[w] && !maxLength.MatchString(w) {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s claims %s with the capability %q, which channel-capabilities/1 does not have", module, c.Name, w))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
// kindProblems is a claim judged against a declared seat's kind: a kinded bench takes one claim per kind,
|
||||||
|
// a usable name; any other seat takes none.
|
||||||
|
func kindProblems(module string, c Claim, s SeatDeclaration, taken map[string]string) []string {
|
||||||
|
if problems := capabilityProblems(module, c, s.Kinded); len(problems) > 0 {
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case !s.Kinded && c.Kind != "":
|
||||||
|
return []string{fmt.Sprintf("%s claims %s of kind %q, and only a kinded bench takes a kind",
|
||||||
|
module, c.Name, c.Kind)}
|
||||||
|
case !s.Kinded:
|
||||||
|
return nil
|
||||||
|
case c.Kind == "":
|
||||||
|
return []string{fmt.Sprintf("%s claims the kinded bench %s and names no kind", module, c.Name)}
|
||||||
|
case !name.MatchString(c.Kind) || strings.Contains(c.Kind, "."):
|
||||||
|
return []string{fmt.Sprintf("%s claims %s of kind %q, which is not a usable name", module, c.Name, c.Kind)}
|
||||||
|
}
|
||||||
|
key := c.Name + "/" + c.Kind
|
||||||
|
if first, ok := taken[key]; ok && first != module {
|
||||||
|
return []string{fmt.Sprintf("%s claims %s of kind %q, which %s already claims; a kind has one holder",
|
||||||
|
module, c.Name, c.Kind, first)}
|
||||||
|
}
|
||||||
|
taken[key] = module
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// unserved is what a seat's protocol promises and the claimant does not answer. Only the tools
|
// unserved is what a seat's protocol promises and the claimant does not answer. Only the tools
|
||||||
// are checked: `accepts` and `emits` are wired by the runtime from the declaration, while a tool
|
// are checked: `accepts` and `emits` are wired by the runtime from the declaration, while a tool
|
||||||
// is code the module either has or has not written — under the claim's serves, or among its own.
|
// is code the module either has or has not written — under the claim's serves, or among its own.
|
||||||
@@ -266,3 +426,70 @@ func shelfOrder(shelf Shelf) []string {
|
|||||||
sort.Strings(out)
|
sort.Strings(out)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var accountName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,30}$`)
|
||||||
|
|
||||||
|
// RunsAsProblems is what one manifest's `runs-as` is held to (novox/hq ADR 0259 §8): an account of the
|
||||||
|
// module's own making — a `user` resource of that name — that owns its secrets, with a bus account of its own,
|
||||||
|
// and that is neither root nor the operator's.
|
||||||
|
func RunsAsProblems(m Manifest) []string {
|
||||||
|
if m.RunsAs == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var problems []string
|
||||||
|
say := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) }
|
||||||
|
switch {
|
||||||
|
case !accountName.MatchString(m.RunsAs):
|
||||||
|
say("%s runs as %q, which is not an account name of the module's own", m.Module, m.RunsAs)
|
||||||
|
return problems
|
||||||
|
case m.RunsAs == "root":
|
||||||
|
say("%s runs as root; a module of its own account runs as an account it makes, never root", m.Module)
|
||||||
|
}
|
||||||
|
made := false
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if fmt.Sprint(r["type"]) == "user" && fmt.Sprint(r["name"]) == m.RunsAs {
|
||||||
|
made = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !made {
|
||||||
|
say("%s runs as %s, which it does not make: a user resource named %s", m.Module, m.RunsAs, m.RunsAs)
|
||||||
|
}
|
||||||
|
if _, has := m.OwnSecrets["broker"]; !has {
|
||||||
|
say("%s runs as its own account and declares no own secret broker: its runtime reaches the bus on an "+
|
||||||
|
"account of its own", m.Module)
|
||||||
|
}
|
||||||
|
if m.SecretsOwner != m.RunsAs {
|
||||||
|
say("%s runs as %s, and its secrets belong to %q: they are the account's own", m.Module, m.RunsAs, m.SecretsOwner)
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrustedHolding is why a module must run as its own account (novox/hq ADR 0259 §8), or "": it holds a seat
|
||||||
|
// whose events it says to one caller each (a warrant), or speaks for a kind of a kinded bench that proves
|
||||||
|
// its sender, or is private (a link's code is shown there). None may be carried by the machine's runtime, which
|
||||||
|
// runs as the operator's account.
|
||||||
|
func TrustedHolding(m Manifest, declared map[string]SeatDeclaration) string {
|
||||||
|
for _, c := range m.Claims {
|
||||||
|
s, ok := declared[c.Name]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range s.Emits {
|
||||||
|
if s.NamedByCaller(e) {
|
||||||
|
return fmt.Sprintf("it holds %s, whose %s it says to one caller each", c.Name, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Kinded {
|
||||||
|
for _, capability := range c.Capabilities {
|
||||||
|
switch capability {
|
||||||
|
case "verified-sender":
|
||||||
|
return fmt.Sprintf("it holds %s of kind %s, which proves its sender", c.Name, c.Kind)
|
||||||
|
case "private":
|
||||||
|
// A private kind is shown a link's code, which makes an account the operator's.
|
||||||
|
return fmt.Sprintf("it holds %s of kind %s, which is private: a link's code is shown there", c.Name, c.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
+35
-19
@@ -230,18 +230,6 @@ var ControllerVerbs = []Verb{
|
|||||||
"why": "an own secret: why it is rotated — recorded in the hand-act log (optional)",
|
"why": "an own secret: why it is rotated — recorded in the hand-act log (optional)",
|
||||||
"cause": "with why: the cause in a word, the word a second rotation for the same reason uses (optional)",
|
"cause": "with why: the cause in a word, the word a second rotation for the same reason uses (optional)",
|
||||||
}, nil)},
|
}, nil)},
|
||||||
{Name: "give", Description: "Take a module's own secret from the operator at their desk (novox/hq ADR 0259 " +
|
|
||||||
"§10): a prompt that does not show what is typed opens on the machine named by at, its answer comes " +
|
|
||||||
"back sealed to this call alone, and is sealed to the module's machine as `secret accept` seals it. " +
|
|
||||||
"The value is never an argument and never in the answer: the answer says it was taken, or why not. " +
|
|
||||||
"Recorded in the hand-act log as a value given at the desk. The prompt waits 25 seconds; dismissed " +
|
|
||||||
"or unanswered, nothing changes. Then push the machine.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"node": "the machine the module runs on, which the secret is sealed to",
|
|
||||||
"module": "the module's name",
|
|
||||||
"secret": "the own secret's name in the module's definition",
|
|
||||||
"at": "the machine the operator sits at, where the prompt opens",
|
|
||||||
}, []string{"node", "module", "secret", "at"})},
|
|
||||||
{Name: "issue", Description: "Give a module on a machine its account on the bus: minted, and sealed to the " +
|
{Name: "issue", Description: "Give a module on a machine its account on the bus: minted, and sealed to the " +
|
||||||
"machine as the module's own secret named broker, read at the next push of that machine. For a module " +
|
"machine as the module's own secret named broker, read at the next push of that machine. For a module " +
|
||||||
"whose definition declares that secret; refused with the reason otherwise. Issued again, it replaces the account.",
|
"whose definition declares that secret; refused with the reason otherwise. Issued again, it replaces the account.",
|
||||||
@@ -249,9 +237,9 @@ var ControllerVerbs = []Verb{
|
|||||||
"node": "the machine that runs the module",
|
"node": "the machine that runs the module",
|
||||||
"module": "the module's name",
|
"module": "the module's name",
|
||||||
}, []string{"node", "module"})},
|
}, []string{"node", "module"})},
|
||||||
{Name: "token", Description: "Issue a one-time token for a machine to join with. Give the public half of the " +
|
{Name: "token", Description: "Refused through a verb since novox/hq ADR 0266: the one-time token a machine " +
|
||||||
"tunnel key the machine made (`nox-mesh-host key`): the machine is given its address and made a peer of " +
|
"joins with is answered to its caller, and whoever may call a verb includes agents. Issue it at the " +
|
||||||
"the hub, and joins through the tunnel. The token is shown once, in the answer.",
|
"controller's terminal: `mesh-controller token issue --new <name> --overlay-key <public half>`.",
|
||||||
Input: schema(map[string]string{
|
Input: schema(map[string]string{
|
||||||
"node": "a machine the mesh already has a record for",
|
"node": "a machine the mesh already has a record for",
|
||||||
"new": "or the name of a machine to create the record for",
|
"new": "or the name of a machine to create the record for",
|
||||||
@@ -279,10 +267,14 @@ var ControllerVerbs = []Verb{
|
|||||||
"list": "\"preferences\": every module's preferences — key, default and why — and the value on each " +
|
"list": "\"preferences\": every module's preferences — key, default and why — and the value on each " +
|
||||||
"machine it is assigned to with where it comes from; module and node narrow it (novox/hq ADR 0262)",
|
"machine it is assigned to with where it comes from; module and node narrow it (novox/hq ADR 0262)",
|
||||||
}, nil, "clear", "replace", "history")},
|
}, nil, "clear", "replace", "history")},
|
||||||
{Name: "command", Description: "Run one command line of the controller's own, as you would type it at its " +
|
{Name: "command", Description: "Run one reading command line of the controller's own, as you would type it at " +
|
||||||
"shell — `node account g14 jochen`, `node show ace`, `module list` — and answer what it printed. The " +
|
"its shell — `node show ace`, `module list`, `plans`, `conditions show <key>` — and answer what it " +
|
||||||
"generic verb beside the named ones (novox/hq ADR 0154): everything the binary can do, without a verb " +
|
"printed. The generic verb beside the named ones (novox/hq ADR 0154), and since ADR 0266 it only reads: " +
|
||||||
"per command. Any node may call any tool (ADR 0175), so nothing is held back here.",
|
"status, version, help, seats, healers, hand-acts, durations, collection, images, artifacts, data, builds, " +
|
||||||
|
"queue, plan, plans (not stop/close/go), doctor (not run), conditions list/show/history, node list/show, " +
|
||||||
|
"module list, settings show/preferences, retire list, cleanup list, delivery plan/walks, bus, mirrors (not " +
|
||||||
|
"--record/--confirm). What writes has its named verb; what sets a key, issues a credential or a token, or " +
|
||||||
|
"accepts, recovers or exports a secret is the controller's terminal's alone.",
|
||||||
Input: schema(map[string]string{
|
Input: schema(map[string]string{
|
||||||
"command": "the command line, as the controller's binary takes it; quotes group a word with spaces",
|
"command": "the command line, as the controller's binary takes it; quotes group a word with spaces",
|
||||||
}, []string{"command"})},
|
}, []string{"command"})},
|
||||||
@@ -437,6 +429,15 @@ var ControllerVerbs = []Verb{
|
|||||||
"cause": "with consumer or older-than: the cause in a word (cleanup-waiting when absent)",
|
"cause": "with consumer or older-than: the cause in a word (cleanup-waiting when absent)",
|
||||||
}, nil, "confirm")},
|
}, nil, "confirm")},
|
||||||
// What a consumer gave up on (novox/hq issue 330): kept in DEAD_LETTERS until a person acts on it.
|
// What a consumer gave up on (novox/hq issue 330): kept in DEAD_LETTERS until a person acts on it.
|
||||||
|
{Name: "root-free", Description: "Whether each machine named is root-free now (novox/hq ADR 0259 §8): no agent " +
|
||||||
|
"there can become root without a person. Judged when asked, never from a condition: free only when the machine " +
|
||||||
|
"names an account its agents run as, its node-engine judged that account unable to become root within the " +
|
||||||
|
"last 15 minutes, and the login shell's execute is not served there. Anything else, a read that failed " +
|
||||||
|
"included, is not free and says why. The router asks it before an answer from a channel proving its sender " +
|
||||||
|
"may approve. Only reads.",
|
||||||
|
Input: listed(schema(map[string]string{
|
||||||
|
"machines": "the machines to judge, by name: a list, or one text separated by commas",
|
||||||
|
}, []string{"machines"}), "machines")},
|
||||||
{Name: "dead-letters", Description: "Every message a consumer on the bus gave up on after handing it over " +
|
{Name: "dead-letters", Description: "Every message a consumer on the bus gave up on after handing it over " +
|
||||||
"as often as it may, kept in DEAD_LETTERS: whose consumer, the subject, how often it was handed over and " +
|
"as often as it may, kept in DEAD_LETTERS: whose consumer, the subject, how often it was handed over and " +
|
||||||
"when it was given up, newest first. With id: that one whole, with what it said. With deliver: hand it " +
|
"when it was given up, newest first. With id: that one whole, with what it said. With deliver: hand it " +
|
||||||
@@ -553,6 +554,21 @@ func schema(properties map[string]string, required []string, switches ...string)
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listed makes the named properties of a schema lists of text: a caller gives them as a JSON array (or, as
|
||||||
|
// any argument, one text separated by commas).
|
||||||
|
func listed(in map[string]any, names ...string) map[string]any {
|
||||||
|
props, _ := in["properties"].(map[string]any)
|
||||||
|
for _, n := range names {
|
||||||
|
p, _ := props[n].(map[string]any)
|
||||||
|
if p == nil {
|
||||||
|
panic("a list that is not a property: " + n)
|
||||||
|
}
|
||||||
|
props[n] = map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||||
|
"description": p["description"]}
|
||||||
|
}
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
|
||||||
// unpromised is what a claim says it serves and the seat's protocol never promised.
|
// unpromised is what a claim says it serves and the seat's protocol never promised.
|
||||||
func unpromised(serves []string, promised []Verb) []string {
|
func unpromised(serves []string, promised []Verb) []string {
|
||||||
has := map[string]bool{}
|
has := map[string]bool{}
|
||||||
|
|||||||
@@ -145,6 +145,13 @@ type Condition struct {
|
|||||||
// Raised is when it was first observed this time; LastObserved the newest observation.
|
// Raised is when it was first observed this time; LastObserved the newest observation.
|
||||||
Raised time.Time `json:"raised"`
|
Raised time.Time `json:"raised"`
|
||||||
LastObserved time.Time `json:"last-observed"`
|
LastObserved time.Time `json:"last-observed"`
|
||||||
|
// First is when this fault was first raised, a reopening within ReopenWithin counted as the same
|
||||||
|
// fault; Gaps are the stretches between, each from a clearing to the reopening after it. Absent on a
|
||||||
|
// first raising, and on one written before they were kept. What asks whether the fault was there at a
|
||||||
|
// moment — the gate, at a send — reads OpenAt, never Raised (novox/hq issue 348): a fault cleared and
|
||||||
|
// reopened after a send was there at the send unless the send fell in one of its gaps.
|
||||||
|
First time.Time `json:"first,omitzero"`
|
||||||
|
Gaps []Gap `json:"gaps,omitempty"`
|
||||||
// Observations is how many times it was observed since raised.
|
// Observations is how many times it was observed since raised.
|
||||||
Observations int `json:"observations"`
|
Observations int `json:"observations"`
|
||||||
// Count is how many times it has been raised, a reopening within ReopenWithin counted.
|
// Count is how many times it has been raised, a reopening within ReopenWithin counted.
|
||||||
@@ -274,3 +281,51 @@ func Order(list []Condition) {
|
|||||||
return list[i].Key < list[j].Key
|
return list[i].Key < list[j].Key
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gap is a stretch in which a fault was cleared, between its clearing and its reopening.
|
||||||
|
type Gap struct {
|
||||||
|
Cleared time.Time `json:"cleared"`
|
||||||
|
Reopened time.Time `json:"reopened"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeptGaps is how many gaps a condition keeps. Past it the oldest go, and the fault is said to have begun
|
||||||
|
// at the reopening after the newest of them: earlier history forgotten, never a fault said older than known.
|
||||||
|
const KeptGaps = 8
|
||||||
|
|
||||||
|
// Began is when this fault began as the mesh knows it: its first raising, a reopening within
|
||||||
|
// ReopenWithin being the same fault again (novox/hq issue 348).
|
||||||
|
func (c Condition) Began() time.Time {
|
||||||
|
if !c.First.IsZero() && c.First.Before(c.Raised) {
|
||||||
|
return c.First
|
||||||
|
}
|
||||||
|
return c.Raised
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAt says the fault was there at t: it began at or before t, and t fell in none of its gaps. A fault
|
||||||
|
// that cleared before a send and came back after it was not there at the send — that is the send's to
|
||||||
|
// answer for — while one that flapped after the send was (novox/hq issue 348).
|
||||||
|
func (c Condition) OpenAt(t time.Time) bool {
|
||||||
|
if t.Before(c.Began()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, g := range c.Gaps {
|
||||||
|
if !t.Before(g.Cleared) && t.Before(g.Reopened) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// reopen is c raised again at now after a clearing at cleared, of a fault that began at first with gaps:
|
||||||
|
// the same fault, with one more gap.
|
||||||
|
func (c *Condition) reopen(first time.Time, gaps []Gap, cleared, now time.Time) {
|
||||||
|
if first.IsZero() || !first.Before(now) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.First = first
|
||||||
|
c.Gaps = append(append([]Gap(nil), gaps...), Gap{Cleared: cleared, Reopened: now})
|
||||||
|
if over := len(c.Gaps) - KeptGaps; over > 0 {
|
||||||
|
c.First = c.Gaps[over-1].Reopened
|
||||||
|
c.Gaps = c.Gaps[over:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,8 +53,19 @@ type Action struct {
|
|||||||
Verb string `json:"verb"`
|
Verb string `json:"verb"`
|
||||||
Machine string `json:"machine,omitempty"`
|
Machine string `json:"machine,omitempty"`
|
||||||
Arguments map[string]string `json:"arguments,omitempty"`
|
Arguments map[string]string `json:"arguments,omitempty"`
|
||||||
|
// Level is how much proof its answer needs (novox/hq ADR 0234 §8, ADR 0259): LevelAcknowledge for what
|
||||||
|
// any granted principal may already do, LevelApprove for what only the operator's proven word does.
|
||||||
|
// The controller asks for every action, and performs the one chosen on the warrant the router issues.
|
||||||
|
Level string `json:"level,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The assurance levels an action's answer needs (novox/hq ADR 0234 §8): acknowledge, approve. Destroy is
|
||||||
|
// not asked for by any condition: nothing carries its second proof yet.
|
||||||
|
const (
|
||||||
|
LevelAcknowledge = "acknowledge"
|
||||||
|
LevelApprove = "approve"
|
||||||
|
)
|
||||||
|
|
||||||
// The two verdicts an explanation opens with.
|
// The two verdicts an explanation opens with.
|
||||||
const (
|
const (
|
||||||
NothingToDo = "Nothing for you to do."
|
NothingToDo = "Nothing for you to do."
|
||||||
@@ -66,7 +77,7 @@ const (
|
|||||||
// only kind of answer a desk click performs until answers are authorised (novox/hq ADR 0258). Its cause
|
// only kind of answer a desk click performs until answers are authorised (novox/hq ADR 0258). Its cause
|
||||||
// marks it as an answer, which the hand-act log does not count as a repair.
|
// marks it as an answer, which the hand-act log does not count as a repair.
|
||||||
func SilenceAction(key string) Action {
|
func SilenceAction(key string) Action {
|
||||||
return Action{Label: "Silence for a week", Verb: "mesh-controller.conditions",
|
return Action{Label: "Silence for a week", Verb: "mesh-controller.conditions", Level: LevelAcknowledge,
|
||||||
Arguments: map[string]string{"silence": key, "for": "7d", "why": "", "cause": CauseOperatorAnswer}}
|
Arguments: map[string]string{"silence": key, "for": "7d", "why": "", "cause": CauseOperatorAnswer}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,8 +77,12 @@ type Keeper struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type clearing struct {
|
type clearing struct {
|
||||||
at time.Time
|
at time.Time
|
||||||
count int
|
count int
|
||||||
|
// began is when the fault that cleared began, and gaps its earlier gaps, so its reopening keeps
|
||||||
|
// them (Condition.OpenAt).
|
||||||
|
began time.Time
|
||||||
|
gaps []Gap
|
||||||
silenced *Silence
|
silenced *Silence
|
||||||
// tried is what healers tried before it cleared: a reopening is the same fault, and what was
|
// tried is what healers tried before it cleared: a reopening is the same fault, and what was
|
||||||
// tried on it is still what was tried.
|
// tried on it is still what was tried.
|
||||||
@@ -120,8 +124,8 @@ func NewKeeper(ctx context.Context, o Options) *Keeper {
|
|||||||
if recent, err := k.history.Since(ctx, k.now().Add(-ReopenWithin)); err == nil {
|
if recent, err := k.history.Since(ctx, k.now().Add(-ReopenWithin)); err == nil {
|
||||||
for _, e := range recent {
|
for _, e := range recent {
|
||||||
if e.Change == ChangeCleared {
|
if e.Change == ChangeCleared {
|
||||||
k.cleared[e.Key] = clearing{at: e.At, count: e.Condition.Count, silenced: e.Condition.Silenced,
|
k.cleared[e.Key] = clearing{at: e.At, count: e.Condition.Count, began: e.Condition.Began(), gaps: e.Condition.Gaps,
|
||||||
tried: e.Condition.Tried}
|
silenced: e.Condition.Silenced, tried: e.Condition.Tried}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -197,6 +201,7 @@ func (k *Keeper) Observe(ctx context.Context, o Observation) (Condition, error)
|
|||||||
k.mu.Lock()
|
k.mu.Lock()
|
||||||
if before, ok := k.cleared[key]; ok && now.Sub(before.at) <= ReopenWithin {
|
if before, ok := k.cleared[key]; ok && now.Sub(before.at) <= ReopenWithin {
|
||||||
c.Count, change = before.count+1, ChangeReopened
|
c.Count, change = before.count+1, ChangeReopened
|
||||||
|
c.reopen(before.began, before.gaps, before.at, now)
|
||||||
// A silence a person gave the condition before it cleared still holds: they said
|
// A silence a person gave the condition before it cleared still holds: they said
|
||||||
// they knew, and the same fault again ten minutes later is what they knew about.
|
// they knew, and the same fault again ten minutes later is what they knew about.
|
||||||
if before.silenced != nil && now.Before(before.silenced.Until) {
|
if before.silenced != nil && now.Before(before.silenced.Until) {
|
||||||
@@ -313,7 +318,7 @@ func (k *Keeper) ClearSaying(ctx context.Context, key, why, resolved string) (bo
|
|||||||
}
|
}
|
||||||
now := k.now().UTC()
|
now := k.now().UTC()
|
||||||
k.mu.Lock()
|
k.mu.Lock()
|
||||||
k.cleared[key] = clearing{at: now, count: c.Count, silenced: c.Silenced, tried: c.Tried}
|
k.cleared[key] = clearing{at: now, count: c.Count, began: c.Began(), gaps: c.Gaps, silenced: c.Silenced, tried: c.Tried}
|
||||||
k.mu.Unlock()
|
k.mu.Unlock()
|
||||||
k.tell(Event{Condition: c, At: now, Change: ChangeCleared, Why: why, Cleared: &now})
|
k.tell(Event{Condition: c, At: now, Change: ChangeCleared, Why: why, Cleared: &now})
|
||||||
return true, nil
|
return true, nil
|
||||||
|
|||||||
@@ -378,3 +378,119 @@ func TestATransitionIsOfferedAgainWhileTheBusIsAway(t *testing.T) {
|
|||||||
t.Fatalf("said %+v, unsaid %d", said, k.Unsaid())
|
t.Fatalf("said %+v, unsaid %d", said, k.Unsaid())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **A reopening keeps when the fault began** (novox/hq issue 348): Raised is the reopening, Began the
|
||||||
|
// first raising — also from a keeper that read what cleared from the history — and past the window a
|
||||||
|
// raising is a new fault that begins then.
|
||||||
|
func TestAReopeningKeepsWhenTheFaultBegan(t *testing.T) {
|
||||||
|
k, store, told, c := keeper(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
first, err := k.Observe(ctx, silent("ace"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !first.Began().Equal(first.Raised) || !first.First.IsZero() {
|
||||||
|
t.Fatalf("a first raising began at %s, raised %s, first %s", first.Began(), first.Raised, first.First)
|
||||||
|
}
|
||||||
|
c.pass(30 * time.Second)
|
||||||
|
if _, err := k.Clear(ctx, "machine.ace.silent", "heard again"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c.pass(time.Minute)
|
||||||
|
again, err := k.Observe(ctx, silent("ace"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !again.Raised.After(first.Raised) || !again.Began().Equal(first.Raised) || len(again.Gaps) != 1 ||
|
||||||
|
!again.Gaps[0].Reopened.Equal(again.Raised) || !again.Gaps[0].Cleared.Before(again.Raised) {
|
||||||
|
t.Fatalf("reopened: raised %s, began %s, gaps %+v; first raised %s", again.Raised, again.Began(), again.Gaps, first.Raised)
|
||||||
|
}
|
||||||
|
if !again.OpenAt(first.Raised) || again.OpenAt(again.Gaps[0].Cleared) || !again.OpenAt(again.Raised) ||
|
||||||
|
again.OpenAt(first.Raised.Add(-time.Second)) {
|
||||||
|
t.Fatalf("open at the wrong moments: %+v", again)
|
||||||
|
}
|
||||||
|
// Through a restarted controller, reading the clearing from the history.
|
||||||
|
if _, err := k.Clear(ctx, "machine.ace.silent", "heard again"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
settled(t, told, 4)
|
||||||
|
k.Close(context.Background())
|
||||||
|
next := NewKeeper(ctx, Options{Store: store, History: store, Now: c.now})
|
||||||
|
defer next.Close(context.Background())
|
||||||
|
c.pass(time.Minute)
|
||||||
|
third, err := next.Observe(ctx, silent("ace"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !third.Began().Equal(first.Raised) || len(third.Gaps) != 2 {
|
||||||
|
t.Fatalf("after a restart the reopening began at %s, not %s, with gaps %+v", third.Began(), first.Raised, third.Gaps)
|
||||||
|
}
|
||||||
|
if _, err := next.Clear(ctx, "machine.ace.silent", "heard again"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c.pass(ReopenWithin + time.Minute)
|
||||||
|
fourth, err := next.Observe(ctx, silent("ace"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !fourth.Began().Equal(fourth.Raised) || len(fourth.Gaps) != 0 {
|
||||||
|
t.Fatalf("past the window the fault began at %s, raised %s, gaps %+v", fourth.Began(), fourth.Raised, fourth.Gaps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past KeptGaps the oldest gaps go, and the fault is said to have begun after them, never before.
|
||||||
|
func TestAFaultKeepsItsNewestGapsAndForgetsWhatWasBefore(t *testing.T) {
|
||||||
|
t0 := time.Date(2026, 10, 9, 10, 0, 0, 0, time.UTC)
|
||||||
|
c := Condition{Raised: t0}
|
||||||
|
first, gaps := t0, []Gap(nil)
|
||||||
|
for i := 1; i <= KeptGaps+2; i++ {
|
||||||
|
cleared, now := t0.Add(time.Duration(2*i)*time.Minute), t0.Add(time.Duration(2*i+1)*time.Minute)
|
||||||
|
c = Condition{Raised: now}
|
||||||
|
c.reopen(first, gaps, cleared, now)
|
||||||
|
first, gaps = c.Began(), c.Gaps
|
||||||
|
}
|
||||||
|
if len(c.Gaps) != KeptGaps || !c.Began().Equal(t0.Add(5*time.Minute)) || c.OpenAt(t0.Add(time.Minute)) {
|
||||||
|
t.Fatalf("after %d gaps: began %s, %d gaps", KeptGaps+2, c.Began(), len(c.Gaps))
|
||||||
|
}
|
||||||
|
// A first time not before the reopening is no earlier fault.
|
||||||
|
d := Condition{Raised: t0}
|
||||||
|
d.reopen(t0, nil, t0.Add(-time.Minute), t0)
|
||||||
|
if !d.First.IsZero() || len(d.Gaps) != 0 {
|
||||||
|
t.Fatalf("a fault reopened at its own first raising: %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two reopenings in one keeper, each after ClearSaying: both gaps kept, the fault begun at its first raising,
|
||||||
|
// and open again at the second clearing's reopening.
|
||||||
|
func TestASecondReopeningKeepsBothGaps(t *testing.T) {
|
||||||
|
k, _, _, c := keeper(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
first, err := k.Observe(ctx, silent("ace"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var cleared []time.Time
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
c.pass(30 * time.Second)
|
||||||
|
cleared = append(cleared, c.now().UTC())
|
||||||
|
if ok, err := k.ClearSaying(ctx, "machine.ace.silent", "heard again", "ace is heard again"); err != nil || !ok {
|
||||||
|
t.Fatalf("cleared %v: %v", ok, err)
|
||||||
|
}
|
||||||
|
c.pass(time.Minute)
|
||||||
|
if _, err := k.Observe(ctx, silent("ace")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, err := k.Open(ctx)
|
||||||
|
if err != nil || len(got) != 1 {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
g := got[0]
|
||||||
|
if !g.Began().Equal(first.Raised) || len(g.Gaps) != 2 || !g.Gaps[0].Cleared.Equal(cleared[0]) ||
|
||||||
|
!g.Gaps[1].Cleared.Equal(cleared[1]) || !g.Gaps[1].Reopened.Equal(g.Raised) || g.Count != 3 {
|
||||||
|
t.Fatalf("after two reopenings: began %s, gaps %+v, count %d", g.Began(), g.Gaps, g.Count)
|
||||||
|
}
|
||||||
|
if g.OpenAt(cleared[0].Add(time.Second)) || !g.OpenAt(cleared[0].Add(-time.Second)) || g.OpenAt(cleared[1].Add(time.Second)) {
|
||||||
|
t.Fatalf("open at the wrong moments: %+v", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,6 +131,10 @@ type Machine struct {
|
|||||||
// home is when that is not the derived one.
|
// home is when that is not the derived one.
|
||||||
Account string `json:"account,omitempty"`
|
Account string `json:"account,omitempty"`
|
||||||
AccountHome string `json:"account-home,omitempty"`
|
AccountHome string `json:"account-home,omitempty"`
|
||||||
|
// AgentAccount is the account agents run as there when it is not the operator's (a pseudonym), and
|
||||||
|
// AgentAccountHome its home when not derived (novox/hq ADR 0266).
|
||||||
|
AgentAccount string `json:"agent-account,omitempty"`
|
||||||
|
AgentAccountHome string `json:"agent-account-home,omitempty"`
|
||||||
// PublicDomain is the domain it answers for, its labels replaced.
|
// PublicDomain is the domain it answers for, its labels replaced.
|
||||||
PublicDomain string `json:"public-domain,omitempty"`
|
PublicDomain string `json:"public-domain,omitempty"`
|
||||||
// Assigned is every module assigned there.
|
// Assigned is every module assigned there.
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package inventory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The agent account (novox/hq ADR 0266): recorded and read back with every node, its home derived when
|
||||||
|
// not stated, cleared by an empty name — and refused when it is root, the operator's own account, or no
|
||||||
|
// login at all, because each of those would say agents have an account of their own while they do not.
|
||||||
|
func TestAgentAccountIsRecordedAndRefusedWhereItWouldNotConfine(t *testing.T) {
|
||||||
|
inv := ForTest(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.SetAccount(ctx, "anchor", "operator", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := inv.NodeByName(ctx, "anchor")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n.AgentAccount != "" || n.AgentHome() != "" {
|
||||||
|
t.Fatalf("a node that names none has agent account %q, home %q", n.AgentAccount, n.AgentHome())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := inv.SetAgentAccount(ctx, "anchor", "agent", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
n, _ = inv.NodeByName(ctx, "anchor")
|
||||||
|
if n.AgentAccount != "agent" || n.AgentHome() != "/home/agent" {
|
||||||
|
t.Fatalf("agent account %q, home %q; want agent, /home/agent", n.AgentAccount, n.AgentHome())
|
||||||
|
}
|
||||||
|
all, err := inv.Nodes(ctx)
|
||||||
|
if err != nil || len(all) != 1 || all[0].AgentAccount != "agent" {
|
||||||
|
t.Fatalf("the listing does not carry the agent account: %+v %v", all, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := inv.SetAgentAccount(ctx, "anchor", "agent", "/srv/agent"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n, _ = inv.NodeByName(ctx, "anchor"); n.AgentHome() != "/srv/agent" {
|
||||||
|
t.Fatalf("the stated home is %q", n.AgentHome())
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range []struct{ account, home, says string }{
|
||||||
|
{"root", "", "may not run as root"},
|
||||||
|
{"operator", "", "operator account"},
|
||||||
|
{"Agent", "", "not a login name"},
|
||||||
|
{"9agent", "", "not a login name"},
|
||||||
|
{"agent", "relative", "absolute"},
|
||||||
|
{"postgres", "", "service account"},
|
||||||
|
{"systemd-network", "", "service account"},
|
||||||
|
{"showcase", "", "service account"},
|
||||||
|
{"", "/home/x", "without an agent account"},
|
||||||
|
} {
|
||||||
|
err := inv.SetAgentAccount(ctx, "anchor", c.account, c.home)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), c.says) {
|
||||||
|
t.Errorf("%q %q: %v; want a refusal saying %q", c.account, c.home, err, c.says)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n, _ = inv.NodeByName(ctx, "anchor"); n.AgentAccount != "agent" {
|
||||||
|
t.Fatalf("a refusal changed the record: %q", n.AgentAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other direction: the operator account may not be named as the agent account either.
|
||||||
|
if err := inv.SetAccount(ctx, "anchor", "agent", ""); err == nil || !strings.Contains(err.Error(), "agent account") {
|
||||||
|
t.Fatalf("the operator account named as the agent account: %v; want a refusal", err)
|
||||||
|
}
|
||||||
|
if n, _ = inv.NodeByName(ctx, "anchor"); n.Account != "operator" {
|
||||||
|
t.Fatalf("a refusal changed the operator account: %q", n.Account)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := inv.SetAgentAccount(ctx, "anchor", "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.SetAccount(ctx, "anchor", "agent", ""); err != nil {
|
||||||
|
t.Fatalf("with the agent account cleared, the name is free: %v", err)
|
||||||
|
}
|
||||||
|
if err := inv.SetAccount(ctx, "anchor", "operator", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n, _ = inv.NodeByName(ctx, "anchor"); n.AgentAccount != "" || n.AgentAccountHome != "" {
|
||||||
|
t.Fatalf("clearing left %q %q", n.AgentAccount, n.AgentAccountHome)
|
||||||
|
}
|
||||||
|
if err := inv.SetAgentAccount(ctx, "nowhere", "agent", ""); !errors.Is(err, ErrNoSuchNode) {
|
||||||
|
t.Fatalf("an unknown node: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package inventory
|
||||||
|
|
||||||
|
// The bus of the lab's proof of the operator's answers (mesh-lab `asks/`, novox/hq ADR 0259).
|
||||||
|
//
|
||||||
|
// The proof runs the router, the Telegram channel and an asker against a real bus, and the bus must be the
|
||||||
|
// one this controller would compose — not a copy of its rules written again in the lab, which would prove
|
||||||
|
// the copy. So the lab asks this test, at the controller's commit, for both halves:
|
||||||
|
//
|
||||||
|
// 1. **Composed** (MESH_LAB_ASKS_OUT and MESH_LAB_ASKS_CATALOGUE set): one machine, `anchor`, running the
|
||||||
|
// router (messenger), the Telegram channel, the desk channel and the machine's runtime as the catalogue
|
||||||
|
// declares them, beside two modules of the lab's own — `lab-asker`, which uses `operator-channel`, and
|
||||||
|
// `lab-bystander`, which does not. Written to the directory: the accounts block exactly as Users and
|
||||||
|
// ComposeAccounts make it, each user's credential, and every membership as MembershipFor makes it.
|
||||||
|
// 2. **Raised** (MESH_LAB_ASKS_BUS set as well): on the lab's running bus, as the controller, what a send
|
||||||
|
// asserts — the mesh's streams and consumers, the seats' work queues and workers, the modules' buckets —
|
||||||
|
// and every membership published where the runtime reads it.
|
||||||
|
//
|
||||||
|
// Without those words it skips: the controller's own suite has nothing to raise.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// labMachine is the one machine of the lab's bus.
|
||||||
|
const labMachine = "anchor"
|
||||||
|
|
||||||
|
// The lab's own modules: one that asks, one that may not.
|
||||||
|
var labManifests = []string{
|
||||||
|
`{"module": "lab-asker", "version": "1", "uses": ["operator-channel"], "state": ["acted"],
|
||||||
|
"own-secrets": {"broker": "${dir:state}/broker"},
|
||||||
|
"resources": [{"id": "state", "type": "directory", "mode": "0700", "place": "."}]}`,
|
||||||
|
`{"module": "lab-bystander", "version": "1", "own-secrets": {"broker": "${dir:state}/broker"},
|
||||||
|
"resources": [{"id": "state", "type": "directory", "mode": "0700", "place": "."}]}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// labCredential is what a lab process connects as: the runtime's credential shape (mesh-tools bus.Credential).
|
||||||
|
type labCredential struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Node string `json:"node,omitempty"`
|
||||||
|
Module string `json:"module,omitempty"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheAsksLabBus(t *testing.T) {
|
||||||
|
out, modules := os.Getenv("MESH_LAB_ASKS_OUT"), os.Getenv("MESH_LAB_ASKS_CATALOGUE")
|
||||||
|
if out == "" || modules == "" {
|
||||||
|
t.Skip("the lab did not ask for its bus (MESH_LAB_ASKS_OUT, MESH_LAB_ASKS_CATALOGUE)")
|
||||||
|
}
|
||||||
|
var manifests []catalogue.Manifest
|
||||||
|
read := func(raw []byte, from string) {
|
||||||
|
m, err := catalogue.ParseManifest(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", from, err)
|
||||||
|
}
|
||||||
|
manifests = append(manifests, m)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"messenger", "telegram", "desk-channel"} {
|
||||||
|
path := filepath.Join(modules, name, "module.json")
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
read(raw, path)
|
||||||
|
}
|
||||||
|
if path := os.Getenv("MESH_LAB_ASKS_RUNTIME"); path != "" {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
read(raw, path)
|
||||||
|
}
|
||||||
|
for i, raw := range labManifests {
|
||||||
|
read([]byte(raw), "the lab's module "+string(rune('1'+i)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// As BusRecords reads the store: every seat any module declares, the mesh's own beside them.
|
||||||
|
seats := map[string]catalogue.SeatDeclaration{}
|
||||||
|
declarers := map[string]string{}
|
||||||
|
for _, m := range manifests {
|
||||||
|
for _, s := range m.DefinesSeats {
|
||||||
|
seats[s.Name], declarers[s.Name] = s, m.Module
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, own := range catalogue.SeatsWithAProtocol() {
|
||||||
|
seats[own.Name] = catalogue.SeatDeclaration{Name: own.Name, Scope: own.Scope, Accepts: own.Accepts,
|
||||||
|
Emits: own.Emits, Serves: own.Serves}
|
||||||
|
}
|
||||||
|
records := broker.Records{Nodes: []string{labMachine}, Assigned: map[string][]broker.Declared{},
|
||||||
|
People: map[string][]string{}, Interchangeable: map[string]bool{}, RootFree: map[string]bool{}}
|
||||||
|
// Whether the lab's machine is root-free is the lab's to say (MESH_LAB_ASKS_ROOT_FREE=true): it has no
|
||||||
|
// node-engine to judge it. Unsaid, it is not, and no kind is composed with verified-sender — as a push
|
||||||
|
// composes on a machine that is not (novox/hq ADR 0259 §8).
|
||||||
|
if os.Getenv("MESH_LAB_ASKS_ROOT_FREE") == "true" {
|
||||||
|
records.RootFree[labMachine] = true
|
||||||
|
}
|
||||||
|
var buckets []broker.Bucket
|
||||||
|
var trafficSeats []broker.Seat
|
||||||
|
for _, m := range manifests {
|
||||||
|
records.Assigned[labMachine] = append(records.Assigned[labMachine], declaredFor(m, seats, declarers))
|
||||||
|
buckets = append(buckets, bucketsOf(m)...)
|
||||||
|
for _, s := range m.DefinesSeats {
|
||||||
|
if seat := asSeat(s, m.Module); seat.Kinded || len(seat.ByCaller) > 0 {
|
||||||
|
trafficSeats = append(trafficSeats, seat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
users, err := broker.Users(records)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each user a password of the lab's, the hash in the composition.
|
||||||
|
passwords := map[string]string{}
|
||||||
|
if raw, err := os.ReadFile(filepath.Join(out, "passwords.json")); err == nil {
|
||||||
|
_ = json.Unmarshal(raw, &passwords)
|
||||||
|
}
|
||||||
|
for i, u := range users {
|
||||||
|
name := u.Username()
|
||||||
|
if passwords[name] == "" {
|
||||||
|
passwords[name] = "lab-" + name + "-" + time.Now().Format("150405.000000")
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(passwords[name]), bcrypt.MinCost)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
users[i].PasswordHash = string(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
bus := os.Getenv("MESH_LAB_ASKS_BUS")
|
||||||
|
if bus == "" {
|
||||||
|
accounts, err := broker.ComposeAccounts(users)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
creds := map[string]labCredential{}
|
||||||
|
for _, u := range users {
|
||||||
|
creds[u.Username()] = labCredential{Node: u.Node, Module: u.Module, User: u.Username(),
|
||||||
|
Password: passwords[u.Username()]}
|
||||||
|
}
|
||||||
|
where := broker.PlacementsOf(records, records.Interchangeable)
|
||||||
|
memberships := map[string]broker.Membership{}
|
||||||
|
for _, d := range records.Assigned[labMachine] {
|
||||||
|
memberships[d.Module] = broker.MembershipFor(labMachine, d, where)
|
||||||
|
}
|
||||||
|
write(t, filepath.Join(out, "accounts.conf"), []byte(accounts))
|
||||||
|
writeJSON(t, filepath.Join(out, "passwords.json"), passwords)
|
||||||
|
writeJSON(t, filepath.Join(out, "credentials.json"), creds)
|
||||||
|
writeJSON(t, filepath.Join(out, "memberships.json"), memberships)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raised on the lab's bus, as the controller, as a send asserts it (cmd/mesh-controller busobjects.go).
|
||||||
|
js, err := broker.Dial(bus, nats.UserInfo("controller", passwords["controller"]), nats.CustomInboxPrefix("_INBOX.controller"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the lab's bus, as the controller: %v", err)
|
||||||
|
}
|
||||||
|
defer js.Close()
|
||||||
|
if err := broker.Raise(js, records.Nodes); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
holders := map[string]broker.Holder{}
|
||||||
|
for _, d := range records.Assigned[labMachine] {
|
||||||
|
for _, s := range d.Holds {
|
||||||
|
if _, taken := holders[s.Name]; !taken {
|
||||||
|
holders[s.Name] = broker.Holder{Node: labMachine, Module: d.Module}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := broker.RaiseSeats(js, MeshSeats(), holders); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
streams, workers := broker.SeatTrafficObjects(users)
|
||||||
|
have := map[string]bool{}
|
||||||
|
for _, s := range streams {
|
||||||
|
have[s.Name] = true
|
||||||
|
}
|
||||||
|
for _, s := range broker.TrafficQueues(trafficSeats) {
|
||||||
|
if !have[s.Name] {
|
||||||
|
streams, have[s.Name] = append(streams, s), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range streams {
|
||||||
|
if err := js.EnsureStream(s); err != nil {
|
||||||
|
t.Fatalf("the work queue %s: %v", s.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range workers {
|
||||||
|
if err := js.EnsureConsumer(c); err != nil {
|
||||||
|
t.Fatalf("the worker %s: %v", c.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range broker.ConsumersOf(users) {
|
||||||
|
if err := js.EnsureConsumer(c.Consumer); err != nil {
|
||||||
|
t.Fatalf("how %s hears what it consumes: %v", c.Module, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := broker.RaiseBuckets(js, buckets); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := js.EnsureControllerBuckets(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
where := broker.PlacementsOf(records, records.Interchangeable)
|
||||||
|
names := make([]string, 0)
|
||||||
|
for _, d := range records.Assigned[labMachine] {
|
||||||
|
body, err := json.Marshal(broker.MembershipFor(labMachine, d, where))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := js.Context().Publish(broker.MembershipSubject(labMachine, d.Module), body); err != nil {
|
||||||
|
t.Fatalf("issuing %s its membership: %v", d.Module, err)
|
||||||
|
}
|
||||||
|
names = append(names, d.Module)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
t.Logf("raised on %s: %d streams of seats, %d workers, %d buckets, memberships for %v", bus, len(streams),
|
||||||
|
len(workers), len(buckets), names)
|
||||||
|
}
|
||||||
|
|
||||||
|
func write(t *testing.T, path string, body []byte) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(t *testing.T, path string, v any) {
|
||||||
|
t.Helper()
|
||||||
|
body, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
write(t, path, body)
|
||||||
|
}
|
||||||
@@ -34,9 +34,12 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) {
|
|||||||
// that seat promises. **Across the whole catalogue, not one manifest**: a seat is declared by
|
// that seat promises. **Across the whole catalogue, not one manifest**: a seat is declared by
|
||||||
// one module and held by another, which is the whole reason a seat exists (ADR 0118).
|
// one module and held by another, which is the whole reason a seat exists (ADR 0118).
|
||||||
seats := map[string]catalogue.SeatDeclaration{}
|
seats := map[string]catalogue.SeatDeclaration{}
|
||||||
|
// And who declared each, so a seat's records are named as the declaring module's buckets (ADR 0259).
|
||||||
|
declarers := map[string]string{}
|
||||||
for _, m := range declared {
|
for _, m := range declared {
|
||||||
for _, s := range m.DefinesSeats {
|
for _, s := range m.DefinesSeats {
|
||||||
seats[s.Name] = s
|
seats[s.Name] = s
|
||||||
|
declarers[s.Name] = m.Module
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// And the mesh's own, which carry protocol too (novox/hq ADR 0121). Added after the modules'
|
// And the mesh's own, which carry protocol too (novox/hq ADR 0121). Added after the modules'
|
||||||
@@ -78,7 +81,7 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) {
|
|||||||
"%s is assigned to %s and is not in the catalogue, so what it may say cannot "+
|
"%s is assigned to %s and is not in the catalogue, so what it may say cannot "+
|
||||||
"be derived", module, n.Name)
|
"be derived", module, n.Name)
|
||||||
}
|
}
|
||||||
d := declaredFor(m, seats)
|
d := declaredFor(m, seats, declarers)
|
||||||
// And the state offered to it as a seat's holder by the modules beside it (novox/hq ADR 0255).
|
// And the state offered to it as a seat's holder by the modules beside it (novox/hq ADR 0255).
|
||||||
// For this machine's key alone (novox/hq ADR 0260): a bar shows its own machine's draw.
|
// For this machine's key alone (novox/hq ADR 0260): a bar shows its own machine's draw.
|
||||||
for _, sr := range catalogue.ReadsGranted(m, onMachine(declared, modules), n.Name) {
|
for _, sr := range catalogue.ReadsGranted(m, onMachine(declared, modules), n.Name) {
|
||||||
@@ -121,7 +124,7 @@ func onMachine(declared map[string]catalogue.Manifest, modules []string) []catal
|
|||||||
|
|
||||||
// declaredFor is one module's manifest as the composer needs it: what it says about itself, and the
|
// declaredFor is one module's manifest as the composer needs it: what it says about itself, and the
|
||||||
// protocol of every seat it holds or uses.
|
// protocol of every seat it holds or uses.
|
||||||
func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaration) broker.Declared {
|
func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaration, declarers map[string]string) broker.Declared {
|
||||||
// A consumed name is a module's event unless it names a seat, and only somebody holding the seat
|
// A consumed name is a module's event unless it names a seat, and only somebody holding the seat
|
||||||
// set can tell (novox/hq ADR 0121). Split here, because the composer cannot look at a name and
|
// set can tell (novox/hq ADR 0121). Split here, because the composer cannot look at a name and
|
||||||
// know — and a role's event read as a module's is a subscription to a namespace nobody owns.
|
// know — and a role's event read as a module's is a subscription to a namespace nobody owns.
|
||||||
@@ -132,6 +135,16 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
|
|||||||
if named {
|
if named {
|
||||||
// A seat's event when the seat says it; else the event of the module of that name — a seat and
|
// A seat's event when the seat says it; else the event of the module of that name — a seat and
|
||||||
// the module holding it may share a name (mesh-delivery, novox/hq ADR 0239).
|
// the module holding it may share a name (mesh-delivery, novox/hq ADR 0239).
|
||||||
|
if s, isASeat := seats[emitter]; isASeat && s.Kinded {
|
||||||
|
// A kinded bench's event is named `<event>` or `<event>.*` and heard from every kind; its
|
||||||
|
// proofs are answered by whoever watches it (ADR 0259 §3).
|
||||||
|
ev := strings.TrimSuffix(event, ".*")
|
||||||
|
if catalogue.SeatSays(s.Emits, ev) {
|
||||||
|
watches = append(watches, broker.Seat{Name: s.Name, Scope: s.Scope, Emits: []string{ev},
|
||||||
|
Kinded: true, Proofs: s.Proofs, DeclaredBy: declarers[s.Name]})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
if s, isASeat := seats[emitter]; isASeat && catalogue.SeatSays(s.Emits, event) {
|
if s, isASeat := seats[emitter]; isASeat && catalogue.SeatSays(s.Emits, event) {
|
||||||
watches = append(watches, broker.Seat{Name: s.Name, Emits: []string{event}})
|
watches = append(watches, broker.Seat{Name: s.Name, Emits: []string{event}})
|
||||||
continue
|
continue
|
||||||
@@ -155,6 +168,8 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
|
|||||||
Reads: m.Reads,
|
Reads: m.Reads,
|
||||||
// And the tools its health asks (novox/hq ADR 0240): the machine's node-engine is granted them.
|
// And the tools its health asks (novox/hq ADR 0240): the machine's node-engine is granted them.
|
||||||
Checks: catalogue.HealthChecks(m),
|
Checks: catalogue.HealthChecks(m),
|
||||||
|
// And whether it runs as an account of its own (novox/hq ADR 0259 §8).
|
||||||
|
RunsAs: m.RunsAs,
|
||||||
}
|
}
|
||||||
// Whether it can be given an account at all: delivered as its own secret named broker, so one
|
// Whether it can be given an account at all: delivered as its own secret named broker, so one
|
||||||
// that declares none has nowhere to read it (novox/hq issue 195).
|
// that declares none has nowhere to read it (novox/hq issue 195).
|
||||||
@@ -168,12 +183,15 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
|
|||||||
// Every seat with a protocol, the mesh's own included. One that says only who does a job is
|
// Every seat with a protocol, the mesh's own included. One that says only who does a job is
|
||||||
// not here and grants nothing, which is most of them.
|
// not here and grants nothing, which is most of them.
|
||||||
if s, hasAProtocol := seats[c.Name]; hasAProtocol {
|
if s, hasAProtocol := seats[c.Name]; hasAProtocol {
|
||||||
d.Holds = append(d.Holds, asSeat(s))
|
held := asSeat(s, declarers[s.Name])
|
||||||
|
held.Kind = c.Kind
|
||||||
|
held.Capabilities = c.Capabilities
|
||||||
|
d.Holds = append(d.Holds, held)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, name := range m.Uses {
|
for _, name := range m.Uses {
|
||||||
if s, declaredSomewhere := seats[name]; declaredSomewhere {
|
if s, declaredSomewhere := seats[name]; declaredSomewhere {
|
||||||
d.Uses = append(d.Uses, asSeat(s))
|
d.Uses = append(d.Uses, asSeat(s, declarers[s.Name]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return d
|
return d
|
||||||
@@ -204,9 +222,17 @@ func (i *Inventory) DeclaredBuckets(ctx context.Context) ([]broker.Bucket, error
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func asSeat(s catalogue.SeatDeclaration) broker.Seat {
|
func asSeat(s catalogue.SeatDeclaration, declarer string) broker.Seat {
|
||||||
return broker.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits,
|
seat := broker.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits,
|
||||||
Serves: catalogue.VerbNames(s.Serves)}
|
Serves: catalogue.VerbNames(s.Serves), Kinded: s.Kinded, ByCaller: s.ByCaller, Proofs: s.Proofs,
|
||||||
|
DeclaredBy: declarer}
|
||||||
|
// A seat's records are its declaring module's buckets, named as the bus holds them (ADR 0259 §3).
|
||||||
|
for _, r := range s.Records {
|
||||||
|
if declarer != "" {
|
||||||
|
seat.Records = append(seat.Records, broker.BucketName(declarer, r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seat
|
||||||
}
|
}
|
||||||
|
|
||||||
// MeshSeats are the mesh's own seats that carry a protocol, as the bus needs them: what to make a work
|
// MeshSeats are the mesh's own seats that carry a protocol, as the bus needs them: what to make a work
|
||||||
@@ -277,3 +303,22 @@ func heldHere(claimed []broker.Seat, holdings []catalogue.Held, node, module str
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeclaredTrafficSeats is every seat any registered module declares that names its caller or its kind
|
||||||
|
// (novox/hq ADR 0259 §3), as the bus needs it: assigned or not, so its work queue exists from registration.
|
||||||
|
func (i *Inventory) DeclaredTrafficSeats(ctx context.Context) ([]broker.Seat, error) {
|
||||||
|
declared, err := i.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot read the catalogue: %w", err)
|
||||||
|
}
|
||||||
|
var out []broker.Seat
|
||||||
|
for _, m := range declared {
|
||||||
|
for _, s := range m.DefinesSeats {
|
||||||
|
seat := asSeat(s, m.Module)
|
||||||
|
if seat.Kinded || len(seat.ByCaller) > 0 {
|
||||||
|
out = append(out, seat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -169,6 +169,27 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SourceIdentity is the forge's id of the repository a module is registered from, 0 when none is recorded.
|
||||||
|
func (i *Inventory) SourceIdentity(ctx context.Context, module string) (int64, error) {
|
||||||
|
var id *int64
|
||||||
|
err := i.store.Pool().QueryRow(ctx, `select source_repo_id from module where name = $1`, module).Scan(&id)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return 0, fmt.Errorf("%w: %s", ErrNoSuchModule, module)
|
||||||
|
}
|
||||||
|
if err != nil || id == nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return *id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSourceIdentity records the forge's id of the repository a module is registered from. 0 — the forge could not
|
||||||
|
// say, at the terminal — keeps the id already recorded: an unknown is never a reason to forget what was known.
|
||||||
|
func (i *Inventory) SetSourceIdentity(ctx context.Context, module string, id int64) error {
|
||||||
|
_, err := i.store.Pool().Exec(ctx,
|
||||||
|
`update module set source_repo_id = coalesce(nullif($2::bigint, 0), source_repo_id) where name = $1`, module, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// hasModule is whether the catalogue holds a module of that name.
|
// hasModule is whether the catalogue holds a module of that name.
|
||||||
func (i *Inventory) hasModule(ctx context.Context, name string) (bool, error) {
|
func (i *Inventory) hasModule(ctx context.Context, name string) (bool, error) {
|
||||||
var one int
|
var one int
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -169,6 +170,12 @@ func (i *Inventory) PreviousBuild(ctx context.Context, module, commit string, fa
|
|||||||
if b.ID == failed.ID || (commit != "" && b.Commit != commit) {
|
if b.ID == failed.ID || (commit != "" && b.Commit != commit) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Only a build of the repository the failed build was made from — the module's, since its take-in
|
||||||
|
// registered it (novox/hq ADR 0266): a build of the module's name from another repository is recorded
|
||||||
|
// and was never registered, and putting it back would register it now.
|
||||||
|
if failed.Repository != "" && !sameRepositoryAs(b.Repository, failed.Repository) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if !failed.AskedOrAt().IsZero() && !b.AskedOrAt().Before(failed.AskedOrAt()) {
|
if !failed.AskedOrAt().IsZero() && !b.AskedOrAt().Before(failed.AskedOrAt()) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -190,6 +197,14 @@ func (i *Inventory) PreviousBuild(ctx context.Context, module, commit string, fa
|
|||||||
return Build{}, false, nil
|
return Build{}, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sameRepositoryAs says two recorded repositories are one, however their case or `.git` is spelled.
|
||||||
|
func sameRepositoryAs(a, b string) bool {
|
||||||
|
trim := func(s string) string {
|
||||||
|
return strings.TrimSuffix(strings.TrimRight(strings.ToLower(strings.TrimSpace(s)), "/"), ".git")
|
||||||
|
}
|
||||||
|
return trim(a) == trim(b)
|
||||||
|
}
|
||||||
|
|
||||||
// RestoreModule puts a module's registered build back to an earlier one: its manifest, the commit it
|
// RestoreModule puts a module's registered build back to an earlier one: its manifest, the commit it
|
||||||
// was built from, and when it was asked — as now, so the build that failed its gate, asked before, can
|
// was built from, and when it was asked — as now, so the build that failed its gate, asked before, can
|
||||||
// never register over it again (issue 219's order). The source's head is left where the merge moved it:
|
// never register over it again (issue 219's order). The source's head is left where the merge moved it:
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ type ResourceHealth struct {
|
|||||||
// Account is the account whose own service manager runs it, or the account a resource of kind account
|
// Account is the account whose own service manager runs it, or the account a resource of kind account
|
||||||
// is (novox/hq ADR 0254).
|
// is (novox/hq ADR 0254).
|
||||||
Account string `json:"account,omitempty"`
|
Account string `json:"account,omitempty"`
|
||||||
|
// Root is "never" on an account verdict that judged whether the account can become root without a
|
||||||
|
// person (novox/hq ADR 0266).
|
||||||
|
Root string `json:"root,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NodeHealth is a machine's newest statement, as kept.
|
// NodeHealth is a machine's newest statement, as kept.
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- A node names the account its 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: any agent there could become root without a person. The decision is an account of the
|
||||||
|
-- agents' own, without sudo, beside the operator's, who keeps theirs. Stated by the operator at the
|
||||||
|
-- controller's terminal, like the operator account (migration 0036), and never by a verb or a setting,
|
||||||
|
-- so no agent can change which account it is.
|
||||||
|
--
|
||||||
|
-- Empty rather than null, as the operator account is: empty is a real state, "agents run as the
|
||||||
|
-- operator's account here" — a workstation's today. The home is stored only when it is not
|
||||||
|
-- /home/<account>; empty means derive it.
|
||||||
|
alter table node add column agent_account text not null default '';
|
||||||
|
alter table node add column agent_account_home text not null default '';
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- A build asked at the controller's terminal says so (novox/hq ADR 0266).
|
||||||
|
--
|
||||||
|
-- A build's outcome registers its module, and a module is what the next push sends: a module named `sudo`
|
||||||
|
-- built from a repository an agent made would grant whoever wrote it root on every node it is assigned.
|
||||||
|
-- So an outcome may register a module only from the repository the catalogue already builds it from — or,
|
||||||
|
-- for a module new to the catalogue, from a repository the catalogue already builds another module from.
|
||||||
|
-- Anything else — a module moved to another repository, a new module from a new repository — is the
|
||||||
|
-- operator's, at the controller's terminal. This column is how the take-in tells: true only for a build
|
||||||
|
-- request kept by a `build` or `replay --register` run at the terminal, never through a verb (whoever may
|
||||||
|
-- call a verb includes agents). False for every request kept before this column existed.
|
||||||
|
alter table build_request add column at_terminal boolean not null default false;
|
||||||
|
|
||||||
|
-- And which repository a module is registered from, by the forge's own id for it (novox/hq ADR 0266): a name
|
||||||
|
-- is not an identity. A repository deleted and made again under the same name is another repository, with
|
||||||
|
-- none of the protection the first had until someone sets it; its outcome must not register as the module's.
|
||||||
|
-- Recorded when a build of it is registered from the mesh's own forge; null until then, and for a source the
|
||||||
|
-- forge does not hold.
|
||||||
|
alter table module add column source_repo_id bigint;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user