Compare commits

..
Author SHA1 Message Date
jochen cf8ada7699 Take a module's own secret through a hidden prompt on the operator's desk, so a bot token never passes through an agent's session (hq ADR 0259 §10)
mesh/delivery-group group feat/a-secret-given-at-the-desk rejected: a member's own check failed
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: TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose (0.62s)
mesh/delivery superseded: a newer head of the same pull request
2026-10-09 10:11:52 +02:00
58 changed files with 441 additions and 2354 deletions
-250
View File
@@ -1,250 +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"))
}
// searchQuietFor is how long the controller lets an agent account's verdict wait for the node-engine's setuid
// search before that is itself the urgent condition: the engine's bound on one search (link.RootSearchBound, the
// engine's own value), counted from when this controller first saw it waiting, never from the engine's start.
const searchQuietFor = link.RootSearchBound
// The kinds of an agent account's verdict, for the quiet a search earns.
const (
verdictOther = iota // anything else: a stale statement, an older engine, no verdict, another unknown
verdictPending // waiting for the search, and otherwise healthy
verdictComplete // judged: healthy, or a way to root found
)
// rootVerdictKind reads a statement for the agent account (novox/hq ADR 0266): pending when every verdict on it
// is healthy or not judged yet because the engine's setuid search runs, at least one of them that; complete when
// every verdict is healthy or one found a way to root. The engine's own "since" is not read: it starts again at
// every restart of the engine.
func rootVerdictKind(agent string, h inventory.NodeHealth, had bool, now time.Time) int {
if !had || now.Sub(h.HeardAt) > verdictFreshFor || h.Contract < link.RootContract {
return verdictOther
}
pending, any := false, false
for _, r := range h.Resources {
if r.Kind != link.KindAccount || r.Target != agent || r.Root != link.RootNever {
continue
}
any = true
switch {
case r.State == link.StateHealthy:
case r.State == link.StateUnhealthy:
return verdictComplete
case r.State == link.StateUnknown && strings.HasPrefix(r.Reason, link.ReasonRootPending):
pending = true
default:
return verdictOther
}
}
switch {
case !any:
return verdictOther
case pending:
return verdictPending
}
return verdictComplete
}
// searchStillRunning says the one thing keeping an agent account from being judged is the node-engine's setuid
// search, and that this controller first saw it waiting less than searchQuietFor ago — kept in the store, so a
// node-engine restarted in a loop does not keep it quiet. A complete verdict forgets when it began.
func searchStillRunning(ctx context.Context, inv *inventory.Inventory, node, agent string, h inventory.NodeHealth,
had bool, now time.Time) (bool, error) {
switch rootVerdictKind(agent, h, had, now) {
case verdictComplete:
return false, inv.RootSearchJudged(ctx, node)
case verdictPending:
since, err := inv.RootSearchPending(ctx, node, now)
if err != nil {
return false, err
}
return now.Sub(since) <= searchQuietFor, nil
}
return false, nil
}
// probeAgentAccounts is DA: every machine that names an agent account has it judged, on its node-engine's
// newest statement, unable to become root without a person (ADR 0266).
func probeAgentAccounts(ctx context.Context, d *doctor) ([]conditions.Observation, error) {
inv := d.open.inventory
nodes, err := inv.Nodes(ctx)
if err != nil {
return nil, err
}
var out []conditions.Observation
for _, n := range nodes {
if n.AgentAccount == "" {
continue
}
h, had, err := inv.HealthOf(ctx, n.Name)
if err != nil {
return nil, err
}
now := time.Now()
quiet, err := searchStillRunning(ctx, inv, n.Name, n.AgentAccount, h, had, now)
if err != nil {
return nil, err
}
confined, why := judgedConfined(n.AgentAccount, h, had, now)
if confined {
continue
}
// Not judged yet only because the first search since the node-engine started is still running: not the
// urgent condition after every restart. The agent is still not confined — ADR 0259's router reads
// agentConfined, not this — and `node show` still says not judged. Loud again once the search fails,
// runs out its bound, or the statement goes stale.
if quiet {
continue
}
out = append(out, conditions.Observation{Scope: conditions.ScopeMachine, ID: n.Name, Token: "agent-root",
Machine: n.Name, Severity: conditions.Urgent,
Summary: fmt.Sprintf("on %s, %s (ADR 0266): an agent there may become root without a person, and "+
"no answer from a channel authorises an act there (ADR 0259 §8)", n.Name, why),
Said: why})
}
return sortedFound(out), nil
}
// agentAccountLines is what `node show` says of the account agents run as.
func agentAccountLines(ctx context.Context, inv *inventory.Inventory, n inventory.Node) []string {
if n.AgentAccount == "" {
return []string{fmt.Sprintf(" agents run as the operator account (%s); no agent account is named",
orNoneKnown(n.Account))}
}
_, confined, why, err := agentConfined(ctx, inv, n.Name)
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
}
-273
View File
@@ -1,273 +0,0 @@
package main
import (
"github.com/novox/mesh-host/rootsearch"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/conditions"
snapshot "github.com/novox/mesh-controller/internal/facts"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// The agent account's judgement (novox/hq ADR 0266): confined only on a healthy account verdict judged for
// root, on the very account; every verdict not given — no statement, an older engine, no verdict on it, a
// verdict of unknown — is "not judged" and fails, never a pass.
func TestAnAgentAccountIsConfinedOnlyOnAHealthyVerdictJudgedForRoot(t *testing.T) {
at := time.Date(2026, 10, 8, 19, 21, 0, 0, time.UTC)
verdict := func(target, root, state, reason string) inventory.ResourceHealth {
return inventory.ResourceHealth{Module: "claude-code", Resource: "claude-code.agent-account",
Kind: link.KindAccount, Target: target, State: state, Reason: reason, Root: root, Account: target}
}
statement := func(contract int, rs ...inventory.ResourceHealth) inventory.NodeHealth {
return inventory.NodeHealth{Node: "anchor", Contract: contract, SaidAt: at, HeardAt: at, Resources: rs}
}
for _, c := range []struct {
name string
h inventory.NodeHealth
had bool
confined bool
says string
}{
{"judged and unable", statement(link.RootContract, verdict("agent", link.RootNever, link.StateHealthy, "")),
true, true, "cannot become root without a person"},
{"judged and able", statement(link.RootContract, verdict("agent", link.RootNever, link.StateUnhealthy,
link.ReasonRoot+": in the group docker, which grants root")), true, false, "in the group docker"},
{"a verdict of unknown", statement(link.RootContract, verdict("agent", link.RootNever, link.StateUnknown,
"sudo could not be read")), true, false, "not judged"},
{"no statement", inventory.NodeHealth{}, false, false, "not judged"},
{"an older engine", statement(link.ReadinessContract, verdict("agent", "", link.StateHealthy, "")),
true, false, "older than the judging"},
{"a verdict on groups only", statement(link.RootContract, verdict("agent", "", link.StateHealthy, "")),
true, false, "no verdict on it"},
{"a verdict on another account", statement(link.RootContract, verdict("ops", link.RootNever, link.StateHealthy, "")),
true, false, "no verdict on it"},
} {
confined, why := judgedConfined("agent", c.h, c.had, at.Add(time.Minute))
if confined != c.confined || !strings.Contains(why, c.says) {
t.Errorf("%s: confined %v, %q; want %v saying %q", c.name, confined, why, c.confined, c.says)
}
}
// A verdict heard longer ago than the bound is no verdict: an agent that stopped the node-engine must not
// leave "healthy" standing.
fresh := statement(link.RootContract, verdict("agent", link.RootNever, link.StateHealthy, ""))
if ok, _ := judgedConfined("agent", fresh, true, at.Add(verdictFreshFor)); !ok {
t.Error("a verdict exactly at the bound is still one")
}
if ok, why := judgedConfined("agent", fresh, true, at.Add(verdictFreshFor+time.Second)); ok ||
!strings.Contains(why, "not judged") {
t.Errorf("a stale healthy verdict passed: %q", why)
}
}
// DA raises an urgent condition, with plain words, on a machine whose agent account is not judged unable to
// become root; a machine that names none is not its to judge.
func TestTheSelfCheckSaysAnAgentAccountThatCanBecomeRoot(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
for _, n := range []string{"anchor", "laptop"} {
if _, err := inv.NodeByName(ctx, n); err != nil {
if _, err := inv.AddNode(ctx, n); err != nil {
t.Fatal(err)
}
}
if err := inv.SetAccount(ctx, n, "ops", ""); err != nil {
t.Fatal(err)
}
}
if err := inv.SetAgentAccount(ctx, "anchor", "agent", ""); err != nil {
t.Fatal(err)
}
d := &doctor{open: open}
found, err := probeAgentAccounts(ctx, d)
if err != nil {
t.Fatal(err)
}
found = onlyMachine(found, "anchor")
if len(found) != 1 || found[0].Machine != "anchor" || found[0].Severity != conditions.Urgent ||
!strings.Contains(found[0].Said, "not judged") {
t.Fatalf("a named agent account with no verdict: %+v", found)
}
w := plainWordings[kindAgentCanBecomeRoot](conditions.Observation{Kind: kindAgentCanBecomeRoot, Machine: "anchor"})
if w.Headline == "" || w.Needs == "" || w.Resolved == "" {
t.Errorf("the condition has no plain words: %+v", w)
}
healthy := inventory.ResourceHealth{Module: "claude-code", Resource: "claude-code.agent-account",
Kind: link.KindAccount, Target: "agent", State: link.StateHealthy, Root: link.RootNever, Account: "agent"}
if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.RootContract,
SaidAt: time.Now(), HeardAt: time.Now(), Resources: []inventory.ResourceHealth{healthy}}); err != nil {
t.Fatal(err)
}
if found, err = probeAgentAccounts(ctx, d); err != nil || len(onlyMachine(found, "anchor")) != 0 {
t.Fatalf("a judged agent account still fails: %+v %v", found, err)
}
if named, confined, why, err := agentConfined(ctx, inv, "anchor"); 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)
}
}
}
// After every node-engine restart its search for setuid programs runs for up to its bound, and the agent account
// is not judged until it ends. DA does not raise that as urgent while the search is within its bound, counted from
// when this controller first saw it waiting — never from the engine's own "since", which a restart resets, so an
// engine restarted in a loop does not keep it quiet. The account is still not confined, and `node show` still
// says not judged; a search that failed, or a way to root found, is urgent at once.
func TestASearchStillRunningAfterARestartIsNotUrgent(t *testing.T) {
if searchQuietFor != rootsearch.Bound {
t.Fatalf("the quiet is %s and the node-engine's bound %s: they are one value", searchQuietFor, rootsearch.Bound)
}
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
if _, err := inv.NodeByName(ctx, "anchor"); err != nil {
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
t.Fatal(err)
}
}
if err := inv.SetAgentAccount(ctx, "anchor", "agent", ""); err != nil {
t.Fatal(err)
}
d := &doctor{open: open}
say := func(state, reason string) []conditions.Observation {
t.Helper()
// The engine's since is always now: it was just restarted.
v := inventory.ResourceHealth{Module: "claude-code", Resource: "claude-code.agent-account",
Kind: link.KindAccount, Target: "agent", State: state, Reason: reason, Root: link.RootNever,
Account: "agent", Since: time.Now()}
if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.RootContract,
SaidAt: time.Now(), HeardAt: time.Now(), Resources: []inventory.ResourceHealth{v}}); err != nil {
t.Fatal(err)
}
found, err := probeAgentAccounts(ctx, d)
if err != nil {
t.Fatal(err)
}
return onlyMachine(found, "anchor")
}
running := link.ReasonRootPending + ": the search for setuid programs, started at 19:21, has not finished yet"
healthy := func() {
t.Helper()
if found := say(link.StateHealthy, ""); len(found) != 0 {
t.Fatalf("a healthy verdict raised: %+v", found)
}
}
if found := say(link.StateUnknown, running); len(found) != 0 {
t.Fatalf("a search first seen now was raised: %+v", found)
}
if _, confined, why, _ := agentConfined(ctx, inv, "anchor"); confined || !strings.Contains(why, "not judged") {
t.Fatalf("an account whose search runs was read as confined: %q", why)
}
// The engine restarted again and again, each statement's own since fresh: the controller's clock runs on.
if _, err := inv.RootSearchPending(ctx, "anchor", time.Now().Add(-searchQuietFor-time.Minute)); err != nil {
t.Fatal(err)
}
healthy() // a complete verdict forgets when the waiting began …
if _, err := inv.RootSearchPending(ctx, "anchor", time.Now().Add(-searchQuietFor-time.Minute)); err != nil {
t.Fatal(err) // … and this restart loop began past the bound
}
if found := say(link.StateUnknown, running); len(found) != 1 || found[0].Severity != conditions.Urgent {
t.Fatalf("a search pending past the bound, by the controller's clock, was not urgent: %+v", found)
}
healthy()
incomplete := rootsearch.ReasonIncomplete + ": the search for setuid programs did not finish (last tried at " +
"19:23: timeout); it is tried again later"
if found := say(link.StateUnknown, incomplete); len(found) != 1 || found[0].Severity != conditions.Urgent {
t.Fatalf("a search that did not finish was not urgent: %+v", found)
}
if found := say(link.StateUnhealthy, link.ReasonRoot+": in the group docker; "+running); len(found) != 1 ||
found[0].Severity != conditions.Urgent {
t.Fatalf("a way to root found while the search runs was not urgent: %+v", found)
}
}
+1 -29
View File
@@ -451,10 +451,6 @@ func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wa
if err != nil {
return "", err
}
// Through a verb, only a repository the catalogue builds from (novox/hq ADR 0266).
if err := verbMayAsk(ctx, source, repository); err != nil {
return "", err
}
ident, err := openIdentity(ctx)
if err != nil {
@@ -504,10 +500,8 @@ func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wa
// never before, so an ask that failed never reads as a build in flight. A dry run registers nothing, and
// is not kept.
keep := !dryRun && asker != ""
// Asked at the terminal is what lets its outcome register a module from a repository the catalogue does
// not build it from (novox/hq ADR 0266); never through a verb.
asked := inventory.BuildRequest{ID: request.ID, Repository: source.Repository, Seat: source.Seat, Path: path,
Ref: ref, For: asker, AtTerminal: startedAtTheTerminal()}
Ref: ref, For: asker}
if wait == 0 {
// Asked and not waited for (novox/hq issue 176): the outcome is the role's event, and the
@@ -637,21 +631,6 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
return manifest, kept, fmt.Errorf("%s built %s (%s), and the mesh does not register it: %w",
result.On, result.Repository, short(result.Commit), err)
}
// **Only from the repository the catalogue builds the module from** (novox/hq ADR 0266): else the trunk
// below is the trunk of whatever repository was built, which may be one an agent made — and a module named
// `sudo` from it would be what the next push sends. Another repository is the operator's, at the terminal.
trunk := result.Trunk
if was, err := inv.SourceOf(ctx, manifest.Module); err == nil && followedBranch(was.Ref) != "" {
trunk = followedBranch(was.Ref)
}
if trunk == "" {
trunk = "main"
}
repoID, err := mayRegisterFrom(ctx, inv, manifest.Module, recorded, result.ID, result.Path, trunk)
if err != nil {
return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", result.On,
manifest.Module, short(result.Commit), err)
}
// **Only a commit on the trunk is published** (novox/hq ADR 0238): a commit off its repository's
// default branch — a pull request's head, a feature branch built by hand, a `rebuild` or `replay
// --register` of one — is for checking, and is never a module's version; nothing could then send it.
@@ -699,10 +678,6 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
}
return manifest, kept, err
}
// Which repository it is registered from, by the forge's own id (novox/hq ADR 0266).
if err := inv.SetSourceIdentity(ctx, manifest.Module, repoID); err != nil {
return manifest, kept, err
}
// The keep set just moved, and new bytes just landed (novox/hq ADR 0189). Asked here rather
// than on a timer of its own: this is the only moment either is true. Never fatal — the build
// worked and the module is registered.
@@ -744,9 +719,6 @@ func buildAndShow(ctx context.Context, source buildSource, path, ref string, wai
if err != nil {
return err
}
if err := verbMayAsk(ctx, source, repository); err != nil {
return err
}
ident, err := openIdentity(ctx)
if err != nil {
return err
-448
View File
@@ -1,448 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"regexp"
"strings"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// Where a build may register a module from (novox/hq ADR 0266).
//
// A build's outcome registers its module, and a registered module is what the next push sends. Before this,
// an outcome registered whatever its manifest named, from whichever repository it was built from: an agent
// that made a repository of its own, committed `modules/sudo/module.json` granting itself a rule without a
// password, and asked the `build` verb for it had its repository registered as the module `sudo` — whose next
// push made the agent root on every node. A fork of the node-engine did the same everywhere. The trunk rule
// (ADR 0238) did not stop it: the trunk it checked was the trunk of the repository built, which was the
// agent's own.
//
// So **an outcome registers a module only from the repository the catalogue already builds that module
// from**; and a module new to the catalogue only from a repository the catalogue already builds another
// module from — whose trunk takes a reviewed, approved merge, which is how a merge adds a module (novox/hq
// issue 300). Anything else — a module moved to another repository, a module from a repository the
// catalogue has never built — is the operator's, at the controller's terminal: allowed only when the build
// request was kept as asked there. Judged at the take-in, which every outcome reaches whoever hears it and
// whichever verb asked it (`build`, `rebuild`, `replay --register`, `assign` with build), and before the ask
// for a call through a verb, so an agent cannot have a build node run a repository the catalogue does not
// build from at all.
// errNotItsSource is an outcome refused for where it was built from.
var errNotItsSource = errors.New("not built from the repository the catalogue builds it from")
// servedVar marks every process the serving controller starts — each verb's command, each child — and the serving
// process itself, so none of them can read as the operator at the terminal (novox/hq ADR 0266). Set by serve
// before it answers anything, inherited by every child through os.Environ.
const servedVar = "MESH_SERVED_BY_THE_CONTROLLER"
// startedAtTheTerminal says this process was started at the controller's terminal: not the serving controller,
// not anything it started, not a verb's command, not a seat call's. The serving controller marks its own
// environment (servedVar), so a build asked in it, or by any process it starts, never reads as the terminal's;
// runVerb also names the verb and the caller.
func startedAtTheTerminal() bool {
return os.Getenv(servedVar) == "" && os.Getenv(verbVar) == "" && os.Getenv(link.CallerVar) == ""
}
// markServed marks this process, and so everything it starts, as the serving controller's.
func markServed() {
if err := os.Setenv(servedVar, "1"); err != nil {
panic("the serving controller could not mark its environment: " + err.Error())
}
}
// sourceForms compares sources however each is spelled: a path on a seat's holder (ADR 0111) or a URL — a
// build asked of a seat's path is registered with the URL composed from it when its outcome does not echo
// the seat. A seat's path is composed into its URL where the seat's holder is known, so both spellings of
// one repository are one; where it is not, a seat's path matches only the same seat's same path.
type sourceForms struct {
bases map[string]string // the seat's clone base, `scheme://host:port`, by seat; "" where it is not known
base func(seat string) string
}
// newSourceForms reads the seats' bases from the mesh once, when first needed.
func newSourceForms(ctx context.Context, inv *inventory.Inventory) *sourceForms {
f := &sourceForms{bases: map[string]string{}}
var world *catalogue.World
f.base = func(seat string) string {
if b, known := f.bases[seat]; known {
return b
}
if world == nil {
w := catalogue.World{}
if shelf, err := inv.Catalogue(ctx); err == nil {
if read, err := theRestOfTheMesh(ctx, inv, shelf, ""); err == nil {
w = read
}
}
world = &w
}
b, err := seatBase(*world, seat)
if err != nil {
b = ""
}
f.bases[seat] = b
return b
}
return f
}
// canonical is one spelling of a repository: lower case, no `.git`, no trailing slash, and a seat's path as
// the URL its holder serves it at where that is known.
func (f *sourceForms) canonical(repository, seat string) string {
trim := func(s string) string {
s = strings.TrimSpace(strings.ToLower(s))
s = strings.TrimRight(s, "/")
return strings.TrimRight(strings.TrimSuffix(s, ".git"), "/")
}
if seat == "" {
return trim(repository)
}
if b := f.base(seat); b != "" {
return trim(b + "/" + strings.Trim(repository, "/"))
}
return "seat:" + seat + ":" + trim(strings.Trim(repository, "/"))
}
// same says two sources are one repository.
func (f *sourceForms) same(aRepository, aSeat, bRepository, bSeat string) bool {
if aRepository == "" || bRepository == "" {
return false
}
return f.canonical(aRepository, aSeat) == f.canonical(bRepository, bSeat)
}
// buildsFrom says the catalogue builds some module from this repository.
func (f *sourceForms) buildsFrom(entries []inventory.Entry, repository, seat string) bool {
for _, e := range entries {
if e.Provided || e.Source.Repository == "" {
continue
}
if f.same(e.Source.Repository, e.Source.Seat, repository, seat) {
return true
}
}
return false
}
// forgeFacts is what the mesh's forge says of a repository a module is registered from (novox/hq ADR 0266).
type forgeFacts struct {
// ID is the forge's own id of the repository: what tells it from one deleted and made again by its name.
ID int64
// Guarded is whether the branch is protected as a module's trunk must be: no direct push, at least one
// required status, and no administrator merging past one. Why says what is missing when it is not.
Guarded bool
Why string
}
// askTheForge asks the forge, through its module's tools on the bus, for a repository's id and its branch's
// protection. A variable so a test needs no forge.
var askTheForge = func(ctx context.Context, owner, repo, branch string) (forgeFacts, error) {
js, err := aBus()
if err != nil {
return forgeFacts{}, err
}
defer js.Close()
bus := link.OverNATS{Conn: js.Conn()}
ask := func(tool string, args map[string]any, into any) error {
raw, _ := json.Marshal(args)
answer, err := link.Ask(ctx, bus, "gitea", tool, raw, 30*time.Second)
if err != nil {
return err
}
if answer.Error != "" {
return fmt.Errorf("gitea.%s: %s", tool, answer.Error)
}
return json.Unmarshal(answer.Result, into)
}
var found struct {
Result struct {
ID int64 `json:"id"`
FullName string `json:"full_name"`
} `json:"result"`
}
if err := ask("gitea_api", map[string]any{"path": "/repos/" + owner + "/" + repo}, &found); err != nil {
return forgeFacts{}, err
}
if found.Result.ID == 0 {
return forgeFacts{}, fmt.Errorf("the forge named no id for %s/%s", owner, repo)
}
var rules struct {
Rules []protectionRule `json:"rules"`
}
if err := ask("gitea_branch_protection_get", map[string]any{"owner": owner, "repo": repo}, &rules); err != nil {
return forgeFacts{}, err
}
facts := judgedRule(rules.Rules, branch)
facts.ID = found.Result.ID
return facts, nil
}
// protectionRule is a branch protection rule as the forge's tool summarises it.
type protectionRule struct {
Rule string `json:"rule"`
Push bool `json:"push"`
RequiredStatuses []string `json:"required_statuses"`
AdminMayOverride bool `json:"admin_may_override"`
}
// judgedRule judges the one rule the forge applies to a branch, as the forge picks it: the rule named for the
// branch, else the first glob rule, in the forge's order, that covers it (gitea's first matching rule). Never a
// later rule that happens to be stronger: the forge does not read it.
func judgedRule(rules []protectionRule, branch string) forgeFacts {
var applied *protectionRule
for i := range rules {
if rules[i].Rule == branch {
applied = &rules[i]
break
}
}
if applied == nil {
for i := range rules {
if ruleCovers(rules[i].Rule, branch) {
applied = &rules[i]
break
}
}
}
switch r := applied; {
case r == nil:
return forgeFacts{Why: fmt.Sprintf("no protection rule covers %s", branch)}
case r.Push:
return forgeFacts{Why: fmt.Sprintf("the rule %s lets a person push to %s directly", r.Rule, branch)}
case len(r.RequiredStatuses) == 0:
return forgeFacts{Why: fmt.Sprintf("the rule %s requires no status before a merge into %s", r.Rule, branch)}
case r.AdminMayOverride:
return forgeFacts{Why: fmt.Sprintf("the rule %s lets an administrator merge into %s past a status", r.Rule, branch)}
}
return forgeFacts{Guarded: true}
}
// ruleCovers says a protection rule's name — a branch, or a glob of them — covers a branch, as the forge reads it.
func ruleCovers(rule, branch string) bool {
if rule == branch {
return true
}
if !strings.ContainsAny(rule, "*?[") {
return false
}
var re strings.Builder
re.WriteString("^")
for i := 0; i < len(rule); i++ {
switch c := rule[i]; {
case c == '*' && i+1 < len(rule) && rule[i+1] == '*':
re.WriteString(".*")
i++
case c == '*':
re.WriteString("[^/]*")
case c == '?':
re.WriteString("[^/]")
default:
re.WriteString(regexp.QuoteMeta(string(c)))
}
}
re.WriteString("$")
ok, _ := regexp.MatchString(re.String(), branch)
return ok
}
// onTheForge is a source's owner and name on the mesh's own forge (the git seat's holder), and whether it is
// there at all.
func (f *sourceForms) onTheForge(repository, seat string) (owner, name string, ok bool) {
var rest string
switch {
case seat == gitSeat:
rest = strings.Trim(repository, "/")
case seat == "":
base := f.base(gitSeat)
if base == "" {
return "", "", false
}
url, prefix := f.canonical(repository, ""), f.canonical(base, "")+"/"
if !strings.HasPrefix(url, prefix) {
return "", "", false
}
// The case the forge spells it with: the URL as given, past the base.
rest = strings.TrimSuffix(strings.Trim(repository[len(prefix):], "/"), ".git")
default:
return "", "", false
}
parts := strings.Split(rest, "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", false
}
return parts[0], parts[1], true
}
// mayRegisterFrom says whether a build's outcome may register module from the source it was built from, and the
// forge's id of that repository to record (novox/hq ADR 0266). Through any verb, only:
//
// - from the module's registered repository — the same repository by the forge's own id, not only its name; or,
// for a module new to the catalogue, from a repository the catalogue builds another module from;
// - and from a repository on the mesh's forge whose trunk is protected as a trunk must be: no direct push, at
// least one required status, no administrator merging past one.
//
// Anything else only when the build request was kept as asked at the controller's terminal, for this very
// repository and path. path is the module's directory as built; branch the trunk it is registered from.
func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module string, built inventory.Source,
buildID, path, branch string) (int64, error) {
forms := newSourceForms(ctx, inv)
was, err := inv.SourceOf(ctx, module)
isNew := errors.Is(err, inventory.ErrNoSuchModule)
if err != nil && !isNew {
return 0, err
}
terminal, err := askedHereFor(ctx, inv, forms, buildID, built, path)
if err != nil {
return 0, err
}
refuse := func(why string) (int64, error) {
return 0, fmt.Errorf("%w: %s. A module is registered from such a source only by a build asked at the "+
"controller's terminal, never through a verb: a registered module is what the next push sends, and whoever "+
"may call a verb includes agents (novox/hq ADR 0266)", errNotItsSource, why)
}
var why string
var alongside []inventory.Entry // the modules a new one's repository already builds
switch {
case !isNew && forms.same(was.Repository, was.Seat, built.Repository, built.Seat):
case !isNew:
registered := was.Repository
if registered == "" {
registered = "no repository (it was handed over by hand)"
}
why = fmt.Sprintf("%s is built from %s, and this build is of %s", module, sourceWords(registered, was.Seat),
sourceWords(built.Repository, built.Seat))
default:
entries, err := inv.Catalogued(ctx)
if err != nil {
return 0, err
}
for _, e := range entries {
if !e.Provided && e.Source.Repository != "" &&
forms.same(e.Source.Repository, e.Source.Seat, built.Repository, built.Seat) {
alongside = append(alongside, e)
}
}
if len(alongside) == 0 {
why = fmt.Sprintf("%s is new to the catalogue, and %s is no repository the catalogue builds a module from",
module, sourceWords(built.Repository, built.Seat))
}
}
if why != "" && !terminal {
return refuse(why)
}
owner, name, onForge := forms.onTheForge(built.Repository, built.Seat)
if !onForge {
if terminal {
fmt.Printf("%s: %s is not on the mesh's forge — registered, as asked at the controller's terminal\n",
buildID, sourceWords(built.Repository, built.Seat))
return 0, nil
}
return refuse(fmt.Sprintf("%s is not on the mesh's forge, so whether its trunk is protected cannot be read",
sourceWords(built.Repository, built.Seat)))
}
facts, err := askTheForge(ctx, owner, name, branch)
if err != nil {
if terminal {
fmt.Printf("%s: the forge could not be asked about %s/%s (%v) — registered, as asked at the controller's "+
"terminal\n", buildID, owner, name, err)
return 0, nil
}
return refuse(fmt.Sprintf("the forge could not say whether %s/%s's %s is protected: %v", owner, name, branch, err))
}
if terminal {
if why != "" || !facts.Guarded {
fmt.Printf("%s: %s — registered, as asked at the controller's terminal\n", buildID,
strings.Trim(why+"; "+facts.Why, "; "))
}
return facts.ID, nil
}
if !facts.Guarded {
return refuse(fmt.Sprintf("%s/%s's %s is not protected as a module's trunk must be: %s", owner, name, branch,
facts.Why))
}
// The same repository by the forge's id, not only its name: one deleted and made again is another.
recorded := map[string]int64{}
if !isNew {
id, err := inv.SourceIdentity(ctx, module)
if err != nil {
return 0, err
}
recorded[module] = id
}
for _, e := range alongside {
id, err := inv.SourceIdentity(ctx, e.Manifest.Module)
if err != nil {
return 0, err
}
recorded[e.Manifest.Module] = id
}
for m, id := range recorded {
if id != 0 && id != facts.ID {
return refuse(fmt.Sprintf("%s/%s is not the repository %s was registered from: the forge knows it as "+
"repository %d, and %s was registered from repository %d — one of that name deleted and made again",
owner, name, m, facts.ID, m, id))
}
}
return facts.ID, nil
}
// askedHereFor says the build was asked at the controller's terminal, for this repository and this path: a kept
// request marked so, whose source is the outcome's (novox/hq ADR 0266).
func askedHereFor(ctx context.Context, inv *inventory.Inventory, forms *sourceForms, buildID string,
built inventory.Source, path string) (bool, error) {
r, found, err := inv.BuildRequestByID(ctx, buildID)
if err != nil || !found || !r.AtTerminal {
return false, err
}
if !forms.same(r.Repository, r.Seat, built.Repository, built.Seat) ||
strings.Trim(r.Path, "/") != strings.Trim(path, "/") {
fmt.Printf("%s was asked at the terminal of %s at %q, and its outcome is of %s at %q: not the terminal's\n",
buildID, sourceWords(r.Repository, r.Seat), r.Path, sourceWords(built.Repository, built.Seat), path)
return false, nil
}
return true, nil
}
// sourceWords is a source as a person reads it.
func sourceWords(repository, seat string) string {
if seat == "" {
return repository
}
return buildSource{Repository: repository, Seat: seat}.String()
}
// verbMayAsk refuses, for a call through a verb, a build of a repository the catalogue builds no module from
// (novox/hq ADR 0266): the build node would run what an agent wrote, and its outcome could never be
// registered anyway. url is the repository as it is cloned. At the terminal anything may be asked.
func verbMayAsk(ctx context.Context, source buildSource, url string) error {
if startedAtTheTerminal() {
return nil
}
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
entries, err := open.inventory.Catalogued(ctx)
if err != nil {
return err
}
forms := newSourceForms(ctx, open.inventory)
if forms.buildsFrom(entries, source.Repository, source.Seat) || forms.buildsFrom(entries, url, "") {
return nil
}
return terminalRefusal("%s is no repository the catalogue builds a module from, and a build of any other is "+
"asked at the controller's terminal only, never through a verb: a build node runs what the repository "+
"says, and its outcome would register a module the next push sends — whoever may call a verb includes "+
"agents (novox/hq ADR 0266). Nothing was asked", source)
}
-380
View File
@@ -1,380 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"hash/fnv"
"os"
"os/exec"
"slices"
"strings"
"sync"
"testing"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// The routes to root a review of ADR 0266 found (novox/hq ADR 0266 §7): an agent makes a repository of its
// own — or forks one the mesh builds from — commits a module.json naming a module the mesh runs everywhere
// (`sudo`, granting itself a rule without a password; `mesh-host`, the node-engine), and asks the `build` verb
// for it. Its outcome was registered under that name from the agent's repository, and the next push sent it.
// onTrunk is an outcome of a commit on its repository's trunk, as the build seat says it.
func onTrunk(id, repository, seat, path string, manifest map[string]any) link.BuildResult {
raw, _ := json.Marshal(manifest)
r := link.BuildResult{ID: id, Repository: "http://forge.internal:20000/" + repository + ".git", Path: path,
Ref: "main", On: "anchor", Commit: "c0ffee0123456789", Manifest: raw,
Trunk: "main", OnTrunk: true, Branches: []string{"main"}}
if seat != "" {
r.Source = &link.SourceOnSeat{Seat: seat, Repository: repository}
}
return r
}
// keptAsked keeps a build request as the asker would: through a verb, or at the terminal.
func keptAsked(t *testing.T, inv *inventory.Inventory, id, repository, path string, atTerminal bool) {
t.Helper()
if err := inv.RecordBuildRequest(t.Context(), inventory.BuildRequest{ID: id, Repository: repository, Seat: "git",
Path: path, For: "build", AtTerminal: atTerminal}); err != nil {
t.Fatal(err)
}
}
// theCatalogue is a mesh whose sudo is built from the catalogue repository and whose node-engine from its own.
func theCatalogue(t *testing.T) *stores {
t.Helper()
open := aMesh(t)
ctx := t.Context()
for _, b := range []link.BuildResult{
onTrunk("build-sudo", "novox/mesh-catalog", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "1"}),
onTrunk("build-host", "novox/mesh-host", "git", "", map[string]any{"module": "mesh-host", "version": "1"}),
} {
keptAsked(t, open.inventory, b.ID, b.Source.Repository, b.Path, true)
if _, _, err := takeIn(ctx, open.inventory, b); err != nil {
t.Fatal(err)
}
}
return open
}
func TestABuildFromAnAgentsRepositoryIsNotRegisteredAsAModuleTheMeshHolds(t *testing.T) {
open := theCatalogue(t)
ctx := t.Context()
for _, c := range []struct {
name, repository, path, module string
}{
{"its own repository naming sudo", "agent/sudo", "modules/sudo", "sudo"},
{"a fork of the catalogue", "agent/mesh-catalog", "modules/sudo", "sudo"},
{"a fork of the node-engine", "agent/mesh-host", "", "mesh-host"},
} {
t.Run(c.name, func(t *testing.T) {
id := "build-" + strings.ReplaceAll(c.repository, "/", "-")
keptAsked(t, open.inventory, id, c.repository, c.path, false) // through the build verb
evil := onTrunk(id, c.repository, "git", c.path, map[string]any{"module": c.module, "version": "evil"})
_, _, err := takeIn(ctx, open.inventory, evil)
if !errors.Is(err, errNotItsSource) {
t.Fatalf("a build of %s was taken in as %s: %v", c.repository, c.module, err)
}
shelf, err := open.inventory.Catalogue(ctx)
if err != nil {
t.Fatal(err)
}
if got := shelf[c.module].Version; got != "1" {
t.Fatalf("%s is now %q, from %s", c.module, got, c.repository)
}
if _, found, _ := open.inventory.BuildByID(ctx, id); !found {
t.Errorf("the refused build %s is not recorded", id)
}
})
}
}
func TestANewModuleFromARepositoryTheCatalogueDoesNotBuildFromIsNotRegistered(t *testing.T) {
open := theCatalogue(t)
ctx := t.Context()
keptAsked(t, open.inventory, "build-new", "agent/tools", "", false)
_, _, err := takeIn(ctx, open.inventory, onTrunk("build-new", "agent/tools", "git", "",
map[string]any{"module": "agent-tools", "version": "1"}))
if !errors.Is(err, errNotItsSource) {
t.Fatalf("a new module from an agent's repository was taken in: %v", err)
}
// And one asked of nobody here — an outcome on the bus no request was kept for — the same.
_, _, err = takeIn(ctx, open.inventory, onTrunk("build-unasked", "agent/tools", "git", "",
map[string]any{"module": "agent-tools", "version": "1"}))
if !errors.Is(err, errNotItsSource) {
t.Fatalf("an outcome nobody asked for was taken in: %v", err)
}
if shelf, _ := open.inventory.Catalogue(ctx); shelf["agent-tools"].Module != "" {
t.Fatal("the refused module is in the catalogue")
}
}
// The operator at the terminal may still move a module, or add one from a new repository.
func TestAtTheTerminalAnotherRepositoryIsRegistered(t *testing.T) {
open := theCatalogue(t)
ctx := t.Context()
keptAsked(t, open.inventory, "build-moved", "novox/sudo", "", true)
if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-moved", "novox/sudo", "git", "",
map[string]any{"module": "sudo", "version": "2"})); err != nil {
t.Fatalf("a move the operator asked for at the terminal was refused: %v", err)
}
if src, _ := open.inventory.SourceOf(ctx, "sudo"); src.Repository != "novox/sudo" {
t.Fatalf("sudo is built from %q", src.Repository)
}
if err := open.inventory.RecordBuildRequest(ctx, inventory.BuildRequest{ID: "build-external",
Repository: "http://forge.internal:20000/someone/app.git", For: "build", AtTerminal: true}); err != nil {
t.Fatal(err)
}
if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-external", "someone/app", "", "",
map[string]any{"module": "app", "version": "1"})); err != nil {
t.Fatalf("a new module the operator asked for at the terminal was refused: %v", err)
}
}
// The delivery's flow is untouched: a merge's rebuild of a module from its own repository, and a merge adding
// a module to a repository the catalogue builds from (novox/hq issue 300), are registered with no terminal.
func TestADeliveryFromTheRegisteredRepositoryIsRegistered(t *testing.T) {
open := theCatalogue(t)
ctx := t.Context()
rebuilt := onTrunk("build-plan", "novox/mesh-catalog", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "2"})
if err := open.inventory.RecordBuildRequest(ctx, inventory.BuildRequest{ID: rebuilt.ID,
Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/sudo", For: "plan"}); err != nil {
t.Fatal(err)
}
if _, _, err := takeIn(ctx, open.inventory, rebuilt); err != nil {
t.Fatalf("a plan's build of the module's own repository was refused: %v", err)
}
added := onTrunk("build-merge", "novox/mesh-catalog", "git", "modules/zram", map[string]any{"module": "zram", "version": "1"})
if _, _, err := takeIn(ctx, open.inventory, added); err != nil {
t.Fatalf("a module a merge added to the catalogue repository was refused: %v", err)
}
shelf, _ := open.inventory.Catalogue(ctx)
if shelf["sudo"].Version != "2" || shelf["zram"].Module == "" {
t.Fatalf("not registered: sudo %q, zram %q", shelf["sudo"].Version, shelf["zram"].Module)
}
}
// Through a verb, a build of a repository the catalogue builds nothing from is not even asked: the build node
// would run what the agent wrote.
func TestAVerbAsksNoBuildOfARepositoryTheCatalogueDoesNotBuildFrom(t *testing.T) {
theCatalogue(t)
ctx := t.Context()
t.Setenv(verbVar, "build")
t.Setenv(link.CallerVar, "node-tools.anchor, through the mesh-controller seat")
err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, "http://forge.internal:20000/agent/sudo.git")
var policy *heldAtTheTerminal
if !errors.As(err, &policy) {
t.Fatalf("a verb's build of an agent's repository was asked: %v", err)
}
if err := verbMayAsk(ctx, buildSource{Repository: "novox/mesh-catalog", Seat: "git"},
"http://forge.internal:20000/novox/mesh-catalog.git"); err != nil {
t.Fatalf("a verb's build of the catalogue repository was refused: %v", err)
}
t.Setenv(verbVar, "")
t.Setenv(link.CallerVar, "")
if err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, ""); err != nil {
t.Fatalf("the terminal was refused: %v", err)
}
}
// asTheOperator keeps a build as asked at the controller's terminal, as the operator's first build of a module
// from a repository the catalogue does not yet build from is (novox/hq ADR 0266), and hands it back.
func asTheOperator(t *testing.T, inv *inventory.Inventory, b link.BuildResult) link.BuildResult {
t.Helper()
repository, seat := b.Repository, ""
if b.Source != nil {
repository, seat = b.Source.Repository, b.Source.Seat
}
if err := inv.RecordBuildRequest(t.Context(), inventory.BuildRequest{ID: b.ID, Repository: repository, Seat: seat,
Path: b.Path, For: "build", AtTerminal: true}); err != nil {
t.Fatal(err)
}
return b
}
// A rollback puts back only a build of the module's own repository: an agent's build of the module's name,
// recorded and refused, at the very commit the machine ran before (a fork carries it), is never registered by
// the back door of a failed gate.
func TestARollbackNeverPutsBackABuildFromAnotherRepository(t *testing.T) {
open := theCatalogue(t)
ctx := t.Context()
inv := open.inventory
fork := onTrunk("build-1791500000000000000", "agent/mesh-catalog", "git", "modules/sudo",
map[string]any{"module": "sudo", "version": "evil"})
fork.Commit = "c0ffee0123456789" // the commit sudo was registered at
keptAsked(t, inv, fork.ID, "agent/mesh-catalog", "modules/sudo", false)
if _, _, err := takeIn(ctx, inv, fork); !errors.Is(err, errNotItsSource) {
t.Fatalf("the fork's build was taken in: %v", err)
}
failed := onTrunk("build-1791600000000000000", "novox/mesh-catalog", "git", "modules/sudo",
map[string]any{"module": "sudo", "version": "2"})
failed.Commit = "badbadbad0123456"
if _, _, err := takeIn(ctx, inv, failed); err != nil {
t.Fatal(err)
}
record, _, err := inv.BuildByID(ctx, failed.ID)
if err != nil {
t.Fatal(err)
}
previous, found, err := inv.PreviousBuild(ctx, "sudo", "c0ffee0123456789", record)
if err != nil {
t.Fatal(err)
}
if found && previous.ID == fork.ID {
t.Fatalf("a rollback would put back the fork's build %s", previous.ID)
}
if !found || previous.ID != "build-sudo" {
t.Fatalf("a rollback puts back %q (found %v), want the registered build-sudo", previous.ID, found)
}
}
// theForge is what the forge says in a test, by owner/name: a repository not named here is protected as a
// trunk must be, with an id of its own.
var theForge sync.Map
func init() {
askTheForge = func(_ context.Context, owner, repo, _ string) (forgeFacts, error) {
if said, ok := theForge.Load(owner + "/" + repo); ok {
switch f := said.(type) {
case error:
return forgeFacts{}, f
case forgeFacts:
return f, nil
}
}
h := fnv.New32a()
_, _ = h.Write([]byte(owner + "/" + repo))
return forgeFacts{ID: int64(h.Sum32()), Guarded: true}, nil
}
}
// A module's trunk the forge does not protect — direct pushes, no required status — or a forge that cannot say,
// registers nothing through a verb: the trunk rule means nothing on a branch anyone pushes to.
func TestATrunkTheForgeDoesNotProtectRegistersNothing(t *testing.T) {
open := theCatalogue(t)
ctx := t.Context()
theForge.Store("novox/unguarded", forgeFacts{ID: 7, Why: "the rule main lets a person push to main directly"})
t.Cleanup(func() { theForge.Delete("novox/unguarded") })
first := onTrunk("build-unguarded-1", "novox/unguarded", "git", "", map[string]any{"module": "unguarded", "version": "1"})
keptAsked(t, open.inventory, first.ID, "novox/unguarded", "", true)
if _, _, err := takeIn(ctx, open.inventory, first); err != nil {
t.Fatalf("at the terminal: %v", err)
}
again := onTrunk("build-unguarded-2", "novox/unguarded", "git", "", map[string]any{"module": "unguarded", "version": "2"})
if _, _, err := takeIn(ctx, open.inventory, again); !errors.Is(err, errNotItsSource) ||
!strings.Contains(err.Error(), "push to main directly") {
t.Fatalf("a rebuild from an unprotected trunk was taken in: %v", err)
}
theForge.Store("novox/unguarded", errors.New("nothing serves gitea.gitea_api"))
if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-unguarded-3", "novox/unguarded", "git", "",
map[string]any{"module": "unguarded", "version": "3"})); !errors.Is(err, errNotItsSource) {
t.Fatalf("a forge that could not say was read as a protected trunk: %v", err)
}
if shelf, _ := open.inventory.Catalogue(ctx); shelf["unguarded"].Version != "1" {
t.Fatalf("unguarded is %q", shelf["unguarded"].Version)
}
}
// A repository deleted and made again under the module's repository's name is another repository: the forge's
// id, recorded at registration, tells them apart.
func TestARepositoryMadeAgainUnderItsNameIsNotTheModulesSource(t *testing.T) {
open := theCatalogue(t)
ctx := t.Context()
theForge.Store("novox/remade", forgeFacts{ID: 100, Guarded: true})
t.Cleanup(func() { theForge.Delete("novox/remade") })
first := onTrunk("build-remade-1", "novox/remade", "git", "", map[string]any{"module": "remade", "version": "1"})
keptAsked(t, open.inventory, first.ID, "novox/remade", "", true)
if _, _, err := takeIn(ctx, open.inventory, first); err != nil {
t.Fatal(err)
}
if id, _ := open.inventory.SourceIdentity(ctx, "remade"); id != 100 {
t.Fatalf("the forge's id was not recorded: %d", id)
}
theForge.Store("novox/remade", forgeFacts{ID: 101, Guarded: true}) // deleted, and made again by an agent
if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-remade-2", "novox/remade", "git", "",
map[string]any{"module": "remade", "version": "evil"})); !errors.Is(err, errNotItsSource) ||
!strings.Contains(err.Error(), "made again") {
t.Fatalf("a repository made again under the name was taken in: %v", err)
}
// And a new module from it, beside the one registered from the first, the same.
if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-remade-3", "novox/remade", "git", "modules/other",
map[string]any{"module": "other", "version": "1"})); !errors.Is(err, errNotItsSource) {
t.Fatalf("a new module from a repository made again was taken in: %v", err)
}
}
// The terminal's mark is the operator's for the repository and path they asked: an outcome of another, under that
// build's id, is not theirs.
func TestATerminalRequestCoversOnlyWhatItAsked(t *testing.T) {
open := theCatalogue(t)
ctx := t.Context()
if err := open.inventory.RecordBuildRequest(ctx, inventory.BuildRequest{ID: "build-asked", Repository: "novox/app",
Seat: "git", Path: "modules/app", For: "build", AtTerminal: true}); err != nil {
t.Fatal(err)
}
other := onTrunk("build-asked", "agent/sudo", "git", "modules/app", map[string]any{"module": "sudo", "version": "evil"})
if _, _, err := takeIn(ctx, open.inventory, other); !errors.Is(err, errNotItsSource) {
t.Fatalf("an outcome of another repository under a terminal request's id was taken in: %v", err)
}
elsewhere := onTrunk("build-asked", "novox/app", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "evil"})
if _, _, err := takeIn(ctx, open.inventory, elsewhere); !errors.Is(err, errNotItsSource) {
t.Fatalf("an outcome of another path under a terminal request's id was taken in: %v", err)
}
}
// The serving controller, and everything it starts, is never the terminal: a build asked in it reads as asked
// through the mesh even when no verb and no caller is named.
func TestTheServingControllerIsNeverTheTerminal(t *testing.T) {
t.Setenv(verbVar, "")
t.Setenv(link.CallerVar, "")
t.Setenv(servedVar, "")
if !startedAtTheTerminal() {
t.Fatal("a process started by hand is not the terminal")
}
markServed()
if startedAtTheTerminal() {
t.Fatal("the serving controller reads as the terminal")
}
child := exec.Command(os.Args[0], "-test.run=^$")
child.Env = os.Environ()
if !slices.Contains(child.Env, servedVar+"=1") {
t.Fatal("what the serving controller starts does not carry its mark")
}
}
// The forge applies one rule to a branch: the rule named for it, else the first glob that covers it. A stronger
// rule later in the list is not what guards the branch, and is not read as if it were (the confirmation review).
func TestTheRuleJudgedIsTheOneTheForgeApplies(t *testing.T) {
guarded := protectionRule{Rule: "*", RequiredStatuses: []string{"mesh/merge-gate"}}
open := protectionRule{Rule: "main", Push: true, RequiredStatuses: []string{"mesh/merge-gate"}}
if f := judgedRule([]protectionRule{guarded, open}, "main"); f.Guarded {
t.Error("an exact rule letting pushes was passed over for a glob that guards")
}
if f := judgedRule([]protectionRule{{Rule: "ma*", RequiredStatuses: nil}, {Rule: "*", RequiredStatuses: []string{"x"}}},
"main"); f.Guarded || !strings.Contains(f.Why, "ma*") {
t.Errorf("the first glob covering the branch was not the one judged: %+v", f)
}
if f := judgedRule([]protectionRule{{Rule: "release/*"}, {Rule: "main", RequiredStatuses: []string{"x"}}}, "main"); !f.Guarded {
t.Errorf("the rule named for the branch was not judged: %+v", f)
}
if f := judgedRule(nil, "main"); f.Guarded {
t.Error("no rule is no protection")
}
}
// An id the forge could not give keeps the id already recorded.
func TestAnUnknownIdentityKeepsTheRecordedOne(t *testing.T) {
open := theCatalogue(t)
ctx := t.Context()
if err := open.inventory.SetSourceIdentity(ctx, "sudo", 100); err != nil {
t.Fatal(err)
}
if err := open.inventory.SetSourceIdentity(ctx, "sudo", 0); err != nil {
t.Fatal(err)
}
if id, _ := open.inventory.SourceIdentity(ctx, "sudo"); id != 100 {
t.Fatalf("the recorded id became %d", id)
}
}
+4 -4
View File
@@ -19,11 +19,11 @@ func TestABuildHeardIsRecordedAndRegistered(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
manifest, _ := json.Marshal(map[string]any{"module": "shop", "version": "3"})
m, _, err := takeIn(ctx, open.inventory, asTheOperator(t, open.inventory, link.BuildResult{
m, _, err := takeIn(ctx, open.inventory, link.BuildResult{
ID: "b-1", Repository: "http://forge.internal:20000/novox/shop.git", Path: "modules/shop",
Ref: "main", On: "anchor", Commit: "abcdef0123", Manifest: manifest,
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/shop"},
}))
})
if err != nil {
t.Fatal(err)
}
@@ -78,7 +78,7 @@ func TestABuildAtACommitKeepsTheBranchTheModuleFollows(t *testing.T) {
Path: "modules/unifi", Ref: ref, On: "anchor", Commit: commit, Manifest: manifest,
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}}
}
if _, _, err := takeIn(ctx, open.inventory, asTheOperator(t, open.inventory, result("b-1", "main", "1111111aaaa"))); err != nil {
if _, _, err := takeIn(ctx, open.inventory, result("b-1", "main", "1111111aaaa")); err != nil {
t.Fatal(err)
}
if _, _, err := takeIn(ctx, open.inventory, result("b-2", "9c97a8a", "9c97a8a1d2c3")); err != nil {
@@ -117,7 +117,7 @@ func TestAnOlderBuildHeardLaterDoesNotReplaceTheNewer(t *testing.T) {
Path: "modules/postgres", Ref: "main", On: "anchor", Commit: "efff5415", Manifest: manifest,
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}}
}
if _, _, err := takeIn(ctx, open.inventory, asTheOperator(t, open.inventory, result(newer, "4bcd5f73"))); err != nil {
if _, _, err := takeIn(ctx, open.inventory, result(newer, "4bcd5f73")); err != nil {
t.Fatal(err)
}
_, _, err := takeIn(ctx, open.inventory, result(older, "0ab07fa9"))
+2
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},
+167
View File
@@ -0,0 +1,167 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/link"
"github.com/novox/mesh-controller/internal/secrets"
)
// A module's own secret given at the operator's desk (novox/hq ADR 0259 §10).
//
// **The value never passes through whoever asked for it.** An agent, or the operator at the mesh MCP
// server, calls `give` with the machine, the module, the secret's name and the desk — never a value. The
// controller makes a sealing keypair for this one call, asks the desk's `node-launcher.secret` to prompt the
// operator without showing what is typed, and is answered with what was typed **sealed to that key**: no
// plaintext on the bus, in a runtime's log or in any call's record. It opens it here, seals it to the
// module's machine exactly as `secret accept` does, and forgets it. What it answers says only that the
// value was taken, or why not.
//
// **What remains** (ADR 0234's accepted residual risk): on an X11 desk any program of the operator's
// account can read the keys as they are typed. And a program that calls the desk's prompt itself, with a
// key of its own, is answered with what the operator typed into a prompt they did not ask for — as it could
// draw a window of its own. The prompt says who asks and for what, so the operator types only into a
// prompt they started.
// deskPromptWithin is how long the prompt waits for the operator: below the runtime's thirty seconds for
// one call, as the launcher's menu is.
const deskPromptWithin = 25
// deskGive is the desk path, its four reaches given so a test needs no store and no bus.
type deskGive struct {
// declares refuses a module or a secret the mesh would refuse, before anybody is asked to type.
declares func(module, name string) error
// ask asks one machine's node-launcher.secret and answers its result, or the holder's refusal.
ask func(machine string, args map[string]any) (json.RawMessage, error)
// accept seals the value as `secret accept` does, and says whether it lives until the module's start.
accept func(value string) (untilStart bool, err error)
// record writes the act in the hand-act log.
record func(link.HandAct) error
}
// errNothingGiven is a prompt dismissed, or not answered in time: nothing changes.
var errNothingGiven = errors.New("nothing was given")
// give asks the desk for the value and seals it; it answers the words said to the caller.
func (d deskGive) give(node, module, name, desk string) (string, error) {
for what, v := range map[string]string{"the machine": node, "the module": module, "the secret": name, "the desk": desk} {
if strings.TrimSpace(v) == "" {
return "", fmt.Errorf("%s is not named", what)
}
}
if err := d.declares(module, name); err != nil {
return "", fmt.Errorf("nobody was asked to type anything: %w", err)
}
public, private, err := secrets.Keypair()
if err != nil {
return "", fmt.Errorf("no key could be made to take the value: %w", err)
}
raw, err := d.ask(desk, map[string]any{
"prompt": name + " for " + module,
"message": fmt.Sprintf("The mesh asks for %s, the own secret of %s on %s. What you type is not shown, "+
"and is sealed before it leaves this machine. Type it only if you asked for this.", name, module, node),
"seal_to": public,
"timeout_seconds": deskPromptWithin,
})
if err != nil {
return "", fmt.Errorf("the desk on %s could not be asked: %w", desk, err)
}
var answer struct {
Sealed string `json:"sealed"`
Cancelled bool `json:"cancelled"`
TimedOut bool `json:"timed_out"`
}
if err := json.Unmarshal(raw, &answer); err != nil {
return "", fmt.Errorf("the desk on %s answered something that is not the prompt's answer", desk)
}
switch {
case answer.TimedOut:
return "", fmt.Errorf("%w: the prompt on %s was not answered within %d seconds", errNothingGiven, desk, deskPromptWithin)
case answer.Cancelled:
return "", fmt.Errorf("%w: the prompt on %s was dismissed", errNothingGiven, desk)
case answer.Sealed == "":
return "", fmt.Errorf("the desk on %s answered no sealed value", desk)
}
opened, err := secrets.Open(private, answer.Sealed)
if err != nil {
// Never the value, never what failed to open: only that it was not sealed to this call.
return "", fmt.Errorf("the desk on %s answered a value not sealed to this call; nothing was taken", desk)
}
value := asSupplied(string(opened))
for i := range opened {
opened[i] = 0
}
if strings.TrimSpace(value) == "" {
return "", fmt.Errorf("%w: the prompt on %s was answered empty", errNothingGiven, desk)
}
untilStart, err := d.accept(value)
value = ""
if err != nil {
return "", err
}
act := link.HandAct{Verb: "secret accept", Args: []string{node, module, name, "--at-desk", desk},
Why: fmt.Sprintf("the operator gave %s for %s on %s at the desk on %s", name, module, node, desk),
Cause: "given-at-the-desk"}
recorded := ""
if err := d.record(act); err != nil {
recorded = fmt.Sprintf("\n this act could NOT be recorded in the hand-act log, and is done anyway: %v", err)
}
words := fmt.Sprintf("%s on %s now holds %q, given at the desk on %s and sealed to %s; the mesh cannot read it "+
"back.\n run `push %s` to send it", module, node, name, desk, node, node)
if untilStart {
words += fmt.Sprintf("\n it lives until %s next starts well under the mesh, and is then replaced with a value "+
"the mesh makes (ADR 0228)", module)
}
return words + recorded, nil
}
// giveAtDesk is `secret accept <node> <module> <name> --at-desk <machine>`: the desk path, on this
// controller's stores and bus.
func giveAtDesk(ctx context.Context, node, module, name, desk string) error {
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
d := deskGive{
declares: func(module, name string) error { return open.inventory.DeclaresOwnSecret(ctx, module, name) },
ask: func(machine string, args map[string]any) (json.RawMessage, error) {
var result json.RawMessage
err := onTheBus(func(conn *nats.Conn) error {
answer, err := link.AskSeatTool(ctx, conn, "node-launcher", "secret", machine, args,
time.Duration(deskPromptWithin+5)*time.Second)
if err != nil {
return err
}
if answer.Error != "" {
return errors.New(answer.Error)
}
result = answer.Result
return nil
})
return result, err
},
accept: func(value string) (bool, error) {
return open.inventory.AcceptGivenSecret(ctx, node, module, name, value)
},
record: func(act link.HandAct) error {
return onTheBus(func(conn *nats.Conn) error {
_, err := link.RecordHandAct(ctx, conn, act)
return err
})
},
}
words, err := d.give(node, module, name, desk)
if err != nil {
return err
}
fmt.Println(words)
return nil
}
+141
View File
@@ -0,0 +1,141 @@
package main
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/link"
"github.com/novox/mesh-controller/internal/secrets"
)
const typed = "123456789:AAEhBP0av28P4XFQnIuR-o-7Xnz1kkUzW3g"
// aDesk is the desk path with a prompt the test answers as the operator would, and what it was asked kept.
func aDesk(t *testing.T, answer func(args map[string]any) (json.RawMessage, error)) (deskGive, *[]string, *[]link.HandAct, *[]map[string]any) {
t.Helper()
var accepted []string
var acts []link.HandAct
var asked []map[string]any
return deskGive{
declares: func(module, name string) error {
if module != "telegram" || name != "telegram-token" {
return errors.New(module + " does not declare " + name + " as an own secret")
}
return nil
},
ask: func(machine string, args map[string]any) (json.RawMessage, error) {
asked = append(asked, args)
return answer(args)
},
accept: func(value string) (bool, error) { accepted = append(accepted, value); return false, nil },
record: func(a link.HandAct) error { acts = append(acts, a); return nil },
}, &accepted, &acts, &asked
}
func sealedTo(t *testing.T, value string) func(args map[string]any) (json.RawMessage, error) {
return func(args map[string]any) (json.RawMessage, error) {
sealed, err := secrets.Seal(args["seal_to"].(string), []byte(value+"\n"))
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(map[string]any{"sealed": sealed})
return raw, nil
}
}
// novox/hq ADR 0259 §10: the value typed at the desk is sealed as `secret accept` seals it, and is in no
// answer, no prompt argument and no act recorded.
func TestASecretGivenAtTheDeskIsSealedAndSaidNowhere(t *testing.T) {
d, accepted, acts, asked := aDesk(t, sealedTo(t, typed))
words, err := d.give("anchor", "telegram", "telegram-token", "laptop")
if err != nil {
t.Fatal(err)
}
if len(*accepted) != 1 || (*accepted)[0] != typed {
t.Fatalf("the value sealed is not what was typed, its line ending taken off")
}
if len(*acts) != 1 || (*acts)[0].Verb != "secret accept" || (*acts)[0].Cause != "given-at-the-desk" ||
!strings.Contains((*acts)[0].Why, "at the desk on laptop") {
t.Errorf("the act: %+v", *acts)
}
raw, _ := json.Marshal(struct {
Words string
Acts []link.HandAct
Asked []map[string]any
}{words, *acts, *asked})
if strings.Contains(string(raw), typed) || strings.Contains(string(raw), "AAEhBP0") {
t.Fatal("the value appears in what was said, asked or recorded")
}
if !strings.Contains(words, "push anchor") || !strings.Contains(words, "given at the desk on laptop") {
t.Errorf("%q", words)
}
if p := (*asked)[0]; p["seal_to"] == "" || p["timeout_seconds"] != deskPromptWithin {
t.Errorf("the prompt was asked %v", p)
}
}
func TestNothingIsAskedForASecretTheMeshWouldRefuse(t *testing.T) {
for _, c := range [][2]string{{"telegram", "chat-id"}, {"nobody", "telegram-token"}} {
d, accepted, _, asked := aDesk(t, sealedTo(t, typed))
if _, err := d.give("anchor", c[0], c[1], "laptop"); err == nil || !strings.Contains(err.Error(), "nobody was asked") {
t.Errorf("%v: %v", c, err)
}
if len(*asked) != 0 || len(*accepted) != 0 {
t.Errorf("%v: the operator was asked anyway", c)
}
}
d, _, _, _ := aDesk(t, sealedTo(t, typed))
if _, err := d.give("anchor", "telegram", "telegram-token", ""); err == nil {
t.Error("no desk was refused nowhere")
}
}
func TestADismissedEmptyLateOrForeignAnswerTakesNothing(t *testing.T) {
for want, answer := range map[string]func(map[string]any) (json.RawMessage, error){
"not answered within 25 seconds": func(map[string]any) (json.RawMessage, error) {
return json.RawMessage(`{"cancelled":true,"timed_out":true}`), nil
},
"was dismissed": func(map[string]any) (json.RawMessage, error) { return json.RawMessage(`{"cancelled":true}`), nil },
"answered empty": sealedTo(t, " "),
"not sealed to this call": func(map[string]any) (json.RawMessage, error) {
other, _, _ := secrets.Keypair()
sealed, _ := secrets.Seal(other, []byte(typed))
raw, _ := json.Marshal(map[string]any{"sealed": sealed})
return raw, nil
},
"could not be asked": func(map[string]any) (json.RawMessage, error) { return nil, errors.New("no session answers") },
} {
d, accepted, acts, _ := aDesk(t, answer)
_, err := d.give("anchor", "telegram", "telegram-token", "laptop")
if err == nil || !strings.Contains(err.Error(), want) || strings.Contains(err.Error(), typed) {
t.Errorf("want %q, got %v", want, err)
}
if len(*accepted) != 0 || len(*acts) != 0 {
t.Errorf("%s: something was taken or recorded", want)
}
}
}
func TestTheGiveVerbRunsTheDeskPathAndTheControllerMayAskTheDesk(t *testing.T) {
argv, err := argvFor("give", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token", "at": "laptop"})
if err != nil || strings.Join(argv, " ") != "secret accept anchor telegram telegram-token --at-desk laptop" {
t.Fatalf("%v %v", argv, err)
}
if _, err := argvFor("give", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token"}); err == nil {
t.Error("give without a desk was taken")
}
perms, err := broker.PermissionsFor(broker.Principal{Kind: broker.KindController})
if err != nil {
t.Fatal(err)
}
found := false
for _, p := range perms.Publish {
found = found || p == "mesh.seat.node-launcher.tool.secret.*"
}
if !found {
t.Error("the controller may not ask the desk's prompt")
}
}
-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
+1 -2
View File
@@ -221,8 +221,7 @@ func TestABuildThatFailsItsGateIsRolledBackOnItsFirstMachineAndGoesNoFurther(t *
t.Fatalf("sent again after the rollback: %v", g.sent)
}
_, _, err = takeIn(ctx, inv, link.BuildResult{ID: "build-2", Repository: "novox/mesh-catalog", Path: "modules/app",
Commit: "c2", Manifest: mustJSON(t, catalogue.Manifest{Module: "app", Version: "c2"}),
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}})
Commit: "c2", Manifest: mustJSON(t, catalogue.Manifest{Module: "app", Version: "c2"})})
if err == nil || !strings.Contains(err.Error(), "failed its gate") {
t.Fatalf("the failed build was registered again: %v", err)
}
+3
View File
@@ -103,6 +103,9 @@ var handActVerbs = []handActVerb{
// to that judgement. Several values rotate for one leak, and a leak that recurs is a defect of the
// module that prints them, an issue against it, not a healer that rotates. A rotation for any other
// cause — a credential that stopped working — counts: a schedule or a healer could take it over.
// A value given at the desk (novox/hq ADR 0259 §10): an outside party's key, such as a bot token, which
// only a person can give. Their word, never a repair.
{Verb: "secret accept", Decision: "a value an outside party issued is given by a person, at their desk"},
{Verb: "secret rotate", Decision: "a value a person judged disclosed is replaced on their word",
DecidedFor: []string{causeLeakedInLogs}},
}
+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)
+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.
-2
View File
@@ -65,8 +65,6 @@ func connectLink(ctx context.Context, inv *inventory.Inventory, enroller link.En
}
func serve(ctx context.Context) (err error) {
// Nothing this process does, or starts, is the operator at the terminal (novox/hq ADR 0266).
markServed()
// The one process whose log is read over time, so the one that says each change to a node's
// unmet seat dependencies once (novox/hq ADR 0207).
logUnheldChanges = true
+1 -1
View File
@@ -26,7 +26,7 @@ func TestAPushSaysWhatItRecreates(t *testing.T) {
aContainerBuild(t, "postgres", "c1111111", "", start.Add(time.Second),
map[string][2]string{"server": {image("e"), ""}}),
} {
if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, b)); err != nil {
if _, _, err := takeIn(ctx, inv, b); err != nil {
t.Fatal(err)
}
}
+1 -1
View File
@@ -71,7 +71,7 @@ func TestARecordedBuildIsCarriedOnlyByAPersonsPush(t *testing.T) {
aContainerBuild(t, "mailu", "c1111111", "", start.Add(time.Second),
map[string][2]string{"smtp": {mailImage, ""}, "imap": {mailImage, ""}}),
} {
if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, b)); err != nil {
if _, _, err := takeIn(ctx, inv, b); err != nil {
t.Fatal(err)
}
}
+2 -2
View File
@@ -539,8 +539,8 @@ func TestReplay301APersonsPushOfAHeldRecordedBuildIsNoRepair(t *testing.T) {
inv := open.inventory
start := time.Now().Add(-time.Hour)
image := "registry.invalid:5000/resolver/server@sha256:" + strings.Repeat("e", 64)
if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, aContainerBuild(t, "resolver", "c1111111", catalogue.PolicyRecord, start,
map[string][2]string{"server": {image, ""}}))); err != nil {
if _, _, err := takeIn(ctx, inv, aContainerBuild(t, "resolver", "c1111111", catalogue.PolicyRecord, start,
map[string][2]string{"server": {image, ""}})); err != nil {
t.Fatal(err)
}
for _, node := range []string{"anchor", "laptop"} {
+2 -2
View File
@@ -54,7 +54,7 @@ func TestARebuildOfAnUnchangedSourceKeepsItsArtifacts(t *testing.T) {
// Another module's merge rebuilt it: a new commit, a new image digest, the same source.
anImageBuild(t, "app", "", "c2bbbbbb", strings.Repeat("b", 64), "src1:same", start.Add(time.Minute)),
} {
if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, b)); err != nil {
if _, _, err := takeIn(ctx, inv, b); err != nil {
t.Fatalf("build %d: %v", i, err)
}
}
@@ -114,7 +114,7 @@ func TestABusRebuiltFromAnUnchangedSourceDemandsNoBusStep(t *testing.T) {
b.Manifest = manifest
return b
}
if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, bus("", "n1111111", strings.Repeat("a", 64), "src1:bus", start))); err != nil {
if _, _, err := takeIn(ctx, inv, bus("", "n1111111", strings.Repeat("a", 64), "src1:bus", start)); err != nil {
t.Fatal(err)
}
if _, err := inv.Assign(ctx, "anchor", "nats"); err != nil {
+9 -141
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.
@@ -252,14 +249,9 @@ func (a *verbArguments) commandLine() ([]string, error) {
// settings verb is where what a verb may not set is refused, and one route is one set of words.
// The command refuses places and accesses through any verb as well; this says so before it runs.
if argv[0] == "settings" && slices.ContainsFunc(argv[1:], func(w string) bool { return w == "set" || w == "clear" }) {
return nil, &heldAtTheTerminal{msg: "settings are set and cleared through the settings verb, not the " +
"generic command; and places and accesses only at the controller's terminal (novox/hq issue 339). " +
"Nothing was done"}
}
// The generic verb only reads (novox/hq ADR 0266): what writes has a named verb that composes its own
// line, or is the operator's at the controller's terminal.
if err := commandReads(argv); err != nil {
return nil, err
return nil, errors.New("settings are set and cleared through the settings verb, not the generic " +
"command; and places and accesses only at the controller's terminal (novox/hq issue 339). " +
"Nothing was done")
}
// The generic verb is no way round the hand-act log (novox/hq to-be 45 §7): a repair through
// it says why, as it would through its own verb.
@@ -729,6 +721,11 @@ func (a *verbArguments) commandLine() ([]string, error) {
argv = append(argv, "--probe", p)
}
return append(argv, "--json"), nil
case "give":
if err := need("node", "module", "secret", "at"); err != nil {
return nil, err
}
return []string{"secret", "accept", str("node"), str("module"), str("secret"), "--at-desk", str("at")}, nil
case "rotate":
if p := str("provision"); p != "" {
argv := []string{"rotate", p}
@@ -1079,8 +1076,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
@@ -1341,131 +1337,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)
}
@@ -271,10 +271,18 @@ var accountedFlags = map[string]map[string]string{
"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",
},
// The desk path of `secret accept` (novox/hq ADR 0259 §10): a value is never an argument of a call.
"secret accept": {
"at-desk": "=at",
"from": "withheld: a file of the control node's is read at a shell, never named by a call",
"provider": "withheld: a pair credential's value is given at a shell; give takes a module's own secret",
"local": "withheld: it goes with --provider",
},
"hand-acts": {"json": "set by the verb: the answer is data"},
"conditions": {"json": "set by the verb: the answer is data"},
"retire": {"json": "set by the verb: the answer is data"},
+12 -72
View File
@@ -2,7 +2,6 @@ package main
import (
"context"
"errors"
"fmt"
"github.com/novox/mesh-controller/internal/link"
"strings"
@@ -109,14 +108,11 @@ 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)
}
}
@@ -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 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)
}
}
+10 -1
View File
@@ -55,6 +55,9 @@ func secretCommand(ctx context.Context, args []string) error {
provider := set.String("provider", "",
"the node providing <name>: the value becomes the PAIR credential between <module> on <node> "+
"and that provider, sealed to both — the vault's operator-delivered secret (ADR 0092)")
desk := set.String("at-desk", "",
"ask the operator for the value in a prompt that does not show it, on this machine's desk; the "+
"answer comes back sealed to this call alone (novox/hq ADR 0259 §10)")
local := set.String("local", "",
"with --provider: the name the credential goes by inside <module>, where its manifest keeps "+
"several for <name> (ADR 0094)")
@@ -65,6 +68,12 @@ func secretCommand(ctx context.Context, args []string) error {
return errors.New(secretUsage)
}
node, module, name := rest[0], rest[1], rest[2]
if *desk != "" {
if *from != "" || *provider != "" {
return errors.New("--at-desk gives a module's own secret, and takes neither --from nor --provider")
}
return giveAtDesk(ctx, node, module, name, *desk)
}
value, err := valueFor(node, module, name, *from)
if err != nil {
@@ -118,7 +127,7 @@ func secretCommand(ctx context.Context, args []string) error {
}
const secretUsage = "secret rotate <node> <module> <name> [--why <text> [--cause <word>]]\n" +
"secret accept <node> <module> <name> [--from <file>] [--provider <node> [--local <name>]]\n" +
"secret accept <node> <module> <name> [--from <file> | --at-desk <machine>] [--provider <node> [--local <name>]]\n" +
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
"secret export [--out <file>]"
+1 -1
View File
@@ -84,7 +84,7 @@ const (
// callBounds are the verbs that may run longer than callDefault, and how long (S7).
var callBounds = map[string]time.Duration{
"push": 30 * time.Minute, "rotate": 30 * time.Minute, "assign": 15 * time.Minute,
"push": 30 * time.Minute, "rotate": 30 * time.Minute, "give": 5 * time.Minute, "assign": 15 * time.Minute,
"unassign": 15 * time.Minute, "command": 30 * time.Minute, "doctor": 3 * time.Minute,
}
+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-20261009103656-1c61b72f354d
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac
-6
View File
@@ -4,12 +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-20261009081005-b28d7bbcbff4 h1:f4rBnKSemuN0Z9dTtRJMigIGfEs6ltFPOILJGHGab74=
git.novox.be/novox/mesh-host v0.0.0-20261009081005-b28d7bbcbff4/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
git.novox.be/novox/mesh-host v0.0.0-20261009101157-2673e7a2c95e h1:H7eVqDILL6e9cMbWSLHTbCqu9ZxDOmyeQhUmWl9QBV0=
git.novox.be/novox/mesh-host v0.0.0-20261009101157-2673e7a2c95e/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d h1:IrmJ+lz21n+eSqKrmXREtR/7raUCBJ+fZvs+BNhuXVI=
git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+8
View File
@@ -166,6 +166,10 @@ var VerbsTheSelfCheckAsks = []SeatVerb{{Seat: "node-intrusion-prevention", Verb:
// the controller's grant that acts, and only through the step a person starts.
var VerbsTheBusStepAsks = []SeatVerb{{Seat: "node-backup", Verb: "now"}}
// VerbsTheControllerAsksForASecret are the seat verbs `give` calls (novox/hq ADR 0259 §10): the operator's
// desk opens a prompt that does not show what is typed, and answers it sealed to the controller's call.
var VerbsTheControllerAsksForASecret = []SeatVerb{{Seat: "node-launcher", Verb: "secret"}}
// VerbsTheControllerAsksTheDeliveryOwner are the mesh-delivery seat's verbs the controller calls (novox/hq
// ADR 0239): its self-check reads `stalled`, and healer H2 takes the one transition the table allows
// through `close`. A mesh seat's verb is flat: no machine in the subject.
@@ -366,6 +370,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
for _, v := range VerbsTheBusStepAsks {
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
}
// And the operator's desk, for a secret given there (ADR 0259 §10).
for _, v := range VerbsTheControllerAsksForASecret {
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
}
// And the delivery's owner, a mesh seat, asked on its flat subjects (ADR 0239).
for _, v := range VerbsTheControllerAsksTheDeliveryOwner {
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb)
+1 -1
View File
@@ -24,7 +24,7 @@ accounts {
jetstream: enabled
users = [
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*"] }
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*"] }
subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built"] }
allow_responses: { max: 1, ttl: "1m" }
} }
-119
View File
@@ -1,119 +0,0 @@
package catalogue
import (
"testing"
)
// The account agents run as (novox/hq ADR 0266): a module names it as a machine fact — the agent account
// where the node names one, the operator's otherwise — and asks the node-engine to judge it never to become
// root only where it is the agents' own.
func TestTheAgentAccountFactFallsBackToTheOperatorAndIsNeverRootOnlyWhenItsOwn(t *testing.T) {
facts := machineFacts(Resolution{Node: "anchor", Account: "ops"}, nil, "")
if facts["agent-account"] != "ops" || facts["agent-home"] != "/home/ops" || facts["agent-root"] != "" {
t.Errorf("with no agent account named, agents run as the operator: %v", facts)
}
facts = machineFacts(Resolution{Node: "anchor", Account: "ops", AccountHome: "/srv/ops"}, nil, "")
if facts["agent-home"] != "/srv/ops" {
t.Errorf("the operator's stated home is the agent's home when they are one account: %v", facts)
}
facts = machineFacts(Resolution{Node: "anchor", Account: "ops", AgentAccount: "agent"}, nil, "")
if facts["agent-account"] != "agent" || facts["agent-home"] != "/home/agent" || facts["agent-root"] != RootNever {
t.Errorf("a named agent account is the agents', never root: %v", facts)
}
if facts["account"] != "ops" {
t.Errorf("the operator account is still the operator's: %v", facts)
}
facts = machineFacts(Resolution{Node: "anchor", AgentAccount: "agent", AgentAccountHome: "/var/lib/agent"}, nil, "")
if facts["agent-home"] != "/var/lib/agent" || facts["agent-root"] != RootNever {
t.Errorf("an agent account with a stated home on a machine with no operator: %v", facts)
}
if _, has := machineFacts(Resolution{Node: "anchor"}, nil, "")["agent-account"]; has {
t.Error("a machine with no account at all names an agent account")
}
}
// The agent's module, in the shape the catalogue's declares it: the account, never root where it is its
// own; its directory under that home, owned by it.
const agentModule = `{"module": "agent", "version": "1", "resources": [
{"id": "account", "type": "user", "name": "${machine:agent-account}", "home": "${machine:agent-home}",
"root": "${machine:agent-root}"},
{"id": "home", "type": "directory", "path": "${machine:agent-home}/.agent", "mode": "0700",
"owner": "${machine:agent-account}"}
]}`
func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing.T) {
m, err := ParseManifest([]byte(agentModule))
if err != nil {
t.Fatal(err)
}
compose := func(r Resolution, with Rendering) (user, home map[string]any) {
t.Helper()
r.Node, r.Modules = "anchor", []Manifest{m}
out, err := r.Declaration(with)
if err != nil {
t.Fatal(err)
}
return fileNamed(out, "agent.account"), fileNamed(out, "agent.home")
}
user, home := compose(Resolution{Account: "ops", AgentAccount: "agent"}, Rendering{JudgesRoot: true})
if user["name"] != "agent" || user[RootField] != RootNever || user["home"] != "/home/agent" {
t.Errorf("an engine that judges root is sent the agent account never to become root: %v", user)
}
if home["path"] != "/home/agent/.agent" || home["owner"] != "agent" {
t.Errorf("the agent's directory is under its own home, its own: %v", home)
}
user, _ = compose(Resolution{Account: "ops", AgentAccount: "agent", AgentAccountHome: "/srv/agent"}, Rendering{JudgesRoot: true})
if user["home"] != "/srv/agent" {
t.Errorf("an agent account named with a home of its own is made there: %v", user)
}
user, _ = compose(Resolution{Account: "ops", AgentAccount: "agent"}, Rendering{})
if _, sent := user[RootField]; sent || user["name"] != "agent" {
t.Errorf("an older engine, which parses strictly, is sent root: %v", user)
}
user, home = compose(Resolution{Account: "ops"}, Rendering{JudgesRoot: true})
if _, sent := user[RootField]; sent || user["name"] != "ops" {
t.Errorf("where agents run as the operator, root asserts nothing and is not sent: %v", user)
}
if home["path"] != "/home/ops/.agent" || home["owner"] != "ops" {
t.Errorf("with no agent account, the agent's directory is the operator's: %v", home)
}
}
func TestTheRuntimeIsToldTheAgentAccount(t *testing.T) {
with := Rendering{ArtifactStore: "anchor.internal:5101",
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
envOf := func(r Resolution) map[string]string {
t.Helper()
r.Node, r.Modules = "anchor", []Manifest{aToolsModule(t, "nftables", "tools/index.js"), theRuntime(t)}
out, err := r.Declaration(with)
if err != nil {
t.Fatal(err)
}
process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
if process == nil {
t.Fatal("no runtime process was composed")
}
return process["env"].(map[string]string)
}
env := envOf(Resolution{Account: "ops", AgentAccount: "agent"})
if env[RuntimeAgentAccount] != "agent" || env[RuntimeAgentHome] != "/home/agent" || env[RuntimeOperatorAccount] != "ops" {
t.Errorf("the runtime is not told whom agents run as: %v", env)
}
env = envOf(Resolution{Account: "ops"})
if env[RuntimeAgentAccount] != "ops" || env[RuntimeAgentHome] != "/home/ops" {
t.Errorf("with no agent account, agents run as the operator: %v", env)
}
env = envOf(Resolution{})
if _, set := env[RuntimeAgentAccount]; set {
t.Errorf("a machine with no account names an agent account: %v", env)
}
if problems := bundleEnvProblems("x", Artifact{Name: "b", Kind: ArtifactBundle, Loads: []string{"x"},
Env: map[string]string{RuntimeAgentAccount: "me"}}); len(problems) == 0 {
t.Error("a bundle may tell the runtime whom agents run as")
}
}
+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
@@ -1006,9 +981,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
+12
View File
@@ -78,6 +78,18 @@ func graphicalSessionSeats() []Seat {
"description": "the lines to choose between, in order"},
"prompt": map[string]any{"type": "string", "description": "what the menu asks (optional)"},
}}},
// A value the operator types and nobody sees (novox/hq ADR 0259 §10): a hidden prompt whose answer
// is sealed to the asker's key, so it is never plaintext on the bus or in any call's record.
// **Optional while its holders catch up** (ADR 0246): rofi serves it once this is live.
{Name: "secret", Optional: true, Description: "Ask the operator for a value in a prompt that " +
"does not show what is typed, and answer it sealed to the key the asker gives — never in " +
"the clear — or cancelled when the prompt was dismissed or not answered in time.",
Input: schema(map[string]string{
"prompt": "what the prompt asks",
"message": "a line saying who asks and for what (optional)",
"seal_to": "the asker's public sealing key: the answer is sealed to it",
"timeout_seconds": "give up after this long (optional)",
}, []string{"prompt", "seal_to"})},
}},
{Name: NotifierSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
{Name: "send", Description: "Show the operator a notification.",
+1 -1
View File
@@ -16,7 +16,7 @@ func TestTheGraphicalSessionsSeatsAreTheMeshsOwnWithTheirVerbs(t *testing.T) {
DisplayServerSeat: {"displays", "layout"},
DisplaySessionSeat: {"reload", "workspaces", "windows"},
TerminalEmulatorSeat: {"open"},
LauncherSeat: {"menu"},
LauncherSeat: {"menu", "secret"},
NotifierSeat: {"send", "history"},
LockScreenSeat: {"lock"},
ClipboardSeat: {"history", "copy"},
+2 -44
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,12 +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), and its `home`, so an account
// the operator named with a home of its own is made there (${machine:agent-home}); the node-engine reads a
// user's home only when it creates the account, so an existing one is never moved.
for _, field := range []string{"path", "owner", "content", "name", "user", "root", "home"} {
// 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
+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 {
+19 -11
View File
@@ -230,6 +230,18 @@ var ControllerVerbs = []Verb{
"why": "an own secret: why it is rotated — recorded in the hand-act log (optional)",
"cause": "with why: the cause in a word, the word a second rotation for the same reason uses (optional)",
}, nil)},
{Name: "give", Description: "Take a module's own secret from the operator at their desk (novox/hq ADR 0259 " +
"§10): a prompt that does not show what is typed opens on the machine named by at, its answer comes " +
"back sealed to this call alone, and is sealed to the module's machine as `secret accept` seals it. " +
"The value is never an argument and never in the answer: the answer says it was taken, or why not. " +
"Recorded in the hand-act log as a value given at the desk. The prompt waits 25 seconds; dismissed " +
"or unanswered, nothing changes. Then push the machine.",
Input: schema(map[string]string{
"node": "the machine the module runs on, which the secret is sealed to",
"module": "the module's name",
"secret": "the own secret's name in the module's definition",
"at": "the machine the operator sits at, where the prompt opens",
}, []string{"node", "module", "secret", "at"})},
{Name: "issue", Description: "Give a module on a machine its account on the bus: minted, and sealed to the " +
"machine as the module's own secret named broker, read at the next push of that machine. For a module " +
"whose definition declares that secret; refused with the reason otherwise. Issued again, it replaces the account.",
@@ -237,9 +249,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 +279,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)
}
}
-21
View File
@@ -169,27 +169,6 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
return nil
}
// SourceIdentity is the forge's id of the repository a module is registered from, 0 when none is recorded.
func (i *Inventory) SourceIdentity(ctx context.Context, module string) (int64, error) {
var id *int64
err := i.store.Pool().QueryRow(ctx, `select source_repo_id from module where name = $1`, module).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return 0, fmt.Errorf("%w: %s", ErrNoSuchModule, module)
}
if err != nil || id == nil {
return 0, err
}
return *id, nil
}
// SetSourceIdentity records the forge's id of the repository a module is registered from. 0 — the forge could not
// say, at the terminal — keeps the id already recorded: an unknown is never a reason to forget what was known.
func (i *Inventory) SetSourceIdentity(ctx context.Context, module string, id int64) error {
_, err := i.store.Pool().Exec(ctx,
`update module set source_repo_id = coalesce(nullif($2::bigint, 0), source_repo_id) where name = $1`, module, id)
return err
}
// hasModule is whether the catalogue holds a module of that name.
func (i *Inventory) hasModule(ctx context.Context, name string) (bool, error) {
var one int
-15
View File
@@ -7,7 +7,6 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -170,12 +169,6 @@ func (i *Inventory) PreviousBuild(ctx context.Context, module, commit string, fa
if b.ID == failed.ID || (commit != "" && b.Commit != commit) {
continue
}
// Only a build of the repository the failed build was made from — the module's, since its take-in
// registered it (novox/hq ADR 0266): a build of the module's name from another repository is recorded
// and was never registered, and putting it back would register it now.
if failed.Repository != "" && !sameRepositoryAs(b.Repository, failed.Repository) {
continue
}
if !failed.AskedOrAt().IsZero() && !b.AskedOrAt().Before(failed.AskedOrAt()) {
continue
}
@@ -197,14 +190,6 @@ func (i *Inventory) PreviousBuild(ctx context.Context, module, commit string, fa
return Build{}, false, nil
}
// sameRepositoryAs says two recorded repositories are one, however their case or `.git` is spelled.
func sameRepositoryAs(a, b string) bool {
trim := func(s string) string {
return strings.TrimSuffix(strings.TrimRight(strings.ToLower(strings.TrimSpace(s)), "/"), ".git")
}
return trim(a) == trim(b)
}
// RestoreModule puts a module's registered build back to an earlier one: its manifest, the commit it
// was built from, and when it was asked — as now, so the build that failed its gate, asked before, can
// never register over it again (issue 219's order). The source's head is left where the merge moved it:
-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 '';
@@ -1,18 +0,0 @@
-- A build asked at the controller's terminal says so (novox/hq ADR 0266).
--
-- A build's outcome registers its module, and a module is what the next push sends: a module named `sudo`
-- built from a repository an agent made would grant whoever wrote it root on every node it is assigned.
-- So an outcome may register a module only from the repository the catalogue already builds it from — or,
-- for a module new to the catalogue, from a repository the catalogue already builds another module from.
-- Anything else — a module moved to another repository, a new module from a new repository — is the
-- operator's, at the controller's terminal. This column is how the take-in tells: true only for a build
-- request kept by a `build` or `replay --register` run at the terminal, never through a verb (whoever may
-- call a verb includes agents). False for every request kept before this column existed.
alter table build_request add column at_terminal boolean not null default false;
-- And which repository a module is registered from, by the forge's own id for it (novox/hq ADR 0266): a name
-- is not an identity. A repository deleted and made again under the same name is another repository, with
-- none of the protection the first had until someone sets it; its outcome must not register as the module's.
-- Recorded when a build of it is registered from the mesh's own forge; null until then, and for a source the
-- forge does not hold.
alter table module add column source_repo_id bigint;
@@ -1,9 +0,0 @@
-- The controller keeps when it first saw an agent account's root verdict waiting for the node-engine's search
-- for setuid programs (novox/hq ADR 0266), cleared by the next complete verdict.
--
-- The self-check does not raise `agent-can-become-root` while that search is within its bound, so a restart is
-- not an urgent condition each time. The node-engine's own "since" starts again at every restart: an agent
-- that restarted the engine in a loop kept the condition quiet for ever. This time is the controller's, kept
-- across the engine's restarts and its own, so the quiet ends once the bound has passed since the search first
-- read as pending, however often the engine started again.
alter table node add column agent_root_pending_since timestamptz;
+2 -139
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,
@@ -1292,24 +1176,3 @@ func (i *Inventory) Sequence(ctx context.Context, id string) (int64, error) {
}
return *n, nil
}
// RootSearchPending keeps when the controller first saw this node's agent account waiting for the node-engine's
// setuid search (novox/hq ADR 0266) and answers it: the first time it is seen since the last complete verdict,
// at, kept; seen again, what was kept. Kept across the engine's restarts and the controller's own.
func (i *Inventory) RootSearchPending(ctx context.Context, node string, at time.Time) (time.Time, error) {
var since time.Time
err := i.store.Pool().QueryRow(ctx,
`update node set agent_root_pending_since = coalesce(agent_root_pending_since, $2)
where name = $1 returning agent_root_pending_since`, node, at).Scan(&since)
if errors.Is(err, pgx.ErrNoRows) {
return time.Time{}, fmt.Errorf("%w: %s", ErrNoSuchNode, node)
}
return since, err
}
// RootSearchJudged forgets when a search began pending: a complete verdict came.
func (i *Inventory) RootSearchJudged(ctx context.Context, node string) error {
_, err := i.store.Pool().Exec(ctx,
`update node set agent_root_pending_since = null where name = $1 and agent_root_pending_since is not null`, node)
return err
}
+3 -21
View File
@@ -38,9 +38,6 @@ type BuildRequest struct {
// unknown. Read as in flight until its outcome or its bound.
OutcomeUnknown string
At time.Time
// AtTerminal says the request was asked at the controller's terminal, never through a verb (novox/hq ADR
// 0266): what lets its outcome register a module from a repository the catalogue does not build it from.
AtTerminal bool
}
// Name is the module this request is expected to register, read from its directory: the last element of
@@ -76,31 +73,16 @@ func (i *Inventory) RecordBuildRequest(ctx context.Context, a BuildRequest) erro
notAsked = &a.NotAsked
}
if _, err := i.store.Pool().Exec(ctx,
`insert into build_request (id, repository, seat, source_path, ref, commit_hash, asked_for, not_asked, asked_at,
at_terminal)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`insert into build_request (id, repository, seat, source_path, ref, commit_hash, asked_for, not_asked, asked_at)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
on conflict (id) do nothing`,
a.ID, a.Repository, a.Seat, strings.Trim(a.Path, "/"), a.Ref, a.Commit, a.For, notAsked, at,
a.AtTerminal); err != nil {
a.ID, a.Repository, a.Seat, strings.Trim(a.Path, "/"), a.Ref, a.Commit, a.For, notAsked, at); err != nil {
return err
}
_, err := i.store.Pool().Exec(ctx, `delete from build_request where asked_at < $1`, time.Now().Add(-KeptFor))
return err
}
// BuildRequestByID is the build request kept under this id, and whether one was kept.
func (i *Inventory) BuildRequestByID(ctx context.Context, id string) (BuildRequest, bool, error) {
var a BuildRequest
err := i.store.Pool().QueryRow(ctx,
`select id, repository, seat, source_path, ref, commit_hash, asked_for, at_terminal, asked_at
from build_request where id = $1`, id).Scan(&a.ID, &a.Repository, &a.Seat, &a.Path, &a.Ref, &a.Commit,
&a.For, &a.AtTerminal, &a.At)
if errors.Is(err, pgx.ErrNoRows) {
return BuildRequest{}, false, nil
}
return a, err == nil, err
}
// MarkNotAsked says a kept build request was never handed over: the words are kept, unless its outcome was
// heard first.
func (i *Inventory) MarkNotAsked(ctx context.Context, id, why string) error {
+13
View File
@@ -554,6 +554,19 @@ func (i *Inventory) declared(ctx context.Context, module string) (catalogue.Mani
return m, nil
}
// DeclaresOwnSecret refuses, in words, a module the mesh does not know or an own secret its definition does
// not declare: asked before anybody is asked for a value, so nobody types one the mesh would refuse.
func (i *Inventory) DeclaresOwnSecret(ctx context.Context, module, name string) error {
m, err := i.declared(ctx, module)
if err != nil {
return err
}
if _, ok := m.OwnSecrets[name]; !ok {
return fmt.Errorf("%s does not declare %q as an own secret; %s", module, name, declaresOwn(m))
}
return nil
}
func declaresOwn(m catalogue.Manifest) string {
if len(m.OwnSecrets) == 0 {
return "it declares no own secrets"
-28
View File
@@ -8,7 +8,6 @@ package link
import (
"encoding/base64"
"github.com/novox/mesh-host/rootsearch"
"strconv"
"strings"
"time"
@@ -421,29 +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"
// ReasonRootPending starts the reason of an account verdict the node-engine cannot give yet because its search
// for setuid programs, started when the engine started, has not finished: not judged yet, said as such, never a
// pass. The node-engine's own words (mesh-host rootsearch.ReasonPending), read from it rather than copied.
const ReasonRootPending = rootsearch.ReasonPending
// RootSearchBound is the longest the node-engine lets one search for setuid programs run (mesh-host
// rootsearch.Bound): the one value both read.
const RootSearchBound = rootsearch.Bound
// 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.
@@ -574,10 +550,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
+1
View File
@@ -48,6 +48,7 @@
"unpin",
"push",
"rotate",
"give",
"issue",
"token",
"settings",
-26
View File
@@ -198,12 +198,6 @@ type File struct {
// by looking rather than by knowing which field won.
Sealed string `json:"sealed,omitempty"`
// Trusted is the catalogue's word on whether the settings this file's content took are trusted (novox/hq
// issue 339). It is the controller's to act on, and a controller takes it out before it sends a declaration.
// Accepted here, and nothing is done with it, so that a controller that passes it on — an older one, or one
// rolled back to — never costs a node its whole declaration. Not part of the file's digest.
Trusted *bool `json:"trusted,omitempty"`
// Secrets are sealed values put into Content where it says `${secret:name}`.
//
// **The one place a secret and a configuration meet, and it happens on the machine.** A
@@ -389,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
@@ -501,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
}
-19
View File
@@ -1,19 +0,0 @@
// Package rootsearch holds what the node-engine's search for setuid programs and the controller that reads its
// verdicts must agree on (novox/hq ADR 0266): how long one search may run, and the words a verdict starts with
// while it has no answer. Public, so the controller imports these rather than keeps copies that could drift.
package rootsearch
import "time"
// Bound is how long one search for setuid programs may run. It governs the whole search, the walk and the
// package manager's answers together; the controller does not raise an agent account as not judged while the
// first search after a start is within it.
const Bound = 15 * time.Minute
// The reasons of a root verdict the search could not answer yet.
const (
// ReasonPending starts the reason while the first search since the engine started runs.
ReasonPending = "not judged yet (search running)"
// ReasonIncomplete starts the reason when no search has finished: one failed or ran out its bound.
ReasonIncomplete = "not judged (search incomplete)"
)
+2 -3
View File
@@ -75,10 +75,9 @@ 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-20261009103656-1c61b72f354d
# 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/rootsearch
github.com/novox/mesh-host/validate
# go.uber.org/automaxprocs v1.6.0
## explicit; go 1.20
@@ -134,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-20261009103656-1c61b72f354d
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac