Author SHA1 Message Date
mesh-admin d059311c0f Merge pull request 'Describe node-nfs-server's exports and test as per-node addresses (hq ADR 0263, review follow-up)' (#166) from fix/shares-review-followups into main 2026-10-08 21:13:05 +00:00
mesh-admin 1a28cb3357 Merge pull request 'Hold the delivery planner to its recorded rules (table + property); a false cycle report found' (#167) from test/planner-rules into main 2026-10-08 20:41:15 +00:00
jochen 758537dd4e Plan a controller merge in the worker-of order in the three-kinds test
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
The test's fixture had no worker-of edge and expected the builder first,
then the controller and the proxy together: the order before hq issue 206.
Since then the build seat's holder follows the controller that defines its
worker, and every controller merge plans controller, builder, proxy in
three tiers. The fixture now carries the edge and the test that order.
2026-10-08 22:22:24 +02:00
jochen add807f034 Say no cycle for a packages edge in a plan's last tier
A packages edge orders nothing, so a module and what packages its source
share a tier by rule; hasCycle counted the edge and the merge handler said
"the last tier depends on itself" of plans with no cycle. Skip the kind as
tiersOf does. The planner tests' cycle rows and property now pass.
2026-10-08 22:22:02 +02:00
jochen 8ca4b04321 Hold the delivery planner to its recorded rules with a table and a property
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check fail: its merge-check.sh failed: FAIL github.com/novox/mesh-controller/cmd/mesh-controller 167.881s
mesh/delivery superseded: a newer head of the same pull request
A table of merges (two repositories, every edge kind, a diamond, a cycle,
files no build reads) and a seeded property over 500 random catalogues pin
what a merge moves and in which tiers, per ADR 0162 and ADR 0238 §3. The
shared-repository rows document today's behaviour that issue 338 would
change, once with hand edges and once with edges derived from the store.

The cycle check fails on main: hasCycle reads a packages edge between two
modules of the last tier as a cycle, so a plan with none is said to have
one. Left failing, marked BUG, for the planner's fix.
2026-10-08 22:15:20 +02:00
mesh-admin 8170fc58a3 Merge pull request 'Keep a passed gate's verdict when the first machine's later reports go quiet (hq issue 335)' (#165) from fix/335-a-passed-gate-is-not-judged-again into main 2026-10-08 19:48:03 +00:00
jochen 5d7d8ee2d6 Describe nfs-server's exports and test as per-node addresses, as the server exports them since the review (hq ADR 0263 rule 5)
mesh/delivery delivering: 0 machine step(s) passed
mesh/delivery-group group fix/shares-review-followups delivering: 1 of 4 delivered
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
2026-10-08 21:14:09 +02:00
jochen 81e5458cbf Test that a carried module takes its lead's pass before its step is read (hq issue 335 review)
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Without the reorder the passed build's walk stopped on the first machine's
later silence; the guard that caught it is now said to be one.
2026-10-08 21:08:12 +02:00
jochen fb74e24c9e Keep a passed gate's verdict when the first machine's later reports go quiet (hq issue 335)
A build that passed on its first machine was judged again from that
machine's next reports while its send to the rest waited; another walk's
unreported send there then failed the passed build at the wait's bound
and put it back.
2026-10-08 21:08:12 +02:00
43 changed files with 829 additions and 1490 deletions
-175
View File
@@ -1,175 +0,0 @@
package main
// The account agents run as (novox/hq ADR 0266).
//
// On the control node every agent session ran as the operator's account, which may become root without a
// password — so any agent there could become root without a person, and ADR 0259 §8 (an answer from the
// operator's phone authorises an act) rests on that being false where the router and its channels run. The
// decision: a node may name an account its agents run as, of their own and without sudo; the operator's
// account keeps its sudo.
//
// - **Named at the controller's terminal only** (`node agent-account`): not a verb, not a setting, so no
// agent can name itself another account. Empty is a real state: agents run as the operator there.
// - **Composed** as `${machine:agent-account}`, `${machine:agent-home}` and `${machine:agent-root}` for the
// agent's module, which declares the account with `root: never`, and as MESH_AGENT_ACCOUNT and
// MESH_AGENT_HOME for its tools (catalogue/machine_into_files.go, runtime.go).
// - **Judged by the machine itself.** The node-engine reads, on every look, whether an account declared
// `root: never` can become root without a person — uid 0, a group that grants root, a sudo rule, a
// secret of the mesh it may read — and says it as the declaring module's account verdict, marked
// Root "never". agentConfined reads that verdict; the self-check (probe DA) raises
// `agent-can-become-root` while it does not hold, and `node show` says it.
//
// A verdict not given is never a pass: an engine older than the judging, an account not yet declared, a
// statement that says nothing of it — each is "not judged", and fails.
import (
"context"
"fmt"
"sort"
"strings"
"time"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// kindAgentCanBecomeRoot is the condition raised while a machine's agent account can become root without
// a person, or is not judged (ADR 0266).
const kindAgentCanBecomeRoot = "agent-can-become-root"
// agentAccountProbe is the self-check's probe of it.
const agentAccountProbe = "DA"
// agentConfined says whether the agents of a machine that names an agent account are confined: the
// machine's newest statement holds a healthy account verdict, judged for root, on that account. named is
// false for a machine that names none — agents run as the operator account there, which this does not
// judge. why is said either way, in the mesh's words; err is a store that could not be read.
//
// The one judgement: `node show`, the self-check, and ADR 0259's router honouring a verified sender read
// it here.
func agentConfined(ctx context.Context, inv *inventory.Inventory, node string) (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, time.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"))
}
// 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
}
confined, why := judgedConfined(n.AgentAccount, h, had, time.Now())
if confined {
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)
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
}
-199
View File
@@ -1,199 +0,0 @@
package main
import (
"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"); err != nil || !named || !confined {
t.Fatalf("agentConfined on anchor: %v %v %q %v", named, confined, why, err)
}
if named, _, why, err := agentConfined(ctx, inv, "laptop"); err != nil || named ||
!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)
}
}
}
+2
View File
@@ -37,6 +37,8 @@ func TestAPushAnswersBeforeItSends(t *testing.T) {
}{
{"push", map[string]any{"node": "anchor", "why": "w"}, true},
{"push", map[string]any{"why": "w"}, true},
{"command", map[string]any{"command": "push anchor --why w"}, true},
{"command", map[string]any{"command": "push --behind --why=w"}, true},
{"command", map[string]any{"command": "builds"}, false},
{"status", map[string]any{}, false},
{"assign", map[string]any{"node": "anchor", "module": "m"}, false},
-5
View File
@@ -121,11 +121,6 @@ var probeRegistry = []probe{
{ID: probeReconnectsID, Asserts: "no user of the bus had its connection dropped more than twelve times in the " +
"last hour: the bus module's nats_closed_connections", From: "issue 327", Kind: kindBusReconnects,
Phase: 1, run: probeReconnects},
// The account agents run as (novox/hq ADR 0266): where a machine names one, its node-engine has judged it
// unable to become root without a person — what ADR 0259 §8 rests an authorised answer on.
{ID: agentAccountProbe, Asserts: "every machine that names an agent account has it judged, on its node-engine's " +
"newest statement, unable to become root without a person", From: "ADR 0266, ADR 0259 §8",
Kind: kindAgentCanBecomeRoot, Phase: 1, run: probeAgentAccounts},
{ID: "DW", Asserts: "the watchdogs of the signals table ran within three of their intervals",
From: "ADR 0227 rule 6: the watchers are watched", Kind: "watchdogs-silent", Phase: 1, run: probeWatchdogs},
// The core's health definitions (novox/hq to-be 45 §8, ADR 0236): what a core component's new build is
+1 -2
View File
@@ -269,8 +269,7 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
engines := map[string]bool{}
for _, n := range nodes {
m := snapshot.Machine{Name: scrub.Machine(n.Name), Length: len(n.Name), Adopted: n.Adopted,
AccountHome: scrub.Text(n.AccountHome), NodeEngine: n.HostVersion, PublicDomain: domains[n.Name],
AgentAccount: scrub.Account(n.AgentAccount), AgentAccountHome: scrub.Text(n.AgentAccountHome)}
AccountHome: scrub.Text(n.AccountHome), NodeEngine: n.HostVersion, PublicDomain: domains[n.Name]}
switch n.Account {
case "", "root":
m.Account = n.Account
+5
View File
@@ -22,6 +22,10 @@ func TestARepairByHandWithoutAReasonIsRefused(t *testing.T) {
{"plans", map[string]any{"close": "plan-1"}},
{"plans", map[string]any{"stop": "plan-1"}},
{"hand-act", map[string]any{"what": "restarted the proxy", "cause": "proxy-stuck"}},
{"command", map[string]any{"command": "push anchor"}},
{"command", map[string]any{"command": "plans close plan-1"}},
{"command", map[string]any{"command": "broker consumer-reset EVENTS controller"}},
{"command", map[string]any{"command": "hand-act record restarted --cause x"}},
} {
argv, err := argvFor(c.verb, c.args)
if c.verb == "plans" && err == nil {
@@ -61,6 +65,7 @@ func TestARepairByHandCarriesItsReason(t *testing.T) {
{"plans", map[string]any{"retry": "plan-1"}, "plans retry plan-1"},
{"hand-act", map[string]any{"what": "restarted", "why": "hung", "cause": "proxy", "condition": "machine.a.silent"},
"hand-act record restarted --why hung --cause proxy --condition machine.a.silent"},
{"command", map[string]any{"command": "push anchor --why stuck"}, "push anchor --why stuck"},
{"command", map[string]any{"command": "plans plan-1"}, "plans plan-1"},
} {
argv, err := argvFor(c.verb, c.args)
-5
View File
@@ -1013,11 +1013,6 @@ func raiseFromFacts(ctx context.Context, open *stores, f snapshot.Facts, shelf m
return notes, err
}
}
if m.AgentAccount != "" {
if err := inv.SetAgentAccount(ctx, m.Name, m.AgentAccount, m.AgentAccountHome); err != nil {
return notes, err
}
}
if m.PublicDomain != "" {
if err := inv.SetPublicDomain(ctx, m.Name, m.PublicDomain); err != nil {
return notes, err
+1 -1
View File
@@ -73,7 +73,7 @@ func stateHealth(ctx context.Context, inv *inventory.Inventory, k *conditions.Ke
for _, r := range h.Resources {
kept := inventory.ResourceHealth{Module: r.Module, Resource: r.Resource, Kind: r.Kind, Target: r.Target,
State: r.State, Reason: r.Reason, Since: r.Since, Streak: r.Streak, Restarts: r.Restarts,
Check: r.Check, Needs: r.Needs, Account: r.Account, Root: r.Root}
Check: r.Check, Needs: r.Needs, Account: r.Account}
resources = append(resources, kept)
if r.State == link.StateUnhealthy && r.Module != "" {
unhealthy[r.Module] = append(unhealthy[r.Module], kept)
-42
View File
@@ -414,9 +414,6 @@ func settingsCommand(ctx context.Context, args []string) error {
// word (novox/hq issue 304). Adding and changing keys needs nothing; removing one needs this.
replace := set.Bool("replace", false, "for set: remove the keys the new layer does not name")
history := set.Bool("history", false, "for show: the layers this one replaced, the latest first")
// Set by the settings verb on every line it composes (novox/hq ADR 0266): a verb may not change where a
// module's directories are placed or which of the machine's paths it reaches.
throughVerb := set.Bool("through-verb", false, "the line came from the settings verb: places and accesses are refused")
positionals, err := parseAround(set, args[1:])
if err != nil {
return err
@@ -448,11 +445,6 @@ func settingsCommand(ctx context.Context, args []string) error {
if err != nil {
return err
}
if *throughVerb {
if key := terminalSettingChanged(before, values); key != "" {
return terminalSettingRefusal(key, positionals[0], where)
}
}
added, changed, removed := settingsChange(before, values)
if len(removed) > 0 && !*replace {
return fmt.Errorf("%s on %s: this layer would no longer set %s. A layer is replaced whole; "+
@@ -604,15 +596,6 @@ func settingsCommand(ctx context.Context, args []string) error {
if len(positionals) != 1 {
return errors.New("settings clear <module> [--node <node>]")
}
if *throughVerb {
before, _, err := inv.Layer(ctx, *node, positionals[0])
if err != nil {
return err
}
if key := terminalSettingChanged(before, nil); key != "" {
return terminalSettingRefusal(key, positionals[0], where)
}
}
if err := inv.ClearSettings(ctx, *node, positionals[0]); err != nil {
return err
}
@@ -1068,28 +1051,3 @@ func declaresTools(m catalogue.Manifest) bool {
}
return false
}
// terminalSettings are the keys a verb may not change (novox/hq ADR 0266). `places` says where the node-engine
// creates and, as root, owns a module's directories, with an owner the setting names; `accesses` says which of
// the machine's paths are mounted into a module's container. Set through a verb, either would let any caller —
// an agent among them — have root hand it a directory, or mount one of the machine's into a container it
// reaches. They are the operator's, at the controller's terminal.
var terminalSettings = []string{catalogue.PlacesSetting, catalogue.AccessesSetting}
// terminalSettingChanged is the first of those keys a layer change would add, change or remove, or "".
func terminalSettingChanged(before, after map[string]any) string {
for _, key := range terminalSettings {
was, _ := json.Marshal(before[key])
now, _ := json.Marshal(after[key])
if string(was) != string(now) {
return key
}
}
return ""
}
func terminalSettingRefusal(key, module, where string) error {
return terminalRefusal("%s of %s on %s is set at the controller's terminal only, never through a verb: it says "+
"where root creates and owns a module's directories, or which of the machine's paths reach its container, "+
"and whoever may call a verb includes agents (novox/hq ADR 0266). Nothing was changed", key, module, where)
}
+1 -62
View File
@@ -100,12 +100,6 @@ func nodeCommand(ctx context.Context, args []string) error {
"containers reaching outward. The machine reports which of its links face outside; see " +
"`node show <name>`")
case "agent-account":
// The account agents run as on this machine, when it is not the operator's (novox/hq ADR 0266). Here,
// at the controller's terminal, and nowhere else: no verb and no setting names it, so no agent can
// name itself another account.
return nodeAgentAccount(ctx, inv, args[1:])
case "account":
// The operator's login on this machine (novox/hq to-be 29): what a home-scoped file is
// owned by and which account `ssh <node>` uses. Reports with no argument; sets with one;
@@ -113,7 +107,7 @@ func nodeCommand(ctx context.Context, args []string) error {
return nodeAccount(ctx, inv, args[1:])
default:
return fmt.Errorf("node has no %q; it has add, list, show, public-domain, account and agent-account", args[0])
return fmt.Errorf("node has no %q; it has add, list, show, public-domain and account", args[0])
}
}
@@ -184,57 +178,6 @@ func nodeAccount(ctx context.Context, inv *inventory.Inventory, positionals []st
return nil
}
const agentAccountUsage = "node agent-account <name> — what it is now; " +
"<name> <account> [home] to name the account agents run as (home defaults to /home/<account>); " +
"<name> --clear to have them run as the operator account again"
// nodeAgentAccount reports, names or clears the account agents run as on a node (novox/hq ADR 0266). Read-
// shaped with no account, like public-domain; clearing is asked for by name.
func nodeAgentAccount(ctx context.Context, inv *inventory.Inventory, args []string) error {
set := flag.NewFlagSet("node agent-account", flag.ContinueOnError)
clear := set.Bool("clear", false, "agents run as the operator account again")
positionals, err := parseAround(set, args)
if err != nil {
return err
}
if len(positionals) == 0 || len(positionals) > 3 {
return errors.New(agentAccountUsage)
}
node := positionals[0]
switch {
case *clear && len(positionals) > 1:
return fmt.Errorf("name an agent account for %s or --clear, not both", node)
case *clear:
if err := inv.SetAgentAccount(ctx, node, "", ""); err != nil {
return err
}
fmt.Printf("agents on %s run as the operator account again\n", node)
fmt.Printf(" run `push %s` to send it; the agent account itself is kept (the mesh never deletes a login)\n", node)
return nil
case len(positionals) >= 2:
home := ""
if len(positionals) == 3 {
home = positionals[2]
}
if err := inv.SetAgentAccount(ctx, node, positionals[1], home); err != nil {
return err
}
fmt.Printf("agents on %s run as %s\n", node, positionals[1])
fmt.Printf(" run `push %s` to send it; the self-check says once its node-engine has judged it "+
"unable to become root\n", node)
return nil
default:
n, err := inv.NodeByName(ctx, node)
if err != nil {
return err
}
for _, line := range agentAccountLines(ctx, inv, n) {
fmt.Println(strings.TrimPrefix(line, " "))
}
return nil
}
}
const publicDomainUsage = "node public-domain <name> — what it is now; " +
"<name> <domain> to set it; <name> --clear to take it away"
@@ -649,10 +592,6 @@ func showNode(ctx context.Context, inv *inventory.Inventory, name string) error
if err := showMode(ctx, inv, node); err != nil {
return err
}
// Whom agents run as here, and whether that account can become root without a person (ADR 0266).
for _, line := range agentAccountLines(ctx, inv, node) {
fmt.Println(line)
}
// The domain its routed names are composed under, when it has one (novox/hq ADR 0066). Shown
// only when set: a machine that serves nothing to the outside has no domain, and saying so of
-9
View File
@@ -110,15 +110,6 @@ var plainWordings = map[string]func(conditions.Observation) words{
"reaches it. It keeps running what it has.", m),
Resolved: m + " can get new instructions again"}
}),
kindAgentCanBecomeRoot: worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
return words{Headline: "Sessions on " + m + " could become root",
Needs: "take the sessions' own account out of every group and rule that grants root; the details say which.",
Explanation: fmt.Sprintf("Assistant sessions on %s run under an account of their own, so that none "+
"can take over the machine without you. The machine cannot show that this holds now, so an answer "+
"from your phone authorises nothing there until it does.", m),
Resolved: "Sessions on " + m + " cannot become root again"}
}),
"own-address-banned": worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
return words{Headline: m + " has banned the mesh",
+1 -17
View File
@@ -136,8 +136,7 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
resolved, err := catalogue.Resolve(shelf, assigned,
catalogue.Node{Name: nodeName, Site: site, Capabilities: capabilities,
At: onNetwork[nodeName], PublicDomain: publicDomain,
Account: who.Account, AccountHome: who.AccountHome,
AgentAccount: who.AgentAccount, AgentAccountHome: who.AgentAccountHome}, world)
Account: who.Account, AccountHome: who.AccountHome}, world)
if err != nil {
// The node's own set does not compose. Marked, because this is the only failure here that
// a mesh-wide gatherer may pass over — see notResolvable.
@@ -886,13 +885,8 @@ func renderingFor(ctx context.Context, open *stores, node string,
if err != nil {
return catalogue.Rendering{}, inventory.Node{}, err
}
judgesRoot, err := engineJudgesRoot(ctx, inv, node)
if err != nil {
return catalogue.Rendering{}, inventory.Node{}, err
}
return catalogue.Rendering{
ReadsHealth: readsHealth,
JudgesRoot: judgesRoot,
BusMembership: memberships[node],
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Foreseen: foreseen, Ports: ports,
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
@@ -915,16 +909,6 @@ func engineReadsHealth(ctx context.Context, inv *inventory.Inventory, node strin
return had && stated.Contract >= link.ReadinessContract, nil
}
// engineJudgesRoot says whether a machine's node-engine judges a user's declared `root` (novox/hq ADR 0266),
// by its own newest statement, for the same reason as engineReadsHealth: an older engine parses strictly.
func engineJudgesRoot(ctx context.Context, inv *inventory.Inventory, node string) (bool, error) {
stated, had, err := inv.HealthOf(ctx, node)
if err != nil {
return false, err
}
return had && stated.Contract >= link.RootContract, nil
}
// zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR
// 0199): the zone settled from that node's settings, the node's private address, the port the
// answering listen is published on there.
+131
View File
@@ -0,0 +1,131 @@
package main
import (
"reflect"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
)
// **The planner over edges the store derives**, not edges written by hand: modules registered, builds
// recorded with what they stood on and which repositories they read, the relation answered by
// inventory.Dependencies (dependenciesOf over the records), and the merge planned by reachOfMerge — the
// path a real merge takes, short of the bus.
//
// **The repository rows are CURRENT BEHAVIOUR, documented — not the rule the operator states**
// (novox/hq issue 338, and the decision pending on it): a build that read a repository gives its module a
// packages edge to every module built from that repository, and mergeCandidates moves it on any merge to
// that repository, whatever the files. So a change to C alone, or to a README, moves the module that
// packages C's repository. ADR 0238 §3 records exactly that today ("a repository a recipe names"); the
// expectations marked 338 change with that decision.
func TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday(t *testing.T) {
inv := inventory.ForTest(t)
ctx := t.Context()
asked := time.Now().Add(-time.Hour)
register := func(m catalogue.Manifest, repository, path string, against []string, read []inventory.ReadRepository) {
t.Helper()
if err := inv.RegisterModule(ctx, m, inventory.Source{Repository: repository, Seat: "git", Path: path,
Ref: "main", BuiltFrom: "old", Asked: asked}); err != nil {
t.Fatal(err)
}
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-" + m.Module, Repository: repository, Ref: "main",
Module: m.Module, Commit: "old", On: "builder", Path: path, Against: against, Read: read, Asked: asked}); err != nil {
t.Fatal(err)
}
}
controllerRead := []inventory.ReadRepository{{Repository: "novox/mesh-controller", Ref: "main"}}
agent := catalogue.Manifest{Module: "build-agent", Version: "1",
Claims: []catalogue.Claim{{Name: "node-build-agent", Scope: catalogue.ScopeNode}}}
// The shape of issue 338.
register(catalogue.Manifest{Module: "mesh-controller", Version: "1"}, "novox/mesh-controller", "", nil, nil)
register(agent, "novox/mesh-catalog", "modules/build-agent", nil, controllerRead)
register(catalogue.Manifest{Module: "route-proxy", Version: "1"}, "novox/mesh-catalog", "modules/route-proxy", nil, controllerRead)
register(catalogue.Manifest{Module: "gitea", Version: "1"}, "novox/mesh-catalog", "modules/gitea", nil, nil)
// A, B and C in one repository; D built against A's artifact, E declaring B, P packaging the repository.
for _, n := range []string{"a", "b", "c"} {
register(catalogue.Manifest{Module: n, Version: "1"}, "novox/one", "modules/"+n, nil, nil)
}
register(catalogue.Manifest{Module: "d", Version: "1"}, "novox/two", "d",
[]string{catalogue.ArtifactStoreScheme + "a/runtime@sha256:" + strings.Repeat("0", 64)}, nil)
register(catalogue.Manifest{Module: "e", Version: "1", Build: &catalogue.Build{
On: []catalogue.BuildsOn{{Arg: "BASE", Module: "b", Artifact: "runtime"}}}}, "novox/two", "e", nil, nil)
register(catalogue.Manifest{Module: "p", Version: "1"}, "novox/two", "p", nil,
[]inventory.ReadRepository{{Repository: "novox/one", Ref: "main"}})
entries, err := inv.Catalogued(ctx)
if err != nil {
t.Fatal(err)
}
read, err := inv.ReadRepositories(ctx)
if err != nil {
t.Fatal(err)
}
edges, err := inv.Dependencies(ctx)
if err != nil {
t.Fatal(err)
}
// The edges the hand-written rows of TestASharedRepositoryMovesWhatPackagesItAsItDoesToday use are
// the ones derived here.
var shared []inventory.Edge
in338 := map[string]bool{"mesh-controller": true, "build-agent": true, "route-proxy": true, "gitea": true}
for _, e := range edges {
if in338[e.From] && in338[e.To] {
shared = append(shared, e)
}
}
if !reflect.DeepEqual(shared, sharedRepositoryEdges) {
t.Errorf("derived %v\nthe hand-written rows use %v", shared, sharedRepositoryEdges)
}
// Each kind derived from its record: built against (stands-on), build.on (declared), read (packages).
for _, want := range []inventory.Edge{
dep("d", inventory.EdgeStandsOn, "a"),
dep("e", inventory.EdgeDeclared, "b"),
dep("p", inventory.EdgePackages, "a"),
dep("p", inventory.EdgePackages, "b"),
dep("p", inventory.EdgePackages, "c"),
dep("d", inventory.EdgeBuiltBy, "build-agent"),
} {
found := false
for _, e := range edges {
found = found || e == want
}
if !found {
t.Errorf("no %s %s %s derived: %v", want.From, want.Kind, want.To, edges)
}
}
for _, c := range []struct {
what, repo string
paths []string
want string
issue338 bool
}{
{"A and B changed, C untouched: D after A, E after B; P packages their repository", "one",
[]string{"modules/a/x.go", "modules/b/x.go"}, "a,b,p | d,e", false},
{"C alone: C, and P, which packages C's repository", "one",
[]string{"modules/c/x.go"}, "c,p", true},
{"a README of the repository P packages: P moves, nothing built from it does", "one",
[]string{"README.md"}, "p", true},
{"the dependent's repository: D alone", "two", []string{"d/main.go"}, "d", false},
{"a README of the controller's repository: all three, three tiers", "mesh-controller",
[]string{"README.md"}, "mesh-controller | build-agent | route-proxy", true},
{"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
[]string{"modules/route-proxy/module.json"}, "route-proxy", false},
{"the build agent's directory: it alone, nothing it builds", "mesh-catalog",
[]string{"modules/build-agent/module.json"}, "build-agent", false},
} {
_, got := planMerge(t, c.repo, c.paths, entries, read, edges)
if got != c.want {
tag := ""
if c.issue338 {
tag = " (current behaviour, issue 338)"
}
t.Errorf("%s: planned %q, wanted %q%s", c.what, got, c.want, tag)
}
}
}
+447
View File
@@ -0,0 +1,447 @@
package main
import (
"fmt"
"math/rand/v2"
"sort"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// The delivery planner's rules, as recorded (novox/hq ADR 0162 §1, ADR 0238 §3), held by one table and
// one property over reachOfMerge — the planner's one answer to "what does this merge move, and in which
// order". A row that fails here on main is a planner that breaks a recorded rule: the row stays, the
// expectation is not bent to the code.
//
// The kinds of edge, as the code reads them (release_plan.go):
//
// kind widens the plan orders the tiers
// stands-on yes yes, after its base is built
// declared yes yes, after its base is built
// packages yes no, the same tier (a code dependency)
// built-by no yes, after the build machine — except for what the build machine stands
// on, and for the controller whose worker it binds
// worker-of no yes, the build seat's holder after the controller (hq issue 206)
const (
repoOne = "http://forge.internal:20000/novox/one.git"
repoTwo = "http://forge.internal:20000/novox/two.git"
)
// dep is one edge of the catalogue's relation: from depends on to, in the way kind says.
func dep(from, kind, to string) inventory.Edge {
return inventory.Edge{From: from, To: to, Kind: kind}
}
// tiered is a plan's tiers as one line: a tier's modules by comma, tiers by " | ". Empty for no plan.
func tiered(tiers [][]string) string {
var out []string
for _, t := range tiers {
out = append(out, strings.Join(t, ","))
}
return strings.Join(out, " | ")
}
// planMerge is what reachOfMerge plans for a merge of these files into a repository's main: its tiers as
// one line, and the files no build reads. It also holds the plan to its own shape: every module it builds
// is in exactly one tier.
func planMerge(t *testing.T, repo string, paths []string, entries []inventory.Entry,
read map[string][]inventory.ReadRepository, edges []inventory.Edge) (mergeReach, string) {
t.Helper()
m := link.SourceMoved{Owner: "novox", Repo: repo, Base: "main", Commit: "head", Paths: paths}
r := reachOfMerge(m, entries, read, edges)
seen := map[string]int{}
for _, tier := range r.Plan.Tiers {
for _, name := range tier {
seen[name]++
}
}
for name := range r.Plan.Modules {
if seen[name] != 1 {
t.Errorf("%s/%v: %s is in %d tiers of %v", repo, paths, name, seen[name], r.Plan.Tiers)
}
}
if len(seen) != len(r.Plan.Modules) {
t.Errorf("%s/%v: the tiers %v hold modules the plan does not (%d)", repo, paths, r.Plan.Tiers, len(r.Plan.Modules))
}
return r, tiered(r.Plan.Tiers)
}
// **A merge moves the modules whose directory it changed, and everything built on them; nothing else.**
// Each row is a catalogue (modules in directories of one or two repositories), its dependencies with
// their kinds, the files one commit changed, and the exact plan: the moved set and its tier order.
func TestAPlanIsWhatTheChangeTouchedAndWhatIsBuiltOnIt(t *testing.T) {
in := func(repo, dir string, names ...string) []inventory.Entry {
var out []inventory.Entry
for _, n := range names {
d := dir + "/" + n
if dir == "" {
d = n
}
out = append(out, fromRepo(n, repo, d))
}
return out
}
// Modules a to f, x and z in novox/one under modules/; g in novox/two at g/.
entries := append(in(repoOne, "modules", "a", "b", "c", "d", "e", "f", "x", "z"), in(repoTwo, "", "g")...)
const (
standsOn = inventory.EdgeStandsOn
declared = inventory.EdgeDeclared
packages = inventory.EdgePackages
builtBy = inventory.EdgeBuiltBy
workerOf = inventory.EdgeWorkerOf
)
// The two real cycles the kinds resolve themselves (ADR 0162 §1, hq issue 206).
runtime := append(in(repoOne, "modules", "runtime", "builder"), fromRepo("controller", repoTwo, ""))
for _, c := range []struct {
what string
entries []inventory.Entry
edges []inventory.Edge
repo string
paths []string
want string // the tiers, " | " between them
unread string
cycle bool
}{
// The operator's case: two modules changed, a third beside them in the same repository untouched,
// each changed one with a dependent.
{what: "A and B changed, C untouched beside them, D on A and E on B",
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("e", declared, "b")},
repo: "one", paths: []string{"modules/a/main.go", "modules/b/module.json"}, want: "a,b | d,e"},
{what: "only A's directory: A and what stands on it",
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("e", declared, "b")},
repo: "one", paths: []string{"modules/a/main.go"}, want: "a | d"},
{what: "only C's directory: C alone, nothing is built on it",
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("e", declared, "b")},
repo: "one", paths: []string{"modules/c/Dockerfile"}, want: "c"},
{what: "only the dependent changed: its base does not move",
edges: []inventory.Edge{dep("d", standsOn, "a")},
repo: "one", paths: []string{"modules/d/main.go"}, want: "d"},
{what: "transitive: F on D on A, A changed",
edges: []inventory.Edge{dep("f", standsOn, "d"), dep("d", standsOn, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a | d | f"},
{what: "transitive across kinds: F declared on D, D packages A",
edges: []inventory.Edge{dep("f", declared, "d"), dep("d", packages, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a,d | f"},
// Each kind alone: X depends on A, A changed (widening), then both changed (ordering).
{what: "stands-on (built against A's artifact) widens", edges: []inventory.Edge{dep("x", standsOn, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a | x"},
{what: "stands-on orders", edges: []inventory.Edge{dep("x", standsOn, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
{what: "declared (build.on) widens", edges: []inventory.Edge{dep("x", declared, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a | x"},
{what: "declared orders", edges: []inventory.Edge{dep("x", declared, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
{what: "packages widens, into the same tier", edges: []inventory.Edge{dep("x", packages, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a,x"},
{what: "packages does not order", edges: []inventory.Edge{dep("x", packages, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a,x"},
{what: "built-by never widens", edges: []inventory.Edge{dep("x", builtBy, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
{what: "built-by orders", edges: []inventory.Edge{dep("x", builtBy, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
{what: "worker-of never widens", edges: []inventory.Edge{dep("x", workerOf, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
{what: "worker-of orders", edges: []inventory.Edge{dep("x", workerOf, "a")},
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
{what: "a change to the base alone does not move what it builds", edges: []inventory.Edge{dep("x", builtBy, "a"),
dep("d", builtBy, "a"), dep("e", standsOn, "a")},
repo: "one", paths: []string{"modules/a/module.json"}, want: "a | e"},
// The cycles the kinds resolve: the build machine stands on the runtime image the runtime image is
// built by; the build seat's holder follows the controller that is built by it.
{what: "the build machine's base comes first, built by the build machine that runs", entries: runtime,
edges: []inventory.Edge{dep("runtime", builtBy, "builder"), dep("builder", standsOn, "runtime")},
repo: "one", paths: []string{"modules/runtime/Dockerfile", "modules/builder/main.go"}, want: "runtime | builder"},
{what: "the runtime image alone takes the build machine on it along", entries: runtime,
edges: []inventory.Edge{dep("runtime", builtBy, "builder"), dep("builder", standsOn, "runtime")},
repo: "one", paths: []string{"modules/runtime/Dockerfile"}, want: "runtime | builder"},
{what: "the build machine alone moves alone", entries: runtime,
edges: []inventory.Edge{dep("runtime", builtBy, "builder"), dep("builder", standsOn, "runtime")},
repo: "one", paths: []string{"modules/builder/main.go"}, want: "builder"},
{what: "the build seat's holder follows the controller it binds the worker of", entries: runtime,
edges: []inventory.Edge{dep("controller", builtBy, "builder"), dep("builder", workerOf, "controller")},
repo: "two", paths: []string{"cmd/main.go"}, want: "controller"},
// Two repositories.
{what: "a dependent in another repository follows its base",
edges: []inventory.Edge{dep("g", standsOn, "a")},
repo: "one", paths: []string{"modules/a/x"}, want: "a | g"},
{what: "a directory of the same name in another repository is not this one's",
edges: []inventory.Edge{dep("g", standsOn, "a")},
repo: "two", paths: []string{"modules/a/x"}, want: "", unread: "modules/a/x"},
{what: "the dependent's own repository moves the dependent alone",
edges: []inventory.Edge{dep("g", standsOn, "a")},
repo: "two", paths: []string{"g/main.go"}, want: "g"},
// A diamond.
{what: "a diamond, one side changed", edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", standsOn, "b")},
repo: "one", paths: []string{"modules/a/x"}, want: "a | d"},
{what: "a diamond, both sides changed", edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", standsOn, "b")},
repo: "one", paths: []string{"modules/a/x", "modules/b/x"}, want: "a,b | d"},
{what: "a diamond on one base", edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", declared, "b"),
dep("a", standsOn, "z"), dep("b", standsOn, "z")},
repo: "one", paths: []string{"modules/z/x"}, want: "z | a,b | d"},
{what: "a diamond of mixed kinds orders on the ordering side only",
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", packages, "b")},
repo: "one", paths: []string{"modules/b/x"}, want: "b,d"},
// A cycle the catalogue should never produce: what remains is one last tier, and said.
{what: "a cycle is one last tier, not lost", edges: []inventory.Edge{dep("a", standsOn, "b"), dep("b", standsOn, "a"),
dep("f", standsOn, "c")},
repo: "one", paths: []string{"modules/a/x", "modules/c/x"}, want: "c | f | a,b", cycle: true},
// Files no build reads.
{what: "a README at the root of a repository whose modules all live below it",
edges: []inventory.Edge{dep("d", standsOn, "a")},
repo: "one", paths: []string{"README.md"}, want: "", unread: "README.md"},
{what: "a directory no module lives in", edges: []inventory.Edge{dep("d", standsOn, "a")},
repo: "one", paths: []string{"modules/lib/x.go", "modules/README.md"}, want: "",
unread: "modules/lib/x.go,modules/README.md"},
{what: "a module's directory beside a root file", edges: []inventory.Edge{dep("d", standsOn, "a")},
repo: "one", paths: []string{"merge-check.sh", "modules/a/x"}, want: "a | d", unread: "merge-check.sh"},
{what: "a directory whose name begins with a module's", edges: []inventory.Edge{dep("d", standsOn, "a")},
repo: "one", paths: []string{"modules/ab/x"}, want: "", unread: "modules/ab/x"},
} {
e := entries
if c.entries != nil {
e = c.entries
}
r, got := planMerge(t, c.repo, c.paths, e, nil, c.edges)
if got != c.want {
t.Errorf("%s: planned %q, wanted %q", c.what, got, c.want)
}
if u := strings.Join(r.Unread, ","); u != c.unread {
t.Errorf("%s: unread %q, wanted %q", c.what, u, c.unread)
}
// A packages edge in the last tier is no cycle; hasCycle said one on main at 8170fc5.
if hasCycle(r.Plan.Tiers, c.edges) != c.cycle {
t.Errorf("%s: a cycle said %v, wanted %v (%v)", c.what, !c.cycle, c.cycle, r.Plan.Tiers)
}
}
}
// **CURRENT BEHAVIOUR, documented — not the rule the operator states.** novox/hq issue 338 (a module
// built from a shared repository moves on every merge to it) and the decision pending on it would change
// every row here. Today:
//
// - mesh-controller is built from its repository's root, so every file of that repository touches it;
// - route-proxy and build-agent package the whole of that repository (a build context), so the build
// record's `read` makes them move on any merge to it, whatever the files, and dependenciesOf gives
// each a packages edge to every module built from it;
// - built-by (route-proxy on build-agent) and worker-of (build-agent on the controller) make it three
// tiers.
//
// These follow ADR 0238 §3 as written ("the whole repository for a module built from its root, and a
// repository a recipe names"), so they are not failures; when the decision on issue 338 lands, these
// expectations change with it. The edges are the ones dependenciesOf derives from this catalogue — held
// to that by TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday, which derives them from the store.
func TestASharedRepositoryMovesWhatPackagesItAsItDoesToday(t *testing.T) {
const catalogueRepo = "http://forge.internal:20000/novox/mesh-catalog.git"
const controllerRepo = "http://forge.internal:20000/novox/mesh-controller.git"
entries := []inventory.Entry{
fromRepo("mesh-controller", controllerRepo, ""),
fromRepo("build-agent", catalogueRepo, "modules/build-agent"),
fromRepo("route-proxy", catalogueRepo, "modules/route-proxy"),
fromRepo("gitea", catalogueRepo, "modules/gitea"),
}
read := map[string][]inventory.ReadRepository{
"build-agent": {{Repository: "novox/mesh-controller", Ref: "main"}},
"route-proxy": {{Repository: "novox/mesh-controller", Ref: "main"}},
}
edges := sharedRepositoryEdges
for _, c := range []struct {
what, repo string
paths []string
want string
}{
// The live three-tier plan of 2026-10-08 (issue 338), in the worker-of order (issue 206) that
// TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency's controller case holds too.
{"a README of the controller's repository moves all three, in three tiers", "mesh-controller",
[]string{"README.md"}, "mesh-controller | build-agent | route-proxy"},
{"the controller's own code: the same", "mesh-controller",
[]string{"cmd/mesh-controller/main.go"}, "mesh-controller | build-agent | route-proxy"},
{"the route proxy's program alone: the same, the controller with it", "mesh-controller",
[]string{"examples/route-proxy/main.go"}, "mesh-controller | build-agent | route-proxy"},
// In the catalogue, where they live, the rule is path-precise.
{"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
[]string{"modules/route-proxy/module.json"}, "route-proxy"},
{"the build agent's directory: it alone, nothing it builds", "mesh-catalog",
[]string{"modules/build-agent/module.json"}, "build-agent"},
{"another module of the catalogue: neither", "mesh-catalog",
[]string{"modules/gitea/index.ts"}, "gitea"},
} {
r, got := planMerge(t, c.repo, c.paths, entries, read, edges)
if got != c.want {
t.Errorf("%s: planned %q, wanted %q (as today; issue 338)", c.what, got, c.want)
}
if c.repo == "mesh-controller" && strings.Join(r.Unread, ",") != "" {
t.Errorf("%s: a root-built module reads every file, and %v were said unread", c.what, r.Unread)
}
}
}
// sharedRepositoryEdges is what dependenciesOf derives for the catalogue of the test above, sorted as it
// sorts them.
var sharedRepositoryEdges = []inventory.Edge{
dep("build-agent", inventory.EdgePackages, "mesh-controller"),
dep("build-agent", inventory.EdgeWorkerOf, "mesh-controller"),
dep("gitea", inventory.EdgeBuiltBy, "build-agent"),
dep("mesh-controller", inventory.EdgeBuiltBy, "build-agent"),
dep("route-proxy", inventory.EdgeBuiltBy, "build-agent"),
dep("route-proxy", inventory.EdgePackages, "mesh-controller"),
}
// **The planner's invariant, over random catalogues.** For any catalogue whose dependencies form no cycle
// and any set of changed files in one repository:
//
// - the plan is exactly the modules of that repository whose directory holds a changed file (every file,
// for a module built from the root), and everything reachable from them along stands-on, declared and
// packages — never along built-by or worker-of;
// - every stands-on, declared, built-by and worker-of edge with both ends in the plan has the module
// depended on in an earlier tier;
// - no cycle is said.
//
// Seeded, so a failure is replayed by its seed and case.
func TestAPlanIsTheTouchedModulesAndWhatIsReachableAlongTheWideningEdges(t *testing.T) {
kinds := []string{inventory.EdgeStandsOn, inventory.EdgeDeclared, inventory.EdgePackages,
inventory.EdgeBuiltBy, inventory.EdgeWorkerOf}
widens := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true, inventory.EdgePackages: true}
orders := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true,
inventory.EdgeBuiltBy: true, inventory.EdgeWorkerOf: true}
// Directory names drawn from one pool, so two repositories hold directories of the same name, and one
// is a prefix of another.
dirs := []string{"a", "ab", "b", "c", "lib/x", "lib/y", "modules/a", "modules/a/sub"}
files := []string{"README.md", "merge-check.sh", "lib/z.go", "docs/x.md", "modules/README.md"}
falseCycles, firstFalseCycle := 0, ""
for _, seed := range []uint64{1, 2, 3, 0x338, 0x162} {
rng := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
for n := 0; n < 100; n++ {
repos := 1 + rng.IntN(3)
repoName := func(i int) string { return fmt.Sprintf("r%d", i) }
count := 1 + rng.IntN(12)
var entries []inventory.Entry
repoOf, dirOf := map[string]int{}, map[string]string{}
for i := 0; i < count; i++ {
name := fmt.Sprintf("m%02d", i)
repo := rng.IntN(repos)
dir := dirs[rng.IntN(len(dirs))]
if rng.IntN(12) == 0 {
dir = "" // built from the repository's root
}
repoOf[name], dirOf[name] = repo, dir
entries = append(entries, fromRepo(name, "http://forge.internal:20000/novox/"+repoName(repo)+".git", dir))
}
// A graph with no cycle: a module depends only on modules made before it.
var edges []inventory.Edge
for i := 1; i < count; i++ {
for j := 0; j < i; j++ {
if rng.IntN(4) == 0 {
edges = append(edges, dep(fmt.Sprintf("m%02d", i), kinds[rng.IntN(len(kinds))], fmt.Sprintf("m%02d", j)))
}
}
}
merged := rng.IntN(repos)
var paths []string
for k := 1 + rng.IntN(4); k > 0; k-- {
if rng.IntN(3) == 0 {
paths = append(paths, files[rng.IntN(len(files))])
} else {
paths = append(paths, dirs[rng.IntN(len(dirs))]+"/f.go")
}
}
// What the rules say.
want := map[string]bool{}
for _, e := range entries {
name := e.Manifest.Module
if repoOf[name] != merged {
continue
}
for _, p := range paths {
if d := dirOf[name]; d == "" || p == d || strings.HasPrefix(p, d+"/") {
want[name] = true
}
}
}
for grew := true; grew; {
grew = false
for _, e := range edges {
if widens[e.Kind] && want[e.To] && !want[e.From] {
want[e.From], grew = true, true
}
}
}
r, _ := planMerge(t, repoName(merged), paths, entries, nil, edges)
got := map[string]bool{}
tierOf := map[string]int{}
for i, tier := range r.Plan.Tiers {
for _, name := range tier {
got[name], tierOf[name] = true, i
}
}
replay := func() string {
return fmt.Sprintf("seed %#x case %d: repository %s, files %v\n modules %v\n edges %v\n tiers %v",
seed, n, repoName(merged), paths, describe(entries), edges, r.Plan.Tiers)
}
if !sameSet(got, want) {
t.Fatalf("planned %v, wanted %v\n%s", keys(got), keys(want), replay())
}
for _, e := range edges {
if orders[e.Kind] && got[e.From] && got[e.To] && tierOf[e.To] >= tierOf[e.From] {
t.Fatalf("%s %s %s, and %s is in tier %d, not before %s's %d\n%s", e.From, e.Kind, e.To,
e.To, tierOf[e.To], e.From, tierOf[e.From], replay())
}
}
if hasCycle(r.Plan.Tiers, edges) {
falseCycles++
if firstFalseCycle == "" {
firstFalseCycle = replay()
}
}
}
}
// Said once, after the other invariants have run over every case, so it hides none of them (hasCycle
// counted a packages edge on main at 8170fc5: 19 of these 500 cases).
if falseCycles > 0 {
t.Errorf("a cycle said of a graph with none in %d of 500 cases; "+
"the first:\n%s", falseCycles, firstFalseCycle)
}
}
func sameSet(a, b map[string]bool) bool {
if len(a) != len(b) {
return false
}
for k := range a {
if !b[k] {
return false
}
}
return true
}
func keys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func describe(entries []inventory.Entry) []string {
var out []string
for _, e := range entries {
out = append(out, fmt.Sprintf("%s@%s:%q", e.Manifest.Module,
strings.TrimSuffix(strings.TrimPrefix(e.Source.Repository, "http://forge.internal:20000/novox/"), ".git"),
e.Source.Path))
}
return out
}
+31 -2
View File
@@ -157,7 +157,9 @@ func reachableFrom(moved []string, edges []inventory.Edge) []string {
return out
}
// hasCycle says whether the tiers' last tier holds modules that still depend on each other.
// hasCycle says whether the tiers' last tier holds modules that still depend on each other. A packages
// edge orders nothing (tiersOf), so a module and what packages its source share a tier by rule: that is
// no cycle, and saying one was is a false report in every such plan's log.
func hasCycle(tiers [][]string, edges []inventory.Edge) bool {
if len(tiers) == 0 {
return false
@@ -167,6 +169,9 @@ func hasCycle(tiers [][]string, edges []inventory.Edge) bool {
last[m] = true
}
for _, e := range edges {
if e.Kind == inventory.EdgePackages {
continue
}
if last[e.From] && last[e.To] {
return true
}
@@ -701,7 +706,6 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
}
}
now := time.Now().UTC()
step := nextRollout(*state, running, policy.Together, reports, now, planWaitBound)
// **Sent with others, judged with them** (issue 281): the gate of the send that carried it is its
// verdict on its first machine. A failure there stopped the plan already.
if state.GatedBy != "" {
@@ -715,6 +719,9 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
}
passedWith(m, state, state.GatedBy, lead.Gate)
}
// Read after its pass is taken over from the send that carried it, so a passed gate is never judged
// again from the first machine's later reports (novox/hq issue 335).
step := nextRollout(*state, running, policy.Together, reports, now, planWaitBound)
switch {
case step.failed != "":
// The first machine refused or failed what it was sent, or never said: the gate failed, and
@@ -974,6 +981,19 @@ func failFirstSend(ctx context.Context, open *stores, p *inventory.Plan, module
fmt.Printf("%s: %s\n", p.ID, p.Note)
}()
g := state.Gate
if g != nil && g.Verdict == inventory.GatePassed {
// **A guard: a build that passed its gate is never put back for what came after** (novox/hq issue 335).
// Not reached while nextRollout answers a passed gate with the rest to send, and advanceOnce reads it
// after a carried module takes over its lead's pass; it is here so that a path added later cannot
// overturn a verdict. If it is reached, the plan stops and says why, and the build is not marked failed.
// The module is left a stopped rollout (its Why said, sent first and not to the rest), which
// `plans retry` takes: it sends the first machine again, and the passed gate then sends the rest.
state.Why = "passed its gate; its walk then stopped: " + why
p.State = inventory.PlanFailed
p.Note = fmt.Sprintf("%s passed its gate on %s in tier %d (%s) and is kept; its walk stopped after: %s",
module, strings.Join(g.Machines, ", "), p.Tier, g.Why, why)
return
}
if g != nil && slices.Contains(g.Returned, module) {
// Put back at once when it broke: its rollback was made then, and is not made again.
state.Why = "put back when it broke; its send failed: " + g.Why
@@ -1061,6 +1081,15 @@ func nextRollout(s inventory.PlanModule, running []string, together bool, report
rest = append(rest, n)
}
}
// **A passed gate is the first machine's verdict, and its later reports are not** (novox/hq issue 335).
// Once the build passed there, what that machine reports next is about whatever it was sent after —
// another walk's send, a push — and says nothing of this build. On 2026-10-08 a build passed on the
// laptop, its send to the rest waited on another walk, that walk sent the laptop a new declaration it did
// not report for half an hour, and the plan read the silence as the first machine never applying the
// passed build: it marked it failed at its gate and put it back. The rest are sent, as the pass said.
if s.Gate != nil && s.Gate.Verdict == inventory.GatePassed {
return rolloutStep{send: rest}
}
var waiting, failed []string
for _, n := range s.First {
r, said := byNode[n]
+10 -5
View File
@@ -27,6 +27,8 @@ func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) {
{From: "mesh-controller", To: "builder", Kind: inventory.EdgeBuiltBy},
{From: "mesh-tools", To: "builder", Kind: inventory.EdgeBuiltBy},
{From: "builder", To: "mesh-tools", Kind: inventory.EdgeStandsOn},
// the build seat's holder follows the controller that defines its worker (hq issue 206)
{From: "builder", To: "mesh-controller", Kind: inventory.EdgeWorkerOf},
{From: "unrelated", To: "alpine", Kind: inventory.EdgeStandsOn},
}
// The runtime image moved: everything on it, and what is built by what is on it.
@@ -62,12 +64,15 @@ func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) {
if len(small) != 3 {
t.Fatalf("a controller merge rebuilds the controller and what packages it: %v", small)
}
// The builder packages the controller's source (same tier by that edge) and the controller is
// built by the builder (next tier by that one): the builder first, then the controller and the
// proxy together — a code dependency in one tier, a runtime dependency across tiers.
// The builder and the proxy package the controller's source, which orders nothing. The builder holds
// the build seat, whose worker the controller defines, so it follows the controller (worker-of,
// novox/hq issue 206), and the controller's built-by edge to it yields: the controller is built by the
// build machine that is running. The proxy is built by the new builder: the controller, the builder,
// the proxy — the live plan of every controller merge. (This read "the builder, then the controller
// and the proxy together" before issue 206, and the fixture had no worker-of edge.)
smallTiers := tiersOf(small, edges)
if len(smallTiers) != 2 || smallTiers[0][0] != "builder" || len(smallTiers[1]) != 2 {
t.Fatalf("the builder, then the controller and the proxy together: %v", smallTiers)
if got := tiered(smallTiers); got != "mesh-controller | builder | route-proxy" {
t.Fatalf("the controller, then the builder, then the proxy: %v", smallTiers)
}
// The builder alone moved: the builder, and nothing it builds.
if only := reachableFrom([]string{"builder"}, edges); len(only) != 1 {
+132
View File
@@ -0,0 +1,132 @@
package main
import (
"reflect"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/inventory"
)
// TestReplay335 replays novox/hq issue 335 (2026-10-08): dunst's new build passed its gate on the laptop
// (healthy 3 times over 2m5s); its send to the rest was refused while another walk's build waited on the
// workstation; that walk then sent the laptop a declaration the laptop did not report on; and thirty minutes
// after the first send the plan read the laptop's silence as the passed build never applied, marked it failed
// at its gate with the pass's own words, and put it back. Written with only what the controller had before
// its fix, so it is laid over the commit before.
func TestReplay335(t *testing.T) {
t.Run("the first machine's later reports", testAPassedGateIsNotJudgedAgainFromTheFirstMachinesLaterReports)
t.Run("a walk stopped after the pass", testAWalkStoppedAfterItsGatePassedKeepsThePass)
}
// novox/hq issue 335: a build that passed its gate on its first machine is not judged again from that
// machine's later reports. On 2026-10-08 the send to the rest waited on another walk, that walk sent the
// first machine a declaration it did not report for half an hour, and the plan failed the passed build at
// the wait's bound and put it back.
func testAPassedGateIsNotJudgedAgainFromTheFirstMachinesLaterReports(t *testing.T) {
sentAt := time.Date(2026, 10, 8, 16, 44, 45, 0, time.UTC)
judged := sentAt.Add(2 * time.Minute)
later := sentAt.Add(5 * time.Minute)
state := inventory.PlanModule{First: []string{"laptop"}, FirstAt: &sentAt,
Gate: &inventory.PlanGate{Machines: []string{"laptop"}, Verdict: inventory.GatePassed,
Why: "healthy 3 times over 2m5s", JudgedAt: &judged, Kept: true}}
running := []string{"laptop", "workstation"}
now := sentAt.Add(30*time.Minute + 9*time.Second)
for what, reports := range map[string][]inventory.Reported{
"no report about what it was sent since": {{Node: "laptop", At: &later, Outcome: inventory.OutcomeApplied, Current: false}},
"another send failed there since": {{Node: "laptop", At: &later, Outcome: inventory.OutcomeFailed, Current: true}},
"no report at all": nil,
} {
step := nextRollout(state, running, false, reports, now, 30*time.Minute)
if step.failed != "" || step.waiting != "" || !reflect.DeepEqual(step.send, []string{"workstation"}) {
t.Errorf("%s: %+v, want the rest sent as the pass said", what, step)
}
}
}
// novox/hq issue 335: whatever stops a walk after its gate passed, the passed build is not marked failed at
// its gate, nor put back.
func testAWalkStoppedAfterItsGatePassedKeepsThePass(t *testing.T) {
sentAt := time.Date(2026, 10, 8, 16, 44, 45, 0, time.UTC)
g := &inventory.PlanGate{Machines: []string{"laptop"}, Verdict: inventory.GatePassed, Why: "healthy 3 times over 2m5s"}
state := &inventory.PlanModule{Build: "build-1", First: []string{"laptop"}, FirstAt: &sentAt, Gate: g}
p := &inventory.Plan{ID: "plan-1", Modules: map[string]*inventory.PlanModule{"dunst": state}}
// No stores: a walk that keeps the pass touches none, and one that reaches for them is putting it back.
defer func() {
if r := recover(); r != nil {
t.Fatalf("the passed build was taken to be failed and put back: %v", r)
}
}()
failFirstSend(t.Context(), nil, p, "dunst", state, []string{"laptop"}, "laptop did not report it applied within 30m0s",
[]string{"workstation"})
if g.Verdict != inventory.GatePassed || g.Rollback != "" {
t.Fatalf("the passed gate became %q, rollback %q", g.Verdict, g.Rollback)
}
if strings.Contains(p.Note, "failed its gate") || strings.Contains(p.Note, "put back") ||
!strings.Contains(p.Note, "did not report it applied") {
t.Fatalf("the note reads %q", p.Note)
}
}
// novox/hq issue 335 review: **a module carried by its lead's send takes over the lead's pass before its own
// step is read.** The state is the one a step leaves when the lead's gate passed and the step ended before the
// carried module's turn (an error read before it, kept with the plan): the lead passed, the carried module has
// no gate of its own yet. Since then another send reached the first machine and it has not reported on it, and
// the first send is older than the wait for a first machine's report. Read before the pass is taken over, the
// carried module's step said the first machine never applied it, and the passed build was put back.
func TestACarriedModuleTakesItsLeadsPassBeforeItsStepIsRead(t *testing.T) {
tm := aTierMesh(t, "m01", "m02")
ctx := t.Context()
inv := tm.open.inventory
advancePlans(ctx, tm.open)
if !reflect.DeepEqual(tm.sent, [][]string{{"anchor"}}) {
t.Fatalf("sent %v: the first machine once, for the tier", tm.sent)
}
p := tm.plan(t)
lead, carried := p.Modules["m01"], p.Modules["m02"]
if lead.Gate == nil || carried.GatedBy != "m01" {
t.Fatalf("m02 is not carried by m01's send: lead %+v, carried %+v", lead.Gate, carried)
}
// The lead passed; the carried module's turn did not come. The first send is past the wait's bound.
sent := time.Now().UTC().Add(-planWaitBound - time.Minute)
judged := sent.Add(2 * time.Minute)
lead.FirstAt, carried.FirstAt, lead.Gate.Since = &sent, &sent, &sent
lead.Gate.Verdict, lead.Gate.Why, lead.Gate.JudgedAt, lead.Gate.Kept = inventory.GatePassed,
"healthy 3 times over 2m5s", &judged, true
carried.Gate = nil
if err := inv.SavePlan(ctx, &p); err != nil {
t.Fatal(err)
}
// Another walk's send reached anchor, which has not reported on it.
if err := inv.RecordSent(ctx, nodeID(t, tm.open, "anchor"), "d-anchor-elsewhere",
map[string]string{"m01": "c2", "m02": "c2"}); err != nil {
t.Fatal(err)
}
advancePlans(ctx, tm.open)
p = tm.plan(t)
if p.State == inventory.PlanFailed {
t.Fatalf("the plan failed after its gate passed: %s", p.Note)
}
if !reflect.DeepEqual(tm.sent, [][]string{{"anchor"}, {"laptop"}}) {
t.Fatalf("sent %v: the rest once, as the pass said", tm.sent)
}
current, err := inv.CurrentBuilds(ctx)
if err != nil {
t.Fatal(err)
}
for _, m := range []string{"m01", "m02"} {
if current[m].Commit != "c2" {
t.Errorf("%s was put back to %s after its gate passed", m, current[m].Commit)
}
if failed, _ := inv.GateFailed(ctx, "build-"+m+"-2"); failed {
t.Errorf("%s's build was marked failed at its gate after the gate passed", m)
}
}
if g := p.Modules["m02"].Gate; g == nil || g.Verdict != inventory.GatePassed {
t.Errorf("m02 did not take over m01's pass: %+v", g)
}
}
+2 -142
View File
@@ -55,9 +55,6 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
return nil, err
}
argv, err := a.commandLine()
if err == nil {
err = terminalOnly(argv)
}
if len(a.misread) > 0 {
// 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.
@@ -248,11 +245,6 @@ func (a *verbArguments) commandLine() ([]string, error) {
if len(argv) == 0 {
return nil, errors.New("command names no command")
}
// 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 nil, 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) {
@@ -805,16 +797,13 @@ func (a *verbArguments) commandLine() ([]string, error) {
var argv []string
switch {
case on("clear"):
argv = []string{"settings", "clear", str("module"), "--through-verb"}
argv = []string{"settings", "clear", str("module")}
case str("values") != "":
argv = []string{"settings", "set", str("module"), str("values")}
// What a set removes is refused unless meant (novox/hq ADR 0217).
if on("replace") {
argv = append(argv, "--replace")
}
// Through a verb, never places or accesses (novox/hq ADR 0266): the command refuses a change to
// either when told the line came from a verb.
argv = append(argv, "--through-verb")
default:
// Neither values nor clear: the layer as it stands, which is what a caller reads before
// replacing it (novox/hq ADR 0217) — and with history, the layers it replaced.
@@ -1074,8 +1063,7 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) {
}
continue
}
var policy *heldAtTheTerminal
if _, err := argvFor(verb, sampleArguments(v)); err != nil && !errors.As(err, &policy) {
if _, err := argvFor(verb, sampleArguments(v)); err != nil {
// **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
@@ -1336,131 +1324,3 @@ func seatAnnouncement(handlers map[string]link.ToolHandler) micro.Info {
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)
}
+3 -3
View File
@@ -268,10 +268,10 @@ var accountedFlags = map[string]map[string]string{
"self": "set by the verb from the repository's form: a path on the forge, or a URL",
"dry-run": "withheld: a dry run answers only when the build ends, which a call cannot wait for; `command` reaches it",
},
"builds": {"n": "=limit"},
"plans": {"n": "=limit", "what-if": "=repository"},
"settings": {"through-verb": "set by the verb on every set and clear: places and accesses are the terminal's (novox/hq ADR 0266)"},
"builds": {"n": "=limit"},
"plans": {"n": "=limit", "what-if": "=repository"},
// The machine's tunnel key, named as the verb's other arguments are (novox/hq ADR 0169).
"token issue": {"overlay-key": "=overlay_key"},
"durations": {
"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",
+16 -76
View File
@@ -2,7 +2,6 @@ package main
import (
"context"
"errors"
"fmt"
"github.com/novox/mesh-controller/internal/link"
"strings"
@@ -109,25 +108,22 @@ func TestRotateTakesAProvisionOrAnOwnSecret(t *testing.T) {
}
}
// `token` answers a joining token to its caller, and whoever may call a verb includes agents: it is the
// controller's terminal's alone (novox/hq ADR 0266), refused through the verb whatever it is given.
func TestTokenIsRefusedThroughAVerb(t *testing.T) {
for _, args := range []map[string]any{{"new": "laptop", "overlay_key": "k", "for": "2h"}, {"node": "ace"}} {
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)
}
// `token` is `token issue` at a shell, with the machine's tunnel key (novox/hq ADR 0169).
func TestTokenIssuesForAMachineAndItsTunnelKey(t *testing.T) {
argv, err := argvFor("token", map[string]any{"new": "laptop", "overlay_key": "k", "for": "2h"})
if err != nil || strings.Join(argv, " ") != "token issue --new laptop --overlay-key k --for 2h" {
t.Fatalf("token: %v %v", argv, err)
}
}
// `settings` is `settings set|clear` at a shell, with the values passed inline (novox/hq issue 198).
func TestSettingsSetsOrClearsALayer(t *testing.T) {
argv, err := argvFor("settings", map[string]any{"module": "dnsmasq", "values": `{"a":1}`, "node": "ace"})
if err != nil || strings.Join(argv, " ") != `settings set dnsmasq {"a":1} --through-verb --node ace` {
if err != nil || strings.Join(argv, " ") != `settings set dnsmasq {"a":1} --node ace` {
t.Fatalf("set on a machine: %v %v", argv, err)
}
argv, _ = argvFor("settings", map[string]any{"module": "dnsmasq", "clear": "true"})
if strings.Join(argv, " ") != "settings clear dnsmasq --through-verb" {
if strings.Join(argv, " ") != "settings clear dnsmasq" {
t.Fatalf("clear for the mesh: %v", argv)
}
// Neither values nor clear reads the layer as it stands (novox/hq ADR 0217): what a caller reads
@@ -241,20 +237,20 @@ func TestAJSONVerbsAnswerIsItsStandardOutput(t *testing.T) {
}
}
// `command` is the generic verb: the command line as given, split as a shell would, nothing added
// (novox/hq ADR 0154, ADR 0175). It once carried an operator's `node account g14 jochen` too; a node's
// accounts are the controller's terminal's alone since ADR 0266 (TestNoVerbSetsANodesAccounts).
// `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
// the control node (novox/hq ADR 0154, ADR 0175).
func TestCommandRunsTheLineAsGiven(t *testing.T) {
argv, err := argvFor("command", map[string]any{"command": "node show g14"})
if err != nil || strings.Join(argv, " ") != "node show g14" {
argv, err := argvFor("command", map[string]any{"command": "node account g14 jochen"})
if err != nil || strings.Join(argv, " ") != "node account g14 jochen" {
t.Fatalf("a plain line: %v %v", argv, err)
}
argv, err = argvFor("command", map[string]any{"command": `settings show 'dns masq' --node ace`})
if err != nil || len(argv) != 5 || argv[2] != "dns masq" {
argv, err = argvFor("command", map[string]any{"command": `settings set dnsmasq '{"a": "b c"}' --node ace`})
if err != nil || len(argv) != 6 || argv[3] != `{"a": "b c"}` {
t.Fatalf("a quoted word stays one word: %q %v", argv, err)
}
argv, err = argvFor("command", map[string]any{"command": `plan "the box" --json`})
if err != nil || len(argv) != 3 || argv[1] != "the box" {
argv, err = argvFor("command", map[string]any{"command": `node add "the box" --adopted`})
if err != nil || len(argv) != 4 || argv[2] != "the box" {
t.Fatalf("double quotes group: %q %v", argv, err)
}
if _, err := argvFor("command", map[string]any{"command": " "}); err == nil {
@@ -359,59 +355,3 @@ 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)
}
}
@@ -1,93 +0,0 @@
package main
import (
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// Where root creates and owns a module's directories, and which of the machine's paths reach its container,
// are the controller's terminal's alone (novox/hq ADR 0266): through the settings verb, a caller who set
// `places` to /etc with an owner of its own would have the next send hand it /etc.
func TestTheSettingsVerbMarksEverySetAndClearAsAVerbs(t *testing.T) {
for _, args := range []map[string]any{
{"module": "plex", "values": `{"places":{"data":"/etc"}}`},
{"module": "plex", "values": `{}`, "replace": "true", "node": "home"},
{"module": "plex", "clear": "true"},
} {
argv, err := argvFor("settings", args)
if err != nil || !strings.Contains(strings.Join(argv, " "), "--through-verb") {
t.Fatalf("%v: %v %v", args, argv, err)
}
}
// The generic verb never reaches settings set or clear at all.
for _, line := range []string{"settings set plex {}", "settings clear plex"} {
if _, err := argvFor("command", map[string]any{"command": line}); err == nil {
t.Fatalf("command ran %q", line)
}
}
}
func TestATerminalSettingIsChangedOnlyAtTheTerminal(t *testing.T) {
cases := []struct {
before, after map[string]any
want string
}{
{nil, map[string]any{"places": map[string]any{"data": "/etc"}}, "places"},
{map[string]any{"accesses": map[string]any{"m": "/storage"}}, map[string]any{}, "accesses"},
{map[string]any{"places": map[string]any{"d": "/srv/d"}}, nil, "places"},
{map[string]any{"places": map[string]any{"d": "/srv/d"}, "a": 1.0},
map[string]any{"places": map[string]any{"d": "/srv/d"}, "a": 2.0}, ""},
}
for _, c := range cases {
if got := terminalSettingChanged(c.before, c.after); got != c.want {
t.Errorf("%v → %v: %q, want %q", c.before, c.after, got, c.want)
}
}
}
// Over the real stores: the verb's line is refused for places, the terminal's is taken, and a clear through
// the verb of a layer that places a directory is refused too.
func TestPlacesAreRefusedThroughTheVerbAndTakenAtTheTerminal(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
Resources: []map[string]any{{"id": "data", "type": "directory", "mode": "0755"},
{"id": "rc", "type": "file", "path": "/etc/notes.conf", "mode": "0644", "content": "x = ${setting:x}\n"}}})
if _, err := assign(ctx, open, "laptop", "notes"); err != nil {
t.Fatal(err)
}
set := func(through bool, values string) error {
args := []string{"set", "notes", values, "--node", "laptop"}
if through {
args = append(args, "--through-verb")
}
return settingsCommand(ctx, args)
}
if err := set(true, `{"places":{"data":{"path":"/srv/notes","owner":"1000:1000"}}}`); err == nil ||
!strings.Contains(err.Error(), "controller's terminal only") {
t.Fatalf("places through the verb: %v", err)
}
if err := set(false, `{"places":{"data":{"path":"/srv/notes","owner":"1000:1000"}},"x":0}`); err != nil {
t.Fatalf("places at the terminal: %v", err)
}
if err := set(true, `{"places":{"data":{"path":"/srv/notes","owner":"1000:1000"}},"x":1}`); err != nil {
t.Fatalf("a verb may change another key and keep places as they are: %v", err)
}
if err := settingsCommand(ctx, []string{"clear", "notes", "--node", "laptop", "--through-verb"}); err == nil ||
!strings.Contains(err.Error(), "controller's terminal only") {
t.Fatalf("a clear through the verb took places away: %v", err)
}
// And never at /etc, from anywhere.
if err := set(false, `{"places":{"data":{"path":"/etc","owner":"1000:1000"}},"x":1}`); err == nil ||
!strings.Contains(err.Error(), "/etc") {
t.Fatalf("a place at /etc: %v", err)
}
// A line break in any setting is refused where it is kept.
if err := set(false, `{"places":{"data":{"path":"/srv/notes","owner":"1000:1000"}},"x":"a\nPATH=/tmp"}`); err == nil ||
!strings.Contains(err.Error(), "line break") {
t.Fatalf("a line break: %v", err)
}
}
+1 -1
View File
@@ -158,7 +158,7 @@ func TestTheVerbsCarryReadReplaceAndMove(t *testing.T) {
}{
{"settings", map[string]any{"module": "plex", "node": "home"}, []string{"settings", "show", "plex", "--node", "home"}},
{"settings", map[string]any{"module": "plex", "history": "true"}, []string{"settings", "show", "plex", "--history"}},
{"settings", map[string]any{"module": "plex", "values": "{}", "replace": "true"}, []string{"settings", "set", "plex", "{}", "--replace", "--through-verb"}},
{"settings", map[string]any{"module": "plex", "values": "{}", "replace": "true"}, []string{"settings", "set", "plex", "{}", "--replace"}},
{"push", map[string]any{"node": "home", "move": "plex", "why": "w"},
[]string{"push", "home", "--wait", "0", "--move", "plex", "--why", "w"}},
{"push", map[string]any{"why": "w"}, []string{"push", "--behind", "--wait", "0", "--why", "w"}},
+1 -1
View File
@@ -35,4 +35,4 @@ require (
// committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and
// fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without
// `go mod vendor` fails loudly, at once, everywhere.
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261008195316-9a85dffc11a2
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac
-10
View File
@@ -4,16 +4,6 @@ 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-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-20261008163010-8390fab5cb35 h1:3uag/9Tv4Y3ippY5Yr1rIIBh23Ur9RbhzOB4CLbKz0I=
git.novox.be/novox/mesh-host v0.0.0-20261008163010-8390fab5cb35/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
git.novox.be/novox/mesh-host v0.0.0-20261008183646-5fc37b44a94e h1:+XxiuXJqWj7ZcGMB2b/WGPsC8zpmXgmwXnBvjw9C/CM=
git.novox.be/novox/mesh-host v0.0.0-20261008183646-5fc37b44a94e/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
git.novox.be/novox/mesh-host v0.0.0-20261008185244-76f3ca12b8b8 h1:T4Bu9ymmcNC0x57EnDkjfZlJ9dvEeK8BRuHZTqw+uuI=
git.novox.be/novox/mesh-host v0.0.0-20261008185244-76f3ca12b8b8/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
git.novox.be/novox/mesh-host v0.0.0-20261008191932-c74cf16b755e h1:oQofUhCNm0m4+pVASxvErqisEOMTkOyWjTAfGqt2lHA=
git.novox.be/novox/mesh-host v0.0.0-20261008191932-c74cf16b755e/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
git.novox.be/novox/mesh-host v0.0.0-20261008195316-9a85dffc11a2 h1:hYRCYzJi97QC8l3SMy6v40bc0wqd9Ms54H9jYR0Bei0=
git.novox.be/novox/mesh-host v0.0.0-20261008195316-9a85dffc11a2/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-157
View File
@@ -1,157 +0,0 @@
package catalogue
import (
"strings"
"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}", "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 {
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"}, 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")
}
}
// No placement and no access at the machine's own system or the mesh's state, however it is spelled (novox/hq
// ADR 0266); a module's own place elsewhere is taken.
func TestAPlacementOrAnAccessAtTheMachinesOwnIsRefused(t *testing.T) {
m := Manifest{Module: "notes", Resources: []map[string]any{{"id": "data", "type": "directory"}},
Accesses: []Access{{ID: "media"}}}
for _, path := range []string{"/", "/etc", "/etc/sudoers.d", "/usr/bin", "/root", "/var/lib", "/home",
"/var/lib/mesh/x", "/var/lib/mesh-host", "/srv/../etc", "/proc/1", "/dev"} {
layers := []Layer{{From: "laptop", Values: map[string]any{PlacesSetting: map[string]any{"data": path}}}}
if _, err := Places(m, layers); err == nil {
t.Errorf("a place at %s was taken", path)
}
layers = []Layer{{From: "laptop", Values: map[string]any{AccessesSetting: map[string]any{"media": path}}}}
if _, err := AccessPlaces(m, layers); err == nil {
t.Errorf("an access at %s was taken", path)
}
}
for _, path := range []string{"/srv/notes", "/mnt/plex/data", "/storage/media", "/home/restic", "/var/lib/notes/data"} {
layers := []Layer{{From: "laptop", Values: map[string]any{PlacesSetting: map[string]any{"data": path}}}}
if got, err := Places(m, layers); err != nil || got["data"].Path != path {
t.Errorf("a place at %s: %v %v", path, got, err)
}
}
}
// A line break or a NUL in any string of any setting is refused, at any depth; PEM blocks alone may hold lines.
func TestASettingHoldsOneLine(t *testing.T) {
m := Manifest{Module: "mailu"}
for _, v := range []any{"a\nDEBUG=1", "a\rb", "a\x00b", map[string]any{"k": []any{"ok", "x\ny"}},
map[string]any{"k\nx": "v"}} {
if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v": v}}}, false); err == nil ||
!strings.Contains(err.Error(), "line break") {
t.Errorf("%q: %v", v, err)
}
}
pem := "-----BEGIN CERTIFICATE-----\nMIIBeDCCAR2gAwIBAgIQ\n-----END CERTIFICATE-----\n"
if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"root": pem}}}, false); err != nil {
t.Errorf("a PEM block: %v", err)
}
if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"root": pem + "PATH=/tmp evil\n"}}}, false); err == nil {
t.Error("a PEM block with a line of something else after it was taken")
}
}
+1 -1
View File
@@ -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).
var bundleEnvWords = map[string]bool{
RuntimeToolModules: true, RuntimeBrokerFile: true, RuntimeOperatorAccount: true,
RuntimeOperatorHome: true, RuntimeToolEnv: true, RuntimeAgentAccount: true, RuntimeAgentHome: true,
RuntimeOperatorHome: true, RuntimeToolEnv: true,
}
// bundleEnvProblems says what is wrong with what a bundle says it is given (novox/hq ADR 0192):
-28
View File
@@ -241,31 +241,6 @@ type Rendering struct {
// 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.
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
@@ -1005,9 +980,6 @@ 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
// published it on — or not sent at all to an engine older than the field (ADR 0240).
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
// 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
+2 -42
View File
@@ -78,47 +78,9 @@ func machineFacts(r Resolution, names map[string]string, meshRange string) map[s
out["account"] = r.Account
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
}
// 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
// root, /home/<account> otherwise. The one place the default is written, so a fact and the store
// cannot disagree about it.
@@ -143,10 +105,8 @@ 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
// `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
// watchers run as that account (novox/hq ADR 0176, ADR 0177) — neither can name the person. And a
// 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).
for _, field := range []string{"path", "owner", "content", "name", "user", "root"} {
// watchers run as that account (novox/hq ADR 0176, ADR 0177) — neither can name the person.
for _, field := range []string{"path", "owner", "content", "name", "user"} {
s, ok := resource[field].(string)
if !ok {
continue
+2 -39
View File
@@ -2,7 +2,6 @@ package catalogue
import (
"fmt"
"path/filepath"
"regexp"
"sort"
"strings"
@@ -45,33 +44,6 @@ type Placement struct {
var ownerShape = regexp.MustCompile(`^[0-9]+:[0-9]+$`)
// systemTrees are where no placement and no access may be: the machine's own system, and the node-engine's
// and the tool runner's state (novox/hq ADR 0266). A placed directory is created and chowned by the
// node-engine as root, and an access is mounted into a container: a place at /etc, owned by an account a
// caller names, hands that account the machine. Refused at or below each of these.
var systemTrees = []string{"/etc", "/usr", "/boot", "/root", "/proc", "/sys", "/dev", "/run", "/bin", "/sbin",
"/lib", "/lib64", "/var/lib/mesh", "/var/lib/mesh-host", "/var/lib/mesh-bus-conf"}
// systemRoots are directories a placement may be below but never be: each holds the whole machine's, or
// every module's or every person's, directories.
var systemRoots = []string{"/", "/var", "/var/lib", "/home", "/mnt", "/srv", "/opt", "/storage", "/data", "/tmp", "/var/tmp"}
// systemPath says why a path is the machine's own and no placement's, or "".
func systemPath(path string) string {
clean := filepath.Clean(path)
for _, root := range systemRoots {
if clean == root {
return clean + " is a whole tree of the machine's"
}
}
for _, tree := range systemTrees {
if clean == tree || strings.HasPrefix(clean, tree+"/") {
return clean + " is in " + tree + ", the machine's own or the mesh's state"
}
}
return ""
}
// accessRef is how a module names one of its accesses: ${access:<id>}.
var accessRef = regexp.MustCompile(`\$\{access:([a-z0-9][a-z0-9-]*)\}`)
@@ -131,11 +103,7 @@ func Places(m Manifest, layers []Layer) (map[string]Placement, error) {
if !strings.HasPrefix(p.Path, "/") {
return nil, fmt.Errorf("%s places %q at %q, which is not an absolute path", m.Module, id, p.Path)
}
p.Path = filepath.Clean(p.Path)
if why := systemPath(p.Path); why != "" {
return nil, fmt.Errorf("%s places %q at %s: %s, and the node-engine would create and own it as "+
"root (novox/hq ADR 0266)", m.Module, id, p.Path, why)
}
p.Path = strings.TrimRight(p.Path, "/")
out[id] = p
}
}
@@ -179,12 +147,7 @@ func AccessPlaces(m Manifest, layers []Layer) (map[string]string, error) {
return nil, fmt.Errorf("%s places the access %q at %v, which is not an absolute path",
m.Module, id, body)
}
path = filepath.Clean(path)
if why := systemPath(path); why != "" {
return nil, fmt.Errorf("%s places the access %q at %s: %s, and an access is mounted into the "+
"module's container (novox/hq ADR 0266)", m.Module, id, path, why)
}
out[id] = path
out[id] = strings.TrimRight(path, "/")
}
}
if len(out) == 0 {
+1 -11
View File
@@ -32,11 +32,6 @@ type Node struct {
// to-be 29). What a home-scoped file is owned by and what ${machine:account} resolves to.
Account 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.
@@ -139,10 +134,6 @@ type Resolution struct {
// here without a store lookup.
Account 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
// contribution placed only where the machine has something (`if-capability`, novox/hq ADR 0255)
// is decided here without a store lookup.
@@ -712,8 +703,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
}
resolution := Resolution{Node: node.Name, At: node.At, PublicDomain: node.PublicDomain,
Account: node.Account, AccountHome: node.AccountHome, AgentAccount: node.AgentAccount,
AgentAccountHome: node.AgentAccountHome, Capabilities: node.Capabilities,
Account: node.Account, AccountHome: node.AccountHome, Capabilities: node.Capabilities,
Because: because, Needs: needs, Unhostable: unhostable, Kept: kept}
for _, n := range providersFirst(order, catalogue) {
resolution.Modules = append(resolution.Modules, catalogue[n])
-9
View File
@@ -83,11 +83,6 @@ const (
RuntimeBrokerFile = "MESH_BROKER_FILE"
RuntimeOperatorAccount = "MESH_OPERATOR_ACCOUNT"
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):
// {"<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
@@ -220,10 +215,6 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
env[RuntimeOperatorHome] = accountHomeOf(r.Account, r.AccountHome)
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
// built; refused with the same words when there is no store to route through.
if err := artifactsInto(process, RuntimeModule, with); err != nil {
-65
View File
@@ -209,68 +209,6 @@ func deepCopy(in map[string]any) map[string]any {
return out
}
// settingsHoldOneLine refuses a line break or a NUL in any string of any setting, for every module, at any
// depth: a key or a value, in an object or a list (novox/hq ADR 0266). A value is substituted into env and
// configuration files the node-engine writes as root (an app's .env, a logind drop-in), and a line break
// there is a directive of the caller's own; a NUL ends a string early wherever C reads it. Judged where a
// setting is kept and again where it is composed, so a stored value with one costs its module its place
// and says which key. One shape is let through: PEM blocks alone (a certificate authority's root handed to a
// provider), whose lines are base64 between BEGIN and END. Anything else that must hold lines is the
// module's own file, not a setting.
func settingsHoldOneLine(module string, layers []Layer) error {
for _, layer := range layers {
for _, key := range sortedKeysAny(layer.Values) {
if at := lineBreakIn(layer.Values[key], key); at != "" {
return fmt.Errorf("%s: the setting %s in %q holds a line break or a NUL, which a file it is written "+
"into would read as a directive of its own (novox/hq ADR 0266); a setting is one line",
module, at, layer.From)
}
}
}
return nil
}
// pemShape is the one value with lines a setting may hold: PEM blocks and nothing else — a certificate
// authority's root the operator hands a provider is one. Its lines are base64 between BEGIN and END: no
// space, quote, dot or underscore, so no path, option or command — at most a padded line an env file would
// read as an empty assignment, which names no program.
var pemShape = regexp.MustCompile(`^(-----BEGIN [A-Z0-9 ]+-----\n([A-Za-z0-9+/]{1,76}={0,2}\n)+-----END [A-Z0-9 ]+-----\n?)+$`)
// lineBreakIn is the path of the first string under v holding \n, \r or NUL, or "".
func lineBreakIn(v any, at string) string {
switch t := v.(type) {
case string:
if strings.ContainsAny(t, "\n\r\x00") && !pemShape.MatchString(t) {
return at
}
case map[string]any:
for _, k := range sortedKeysAny(t) {
if strings.ContainsAny(k, "\n\r\x00") {
return at + "." + strings.ToValidUTF8(strings.NewReplacer("\n", "\\n", "\r", "\\r", "\x00", "\\0").Replace(k), "?")
}
if found := lineBreakIn(t[k], at+"."+k); found != "" {
return found
}
}
case []any:
for i, e := range t {
if found := lineBreakIn(e, fmt.Sprintf("%s[%d]", at, i)); found != "" {
return found
}
}
}
return ""
}
func sortedKeysAny(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// UnusedSettings names settings that reach nothing.
//
// Somebody who sets a key on a module with nothing mergeable, or misspells one, has changed
@@ -467,9 +405,6 @@ var networkName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
// refused where it is stored (SetSettings, with UnusedSettings) and said where a plan is read,
// and never costs a module its place.
func JudgeSettings(m Manifest, layers []Layer, adopted bool) error {
if err := settingsHoldOneLine(m.Module, layers); err != nil {
return err
}
// With no layers too: a definition may ask for a setting nobody made — an access placed by
// nobody, a file's ${setting:…} nothing sets — and that is the same statement, missing.
if _, err := GivenPorts(m, layers); err != nil {
+9 -6
View File
@@ -29,9 +29,11 @@ const MountsSeat = "node-mounts"
func nfsServerVerbs() []Verb {
return []Verb{
{Name: "exports", Description: "Every share this machine exports: its name, its path, read-write or " +
"read-only, the owner every client is mapped to (uid and gid), the clients it is exported to (the " +
"private network's range), and whether the kernel holds it now. Also the exports found that are " +
"not the mesh's: a dataset's sharenfs property, a line in /etc/exports.",
"read-only, the owner every client is mapped to (uid and gid), each node it is exported to with that " +
"node's private address and access (only the nodes the server grants it to and that ask for it), " +
"what is granted and not asked for or asked for and not granted, and whether the kernel holds it " +
"now. Also the exports found that are not the mesh's: a dataset's sharenfs property, a line in " +
"/etc/exports.",
Input: schema(map[string]string{}, nil),
Replaces: []string{"exportfs -v", "cat /etc/exports", "zfs get sharenfs"}},
{Name: "clients", Description: "Which machines have mounted which share now, as the NFS server " +
@@ -39,11 +41,12 @@ func nfsServerVerbs() []Verb {
Input: schema(map[string]string{}, nil),
Replaces: []string{"ss -tn sport = :2049", "cat /proc/fs/nfsd/clients/*/info"}},
{Name: "test", Description: "Whether this machine exports one share for the mesh now, and whether its " +
"NFS service is up; with an address, also whether that address is inside the private network's " +
"range the share is exported to. What a machine mounting the share asks before it mounts.",
"NFS service is up; with an address, also whether the share is exported to that address: a node's " +
"own private address, when the server grants it the share and the node asks for it. What a machine " +
"mounting the share asks before it mounts.",
Input: schema(map[string]string{
"share": "the share, by its name",
"address": "a client's address on the private network (optional)",
"address": "a node's private address, to ask whether the share is exported to it (optional)",
}, []string{"share"}),
Replaces: []string{"showmount -e"}},
{Name: "reload", Description: "Have the kernel read this machine's export files again now (exportfs " +
+18
View File
@@ -159,3 +159,21 @@ func TestReloadSaysWhatItDoes(t *testing.T) {
}
}
}
// A share is exported to each node the server grants it to and that asks for it, at that node's own
// address (novox/hq ADR 0263 rule 5) — never to the private network's whole range. The verbs that
// describe the export say so, and do not promise the range.
func TestTheExportVerbsDescribePerNodeAddresses(t *testing.T) {
s, _ := SeatNamed(NFSServerSeat)
for _, v := range s.Serves {
if v.Name != "exports" && v.Name != "test" {
continue
}
if strings.Contains(v.Description, "private network's range") {
t.Errorf("%s still describes the export as the private network's range: %s", v.Name, v.Description)
}
if !strings.Contains(v.Description, "address") {
t.Errorf("%s does not say the export is to each node's address: %s", v.Name, v.Description)
}
}
}
+7 -11
View File
@@ -237,9 +237,9 @@ var ControllerVerbs = []Verb{
"node": "the machine that runs the module",
"module": "the module's name",
}, []string{"node", "module"})},
{Name: "token", Description: "Refused through a verb since novox/hq ADR 0266: the one-time token a machine " +
"joins with is answered to its caller, and whoever may call a verb includes agents. Issue it at the " +
"controller's terminal: `mesh-controller token issue --new <name> --overlay-key <public half>`.",
{Name: "token", Description: "Issue a one-time token for a machine to join with. Give the public half of the " +
"tunnel key the machine made (`nox-mesh-host key`): the machine is given its address and made a peer of " +
"the hub, and joins through the tunnel. The token is shown once, in the answer.",
Input: schema(map[string]string{
"node": "a machine the mesh already has a record for",
"new": "or the name of a machine to create the record for",
@@ -267,14 +267,10 @@ var ControllerVerbs = []Verb{
"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)",
}, nil, "clear", "replace", "history")},
{Name: "command", Description: "Run one reading command line of the controller's own, as you would type it at " +
"its shell — `node show ace`, `module list`, `plans`, `conditions show <key>` — and answer what it " +
"printed. The generic verb beside the named ones (novox/hq ADR 0154), and since ADR 0266 it only reads: " +
"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.",
{Name: "command", Description: "Run one command line of the controller's own, as you would type it at its " +
"shell — `node account g14 jochen`, `node show ace`, `module list` — and answer what it printed. The " +
"generic verb beside the named ones (novox/hq ADR 0154): everything the binary can do, without a verb " +
"per command. Any node may call any tool (ADR 0175), so nothing is held back here.",
Input: schema(map[string]string{
"command": "the command line, as the controller's binary takes it; quotes group a word with spaces",
}, []string{"command"})},
-4
View File
@@ -131,10 +131,6 @@ type Machine struct {
// home is when that is not the derived one.
Account string `json:"account,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 string `json:"public-domain,omitempty"`
// Assigned is every module assigned there.
-93
View File
@@ -1,93 +0,0 @@
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)
}
}
-3
View File
@@ -31,9 +31,6 @@ type ResourceHealth struct {
// Account is the account whose own service manager runs it, or the account a resource of kind account
// is (novox/hq ADR 0254).
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.
@@ -1,13 +0,0 @@
-- 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 '';
+2 -118
View File
@@ -9,7 +9,6 @@ import (
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strings"
"time"
@@ -70,14 +69,6 @@ type Node struct {
Account string
AccountHome string
// AgentAccount is the login agents run as on this machine when it is not the operator's — `agent`
// on the control node (novox/hq ADR 0266): an account of their own, without sudo, so no agent there
// can become root without a person. Empty means agents run as the operator account. Stated at the
// controller's terminal only, never by a verb or a setting. AgentAccountHome is its home when not
// /home/<account>; empty means derive it.
AgentAccount string
AgentAccountHome string
// HostVersion is the version of the host this machine reported running (novox/hq 04-ISSUES/087).
// Empty when it has not said since the mesh began keeping it — which is not the same as running
// no host, so nothing derives "behind" from an empty one.
@@ -100,17 +91,6 @@ func (n Node) Home() string {
}
}
// AgentHome is the agent account's home, derived when not stored; empty when no agent account is named.
func (n Node) AgentHome() string {
if n.AgentAccount == "" {
return ""
}
if n.AgentAccountHome != "" {
return n.AgentAccountHome
}
return "/home/" + n.AgentAccount
}
// Silent is how long since this node was last heard from, and whether it ever was.
func (n Node) Silent() (time.Duration, bool) {
if n.LastSeen.IsZero() {
@@ -167,14 +147,14 @@ func (i *Inventory) AddNodeAs(ctx context.Context, name string, adopted bool) (N
// nodeColumns and scanNode are the one reading of a node row, so every way of finding a node
// says whether it is adopted.
const nodeColumns = `id, name, created, last_seen, adopted, adopted_since, account, account_home,
agent_account, agent_account_home, host_version`
host_version`
func scanNode(row pgx.Row) (Node, error) {
var n Node
var seen, since *time.Time
var host *string
if err := row.Scan(&n.ID, &n.Name, &n.Created, &seen, &n.Adopted, &since,
&n.Account, &n.AccountHome, &n.AgentAccount, &n.AgentAccountHome, &host); err != nil {
&n.Account, &n.AccountHome, &host); err != nil {
return Node{}, err
}
if host != nil {
@@ -192,23 +172,7 @@ func scanNode(row pgx.Row) (Node, error) {
// SetAccount records the operator account on a node — its human login — and optionally where that
// account's home is (novox/hq to-be 29). An empty home means the mesh derives it. Clearing the
// account (empty name) is allowed: a machine may stop having a known operator.
//
// **Never the node's agent account** (novox/hq ADR 0266): the operator account may become root, and the
// agent account exists so agents cannot; naming the one as the other gives agents root. Refused here as
// SetAgentAccount refuses the other direction.
func (i *Inventory) SetAccount(ctx context.Context, node, account, home string) error {
account = strings.TrimSpace(account)
if account != "" {
n, err := i.NodeByName(ctx, node)
if err != nil {
return err
}
if n.AgentAccount != "" && n.AgentAccount == account {
return fmt.Errorf("%s is %s's agent account: the operator account may become root, and agents run as "+
"%s so that they cannot (novox/hq ADR 0266); clear the agent account first "+
"(node agent-account %s --clear) if the operator is to log in as it", account, node, account, node)
}
}
tag, err := i.store.Pool().Exec(ctx,
`update node set account = $1, account_home = $2 where name = $3`, account, home, node)
if err != nil {
@@ -220,86 +184,6 @@ func (i *Inventory) SetAccount(ctx context.Context, node, account, home string)
return nil
}
// loginName is what a login may be called: what useradd accepts by default, lower case, a letter or
// an underscore first.
var loginName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
// SetAgentAccount records the account agents run as on a node, and optionally its home (novox/hq ADR
// 0266). An empty account clears it: agents run as the operator account again.
//
// **Refused, rather than recorded and judged later:** root, which is the very thing the account exists
// to keep agents from; the node's operator account, which may become root without a password and is
// what agents ran as before — naming it here would say the agents have an account of their own while
// they do not; and a name no machine would accept as a login.
func (i *Inventory) SetAgentAccount(ctx context.Context, node, account, home string) error {
account, home = strings.TrimSpace(account), strings.TrimSpace(home)
if account == "" && home != "" {
return errors.New("a home without an agent account says nothing; name the account too")
}
if account != "" {
if err := AgentAccountRefusal(account, home); err != nil {
return err
}
n, err := i.NodeByName(ctx, node)
if err != nil {
return err
}
if n.Account != "" && n.Account == account {
return fmt.Errorf("%s is %s's operator account: agents would run as the operator, who may become "+
"root; clear the agent account instead (node agent-account %s --clear)", account, node, node)
}
}
tag, err := i.store.Pool().Exec(ctx,
`update node set agent_account = $1, agent_account_home = $2 where name = $3`, account, home, node)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("%w: %s", ErrNoSuchNode, node)
}
return nil
}
// serviceAccounts are the system and service accounts a machine of the mesh has, or a module of the
// catalogue declares (showcase, and the accounts ADR 0259 gives the router and the channels). The controller
// cannot read a machine's user database, so this list is the controller's half; the node-engine's half is
// refusing to take an existing account below the first login uid as one that must never become root.
var serviceAccounts = map[string]bool{
"root": true, "bin": true, "daemon": true, "sys": true, "adm": true, "nobody": true, "mail": true,
"ftp": true, "http": true, "www-data": true, "git": true, "sshd": true, "dbus": true, "polkitd": true,
"postgres": true, "docker": true, "nats": true, "redis": true, "uuidd": true, "dnsmasq": true,
"avahi": true, "rtkit": true, "colord": true, "geoclue": true, "tss": true, "alpm": true, "usbmux": true,
"showcase": true, "messenger": true, "telegram": true,
}
// serviceAccount says whether a name is a system or service account: one of the list, or a name of
// systemd's own (systemd-…).
func serviceAccount(name string) bool {
return serviceAccounts[name] || strings.HasPrefix(name, "systemd-")
}
// AgentAccountRefusal is why an agent account cannot be named, or nil: root, a malformed login, or a
// home that is not an absolute path.
func AgentAccountRefusal(account, home string) error {
if account == "root" {
return errors.New("agents may not run as root: the agent account exists to keep them from it " +
"(novox/hq ADR 0266)")
}
if !loginName.MatchString(account) {
return fmt.Errorf("%q is not a login name: lower case letters, digits, _ and -, a letter or _ first, "+
"at most 32", account)
}
if serviceAccount(account) {
return fmt.Errorf("%q is a system or service account a machine or a module already has: the agent "+
"account is one the mesh creates for agents alone, which nothing else runs as or owns files as "+
"(novox/hq ADR 0266); name a new one, such as agent", account)
}
if home != "" && !strings.HasPrefix(home, "/") {
return fmt.Errorf("the agent account's home %q is not an absolute path", home)
}
return nil
}
// Nodes are every node record, oldest first.
func (i *Inventory) Nodes(ctx context.Context) ([]Node, error) {
rows, err := i.store.Pool().Query(ctx,
-18
View File
@@ -420,20 +420,6 @@ const LivenessContract = 1
// because an older one parses strictly and would refuse the whole declaration for it.
const ReadinessContract = 2
// RootContract is the statement of an engine that also judges a user's declared `root` (novox/hq ADR 0266):
// whether an account declared never to become root without a person can — uid 0, a group that grants root,
// a sudo rule, a secret of the mesh it may read. Only to such an engine is the field sent: an older one
// parses strictly and would refuse the whole declaration for it.
const RootContract = 3
// ReasonRoot starts the reason of an account verdict that found a way to root (ADR 0266); the node-engine's
// own words (mesh-host internal/accounts ReasonRoot).
const ReasonRoot = "can become root without a person"
// RootNever is the value of a user's `root`, and of a verdict's Root, that the account must never become
// root without a person (ADR 0266).
const RootNever = "never"
// Health is one statement of a machine's long-running resources (to-be 48 §4): in every report, as the
// event HealthSubject between reports on each change, and again every minute while one is not healthy.
// The node-engine's own (mesh-host internal/link Health); a test on each side holds the field names.
@@ -556,10 +542,6 @@ type ResourceHealth struct {
// manager, and the account itself for a resource of kind KindAccount (novox/hq ADR 0254). Empty from an
// engine older than that, and for anything the machine's own manager or runtime runs.
Account string `json:"account,omitempty"`
// Root is "never" on a verdict of kind KindAccount whose account is declared never to become root
// without a person (novox/hq ADR 0266): the engine judged that too, and a healthy verdict says it cannot.
// Empty from an engine older than RootContract, and on every other verdict.
Root string `json:"root,omitempty"`
}
// HealthSaid is the health event's body: the machine and its statement. The machine is read from the
-20
View File
@@ -383,25 +383,8 @@ type User struct {
// has it not. On the account rather than on the unit, because it is the account's: two units of
// one account cannot disagree about it, and undeclaring one of them must not stop the other.
Linger *bool `json:"linger,omitempty"`
// Root says whether this account may become root without a person (novox/hq ADR 0266). "never"
// is the agents' own account: the login an agent session runs as on a machine where it must not
// reach root by itself. Empty asserts nothing, as Shell's does.
//
// **A statement the engine judges, never one it acts on.** The apply gives an account it creates
// no password, no sudo rule and no group beyond those declared, as it always has, and takes none
// away from one it finds: a sudo rule or a group granted by hand is a person's to remove, and a
// declaration that silently stripped them would be the mesh deciding what a person's machine
// grants. What "never" adds is the look: on every look the engine reads whether the account can
// become root by itself — by its uid, a group that grants root, any sudo rule, or a secret the mesh
// placed that it can read — and says it unhealthy while it can (internal/accounts), so the
// controller can tell a machine where it holds from one where it does not.
Root string `json:"root,omitempty"`
}
// RootNever is the one value Root takes besides empty: the account never becomes root without a person.
const RootNever = "never"
// Network is a named network on this machine.
//
// **A name and nothing else.** Not a driver, a subnet or a gateway: each of those is something a
@@ -495,9 +478,6 @@ func (u *User) validate(where string, _ bool) []string {
if u.Home != "" && !strings.HasPrefix(u.Home, "/") {
problems = append(problems, where+": a home directory is an absolute path")
}
if u.Root != "" && u.Root != RootNever {
problems = append(problems, fmt.Sprintf("%s: root is %q or absent, and %q is neither", where, RootNever, u.Root))
}
return problems
}
+2 -2
View File
@@ -75,7 +75,7 @@ github.com/nats-io/nkeys
# github.com/nats-io/nuid v1.0.1
## explicit
github.com/nats-io/nuid
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261008195316-9a85dffc11a2
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac
## explicit; go 1.26.0
github.com/novox/mesh-host/internal/declaration
github.com/novox/mesh-host/validate
@@ -133,4 +133,4 @@ golang.org/x/text/width
# golang.org/x/time v0.15.0
## explicit; go 1.25.0
golang.org/x/time/rate
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261008195316-9a85dffc11a2
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac