Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d059311c0f | ||
|
|
1a28cb3357 | ||
|
|
758537dd4e | ||
|
|
add807f034 | ||
|
|
8ca4b04321 | ||
|
|
8170fc58a3 | ||
|
|
5d7d8ee2d6 | ||
|
|
81e5458cbf | ||
|
|
fb74e24c9e |
@@ -1,175 +0,0 @@
|
||||
package main
|
||||
|
||||
// The account agents run as (novox/hq ADR 0266).
|
||||
//
|
||||
// On the control node every agent session ran as the operator's account, which may become root without a
|
||||
// password — so any agent there could become root without a person, and ADR 0259 §8 (an answer from the
|
||||
// operator's phone authorises an act) rests on that being false where the router and its channels run. The
|
||||
// decision: a node may name an account its agents run as, of their own and without sudo; the operator's
|
||||
// account keeps its sudo.
|
||||
//
|
||||
// - **Named at the controller's terminal only** (`node agent-account`): not a verb, not a setting, so no
|
||||
// agent can name itself another account. Empty is a real state: agents run as the operator there.
|
||||
// - **Composed** as `${machine:agent-account}`, `${machine:agent-home}` and `${machine:agent-root}` for the
|
||||
// agent's module, which declares the account with `root: never`, and as MESH_AGENT_ACCOUNT and
|
||||
// MESH_AGENT_HOME for its tools (catalogue/machine_into_files.go, runtime.go).
|
||||
// - **Judged by the machine itself.** The node-engine reads, on every look, whether an account declared
|
||||
// `root: never` can become root without a person — uid 0, a group that grants root, a sudo rule, a
|
||||
// secret of the mesh it may read — and says it as the declaring module's account verdict, marked
|
||||
// Root "never". agentConfined reads that verdict; the self-check (probe DA) raises
|
||||
// `agent-can-become-root` while it does not hold, and `node show` says it.
|
||||
//
|
||||
// A verdict not given is never a pass: an engine older than the judging, an account not yet declared, a
|
||||
// statement that says nothing of it — each is "not judged", and fails.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// kindAgentCanBecomeRoot is the condition raised while a machine's agent account can become root without
|
||||
// a person, or is not judged (ADR 0266).
|
||||
const kindAgentCanBecomeRoot = "agent-can-become-root"
|
||||
|
||||
// agentAccountProbe is the self-check's probe of it.
|
||||
const agentAccountProbe = "DA"
|
||||
|
||||
// agentConfined says whether the agents of a machine that names an agent account are confined: the
|
||||
// machine's newest statement holds a healthy account verdict, judged for root, on that account. named is
|
||||
// false for a machine that names none — agents run as the operator account there, which this does not
|
||||
// judge. why is said either way, in the mesh's words; err is a store that could not be read.
|
||||
//
|
||||
// The one judgement: `node show`, the self-check, and ADR 0259's router honouring a verified sender read
|
||||
// it here.
|
||||
func agentConfined(ctx context.Context, inv *inventory.Inventory, node string) (named, confined bool, why string, err error) {
|
||||
n, err := inv.NodeByName(ctx, node)
|
||||
if err != nil {
|
||||
return false, false, "", err
|
||||
}
|
||||
if n.AgentAccount == "" {
|
||||
return false, false, fmt.Sprintf("%s names no agent account: agents run as the operator account (%s)",
|
||||
node, orNoneKnown(n.Account)), nil
|
||||
}
|
||||
h, had, err := inv.HealthOf(ctx, node)
|
||||
if err != nil {
|
||||
return true, false, "", err
|
||||
}
|
||||
confined, why = judgedConfined(n.AgentAccount, h, had, time.Now())
|
||||
return true, confined, why, nil
|
||||
}
|
||||
|
||||
// verdictFreshFor is how old the statement holding the verdict may be, by this controller's clock. A
|
||||
// node-engine states its health on every change and at least every five minutes (mesh-host's sayAnyway), so
|
||||
// three statements missed is a node-engine stopped, or a machine away. **A stale verdict is not a pass**: an
|
||||
// agent that stopped the node-engine must not leave "cannot become root" standing from before.
|
||||
const verdictFreshFor = 15 * time.Minute
|
||||
|
||||
// judgedConfined is the judgement over one statement, without the store, at now.
|
||||
func judgedConfined(agent string, h inventory.NodeHealth, had bool, now time.Time) (bool, string) {
|
||||
if !had {
|
||||
return false, fmt.Sprintf("the agent account %s is not judged: the machine's node-engine has stated "+
|
||||
"nothing of what it runs", agent)
|
||||
}
|
||||
if age := now.Sub(h.HeardAt); age > verdictFreshFor {
|
||||
return false, fmt.Sprintf("the agent account %s is not judged: the machine's newest statement was heard at "+
|
||||
"%s, more than %d minutes ago, and a verdict that old is not a verdict on now", agent,
|
||||
h.HeardAt.Local().Format("2006-01-02 15:04"), int(verdictFreshFor.Minutes()))
|
||||
}
|
||||
if h.Contract < link.RootContract {
|
||||
return false, fmt.Sprintf("the agent account %s is not judged: the machine's node-engine is older than "+
|
||||
"the judging of an account's root (its statement's contract is %d, the judging is %d)",
|
||||
agent, h.Contract, link.RootContract)
|
||||
}
|
||||
var verdicts []inventory.ResourceHealth
|
||||
for _, r := range h.Resources {
|
||||
if r.Kind == link.KindAccount && r.Target == agent && r.Root == link.RootNever {
|
||||
verdicts = append(verdicts, r)
|
||||
}
|
||||
}
|
||||
if len(verdicts) == 0 {
|
||||
return false, fmt.Sprintf("the agent account %s is not judged: the machine's newest statement holds no "+
|
||||
"verdict on it — no module there declares it never to become root, or the declaration naming it "+
|
||||
"has not been applied", agent)
|
||||
}
|
||||
sort.Slice(verdicts, func(i, j int) bool {
|
||||
return verdicts[i].Module+verdicts[i].Resource < verdicts[j].Module+verdicts[j].Resource
|
||||
})
|
||||
for _, v := range verdicts {
|
||||
switch v.State {
|
||||
case link.StateHealthy:
|
||||
case link.StateUnhealthy:
|
||||
// The engine's own words, which start with link.ReasonRoot when it found a way to root.
|
||||
return false, fmt.Sprintf("the agent account %s %s (said by %s's %s)", agent,
|
||||
orNoneKnown(v.Reason), v.Module, v.Resource)
|
||||
default:
|
||||
return false, fmt.Sprintf("the agent account %s is not judged: %s (%s's %s, %s)", agent,
|
||||
orNoneKnown(v.Reason), v.Module, v.Resource, v.State)
|
||||
}
|
||||
}
|
||||
return true, fmt.Sprintf("the agent account %s cannot become root without a person (judged %s)", agent,
|
||||
h.SaidAt.Local().Format("2006-01-02 15:04"))
|
||||
}
|
||||
|
||||
// probeAgentAccounts is DA: every machine that names an agent account has it judged, on its node-engine's
|
||||
// newest statement, unable to become root without a person (ADR 0266).
|
||||
func probeAgentAccounts(ctx context.Context, d *doctor) ([]conditions.Observation, error) {
|
||||
inv := d.open.inventory
|
||||
nodes, err := inv.Nodes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []conditions.Observation
|
||||
for _, n := range nodes {
|
||||
if n.AgentAccount == "" {
|
||||
continue
|
||||
}
|
||||
h, had, err := inv.HealthOf(ctx, n.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
confined, why := judgedConfined(n.AgentAccount, h, had, time.Now())
|
||||
if confined {
|
||||
continue
|
||||
}
|
||||
out = append(out, conditions.Observation{Scope: conditions.ScopeMachine, ID: n.Name, Token: "agent-root",
|
||||
Machine: n.Name, Severity: conditions.Urgent,
|
||||
Summary: fmt.Sprintf("on %s, %s (ADR 0266): an agent there may become root without a person, and "+
|
||||
"no answer from a channel authorises an act there (ADR 0259 §8)", n.Name, why),
|
||||
Said: why})
|
||||
}
|
||||
return sortedFound(out), nil
|
||||
}
|
||||
|
||||
// agentAccountLines is what `node show` says of the account agents run as.
|
||||
func agentAccountLines(ctx context.Context, inv *inventory.Inventory, n inventory.Node) []string {
|
||||
if n.AgentAccount == "" {
|
||||
return []string{fmt.Sprintf(" agents run as the operator account (%s); no agent account is named",
|
||||
orNoneKnown(n.Account))}
|
||||
}
|
||||
_, confined, why, err := agentConfined(ctx, inv, n.Name)
|
||||
if err != nil {
|
||||
return []string{fmt.Sprintf(" agents run as %s (home %s); whether it can become root could NOT be read: %v",
|
||||
n.AgentAccount, n.AgentHome(), err)}
|
||||
}
|
||||
verdict := "CAN become root, or is not judged: " + why
|
||||
if confined {
|
||||
verdict = why
|
||||
}
|
||||
return []string{fmt.Sprintf(" agents run as %s (home %s)", n.AgentAccount, n.AgentHome()),
|
||||
" " + verdict}
|
||||
}
|
||||
|
||||
// orNoneKnown is a value, or that none is known.
|
||||
func orNoneKnown(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "none known"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
snapshot "github.com/novox/mesh-controller/internal/facts"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// The agent account's judgement (novox/hq ADR 0266): confined only on a healthy account verdict judged for
|
||||
// root, on the very account; every verdict not given — no statement, an older engine, no verdict on it, a
|
||||
// verdict of unknown — is "not judged" and fails, never a pass.
|
||||
func TestAnAgentAccountIsConfinedOnlyOnAHealthyVerdictJudgedForRoot(t *testing.T) {
|
||||
at := time.Date(2026, 10, 8, 19, 21, 0, 0, time.UTC)
|
||||
verdict := func(target, root, state, reason string) inventory.ResourceHealth {
|
||||
return inventory.ResourceHealth{Module: "claude-code", Resource: "claude-code.agent-account",
|
||||
Kind: link.KindAccount, Target: target, State: state, Reason: reason, Root: root, Account: target}
|
||||
}
|
||||
statement := func(contract int, rs ...inventory.ResourceHealth) inventory.NodeHealth {
|
||||
return inventory.NodeHealth{Node: "anchor", Contract: contract, SaidAt: at, HeardAt: at, Resources: rs}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
h inventory.NodeHealth
|
||||
had bool
|
||||
confined bool
|
||||
says string
|
||||
}{
|
||||
{"judged and unable", statement(link.RootContract, verdict("agent", link.RootNever, link.StateHealthy, "")),
|
||||
true, true, "cannot become root without a person"},
|
||||
{"judged and able", statement(link.RootContract, verdict("agent", link.RootNever, link.StateUnhealthy,
|
||||
link.ReasonRoot+": in the group docker, which grants root")), true, false, "in the group docker"},
|
||||
{"a verdict of unknown", statement(link.RootContract, verdict("agent", link.RootNever, link.StateUnknown,
|
||||
"sudo could not be read")), true, false, "not judged"},
|
||||
{"no statement", inventory.NodeHealth{}, false, false, "not judged"},
|
||||
{"an older engine", statement(link.ReadinessContract, verdict("agent", "", link.StateHealthy, "")),
|
||||
true, false, "older than the judging"},
|
||||
{"a verdict on groups only", statement(link.RootContract, verdict("agent", "", link.StateHealthy, "")),
|
||||
true, false, "no verdict on it"},
|
||||
{"a verdict on another account", statement(link.RootContract, verdict("ops", link.RootNever, link.StateHealthy, "")),
|
||||
true, false, "no verdict on it"},
|
||||
} {
|
||||
confined, why := judgedConfined("agent", c.h, c.had, at.Add(time.Minute))
|
||||
if confined != c.confined || !strings.Contains(why, c.says) {
|
||||
t.Errorf("%s: confined %v, %q; want %v saying %q", c.name, confined, why, c.confined, c.says)
|
||||
}
|
||||
}
|
||||
// A verdict heard longer ago than the bound is no verdict: an agent that stopped the node-engine must not
|
||||
// leave "healthy" standing.
|
||||
fresh := statement(link.RootContract, verdict("agent", link.RootNever, link.StateHealthy, ""))
|
||||
if ok, _ := judgedConfined("agent", fresh, true, at.Add(verdictFreshFor)); !ok {
|
||||
t.Error("a verdict exactly at the bound is still one")
|
||||
}
|
||||
if ok, why := judgedConfined("agent", fresh, true, at.Add(verdictFreshFor+time.Second)); ok ||
|
||||
!strings.Contains(why, "not judged") {
|
||||
t.Errorf("a stale healthy verdict passed: %q", why)
|
||||
}
|
||||
}
|
||||
|
||||
// DA raises an urgent condition, with plain words, on a machine whose agent account is not judged unable to
|
||||
// become root; a machine that names none is not its to judge.
|
||||
func TestTheSelfCheckSaysAnAgentAccountThatCanBecomeRoot(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
for _, n := range []string{"anchor", "laptop"} {
|
||||
if _, err := inv.NodeByName(ctx, n); err != nil {
|
||||
if _, err := inv.AddNode(ctx, n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := inv.SetAccount(ctx, n, "ops", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := inv.SetAgentAccount(ctx, "anchor", "agent", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := &doctor{open: open}
|
||||
found, err := probeAgentAccounts(ctx, d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found = onlyMachine(found, "anchor")
|
||||
if len(found) != 1 || found[0].Machine != "anchor" || found[0].Severity != conditions.Urgent ||
|
||||
!strings.Contains(found[0].Said, "not judged") {
|
||||
t.Fatalf("a named agent account with no verdict: %+v", found)
|
||||
}
|
||||
w := plainWordings[kindAgentCanBecomeRoot](conditions.Observation{Kind: kindAgentCanBecomeRoot, Machine: "anchor"})
|
||||
if w.Headline == "" || w.Needs == "" || w.Resolved == "" {
|
||||
t.Errorf("the condition has no plain words: %+v", w)
|
||||
}
|
||||
|
||||
healthy := inventory.ResourceHealth{Module: "claude-code", Resource: "claude-code.agent-account",
|
||||
Kind: link.KindAccount, Target: "agent", State: link.StateHealthy, Root: link.RootNever, Account: "agent"}
|
||||
if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.RootContract,
|
||||
SaidAt: time.Now(), HeardAt: time.Now(), Resources: []inventory.ResourceHealth{healthy}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if found, err = probeAgentAccounts(ctx, d); err != nil || len(onlyMachine(found, "anchor")) != 0 {
|
||||
t.Fatalf("a judged agent account still fails: %+v %v", found, err)
|
||||
}
|
||||
if named, confined, why, err := agentConfined(ctx, inv, "anchor"); err != nil || !named || !confined {
|
||||
t.Fatalf("agentConfined on anchor: %v %v %q %v", named, confined, why, err)
|
||||
}
|
||||
if named, _, why, err := agentConfined(ctx, inv, "laptop"); err != nil || named ||
|
||||
!strings.Contains(why, "operator account") {
|
||||
t.Fatalf("agentConfined on a machine naming none: %v %q %v", named, why, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAgentRootWordsArePlain(t *testing.T) {
|
||||
w := plainWordings[kindAgentCanBecomeRoot](conditions.Observation{Kind: kindAgentCanBecomeRoot, Machine: "anchor"})
|
||||
if why, ok := conditions.PlainWords(w, "anchor"); !ok {
|
||||
t.Fatalf("not plain: %s: %+v", why, w)
|
||||
}
|
||||
}
|
||||
|
||||
func onlyMachine(obs []conditions.Observation, machine string) []conditions.Observation {
|
||||
var out []conditions.Observation
|
||||
for _, o := range obs {
|
||||
if o.Machine == machine {
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The snapshot a merge check composes from carries the agent account as a pseudonym (novox/hq ADR 0266),
|
||||
// so a change is judged against machines that name one, and the name never leaves.
|
||||
func TestTheFactsCarryTheAgentAccountAsAPseudonym(t *testing.T) {
|
||||
open, _ := aMeshWithSecrets(t)
|
||||
ctx := t.Context()
|
||||
if err := open.inventory.SetAccount(ctx, "anchor", "keeper", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := open.inventory.SetAgentAccount(ctx, "anchor", "warden", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := gatherFacts(ctx, open, "2.11.17")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := f.Encode()
|
||||
if strings.Contains(string(body), "warden") {
|
||||
t.Error("the agent account's name is in the snapshot")
|
||||
}
|
||||
m, ok := f.Machine(snapshot.Pseudonym("machine", "anchor"))
|
||||
if !ok || m.AgentAccount != snapshot.Pseudonym("account", "warden") || m.Account == m.AgentAccount {
|
||||
t.Fatalf("the anchor's agent account reads as %q (operator %q)", m.AgentAccount, m.Account)
|
||||
}
|
||||
}
|
||||
|
||||
// No verb runs a `node` command that sets something: through the generic `command` verb, `node account`,
|
||||
// `node agent-account` and every other `node` subcommand but list and show are refused, naming the terminal.
|
||||
func TestNoVerbSetsANodesAccounts(t *testing.T) {
|
||||
for _, line := range []string{
|
||||
"node agent-account novox --clear",
|
||||
"node agent-account novox ops",
|
||||
"node account novox agent",
|
||||
"node account novox",
|
||||
"node add intruder",
|
||||
"node public-domain novox --clear",
|
||||
"node",
|
||||
"node frobnicate",
|
||||
} {
|
||||
argv, err := argvFor("command", map[string]any{"command": line})
|
||||
if err == nil || !strings.Contains(err.Error(), "controller's terminal") ||
|
||||
!strings.Contains(err.Error(), "ADR 0266") {
|
||||
t.Errorf("%q ran as %v (%v); want a refusal naming the terminal", line, argv, err)
|
||||
}
|
||||
}
|
||||
for _, line := range []string{"node show novox", "node list --json", "status --json"} {
|
||||
if _, err := argvFor("command", map[string]any{"command": line}); err != nil {
|
||||
t.Errorf("%q, a read, was refused: %v", line, err)
|
||||
}
|
||||
}
|
||||
if err := terminalOnly([]string{"node", "account", "a", "b"}); err == nil {
|
||||
t.Error("the refusal is not only the command verb's")
|
||||
}
|
||||
}
|
||||
|
||||
// Naming the agent account is the controller's terminal's alone: the `node` verb only shows.
|
||||
func TestTheNodeVerbOnlyShows(t *testing.T) {
|
||||
argv, err := argvFor("node", map[string]any{"node": "anchor"})
|
||||
if err != nil || strings.Join(argv, " ") != "node show anchor" {
|
||||
t.Fatalf("the node verb runs %v (%v)", argv, err)
|
||||
}
|
||||
for _, v := range catalogue.ControllerVerbs {
|
||||
if strings.Contains(v.Name, "agent") {
|
||||
t.Errorf("a verb %q may name the agent account", v.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -414,9 +414,6 @@ func settingsCommand(ctx context.Context, args []string) error {
|
||||
// word (novox/hq issue 304). Adding and changing keys needs nothing; removing one needs this.
|
||||
replace := set.Bool("replace", false, "for set: remove the keys the new layer does not name")
|
||||
history := set.Bool("history", false, "for show: the layers this one replaced, the latest first")
|
||||
// Set by the settings verb on every line it composes (novox/hq ADR 0266): a verb may not change where a
|
||||
// module's directories are placed or which of the machine's paths it reaches.
|
||||
throughVerb := set.Bool("through-verb", false, "the line came from the settings verb: places and accesses are refused")
|
||||
positionals, err := parseAround(set, args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -448,11 +445,6 @@ func settingsCommand(ctx context.Context, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *throughVerb {
|
||||
if key := terminalSettingChanged(before, values); key != "" {
|
||||
return terminalSettingRefusal(key, positionals[0], where)
|
||||
}
|
||||
}
|
||||
added, changed, removed := settingsChange(before, values)
|
||||
if len(removed) > 0 && !*replace {
|
||||
return fmt.Errorf("%s on %s: this layer would no longer set %s. A layer is replaced whole; "+
|
||||
@@ -604,15 +596,6 @@ func settingsCommand(ctx context.Context, args []string) error {
|
||||
if len(positionals) != 1 {
|
||||
return errors.New("settings clear <module> [--node <node>]")
|
||||
}
|
||||
if *throughVerb {
|
||||
before, _, err := inv.Layer(ctx, *node, positionals[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if key := terminalSettingChanged(before, nil); key != "" {
|
||||
return terminalSettingRefusal(key, positionals[0], where)
|
||||
}
|
||||
}
|
||||
if err := inv.ClearSettings(ctx, *node, positionals[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1068,28 +1051,3 @@ func declaresTools(m catalogue.Manifest) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// terminalSettings are the keys a verb may not change (novox/hq ADR 0266). `places` says where the node-engine
|
||||
// creates and, as root, owns a module's directories, with an owner the setting names; `accesses` says which of
|
||||
// the machine's paths are mounted into a module's container. Set through a verb, either would let any caller —
|
||||
// an agent among them — have root hand it a directory, or mount one of the machine's into a container it
|
||||
// reaches. They are the operator's, at the controller's terminal.
|
||||
var terminalSettings = []string{catalogue.PlacesSetting, catalogue.AccessesSetting}
|
||||
|
||||
// terminalSettingChanged is the first of those keys a layer change would add, change or remove, or "".
|
||||
func terminalSettingChanged(before, after map[string]any) string {
|
||||
for _, key := range terminalSettings {
|
||||
was, _ := json.Marshal(before[key])
|
||||
now, _ := json.Marshal(after[key])
|
||||
if string(was) != string(now) {
|
||||
return key
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func terminalSettingRefusal(key, module, where string) error {
|
||||
return terminalRefusal("%s of %s on %s is set at the controller's terminal only, never through a verb: it says "+
|
||||
"where root creates and owns a module's directories, or which of the machine's paths reach its container, "+
|
||||
"and whoever may call a verb includes agents (novox/hq ADR 0266). Nothing was changed", key, module, where)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// **The planner over edges the store derives**, not edges written by hand: modules registered, builds
|
||||
// recorded with what they stood on and which repositories they read, the relation answered by
|
||||
// inventory.Dependencies (dependenciesOf over the records), and the merge planned by reachOfMerge — the
|
||||
// path a real merge takes, short of the bus.
|
||||
//
|
||||
// **The repository rows are CURRENT BEHAVIOUR, documented — not the rule the operator states**
|
||||
// (novox/hq issue 338, and the decision pending on it): a build that read a repository gives its module a
|
||||
// packages edge to every module built from that repository, and mergeCandidates moves it on any merge to
|
||||
// that repository, whatever the files. So a change to C alone, or to a README, moves the module that
|
||||
// packages C's repository. ADR 0238 §3 records exactly that today ("a repository a recipe names"); the
|
||||
// expectations marked 338 change with that decision.
|
||||
func TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday(t *testing.T) {
|
||||
inv := inventory.ForTest(t)
|
||||
ctx := t.Context()
|
||||
asked := time.Now().Add(-time.Hour)
|
||||
register := func(m catalogue.Manifest, repository, path string, against []string, read []inventory.ReadRepository) {
|
||||
t.Helper()
|
||||
if err := inv.RegisterModule(ctx, m, inventory.Source{Repository: repository, Seat: "git", Path: path,
|
||||
Ref: "main", BuiltFrom: "old", Asked: asked}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-" + m.Module, Repository: repository, Ref: "main",
|
||||
Module: m.Module, Commit: "old", On: "builder", Path: path, Against: against, Read: read, Asked: asked}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
controllerRead := []inventory.ReadRepository{{Repository: "novox/mesh-controller", Ref: "main"}}
|
||||
agent := catalogue.Manifest{Module: "build-agent", Version: "1",
|
||||
Claims: []catalogue.Claim{{Name: "node-build-agent", Scope: catalogue.ScopeNode}}}
|
||||
|
||||
// The shape of issue 338.
|
||||
register(catalogue.Manifest{Module: "mesh-controller", Version: "1"}, "novox/mesh-controller", "", nil, nil)
|
||||
register(agent, "novox/mesh-catalog", "modules/build-agent", nil, controllerRead)
|
||||
register(catalogue.Manifest{Module: "route-proxy", Version: "1"}, "novox/mesh-catalog", "modules/route-proxy", nil, controllerRead)
|
||||
register(catalogue.Manifest{Module: "gitea", Version: "1"}, "novox/mesh-catalog", "modules/gitea", nil, nil)
|
||||
// A, B and C in one repository; D built against A's artifact, E declaring B, P packaging the repository.
|
||||
for _, n := range []string{"a", "b", "c"} {
|
||||
register(catalogue.Manifest{Module: n, Version: "1"}, "novox/one", "modules/"+n, nil, nil)
|
||||
}
|
||||
register(catalogue.Manifest{Module: "d", Version: "1"}, "novox/two", "d",
|
||||
[]string{catalogue.ArtifactStoreScheme + "a/runtime@sha256:" + strings.Repeat("0", 64)}, nil)
|
||||
register(catalogue.Manifest{Module: "e", Version: "1", Build: &catalogue.Build{
|
||||
On: []catalogue.BuildsOn{{Arg: "BASE", Module: "b", Artifact: "runtime"}}}}, "novox/two", "e", nil, nil)
|
||||
register(catalogue.Manifest{Module: "p", Version: "1"}, "novox/two", "p", nil,
|
||||
[]inventory.ReadRepository{{Repository: "novox/one", Ref: "main"}})
|
||||
|
||||
entries, err := inv.Catalogued(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
read, err := inv.ReadRepositories(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
edges, err := inv.Dependencies(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The edges the hand-written rows of TestASharedRepositoryMovesWhatPackagesItAsItDoesToday use are
|
||||
// the ones derived here.
|
||||
var shared []inventory.Edge
|
||||
in338 := map[string]bool{"mesh-controller": true, "build-agent": true, "route-proxy": true, "gitea": true}
|
||||
for _, e := range edges {
|
||||
if in338[e.From] && in338[e.To] {
|
||||
shared = append(shared, e)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(shared, sharedRepositoryEdges) {
|
||||
t.Errorf("derived %v\nthe hand-written rows use %v", shared, sharedRepositoryEdges)
|
||||
}
|
||||
// Each kind derived from its record: built against (stands-on), build.on (declared), read (packages).
|
||||
for _, want := range []inventory.Edge{
|
||||
dep("d", inventory.EdgeStandsOn, "a"),
|
||||
dep("e", inventory.EdgeDeclared, "b"),
|
||||
dep("p", inventory.EdgePackages, "a"),
|
||||
dep("p", inventory.EdgePackages, "b"),
|
||||
dep("p", inventory.EdgePackages, "c"),
|
||||
dep("d", inventory.EdgeBuiltBy, "build-agent"),
|
||||
} {
|
||||
found := false
|
||||
for _, e := range edges {
|
||||
found = found || e == want
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no %s %s %s derived: %v", want.From, want.Kind, want.To, edges)
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
what, repo string
|
||||
paths []string
|
||||
want string
|
||||
issue338 bool
|
||||
}{
|
||||
{"A and B changed, C untouched: D after A, E after B; P packages their repository", "one",
|
||||
[]string{"modules/a/x.go", "modules/b/x.go"}, "a,b,p | d,e", false},
|
||||
{"C alone: C, and P, which packages C's repository", "one",
|
||||
[]string{"modules/c/x.go"}, "c,p", true},
|
||||
{"a README of the repository P packages: P moves, nothing built from it does", "one",
|
||||
[]string{"README.md"}, "p", true},
|
||||
{"the dependent's repository: D alone", "two", []string{"d/main.go"}, "d", false},
|
||||
{"a README of the controller's repository: all three, three tiers", "mesh-controller",
|
||||
[]string{"README.md"}, "mesh-controller | build-agent | route-proxy", true},
|
||||
{"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
|
||||
[]string{"modules/route-proxy/module.json"}, "route-proxy", false},
|
||||
{"the build agent's directory: it alone, nothing it builds", "mesh-catalog",
|
||||
[]string{"modules/build-agent/module.json"}, "build-agent", false},
|
||||
} {
|
||||
_, got := planMerge(t, c.repo, c.paths, entries, read, edges)
|
||||
if got != c.want {
|
||||
tag := ""
|
||||
if c.issue338 {
|
||||
tag = " (current behaviour, issue 338)"
|
||||
}
|
||||
t.Errorf("%s: planned %q, wanted %q%s", c.what, got, c.want, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// The delivery planner's rules, as recorded (novox/hq ADR 0162 §1, ADR 0238 §3), held by one table and
|
||||
// one property over reachOfMerge — the planner's one answer to "what does this merge move, and in which
|
||||
// order". A row that fails here on main is a planner that breaks a recorded rule: the row stays, the
|
||||
// expectation is not bent to the code.
|
||||
//
|
||||
// The kinds of edge, as the code reads them (release_plan.go):
|
||||
//
|
||||
// kind widens the plan orders the tiers
|
||||
// stands-on yes yes, after its base is built
|
||||
// declared yes yes, after its base is built
|
||||
// packages yes no, the same tier (a code dependency)
|
||||
// built-by no yes, after the build machine — except for what the build machine stands
|
||||
// on, and for the controller whose worker it binds
|
||||
// worker-of no yes, the build seat's holder after the controller (hq issue 206)
|
||||
|
||||
const (
|
||||
repoOne = "http://forge.internal:20000/novox/one.git"
|
||||
repoTwo = "http://forge.internal:20000/novox/two.git"
|
||||
)
|
||||
|
||||
// dep is one edge of the catalogue's relation: from depends on to, in the way kind says.
|
||||
func dep(from, kind, to string) inventory.Edge {
|
||||
return inventory.Edge{From: from, To: to, Kind: kind}
|
||||
}
|
||||
|
||||
// tiered is a plan's tiers as one line: a tier's modules by comma, tiers by " | ". Empty for no plan.
|
||||
func tiered(tiers [][]string) string {
|
||||
var out []string
|
||||
for _, t := range tiers {
|
||||
out = append(out, strings.Join(t, ","))
|
||||
}
|
||||
return strings.Join(out, " | ")
|
||||
}
|
||||
|
||||
// planMerge is what reachOfMerge plans for a merge of these files into a repository's main: its tiers as
|
||||
// one line, and the files no build reads. It also holds the plan to its own shape: every module it builds
|
||||
// is in exactly one tier.
|
||||
func planMerge(t *testing.T, repo string, paths []string, entries []inventory.Entry,
|
||||
read map[string][]inventory.ReadRepository, edges []inventory.Edge) (mergeReach, string) {
|
||||
t.Helper()
|
||||
m := link.SourceMoved{Owner: "novox", Repo: repo, Base: "main", Commit: "head", Paths: paths}
|
||||
r := reachOfMerge(m, entries, read, edges)
|
||||
seen := map[string]int{}
|
||||
for _, tier := range r.Plan.Tiers {
|
||||
for _, name := range tier {
|
||||
seen[name]++
|
||||
}
|
||||
}
|
||||
for name := range r.Plan.Modules {
|
||||
if seen[name] != 1 {
|
||||
t.Errorf("%s/%v: %s is in %d tiers of %v", repo, paths, name, seen[name], r.Plan.Tiers)
|
||||
}
|
||||
}
|
||||
if len(seen) != len(r.Plan.Modules) {
|
||||
t.Errorf("%s/%v: the tiers %v hold modules the plan does not (%d)", repo, paths, r.Plan.Tiers, len(r.Plan.Modules))
|
||||
}
|
||||
return r, tiered(r.Plan.Tiers)
|
||||
}
|
||||
|
||||
// **A merge moves the modules whose directory it changed, and everything built on them; nothing else.**
|
||||
// Each row is a catalogue (modules in directories of one or two repositories), its dependencies with
|
||||
// their kinds, the files one commit changed, and the exact plan: the moved set and its tier order.
|
||||
func TestAPlanIsWhatTheChangeTouchedAndWhatIsBuiltOnIt(t *testing.T) {
|
||||
in := func(repo, dir string, names ...string) []inventory.Entry {
|
||||
var out []inventory.Entry
|
||||
for _, n := range names {
|
||||
d := dir + "/" + n
|
||||
if dir == "" {
|
||||
d = n
|
||||
}
|
||||
out = append(out, fromRepo(n, repo, d))
|
||||
}
|
||||
return out
|
||||
}
|
||||
// Modules a to f, x and z in novox/one under modules/; g in novox/two at g/.
|
||||
entries := append(in(repoOne, "modules", "a", "b", "c", "d", "e", "f", "x", "z"), in(repoTwo, "", "g")...)
|
||||
const (
|
||||
standsOn = inventory.EdgeStandsOn
|
||||
declared = inventory.EdgeDeclared
|
||||
packages = inventory.EdgePackages
|
||||
builtBy = inventory.EdgeBuiltBy
|
||||
workerOf = inventory.EdgeWorkerOf
|
||||
)
|
||||
// The two real cycles the kinds resolve themselves (ADR 0162 §1, hq issue 206).
|
||||
runtime := append(in(repoOne, "modules", "runtime", "builder"), fromRepo("controller", repoTwo, ""))
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
entries []inventory.Entry
|
||||
edges []inventory.Edge
|
||||
repo string
|
||||
paths []string
|
||||
want string // the tiers, " | " between them
|
||||
unread string
|
||||
cycle bool
|
||||
}{
|
||||
// The operator's case: two modules changed, a third beside them in the same repository untouched,
|
||||
// each changed one with a dependent.
|
||||
{what: "A and B changed, C untouched beside them, D on A and E on B",
|
||||
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("e", declared, "b")},
|
||||
repo: "one", paths: []string{"modules/a/main.go", "modules/b/module.json"}, want: "a,b | d,e"},
|
||||
{what: "only A's directory: A and what stands on it",
|
||||
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("e", declared, "b")},
|
||||
repo: "one", paths: []string{"modules/a/main.go"}, want: "a | d"},
|
||||
{what: "only C's directory: C alone, nothing is built on it",
|
||||
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("e", declared, "b")},
|
||||
repo: "one", paths: []string{"modules/c/Dockerfile"}, want: "c"},
|
||||
{what: "only the dependent changed: its base does not move",
|
||||
edges: []inventory.Edge{dep("d", standsOn, "a")},
|
||||
repo: "one", paths: []string{"modules/d/main.go"}, want: "d"},
|
||||
{what: "transitive: F on D on A, A changed",
|
||||
edges: []inventory.Edge{dep("f", standsOn, "d"), dep("d", standsOn, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a | d | f"},
|
||||
{what: "transitive across kinds: F declared on D, D packages A",
|
||||
edges: []inventory.Edge{dep("f", declared, "d"), dep("d", packages, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a,d | f"},
|
||||
|
||||
// Each kind alone: X depends on A, A changed (widening), then both changed (ordering).
|
||||
{what: "stands-on (built against A's artifact) widens", edges: []inventory.Edge{dep("x", standsOn, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a | x"},
|
||||
{what: "stands-on orders", edges: []inventory.Edge{dep("x", standsOn, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
|
||||
{what: "declared (build.on) widens", edges: []inventory.Edge{dep("x", declared, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a | x"},
|
||||
{what: "declared orders", edges: []inventory.Edge{dep("x", declared, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
|
||||
{what: "packages widens, into the same tier", edges: []inventory.Edge{dep("x", packages, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a,x"},
|
||||
{what: "packages does not order", edges: []inventory.Edge{dep("x", packages, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a,x"},
|
||||
{what: "built-by never widens", edges: []inventory.Edge{dep("x", builtBy, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
|
||||
{what: "built-by orders", edges: []inventory.Edge{dep("x", builtBy, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
|
||||
{what: "worker-of never widens", edges: []inventory.Edge{dep("x", workerOf, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
|
||||
{what: "worker-of orders", edges: []inventory.Edge{dep("x", workerOf, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
|
||||
{what: "a change to the base alone does not move what it builds", edges: []inventory.Edge{dep("x", builtBy, "a"),
|
||||
dep("d", builtBy, "a"), dep("e", standsOn, "a")},
|
||||
repo: "one", paths: []string{"modules/a/module.json"}, want: "a | e"},
|
||||
|
||||
// The cycles the kinds resolve: the build machine stands on the runtime image the runtime image is
|
||||
// built by; the build seat's holder follows the controller that is built by it.
|
||||
{what: "the build machine's base comes first, built by the build machine that runs", entries: runtime,
|
||||
edges: []inventory.Edge{dep("runtime", builtBy, "builder"), dep("builder", standsOn, "runtime")},
|
||||
repo: "one", paths: []string{"modules/runtime/Dockerfile", "modules/builder/main.go"}, want: "runtime | builder"},
|
||||
{what: "the runtime image alone takes the build machine on it along", entries: runtime,
|
||||
edges: []inventory.Edge{dep("runtime", builtBy, "builder"), dep("builder", standsOn, "runtime")},
|
||||
repo: "one", paths: []string{"modules/runtime/Dockerfile"}, want: "runtime | builder"},
|
||||
{what: "the build machine alone moves alone", entries: runtime,
|
||||
edges: []inventory.Edge{dep("runtime", builtBy, "builder"), dep("builder", standsOn, "runtime")},
|
||||
repo: "one", paths: []string{"modules/builder/main.go"}, want: "builder"},
|
||||
{what: "the build seat's holder follows the controller it binds the worker of", entries: runtime,
|
||||
edges: []inventory.Edge{dep("controller", builtBy, "builder"), dep("builder", workerOf, "controller")},
|
||||
repo: "two", paths: []string{"cmd/main.go"}, want: "controller"},
|
||||
|
||||
// Two repositories.
|
||||
{what: "a dependent in another repository follows its base",
|
||||
edges: []inventory.Edge{dep("g", standsOn, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a | g"},
|
||||
{what: "a directory of the same name in another repository is not this one's",
|
||||
edges: []inventory.Edge{dep("g", standsOn, "a")},
|
||||
repo: "two", paths: []string{"modules/a/x"}, want: "", unread: "modules/a/x"},
|
||||
{what: "the dependent's own repository moves the dependent alone",
|
||||
edges: []inventory.Edge{dep("g", standsOn, "a")},
|
||||
repo: "two", paths: []string{"g/main.go"}, want: "g"},
|
||||
|
||||
// A diamond.
|
||||
{what: "a diamond, one side changed", edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", standsOn, "b")},
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a | d"},
|
||||
{what: "a diamond, both sides changed", edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", standsOn, "b")},
|
||||
repo: "one", paths: []string{"modules/a/x", "modules/b/x"}, want: "a,b | d"},
|
||||
{what: "a diamond on one base", edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", declared, "b"),
|
||||
dep("a", standsOn, "z"), dep("b", standsOn, "z")},
|
||||
repo: "one", paths: []string{"modules/z/x"}, want: "z | a,b | d"},
|
||||
{what: "a diamond of mixed kinds orders on the ordering side only",
|
||||
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", packages, "b")},
|
||||
repo: "one", paths: []string{"modules/b/x"}, want: "b,d"},
|
||||
|
||||
// A cycle the catalogue should never produce: what remains is one last tier, and said.
|
||||
{what: "a cycle is one last tier, not lost", edges: []inventory.Edge{dep("a", standsOn, "b"), dep("b", standsOn, "a"),
|
||||
dep("f", standsOn, "c")},
|
||||
repo: "one", paths: []string{"modules/a/x", "modules/c/x"}, want: "c | f | a,b", cycle: true},
|
||||
|
||||
// Files no build reads.
|
||||
{what: "a README at the root of a repository whose modules all live below it",
|
||||
edges: []inventory.Edge{dep("d", standsOn, "a")},
|
||||
repo: "one", paths: []string{"README.md"}, want: "", unread: "README.md"},
|
||||
{what: "a directory no module lives in", edges: []inventory.Edge{dep("d", standsOn, "a")},
|
||||
repo: "one", paths: []string{"modules/lib/x.go", "modules/README.md"}, want: "",
|
||||
unread: "modules/lib/x.go,modules/README.md"},
|
||||
{what: "a module's directory beside a root file", edges: []inventory.Edge{dep("d", standsOn, "a")},
|
||||
repo: "one", paths: []string{"merge-check.sh", "modules/a/x"}, want: "a | d", unread: "merge-check.sh"},
|
||||
{what: "a directory whose name begins with a module's", edges: []inventory.Edge{dep("d", standsOn, "a")},
|
||||
repo: "one", paths: []string{"modules/ab/x"}, want: "", unread: "modules/ab/x"},
|
||||
} {
|
||||
e := entries
|
||||
if c.entries != nil {
|
||||
e = c.entries
|
||||
}
|
||||
r, got := planMerge(t, c.repo, c.paths, e, nil, c.edges)
|
||||
if got != c.want {
|
||||
t.Errorf("%s: planned %q, wanted %q", c.what, got, c.want)
|
||||
}
|
||||
if u := strings.Join(r.Unread, ","); u != c.unread {
|
||||
t.Errorf("%s: unread %q, wanted %q", c.what, u, c.unread)
|
||||
}
|
||||
// A packages edge in the last tier is no cycle; hasCycle said one on main at 8170fc5.
|
||||
if hasCycle(r.Plan.Tiers, c.edges) != c.cycle {
|
||||
t.Errorf("%s: a cycle said %v, wanted %v (%v)", c.what, !c.cycle, c.cycle, r.Plan.Tiers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **CURRENT BEHAVIOUR, documented — not the rule the operator states.** novox/hq issue 338 (a module
|
||||
// built from a shared repository moves on every merge to it) and the decision pending on it would change
|
||||
// every row here. Today:
|
||||
//
|
||||
// - mesh-controller is built from its repository's root, so every file of that repository touches it;
|
||||
// - route-proxy and build-agent package the whole of that repository (a build context), so the build
|
||||
// record's `read` makes them move on any merge to it, whatever the files, and dependenciesOf gives
|
||||
// each a packages edge to every module built from it;
|
||||
// - built-by (route-proxy on build-agent) and worker-of (build-agent on the controller) make it three
|
||||
// tiers.
|
||||
//
|
||||
// These follow ADR 0238 §3 as written ("the whole repository for a module built from its root, and a
|
||||
// repository a recipe names"), so they are not failures; when the decision on issue 338 lands, these
|
||||
// expectations change with it. The edges are the ones dependenciesOf derives from this catalogue — held
|
||||
// to that by TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday, which derives them from the store.
|
||||
func TestASharedRepositoryMovesWhatPackagesItAsItDoesToday(t *testing.T) {
|
||||
const catalogueRepo = "http://forge.internal:20000/novox/mesh-catalog.git"
|
||||
const controllerRepo = "http://forge.internal:20000/novox/mesh-controller.git"
|
||||
entries := []inventory.Entry{
|
||||
fromRepo("mesh-controller", controllerRepo, ""),
|
||||
fromRepo("build-agent", catalogueRepo, "modules/build-agent"),
|
||||
fromRepo("route-proxy", catalogueRepo, "modules/route-proxy"),
|
||||
fromRepo("gitea", catalogueRepo, "modules/gitea"),
|
||||
}
|
||||
read := map[string][]inventory.ReadRepository{
|
||||
"build-agent": {{Repository: "novox/mesh-controller", Ref: "main"}},
|
||||
"route-proxy": {{Repository: "novox/mesh-controller", Ref: "main"}},
|
||||
}
|
||||
edges := sharedRepositoryEdges
|
||||
for _, c := range []struct {
|
||||
what, repo string
|
||||
paths []string
|
||||
want string
|
||||
}{
|
||||
// The live three-tier plan of 2026-10-08 (issue 338), in the worker-of order (issue 206) that
|
||||
// TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency's controller case holds too.
|
||||
{"a README of the controller's repository moves all three, in three tiers", "mesh-controller",
|
||||
[]string{"README.md"}, "mesh-controller | build-agent | route-proxy"},
|
||||
{"the controller's own code: the same", "mesh-controller",
|
||||
[]string{"cmd/mesh-controller/main.go"}, "mesh-controller | build-agent | route-proxy"},
|
||||
{"the route proxy's program alone: the same, the controller with it", "mesh-controller",
|
||||
[]string{"examples/route-proxy/main.go"}, "mesh-controller | build-agent | route-proxy"},
|
||||
// In the catalogue, where they live, the rule is path-precise.
|
||||
{"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
|
||||
[]string{"modules/route-proxy/module.json"}, "route-proxy"},
|
||||
{"the build agent's directory: it alone, nothing it builds", "mesh-catalog",
|
||||
[]string{"modules/build-agent/module.json"}, "build-agent"},
|
||||
{"another module of the catalogue: neither", "mesh-catalog",
|
||||
[]string{"modules/gitea/index.ts"}, "gitea"},
|
||||
} {
|
||||
r, got := planMerge(t, c.repo, c.paths, entries, read, edges)
|
||||
if got != c.want {
|
||||
t.Errorf("%s: planned %q, wanted %q (as today; issue 338)", c.what, got, c.want)
|
||||
}
|
||||
if c.repo == "mesh-controller" && strings.Join(r.Unread, ",") != "" {
|
||||
t.Errorf("%s: a root-built module reads every file, and %v were said unread", c.what, r.Unread)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sharedRepositoryEdges is what dependenciesOf derives for the catalogue of the test above, sorted as it
|
||||
// sorts them.
|
||||
var sharedRepositoryEdges = []inventory.Edge{
|
||||
dep("build-agent", inventory.EdgePackages, "mesh-controller"),
|
||||
dep("build-agent", inventory.EdgeWorkerOf, "mesh-controller"),
|
||||
dep("gitea", inventory.EdgeBuiltBy, "build-agent"),
|
||||
dep("mesh-controller", inventory.EdgeBuiltBy, "build-agent"),
|
||||
dep("route-proxy", inventory.EdgeBuiltBy, "build-agent"),
|
||||
dep("route-proxy", inventory.EdgePackages, "mesh-controller"),
|
||||
}
|
||||
|
||||
// **The planner's invariant, over random catalogues.** For any catalogue whose dependencies form no cycle
|
||||
// and any set of changed files in one repository:
|
||||
//
|
||||
// - the plan is exactly the modules of that repository whose directory holds a changed file (every file,
|
||||
// for a module built from the root), and everything reachable from them along stands-on, declared and
|
||||
// packages — never along built-by or worker-of;
|
||||
// - every stands-on, declared, built-by and worker-of edge with both ends in the plan has the module
|
||||
// depended on in an earlier tier;
|
||||
// - no cycle is said.
|
||||
//
|
||||
// Seeded, so a failure is replayed by its seed and case.
|
||||
func TestAPlanIsTheTouchedModulesAndWhatIsReachableAlongTheWideningEdges(t *testing.T) {
|
||||
kinds := []string{inventory.EdgeStandsOn, inventory.EdgeDeclared, inventory.EdgePackages,
|
||||
inventory.EdgeBuiltBy, inventory.EdgeWorkerOf}
|
||||
widens := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true, inventory.EdgePackages: true}
|
||||
orders := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true,
|
||||
inventory.EdgeBuiltBy: true, inventory.EdgeWorkerOf: true}
|
||||
// Directory names drawn from one pool, so two repositories hold directories of the same name, and one
|
||||
// is a prefix of another.
|
||||
dirs := []string{"a", "ab", "b", "c", "lib/x", "lib/y", "modules/a", "modules/a/sub"}
|
||||
files := []string{"README.md", "merge-check.sh", "lib/z.go", "docs/x.md", "modules/README.md"}
|
||||
|
||||
falseCycles, firstFalseCycle := 0, ""
|
||||
for _, seed := range []uint64{1, 2, 3, 0x338, 0x162} {
|
||||
rng := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
|
||||
for n := 0; n < 100; n++ {
|
||||
repos := 1 + rng.IntN(3)
|
||||
repoName := func(i int) string { return fmt.Sprintf("r%d", i) }
|
||||
count := 1 + rng.IntN(12)
|
||||
var entries []inventory.Entry
|
||||
repoOf, dirOf := map[string]int{}, map[string]string{}
|
||||
for i := 0; i < count; i++ {
|
||||
name := fmt.Sprintf("m%02d", i)
|
||||
repo := rng.IntN(repos)
|
||||
dir := dirs[rng.IntN(len(dirs))]
|
||||
if rng.IntN(12) == 0 {
|
||||
dir = "" // built from the repository's root
|
||||
}
|
||||
repoOf[name], dirOf[name] = repo, dir
|
||||
entries = append(entries, fromRepo(name, "http://forge.internal:20000/novox/"+repoName(repo)+".git", dir))
|
||||
}
|
||||
// A graph with no cycle: a module depends only on modules made before it.
|
||||
var edges []inventory.Edge
|
||||
for i := 1; i < count; i++ {
|
||||
for j := 0; j < i; j++ {
|
||||
if rng.IntN(4) == 0 {
|
||||
edges = append(edges, dep(fmt.Sprintf("m%02d", i), kinds[rng.IntN(len(kinds))], fmt.Sprintf("m%02d", j)))
|
||||
}
|
||||
}
|
||||
}
|
||||
merged := rng.IntN(repos)
|
||||
var paths []string
|
||||
for k := 1 + rng.IntN(4); k > 0; k-- {
|
||||
if rng.IntN(3) == 0 {
|
||||
paths = append(paths, files[rng.IntN(len(files))])
|
||||
} else {
|
||||
paths = append(paths, dirs[rng.IntN(len(dirs))]+"/f.go")
|
||||
}
|
||||
}
|
||||
|
||||
// What the rules say.
|
||||
want := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
name := e.Manifest.Module
|
||||
if repoOf[name] != merged {
|
||||
continue
|
||||
}
|
||||
for _, p := range paths {
|
||||
if d := dirOf[name]; d == "" || p == d || strings.HasPrefix(p, d+"/") {
|
||||
want[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for grew := true; grew; {
|
||||
grew = false
|
||||
for _, e := range edges {
|
||||
if widens[e.Kind] && want[e.To] && !want[e.From] {
|
||||
want[e.From], grew = true, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r, _ := planMerge(t, repoName(merged), paths, entries, nil, edges)
|
||||
got := map[string]bool{}
|
||||
tierOf := map[string]int{}
|
||||
for i, tier := range r.Plan.Tiers {
|
||||
for _, name := range tier {
|
||||
got[name], tierOf[name] = true, i
|
||||
}
|
||||
}
|
||||
replay := func() string {
|
||||
return fmt.Sprintf("seed %#x case %d: repository %s, files %v\n modules %v\n edges %v\n tiers %v",
|
||||
seed, n, repoName(merged), paths, describe(entries), edges, r.Plan.Tiers)
|
||||
}
|
||||
if !sameSet(got, want) {
|
||||
t.Fatalf("planned %v, wanted %v\n%s", keys(got), keys(want), replay())
|
||||
}
|
||||
for _, e := range edges {
|
||||
if orders[e.Kind] && got[e.From] && got[e.To] && tierOf[e.To] >= tierOf[e.From] {
|
||||
t.Fatalf("%s %s %s, and %s is in tier %d, not before %s's %d\n%s", e.From, e.Kind, e.To,
|
||||
e.To, tierOf[e.To], e.From, tierOf[e.From], replay())
|
||||
}
|
||||
}
|
||||
if hasCycle(r.Plan.Tiers, edges) {
|
||||
falseCycles++
|
||||
if firstFalseCycle == "" {
|
||||
firstFalseCycle = replay()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Said once, after the other invariants have run over every case, so it hides none of them (hasCycle
|
||||
// counted a packages edge on main at 8170fc5: 19 of these 500 cases).
|
||||
if falseCycles > 0 {
|
||||
t.Errorf("a cycle said of a graph with none in %d of 500 cases; "+
|
||||
"the first:\n%s", falseCycles, firstFalseCycle)
|
||||
}
|
||||
}
|
||||
|
||||
func sameSet(a, b map[string]bool) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for k := range a {
|
||||
if !b[k] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func keys(m map[string]bool) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func describe(entries []inventory.Entry) []string {
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
out = append(out, fmt.Sprintf("%s@%s:%q", e.Manifest.Module,
|
||||
strings.TrimSuffix(strings.TrimPrefix(e.Source.Repository, "http://forge.internal:20000/novox/"), ".git"),
|
||||
e.Source.Path))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -157,7 +157,9 @@ func reachableFrom(moved []string, edges []inventory.Edge) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// hasCycle says whether the tiers' last tier holds modules that still depend on each other.
|
||||
// hasCycle says whether the tiers' last tier holds modules that still depend on each other. A packages
|
||||
// edge orders nothing (tiersOf), so a module and what packages its source share a tier by rule: that is
|
||||
// no cycle, and saying one was is a false report in every such plan's log.
|
||||
func hasCycle(tiers [][]string, edges []inventory.Edge) bool {
|
||||
if len(tiers) == 0 {
|
||||
return false
|
||||
@@ -167,6 +169,9 @@ func hasCycle(tiers [][]string, edges []inventory.Edge) bool {
|
||||
last[m] = true
|
||||
}
|
||||
for _, e := range edges {
|
||||
if e.Kind == inventory.EdgePackages {
|
||||
continue
|
||||
}
|
||||
if last[e.From] && last[e.To] {
|
||||
return true
|
||||
}
|
||||
@@ -701,7 +706,6 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
step := nextRollout(*state, running, policy.Together, reports, now, planWaitBound)
|
||||
// **Sent with others, judged with them** (issue 281): the gate of the send that carried it is its
|
||||
// verdict on its first machine. A failure there stopped the plan already.
|
||||
if state.GatedBy != "" {
|
||||
@@ -715,6 +719,9 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
||||
}
|
||||
passedWith(m, state, state.GatedBy, lead.Gate)
|
||||
}
|
||||
// Read after its pass is taken over from the send that carried it, so a passed gate is never judged
|
||||
// again from the first machine's later reports (novox/hq issue 335).
|
||||
step := nextRollout(*state, running, policy.Together, reports, now, planWaitBound)
|
||||
switch {
|
||||
case step.failed != "":
|
||||
// The first machine refused or failed what it was sent, or never said: the gate failed, and
|
||||
@@ -974,6 +981,19 @@ func failFirstSend(ctx context.Context, open *stores, p *inventory.Plan, module
|
||||
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
||||
}()
|
||||
g := state.Gate
|
||||
if g != nil && g.Verdict == inventory.GatePassed {
|
||||
// **A guard: a build that passed its gate is never put back for what came after** (novox/hq issue 335).
|
||||
// Not reached while nextRollout answers a passed gate with the rest to send, and advanceOnce reads it
|
||||
// after a carried module takes over its lead's pass; it is here so that a path added later cannot
|
||||
// overturn a verdict. If it is reached, the plan stops and says why, and the build is not marked failed.
|
||||
// The module is left a stopped rollout (its Why said, sent first and not to the rest), which
|
||||
// `plans retry` takes: it sends the first machine again, and the passed gate then sends the rest.
|
||||
state.Why = "passed its gate; its walk then stopped: " + why
|
||||
p.State = inventory.PlanFailed
|
||||
p.Note = fmt.Sprintf("%s passed its gate on %s in tier %d (%s) and is kept; its walk stopped after: %s",
|
||||
module, strings.Join(g.Machines, ", "), p.Tier, g.Why, why)
|
||||
return
|
||||
}
|
||||
if g != nil && slices.Contains(g.Returned, module) {
|
||||
// Put back at once when it broke: its rollback was made then, and is not made again.
|
||||
state.Why = "put back when it broke; its send failed: " + g.Why
|
||||
@@ -1061,6 +1081,15 @@ func nextRollout(s inventory.PlanModule, running []string, together bool, report
|
||||
rest = append(rest, n)
|
||||
}
|
||||
}
|
||||
// **A passed gate is the first machine's verdict, and its later reports are not** (novox/hq issue 335).
|
||||
// Once the build passed there, what that machine reports next is about whatever it was sent after —
|
||||
// another walk's send, a push — and says nothing of this build. On 2026-10-08 a build passed on the
|
||||
// laptop, its send to the rest waited on another walk, that walk sent the laptop a new declaration it did
|
||||
// not report for half an hour, and the plan read the silence as the first machine never applying the
|
||||
// passed build: it marked it failed at its gate and put it back. The rest are sent, as the pass said.
|
||||
if s.Gate != nil && s.Gate.Verdict == inventory.GatePassed {
|
||||
return rolloutStep{send: rest}
|
||||
}
|
||||
var waiting, failed []string
|
||||
for _, n := range s.First {
|
||||
r, said := byNode[n]
|
||||
|
||||
@@ -27,6 +27,8 @@ func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) {
|
||||
{From: "mesh-controller", To: "builder", Kind: inventory.EdgeBuiltBy},
|
||||
{From: "mesh-tools", To: "builder", Kind: inventory.EdgeBuiltBy},
|
||||
{From: "builder", To: "mesh-tools", Kind: inventory.EdgeStandsOn},
|
||||
// the build seat's holder follows the controller that defines its worker (hq issue 206)
|
||||
{From: "builder", To: "mesh-controller", Kind: inventory.EdgeWorkerOf},
|
||||
{From: "unrelated", To: "alpine", Kind: inventory.EdgeStandsOn},
|
||||
}
|
||||
// The runtime image moved: everything on it, and what is built by what is on it.
|
||||
@@ -62,12 +64,15 @@ func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) {
|
||||
if len(small) != 3 {
|
||||
t.Fatalf("a controller merge rebuilds the controller and what packages it: %v", small)
|
||||
}
|
||||
// The builder packages the controller's source (same tier by that edge) and the controller is
|
||||
// built by the builder (next tier by that one): the builder first, then the controller and the
|
||||
// proxy together — a code dependency in one tier, a runtime dependency across tiers.
|
||||
// The builder and the proxy package the controller's source, which orders nothing. The builder holds
|
||||
// the build seat, whose worker the controller defines, so it follows the controller (worker-of,
|
||||
// novox/hq issue 206), and the controller's built-by edge to it yields: the controller is built by the
|
||||
// build machine that is running. The proxy is built by the new builder: the controller, the builder,
|
||||
// the proxy — the live plan of every controller merge. (This read "the builder, then the controller
|
||||
// and the proxy together" before issue 206, and the fixture had no worker-of edge.)
|
||||
smallTiers := tiersOf(small, edges)
|
||||
if len(smallTiers) != 2 || smallTiers[0][0] != "builder" || len(smallTiers[1]) != 2 {
|
||||
t.Fatalf("the builder, then the controller and the proxy together: %v", smallTiers)
|
||||
if got := tiered(smallTiers); got != "mesh-controller | builder | route-proxy" {
|
||||
t.Fatalf("the controller, then the builder, then the proxy: %v", smallTiers)
|
||||
}
|
||||
// The builder alone moved: the builder, and nothing it builds.
|
||||
if only := reachableFrom([]string{"builder"}, edges); len(only) != 1 {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// TestReplay335 replays novox/hq issue 335 (2026-10-08): dunst's new build passed its gate on the laptop
|
||||
// (healthy 3 times over 2m5s); its send to the rest was refused while another walk's build waited on the
|
||||
// workstation; that walk then sent the laptop a declaration the laptop did not report on; and thirty minutes
|
||||
// after the first send the plan read the laptop's silence as the passed build never applied, marked it failed
|
||||
// at its gate with the pass's own words, and put it back. Written with only what the controller had before
|
||||
// its fix, so it is laid over the commit before.
|
||||
func TestReplay335(t *testing.T) {
|
||||
t.Run("the first machine's later reports", testAPassedGateIsNotJudgedAgainFromTheFirstMachinesLaterReports)
|
||||
t.Run("a walk stopped after the pass", testAWalkStoppedAfterItsGatePassedKeepsThePass)
|
||||
}
|
||||
|
||||
// novox/hq issue 335: a build that passed its gate on its first machine is not judged again from that
|
||||
// machine's later reports. On 2026-10-08 the send to the rest waited on another walk, that walk sent the
|
||||
// first machine a declaration it did not report for half an hour, and the plan failed the passed build at
|
||||
// the wait's bound and put it back.
|
||||
func testAPassedGateIsNotJudgedAgainFromTheFirstMachinesLaterReports(t *testing.T) {
|
||||
sentAt := time.Date(2026, 10, 8, 16, 44, 45, 0, time.UTC)
|
||||
judged := sentAt.Add(2 * time.Minute)
|
||||
later := sentAt.Add(5 * time.Minute)
|
||||
state := inventory.PlanModule{First: []string{"laptop"}, FirstAt: &sentAt,
|
||||
Gate: &inventory.PlanGate{Machines: []string{"laptop"}, Verdict: inventory.GatePassed,
|
||||
Why: "healthy 3 times over 2m5s", JudgedAt: &judged, Kept: true}}
|
||||
running := []string{"laptop", "workstation"}
|
||||
now := sentAt.Add(30*time.Minute + 9*time.Second)
|
||||
|
||||
for what, reports := range map[string][]inventory.Reported{
|
||||
"no report about what it was sent since": {{Node: "laptop", At: &later, Outcome: inventory.OutcomeApplied, Current: false}},
|
||||
"another send failed there since": {{Node: "laptop", At: &later, Outcome: inventory.OutcomeFailed, Current: true}},
|
||||
"no report at all": nil,
|
||||
} {
|
||||
step := nextRollout(state, running, false, reports, now, 30*time.Minute)
|
||||
if step.failed != "" || step.waiting != "" || !reflect.DeepEqual(step.send, []string{"workstation"}) {
|
||||
t.Errorf("%s: %+v, want the rest sent as the pass said", what, step)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq issue 335: whatever stops a walk after its gate passed, the passed build is not marked failed at
|
||||
// its gate, nor put back.
|
||||
func testAWalkStoppedAfterItsGatePassedKeepsThePass(t *testing.T) {
|
||||
sentAt := time.Date(2026, 10, 8, 16, 44, 45, 0, time.UTC)
|
||||
g := &inventory.PlanGate{Machines: []string{"laptop"}, Verdict: inventory.GatePassed, Why: "healthy 3 times over 2m5s"}
|
||||
state := &inventory.PlanModule{Build: "build-1", First: []string{"laptop"}, FirstAt: &sentAt, Gate: g}
|
||||
p := &inventory.Plan{ID: "plan-1", Modules: map[string]*inventory.PlanModule{"dunst": state}}
|
||||
// No stores: a walk that keeps the pass touches none, and one that reaches for them is putting it back.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("the passed build was taken to be failed and put back: %v", r)
|
||||
}
|
||||
}()
|
||||
failFirstSend(t.Context(), nil, p, "dunst", state, []string{"laptop"}, "laptop did not report it applied within 30m0s",
|
||||
[]string{"workstation"})
|
||||
if g.Verdict != inventory.GatePassed || g.Rollback != "" {
|
||||
t.Fatalf("the passed gate became %q, rollback %q", g.Verdict, g.Rollback)
|
||||
}
|
||||
if strings.Contains(p.Note, "failed its gate") || strings.Contains(p.Note, "put back") ||
|
||||
!strings.Contains(p.Note, "did not report it applied") {
|
||||
t.Fatalf("the note reads %q", p.Note)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq issue 335 review: **a module carried by its lead's send takes over the lead's pass before its own
|
||||
// step is read.** The state is the one a step leaves when the lead's gate passed and the step ended before the
|
||||
// carried module's turn (an error read before it, kept with the plan): the lead passed, the carried module has
|
||||
// no gate of its own yet. Since then another send reached the first machine and it has not reported on it, and
|
||||
// the first send is older than the wait for a first machine's report. Read before the pass is taken over, the
|
||||
// carried module's step said the first machine never applied it, and the passed build was put back.
|
||||
func TestACarriedModuleTakesItsLeadsPassBeforeItsStepIsRead(t *testing.T) {
|
||||
tm := aTierMesh(t, "m01", "m02")
|
||||
ctx := t.Context()
|
||||
inv := tm.open.inventory
|
||||
|
||||
advancePlans(ctx, tm.open)
|
||||
if !reflect.DeepEqual(tm.sent, [][]string{{"anchor"}}) {
|
||||
t.Fatalf("sent %v: the first machine once, for the tier", tm.sent)
|
||||
}
|
||||
p := tm.plan(t)
|
||||
lead, carried := p.Modules["m01"], p.Modules["m02"]
|
||||
if lead.Gate == nil || carried.GatedBy != "m01" {
|
||||
t.Fatalf("m02 is not carried by m01's send: lead %+v, carried %+v", lead.Gate, carried)
|
||||
}
|
||||
// The lead passed; the carried module's turn did not come. The first send is past the wait's bound.
|
||||
sent := time.Now().UTC().Add(-planWaitBound - time.Minute)
|
||||
judged := sent.Add(2 * time.Minute)
|
||||
lead.FirstAt, carried.FirstAt, lead.Gate.Since = &sent, &sent, &sent
|
||||
lead.Gate.Verdict, lead.Gate.Why, lead.Gate.JudgedAt, lead.Gate.Kept = inventory.GatePassed,
|
||||
"healthy 3 times over 2m5s", &judged, true
|
||||
carried.Gate = nil
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Another walk's send reached anchor, which has not reported on it.
|
||||
if err := inv.RecordSent(ctx, nodeID(t, tm.open, "anchor"), "d-anchor-elsewhere",
|
||||
map[string]string{"m01": "c2", "m02": "c2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
advancePlans(ctx, tm.open)
|
||||
p = tm.plan(t)
|
||||
if p.State == inventory.PlanFailed {
|
||||
t.Fatalf("the plan failed after its gate passed: %s", p.Note)
|
||||
}
|
||||
if !reflect.DeepEqual(tm.sent, [][]string{{"anchor"}, {"laptop"}}) {
|
||||
t.Fatalf("sent %v: the rest once, as the pass said", tm.sent)
|
||||
}
|
||||
current, err := inv.CurrentBuilds(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range []string{"m01", "m02"} {
|
||||
if current[m].Commit != "c2" {
|
||||
t.Errorf("%s was put back to %s after its gate passed", m, current[m].Commit)
|
||||
}
|
||||
if failed, _ := inv.GateFailed(ctx, "build-"+m+"-2"); failed {
|
||||
t.Errorf("%s's build was marked failed at its gate after the gate passed", m)
|
||||
}
|
||||
}
|
||||
if g := p.Modules["m02"].Gate; g == nil || g.Verdict != inventory.GatePassed {
|
||||
t.Errorf("m02 did not take over m01's pass: %+v", g)
|
||||
}
|
||||
}
|
||||
@@ -55,9 +55,6 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
|
||||
return nil, err
|
||||
}
|
||||
argv, err := a.commandLine()
|
||||
if err == nil {
|
||||
err = terminalOnly(argv)
|
||||
}
|
||||
if len(a.misread) > 0 {
|
||||
// The table and the command line disagree: the verb reads an argument no caller can see
|
||||
// in its schema, so no caller could ever pass it.
|
||||
@@ -248,11 +245,6 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
if len(argv) == 0 {
|
||||
return nil, errors.New("command names no command")
|
||||
}
|
||||
// The generic verb only reads (novox/hq ADR 0266): what writes has a named verb that composes its own
|
||||
// line, or is the operator's at the controller's terminal.
|
||||
if err := commandReads(argv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The generic verb is no way round the hand-act log (novox/hq to-be 45 §7): a repair through
|
||||
// it says why, as it would through its own verb.
|
||||
if repair := repairingCommand(argv); repair != "" && !slices.ContainsFunc(argv, isWhyFlag) {
|
||||
@@ -805,16 +797,13 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
var argv []string
|
||||
switch {
|
||||
case on("clear"):
|
||||
argv = []string{"settings", "clear", str("module"), "--through-verb"}
|
||||
argv = []string{"settings", "clear", str("module")}
|
||||
case str("values") != "":
|
||||
argv = []string{"settings", "set", str("module"), str("values")}
|
||||
// What a set removes is refused unless meant (novox/hq ADR 0217).
|
||||
if on("replace") {
|
||||
argv = append(argv, "--replace")
|
||||
}
|
||||
// Through a verb, never places or accesses (novox/hq ADR 0266): the command refuses a change to
|
||||
// either when told the line came from a verb.
|
||||
argv = append(argv, "--through-verb")
|
||||
default:
|
||||
// Neither values nor clear: the layer as it stands, which is what a caller reads before
|
||||
// replacing it (novox/hq ADR 0217) — and with history, the layers it replaced.
|
||||
@@ -1074,8 +1063,7 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
var policy *heldAtTheTerminal
|
||||
if _, err := argvFor(verb, sampleArguments(v)); err != nil && !errors.As(err, &policy) {
|
||||
if _, err := argvFor(verb, sampleArguments(v)); err != nil {
|
||||
// **A row ahead of this binary is not a reason to go silent.**
|
||||
//
|
||||
// The row is the store's and a control plane follows it (novox/hq ADR 0154), so a verb
|
||||
@@ -1336,131 +1324,3 @@ func seatAnnouncement(handlers map[string]link.ToolHandler) micro.Info {
|
||||
Endpoints: endpoints,
|
||||
}
|
||||
}
|
||||
|
||||
// nodeReads are the `node` subcommands a verb may run: the ones that only read.
|
||||
var nodeReads = map[string]bool{"list": true, "show": true}
|
||||
|
||||
// flagsOnly says a command line's rest names no subcommand: empty, or beginning with a flag. For a command
|
||||
// with no subcommands every word is a flag, its value or a name it reads.
|
||||
func flagsOnly(rest []string) bool { return len(rest) == 0 || strings.HasPrefix(rest[0], "-") }
|
||||
|
||||
// subIn says the rest begins with one of these subcommands.
|
||||
func subIn(rest []string, subs ...string) bool {
|
||||
return len(rest) > 0 && slices.Contains(subs, rest[0])
|
||||
}
|
||||
|
||||
// commandReadForms are the command lines the generic `command` verb may run (novox/hq ADR 0266): **an allow
|
||||
// list of the ones that only read**, judged command by command. Anything else — every command that writes a
|
||||
// record, sends, builds, issues an account or a token, sets a key, accepts, rotates, recovers or exports a
|
||||
// secret — is refused, and a command added later is refused until it is judged a read. Writing has its named
|
||||
// verbs, which compose their own lines and are judged by terminalOnly; the rest is the operator's at the
|
||||
// controller's terminal.
|
||||
var commandReadForms = map[string]func(rest []string) bool{
|
||||
"status": flagsOnly, "version": flagsOnly, "help": flagsOnly, "seats": flagsOnly, "healers": flagsOnly,
|
||||
"hand-acts": flagsOnly, "durations": flagsOnly, "collection": flagsOnly, "images": flagsOnly,
|
||||
"artifacts": flagsOnly, "data": flagsOnly, "builds": flagsOnly, "queue": flagsOnly,
|
||||
// `plan <node>` previews a node's declaration; it sends nothing.
|
||||
"plan": func([]string) bool { return true },
|
||||
// `plans` lists and `plans <id>` shows one; `plans stop|close|go` acts.
|
||||
// Judged on every word, not the first: a flag before the subcommand (`plans --json go <id>`) still acts.
|
||||
"plans": func(r []string) bool {
|
||||
return !slices.ContainsFunc(r, func(w string) bool { return slices.Contains(plansActs, w) })
|
||||
},
|
||||
// `doctor` answers the last run, `probes` and `signals` describe; `doctor run` runs.
|
||||
"doctor": func(r []string) bool { return flagsOnly(r) || subIn(r, "probes", "signals") },
|
||||
"conditions": func(r []string) bool { return flagsOnly(r) || subIn(r, "list", "show", "history") },
|
||||
"node": func(r []string) bool { return subIn(r, "list", "show") },
|
||||
"module": func(r []string) bool { return subIn(r, "list") },
|
||||
"settings": func(r []string) bool { return subIn(r, "show", "preferences") },
|
||||
"retire": func(r []string) bool { return subIn(r, "list") },
|
||||
"cleanup": func(r []string) bool { return subIn(r, "list") },
|
||||
"delivery": func(r []string) bool { return subIn(r, "plan", "walks") },
|
||||
// `bus` alone says the bus's step; `bus upgrade` takes one.
|
||||
"bus": func(r []string) bool { return len(r) == 0 },
|
||||
// `mirrors` lists; --record and --confirm keep a mirror.
|
||||
"mirrors": func(r []string) bool {
|
||||
return flagsOnly(r) && !slices.ContainsFunc(r, func(w string) bool {
|
||||
return w == "--record" || w == "-record" || strings.HasPrefix(w, "--record=") || strings.HasPrefix(w, "-record=") ||
|
||||
w == "--confirm" || w == "-confirm" || strings.HasPrefix(w, "--confirm=")
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// plansActs are the `plans` subcommands that act on a walk; no other word of a plans line is one of them.
|
||||
var plansActs = []string{"go", "stop", "close", "retry"}
|
||||
|
||||
// heldAtTheTerminal is a refusal of policy (novox/hq ADR 0266): the verb is known and served, and this line is
|
||||
// the operator's at the controller's terminal. Never read as a verb this binary is behind on.
|
||||
type heldAtTheTerminal struct{ msg string }
|
||||
|
||||
func (e *heldAtTheTerminal) Error() string { return e.msg }
|
||||
|
||||
func terminalRefusal(format string, args ...any) error {
|
||||
return &heldAtTheTerminal{fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
// commandReads refuses a line the generic verb may not run, saying what it may.
|
||||
func commandReads(argv []string) error {
|
||||
if read, ok := commandReadForms[argv[0]]; ok && read(argv[1:]) {
|
||||
return nil
|
||||
}
|
||||
return terminalRefusal("%q is not a reading command, and the generic command verb only reads (novox/hq ADR 0266): "+
|
||||
"whoever may call a verb includes agents, and a line that writes, issues, sets a key or reveals a secret "+
|
||||
"would be theirs to run. Use the named verb for it, or run it at the controller's terminal. The verb may "+
|
||||
"run: %s. Nothing was done", strings.Join(argv, " "), commandReadNames())
|
||||
}
|
||||
|
||||
func commandReadNames() string {
|
||||
names := make([]string, 0, len(commandReadForms))
|
||||
for n := range commandReadForms {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return strings.Join(names, ", ") + " (each in its reading forms)"
|
||||
}
|
||||
|
||||
// terminalOnlyCommands are the commands no verb runs, whatever composed them (novox/hq ADR 0266): they set
|
||||
// the operator's key, issue a credential or a token that is answered to the caller, or accept, recover or
|
||||
// export a secret. Their answers or effects hand whoever calls them what the runtime's account holds.
|
||||
var terminalOnlyCommands = map[string]string{
|
||||
"operator": "the operator's key and credential",
|
||||
"identity": "the mesh's identity keys",
|
||||
"token": "a token a machine joins with, answered to the caller",
|
||||
"broker": "the bus's accounts",
|
||||
"api": "the controller's API keys",
|
||||
"licence": "the licences' secrets",
|
||||
}
|
||||
|
||||
// terminalOnly refuses, through any verb, a command that is the operator's at the controller's terminal
|
||||
// alone (novox/hq ADR 0266). **Every `node` subcommand that is not a read**: `node account` and
|
||||
// `node agent-account` above all. Whoever may call a verb includes agents, and an agent that named itself
|
||||
// the operator account, or cleared the agent account, would have the next send grant it root through the
|
||||
// sudo module's rule. An allow list, so a subcommand added later is refused until it is judged a read.
|
||||
func terminalOnly(argv []string) error {
|
||||
if len(argv) == 0 {
|
||||
return nil
|
||||
}
|
||||
if what, kept := terminalOnlyCommands[argv[0]]; kept {
|
||||
return terminalRefusal("%s is run at the controller's terminal only, never through a verb: it holds %s, and "+
|
||||
"whoever may call a verb includes agents (novox/hq ADR 0266). Nothing was done", argv[0], what)
|
||||
}
|
||||
// Of a secret's commands only rotation, which seals the new value to the machine that uses it.
|
||||
if argv[0] == "secret" && (len(argv) < 2 || argv[1] != "rotate") {
|
||||
return terminalRefusal("secret %s is run at the controller's terminal only, never through a verb: accepting, "+
|
||||
"recovering or exporting a secret hands it to whoever asks, and that includes agents (novox/hq ADR "+
|
||||
"0266). Nothing was done", strings.Join(argv[1:], " "))
|
||||
}
|
||||
if argv[0] != "node" {
|
||||
return nil
|
||||
}
|
||||
if len(argv) > 1 && nodeReads[argv[1]] {
|
||||
return nil
|
||||
}
|
||||
sub := "node"
|
||||
if len(argv) > 1 {
|
||||
sub += " " + argv[1]
|
||||
}
|
||||
return terminalRefusal("%s is run at the controller's terminal only, never through a verb: a node's accounts "+
|
||||
"decide who may become root on it (novox/hq ADR 0266). A verb may run node list and node show. "+
|
||||
"Nothing was done", sub)
|
||||
}
|
||||
|
||||
@@ -268,10 +268,10 @@ var accountedFlags = map[string]map[string]string{
|
||||
"self": "set by the verb from the repository's form: a path on the forge, or a URL",
|
||||
"dry-run": "withheld: a dry run answers only when the build ends, which a call cannot wait for; `command` reaches it",
|
||||
},
|
||||
"builds": {"n": "=limit"},
|
||||
"plans": {"n": "=limit", "what-if": "=repository"},
|
||||
"settings": {"through-verb": "set by the verb on every set and clear: places and accesses are the terminal's (novox/hq ADR 0266)"},
|
||||
"builds": {"n": "=limit"},
|
||||
"plans": {"n": "=limit", "what-if": "=repository"},
|
||||
// The machine's tunnel key, named as the verb's other arguments are (novox/hq ADR 0169).
|
||||
"token issue": {"overlay-key": "=overlay_key"},
|
||||
"durations": {
|
||||
"json": "set by the verb: the answer is data",
|
||||
"all": "withheld: every measurement of a fortnight is more than a call should carry; `command` reaches it",
|
||||
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"strings"
|
||||
@@ -109,25 +108,22 @@ func TestRotateTakesAProvisionOrAnOwnSecret(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// `token` answers a joining token to its caller, and whoever may call a verb includes agents: it is the
|
||||
// controller's terminal's alone (novox/hq ADR 0266), refused through the verb whatever it is given.
|
||||
func TestTokenIsRefusedThroughAVerb(t *testing.T) {
|
||||
for _, args := range []map[string]any{{"new": "laptop", "overlay_key": "k", "for": "2h"}, {"node": "ace"}} {
|
||||
argv, err := argvFor("token", args)
|
||||
if err == nil || !strings.Contains(err.Error(), "controller's terminal only") {
|
||||
t.Fatalf("token %v: %v %v", args, argv, err)
|
||||
}
|
||||
// `token` is `token issue` at a shell, with the machine's tunnel key (novox/hq ADR 0169).
|
||||
func TestTokenIssuesForAMachineAndItsTunnelKey(t *testing.T) {
|
||||
argv, err := argvFor("token", map[string]any{"new": "laptop", "overlay_key": "k", "for": "2h"})
|
||||
if err != nil || strings.Join(argv, " ") != "token issue --new laptop --overlay-key k --for 2h" {
|
||||
t.Fatalf("token: %v %v", argv, err)
|
||||
}
|
||||
}
|
||||
|
||||
// `settings` is `settings set|clear` at a shell, with the values passed inline (novox/hq issue 198).
|
||||
func TestSettingsSetsOrClearsALayer(t *testing.T) {
|
||||
argv, err := argvFor("settings", map[string]any{"module": "dnsmasq", "values": `{"a":1}`, "node": "ace"})
|
||||
if err != nil || strings.Join(argv, " ") != `settings set dnsmasq {"a":1} --through-verb --node ace` {
|
||||
if err != nil || strings.Join(argv, " ") != `settings set dnsmasq {"a":1} --node ace` {
|
||||
t.Fatalf("set on a machine: %v %v", argv, err)
|
||||
}
|
||||
argv, _ = argvFor("settings", map[string]any{"module": "dnsmasq", "clear": "true"})
|
||||
if strings.Join(argv, " ") != "settings clear dnsmasq --through-verb" {
|
||||
if strings.Join(argv, " ") != "settings clear dnsmasq" {
|
||||
t.Fatalf("clear for the mesh: %v", argv)
|
||||
}
|
||||
// Neither values nor clear reads the layer as it stands (novox/hq ADR 0217): what a caller reads
|
||||
@@ -241,20 +237,20 @@ func TestAJSONVerbsAnswerIsItsStandardOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// `command` is the generic verb: the command line as given, split as a shell would, nothing added
|
||||
// (novox/hq ADR 0154, ADR 0175). It once carried an operator's `node account g14 jochen` too; a node's
|
||||
// accounts are the controller's terminal's alone since ADR 0266 (TestNoVerbSetsANodesAccounts).
|
||||
// `command` is the generic verb: the command line as given, split as a shell would, nothing added —
|
||||
// so an operator's `node account g14 jochen` is one call through the console rather than a shell on
|
||||
// the control node (novox/hq ADR 0154, ADR 0175).
|
||||
func TestCommandRunsTheLineAsGiven(t *testing.T) {
|
||||
argv, err := argvFor("command", map[string]any{"command": "node show g14"})
|
||||
if err != nil || strings.Join(argv, " ") != "node show g14" {
|
||||
argv, err := argvFor("command", map[string]any{"command": "node account g14 jochen"})
|
||||
if err != nil || strings.Join(argv, " ") != "node account g14 jochen" {
|
||||
t.Fatalf("a plain line: %v %v", argv, err)
|
||||
}
|
||||
argv, err = argvFor("command", map[string]any{"command": `settings show 'dns masq' --node ace`})
|
||||
if err != nil || len(argv) != 5 || argv[2] != "dns masq" {
|
||||
argv, err = argvFor("command", map[string]any{"command": `settings set dnsmasq '{"a": "b c"}' --node ace`})
|
||||
if err != nil || len(argv) != 6 || argv[3] != `{"a": "b c"}` {
|
||||
t.Fatalf("a quoted word stays one word: %q %v", argv, err)
|
||||
}
|
||||
argv, err = argvFor("command", map[string]any{"command": `plan "the box" --json`})
|
||||
if err != nil || len(argv) != 3 || argv[1] != "the box" {
|
||||
argv, err = argvFor("command", map[string]any{"command": `node add "the box" --adopted`})
|
||||
if err != nil || len(argv) != 4 || argv[2] != "the box" {
|
||||
t.Fatalf("double quotes group: %q %v", argv, err)
|
||||
}
|
||||
if _, err := argvFor("command", map[string]any{"command": " "}); err == nil {
|
||||
@@ -359,59 +355,3 @@ func TestTheControllerAnnouncesTheVerbsItServes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The generic verb only reads (novox/hq ADR 0266): an allow list of reading forms, and every line that
|
||||
// writes, issues, sets a key or reveals a secret refused — the chain a review found ran through it: set the
|
||||
// operator's key to one the caller holds, rotate secrets sealed to it, open them.
|
||||
func TestTheCommandVerbOnlyReads(t *testing.T) {
|
||||
for _, line := range []string{
|
||||
"operator key set --replace k", "operator issue", "secret accept a b", "secret rotate a b c",
|
||||
"secret recover a", "secret export a", "token issue --new x", "identity show", "broker users",
|
||||
"api key", "licence show", "push anchor --why w", "assign novox m", "settings set m {}",
|
||||
"settings clear m", "module add f", "module check /etc", "module forget m", "module issue m --node a",
|
||||
"seat rename a b", "plans close p --why w", "plans go p", "plans retry p", "plans stop p",
|
||||
"plans --json go p", "plans -n 3 close p", "plans --what-if r retry p", "doctor run", "conditions silence c --why w",
|
||||
"retire approve x", "cleanup delete x", "delivery check", "delivery go x", "bus upgrade",
|
||||
"mirrors --record x", "mirrors --confirm", "hand-act record x --why y --cause z", "serve", "migrate",
|
||||
"prepare", "declare x", "overlay x", "facts", "merge-gate", "check-here", "build x", "rebuild x",
|
||||
"cancel x", "kill x", "clear x", "replay x", "pause", "resume", "pin a b", "unpin a", "take x",
|
||||
"converge", "adopt x", "rollout x", "upgrade x", "collect", "board", "builder", "ask x", "frobnicate",
|
||||
} {
|
||||
argv, err := argvFor("command", map[string]any{"command": line})
|
||||
var policy *heldAtTheTerminal
|
||||
if err == nil || !errors.As(err, &policy) {
|
||||
t.Errorf("%q ran as %v (%v); the generic verb only reads", line, argv, err)
|
||||
}
|
||||
}
|
||||
for _, line := range []string{
|
||||
"status --json", "version", "seats --json", "healers", "hand-acts --days 3", "durations", "collection",
|
||||
"images", "artifacts --collected", "data --machine a", "builds --log b", "queue", "plan ace --diff",
|
||||
"plans", "plans plan-1", "doctor", "doctor probes", "doctor signals", "conditions", "conditions list",
|
||||
"conditions show c", "conditions history", "node list", "node show ace", "module list",
|
||||
"settings show m", "settings preferences", "retire list", "cleanup list", "delivery plan --repository r",
|
||||
"delivery walks", "bus", "mirrors --json",
|
||||
} {
|
||||
if _, err := argvFor("command", map[string]any{"command": line}); err != nil {
|
||||
t.Errorf("%q, a read, was refused: %v", line, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What hands a caller a key, a credential or a secret is refused whichever verb composed it.
|
||||
func TestNoVerbSetsTheOperatorsKeyOrRevealsASecret(t *testing.T) {
|
||||
for _, argv := range [][]string{
|
||||
{"operator", "key", "set"}, {"operator", "issue"}, {"identity"}, {"token", "issue"}, {"broker", "users"},
|
||||
{"api"}, {"licence"}, {"secret", "export", "x"}, {"secret", "recover", "x"}, {"secret", "accept", "x"}, {"secret"},
|
||||
} {
|
||||
if err := terminalOnly(argv); err == nil {
|
||||
t.Errorf("%v passed", argv)
|
||||
}
|
||||
}
|
||||
if err := terminalOnly([]string{"secret", "rotate", "n", "m", "s"}); err != nil {
|
||||
t.Errorf("rotating seals to the machine that uses the secret, and stays a verb's: %v", err)
|
||||
}
|
||||
// A policy refusal is not a verb this binary is behind on: every verb is still served.
|
||||
if _, behind, err := seatToolHandlers(); err != nil || len(behind) != 0 {
|
||||
t.Fatalf("behind %v: %v", behind, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// Where root creates and owns a module's directories, and which of the machine's paths reach its container,
|
||||
// are the controller's terminal's alone (novox/hq ADR 0266): through the settings verb, a caller who set
|
||||
// `places` to /etc with an owner of its own would have the next send hand it /etc.
|
||||
|
||||
func TestTheSettingsVerbMarksEverySetAndClearAsAVerbs(t *testing.T) {
|
||||
for _, args := range []map[string]any{
|
||||
{"module": "plex", "values": `{"places":{"data":"/etc"}}`},
|
||||
{"module": "plex", "values": `{}`, "replace": "true", "node": "home"},
|
||||
{"module": "plex", "clear": "true"},
|
||||
} {
|
||||
argv, err := argvFor("settings", args)
|
||||
if err != nil || !strings.Contains(strings.Join(argv, " "), "--through-verb") {
|
||||
t.Fatalf("%v: %v %v", args, argv, err)
|
||||
}
|
||||
}
|
||||
// The generic verb never reaches settings set or clear at all.
|
||||
for _, line := range []string{"settings set plex {}", "settings clear plex"} {
|
||||
if _, err := argvFor("command", map[string]any{"command": line}); err == nil {
|
||||
t.Fatalf("command ran %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestATerminalSettingIsChangedOnlyAtTheTerminal(t *testing.T) {
|
||||
cases := []struct {
|
||||
before, after map[string]any
|
||||
want string
|
||||
}{
|
||||
{nil, map[string]any{"places": map[string]any{"data": "/etc"}}, "places"},
|
||||
{map[string]any{"accesses": map[string]any{"m": "/storage"}}, map[string]any{}, "accesses"},
|
||||
{map[string]any{"places": map[string]any{"d": "/srv/d"}}, nil, "places"},
|
||||
{map[string]any{"places": map[string]any{"d": "/srv/d"}, "a": 1.0},
|
||||
map[string]any{"places": map[string]any{"d": "/srv/d"}, "a": 2.0}, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := terminalSettingChanged(c.before, c.after); got != c.want {
|
||||
t.Errorf("%v → %v: %q, want %q", c.before, c.after, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Over the real stores: the verb's line is refused for places, the terminal's is taken, and a clear through
|
||||
// the verb of a layer that places a directory is refused too.
|
||||
func TestPlacesAreRefusedThroughTheVerbAndTakenAtTheTerminal(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
|
||||
Resources: []map[string]any{{"id": "data", "type": "directory", "mode": "0755"},
|
||||
{"id": "rc", "type": "file", "path": "/etc/notes.conf", "mode": "0644", "content": "x = ${setting:x}\n"}}})
|
||||
if _, err := assign(ctx, open, "laptop", "notes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set := func(through bool, values string) error {
|
||||
args := []string{"set", "notes", values, "--node", "laptop"}
|
||||
if through {
|
||||
args = append(args, "--through-verb")
|
||||
}
|
||||
return settingsCommand(ctx, args)
|
||||
}
|
||||
if err := set(true, `{"places":{"data":{"path":"/srv/notes","owner":"1000:1000"}}}`); err == nil ||
|
||||
!strings.Contains(err.Error(), "controller's terminal only") {
|
||||
t.Fatalf("places through the verb: %v", err)
|
||||
}
|
||||
if err := set(false, `{"places":{"data":{"path":"/srv/notes","owner":"1000:1000"}},"x":0}`); err != nil {
|
||||
t.Fatalf("places at the terminal: %v", err)
|
||||
}
|
||||
if err := set(true, `{"places":{"data":{"path":"/srv/notes","owner":"1000:1000"}},"x":1}`); err != nil {
|
||||
t.Fatalf("a verb may change another key and keep places as they are: %v", err)
|
||||
}
|
||||
if err := settingsCommand(ctx, []string{"clear", "notes", "--node", "laptop", "--through-verb"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "controller's terminal only") {
|
||||
t.Fatalf("a clear through the verb took places away: %v", err)
|
||||
}
|
||||
// And never at /etc, from anywhere.
|
||||
if err := set(false, `{"places":{"data":{"path":"/etc","owner":"1000:1000"}},"x":1}`); err == nil ||
|
||||
!strings.Contains(err.Error(), "/etc") {
|
||||
t.Fatalf("a place at /etc: %v", err)
|
||||
}
|
||||
// A line break in any setting is refused where it is kept.
|
||||
if err := set(false, `{"places":{"data":{"path":"/srv/notes","owner":"1000:1000"}},"x":"a\nPATH=/tmp"}`); err == nil ||
|
||||
!strings.Contains(err.Error(), "line break") {
|
||||
t.Fatalf("a line break: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -158,7 +158,7 @@ func TestTheVerbsCarryReadReplaceAndMove(t *testing.T) {
|
||||
}{
|
||||
{"settings", map[string]any{"module": "plex", "node": "home"}, []string{"settings", "show", "plex", "--node", "home"}},
|
||||
{"settings", map[string]any{"module": "plex", "history": "true"}, []string{"settings", "show", "plex", "--history"}},
|
||||
{"settings", map[string]any{"module": "plex", "values": "{}", "replace": "true"}, []string{"settings", "set", "plex", "{}", "--replace", "--through-verb"}},
|
||||
{"settings", map[string]any{"module": "plex", "values": "{}", "replace": "true"}, []string{"settings", "set", "plex", "{}", "--replace"}},
|
||||
{"push", map[string]any{"node": "home", "move": "plex", "why": "w"},
|
||||
[]string{"push", "home", "--wait", "0", "--move", "plex", "--why", "w"}},
|
||||
{"push", map[string]any{"why": "w"}, []string{"push", "--behind", "--wait", "0", "--why", "w"}},
|
||||
|
||||
@@ -35,4 +35,4 @@ require (
|
||||
// committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and
|
||||
// fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without
|
||||
// `go mod vendor` fails loudly, at once, everywhere.
|
||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261008195316-9a85dffc11a2
|
||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac
|
||||
|
||||
@@ -4,16 +4,6 @@ git.novox.be/novox/mesh-host v0.0.0-20261007120832-bdd44154ccac h1:KvnKtJ2rWeIE/
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261007120832-bdd44154ccac/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac h1:yLtFS0pDCCqIE9Zx8hgXEFG9fUWzf8L9WQoKV+Amk1E=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261008163010-8390fab5cb35 h1:3uag/9Tv4Y3ippY5Yr1rIIBh23Ur9RbhzOB4CLbKz0I=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261008163010-8390fab5cb35/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261008183646-5fc37b44a94e h1:+XxiuXJqWj7ZcGMB2b/WGPsC8zpmXgmwXnBvjw9C/CM=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261008183646-5fc37b44a94e/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261008185244-76f3ca12b8b8 h1:T4Bu9ymmcNC0x57EnDkjfZlJ9dvEeK8BRuHZTqw+uuI=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261008185244-76f3ca12b8b8/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261008191932-c74cf16b755e h1:oQofUhCNm0m4+pVASxvErqisEOMTkOyWjTAfGqt2lHA=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261008191932-c74cf16b755e/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261008195316-9a85dffc11a2 h1:hYRCYzJi97QC8l3SMy6v40bc0wqd9Ms54H9jYR0Bei0=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261008195316-9a85dffc11a2/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The account agents run as (novox/hq ADR 0266): a module names it as a machine fact — the agent account
|
||||
// where the node names one, the operator's otherwise — and asks the node-engine to judge it never to become
|
||||
// root only where it is the agents' own.
|
||||
|
||||
func TestTheAgentAccountFactFallsBackToTheOperatorAndIsNeverRootOnlyWhenItsOwn(t *testing.T) {
|
||||
facts := machineFacts(Resolution{Node: "anchor", Account: "ops"}, nil, "")
|
||||
if facts["agent-account"] != "ops" || facts["agent-home"] != "/home/ops" || facts["agent-root"] != "" {
|
||||
t.Errorf("with no agent account named, agents run as the operator: %v", facts)
|
||||
}
|
||||
facts = machineFacts(Resolution{Node: "anchor", Account: "ops", AccountHome: "/srv/ops"}, nil, "")
|
||||
if facts["agent-home"] != "/srv/ops" {
|
||||
t.Errorf("the operator's stated home is the agent's home when they are one account: %v", facts)
|
||||
}
|
||||
facts = machineFacts(Resolution{Node: "anchor", Account: "ops", AgentAccount: "agent"}, nil, "")
|
||||
if facts["agent-account"] != "agent" || facts["agent-home"] != "/home/agent" || facts["agent-root"] != RootNever {
|
||||
t.Errorf("a named agent account is the agents', never root: %v", facts)
|
||||
}
|
||||
if facts["account"] != "ops" {
|
||||
t.Errorf("the operator account is still the operator's: %v", facts)
|
||||
}
|
||||
facts = machineFacts(Resolution{Node: "anchor", AgentAccount: "agent", AgentAccountHome: "/var/lib/agent"}, nil, "")
|
||||
if facts["agent-home"] != "/var/lib/agent" || facts["agent-root"] != RootNever {
|
||||
t.Errorf("an agent account with a stated home on a machine with no operator: %v", facts)
|
||||
}
|
||||
if _, has := machineFacts(Resolution{Node: "anchor"}, nil, "")["agent-account"]; has {
|
||||
t.Error("a machine with no account at all names an agent account")
|
||||
}
|
||||
}
|
||||
|
||||
// The agent's module, in the shape the catalogue's declares it: the account, never root where it is its
|
||||
// own; its directory under that home, owned by it.
|
||||
const agentModule = `{"module": "agent", "version": "1", "resources": [
|
||||
{"id": "account", "type": "user", "name": "${machine:agent-account}", "root": "${machine:agent-root}"},
|
||||
{"id": "home", "type": "directory", "path": "${machine:agent-home}/.agent", "mode": "0700",
|
||||
"owner": "${machine:agent-account}"}
|
||||
]}`
|
||||
|
||||
func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(agentModule))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
compose := func(r Resolution, with Rendering) (user, home map[string]any) {
|
||||
t.Helper()
|
||||
r.Node, r.Modules = "anchor", []Manifest{m}
|
||||
out, err := r.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fileNamed(out, "agent.account"), fileNamed(out, "agent.home")
|
||||
}
|
||||
|
||||
user, home := compose(Resolution{Account: "ops", AgentAccount: "agent"}, Rendering{JudgesRoot: true})
|
||||
if user["name"] != "agent" || user[RootField] != RootNever {
|
||||
t.Errorf("an engine that judges root is sent the agent account never to become root: %v", user)
|
||||
}
|
||||
if home["path"] != "/home/agent/.agent" || home["owner"] != "agent" {
|
||||
t.Errorf("the agent's directory is under its own home, its own: %v", home)
|
||||
}
|
||||
|
||||
user, _ = compose(Resolution{Account: "ops", AgentAccount: "agent"}, Rendering{})
|
||||
if _, sent := user[RootField]; sent || user["name"] != "agent" {
|
||||
t.Errorf("an older engine, which parses strictly, is sent root: %v", user)
|
||||
}
|
||||
|
||||
user, home = compose(Resolution{Account: "ops"}, Rendering{JudgesRoot: true})
|
||||
if _, sent := user[RootField]; sent || user["name"] != "ops" {
|
||||
t.Errorf("where agents run as the operator, root asserts nothing and is not sent: %v", user)
|
||||
}
|
||||
if home["path"] != "/home/ops/.agent" || home["owner"] != "ops" {
|
||||
t.Errorf("with no agent account, the agent's directory is the operator's: %v", home)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRuntimeIsToldTheAgentAccount(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
|
||||
envOf := func(r Resolution) map[string]string {
|
||||
t.Helper()
|
||||
r.Node, r.Modules = "anchor", []Manifest{aToolsModule(t, "nftables", "tools/index.js"), theRuntime(t)}
|
||||
out, err := r.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
||||
if process == nil {
|
||||
t.Fatal("no runtime process was composed")
|
||||
}
|
||||
return process["env"].(map[string]string)
|
||||
}
|
||||
env := envOf(Resolution{Account: "ops", AgentAccount: "agent"})
|
||||
if env[RuntimeAgentAccount] != "agent" || env[RuntimeAgentHome] != "/home/agent" || env[RuntimeOperatorAccount] != "ops" {
|
||||
t.Errorf("the runtime is not told whom agents run as: %v", env)
|
||||
}
|
||||
env = envOf(Resolution{Account: "ops"})
|
||||
if env[RuntimeAgentAccount] != "ops" || env[RuntimeAgentHome] != "/home/ops" {
|
||||
t.Errorf("with no agent account, agents run as the operator: %v", env)
|
||||
}
|
||||
env = envOf(Resolution{})
|
||||
if _, set := env[RuntimeAgentAccount]; set {
|
||||
t.Errorf("a machine with no account names an agent account: %v", env)
|
||||
}
|
||||
if problems := bundleEnvProblems("x", Artifact{Name: "b", Kind: ArtifactBundle, Loads: []string{"x"},
|
||||
Env: map[string]string{RuntimeAgentAccount: "me"}}); len(problems) == 0 {
|
||||
t.Error("a bundle may tell the runtime whom agents run as")
|
||||
}
|
||||
}
|
||||
|
||||
// No placement and no access at the machine's own system or the mesh's state, however it is spelled (novox/hq
|
||||
// ADR 0266); a module's own place elsewhere is taken.
|
||||
func TestAPlacementOrAnAccessAtTheMachinesOwnIsRefused(t *testing.T) {
|
||||
m := Manifest{Module: "notes", Resources: []map[string]any{{"id": "data", "type": "directory"}},
|
||||
Accesses: []Access{{ID: "media"}}}
|
||||
for _, path := range []string{"/", "/etc", "/etc/sudoers.d", "/usr/bin", "/root", "/var/lib", "/home",
|
||||
"/var/lib/mesh/x", "/var/lib/mesh-host", "/srv/../etc", "/proc/1", "/dev"} {
|
||||
layers := []Layer{{From: "laptop", Values: map[string]any{PlacesSetting: map[string]any{"data": path}}}}
|
||||
if _, err := Places(m, layers); err == nil {
|
||||
t.Errorf("a place at %s was taken", path)
|
||||
}
|
||||
layers = []Layer{{From: "laptop", Values: map[string]any{AccessesSetting: map[string]any{"media": path}}}}
|
||||
if _, err := AccessPlaces(m, layers); err == nil {
|
||||
t.Errorf("an access at %s was taken", path)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/srv/notes", "/mnt/plex/data", "/storage/media", "/home/restic", "/var/lib/notes/data"} {
|
||||
layers := []Layer{{From: "laptop", Values: map[string]any{PlacesSetting: map[string]any{"data": path}}}}
|
||||
if got, err := Places(m, layers); err != nil || got["data"].Path != path {
|
||||
t.Errorf("a place at %s: %v %v", path, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A line break or a NUL in any string of any setting is refused, at any depth; PEM blocks alone may hold lines.
|
||||
func TestASettingHoldsOneLine(t *testing.T) {
|
||||
m := Manifest{Module: "mailu"}
|
||||
for _, v := range []any{"a\nDEBUG=1", "a\rb", "a\x00b", map[string]any{"k": []any{"ok", "x\ny"}},
|
||||
map[string]any{"k\nx": "v"}} {
|
||||
if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v": v}}}, false); err == nil ||
|
||||
!strings.Contains(err.Error(), "line break") {
|
||||
t.Errorf("%q: %v", v, err)
|
||||
}
|
||||
}
|
||||
pem := "-----BEGIN CERTIFICATE-----\nMIIBeDCCAR2gAwIBAgIQ\n-----END CERTIFICATE-----\n"
|
||||
if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"root": pem}}}, false); err != nil {
|
||||
t.Errorf("a PEM block: %v", err)
|
||||
}
|
||||
if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"root": pem + "PATH=/tmp evil\n"}}}, false); err == nil {
|
||||
t.Error("a PEM block with a line of something else after it was taken")
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -241,31 +241,6 @@ type Rendering struct {
|
||||
// refuses a field it does not know, whole — so to it the field is not sent, and what it runs is
|
||||
// judged by liveness alone.
|
||||
ReadsHealth bool
|
||||
|
||||
// JudgesRoot says this machine's node-engine judges a user's declared `root` (novox/hq ADR 0266: its
|
||||
// statement's contract is link.RootContract or later). To an older, strict engine the field is not
|
||||
// sent, and the account it names is not judged — which the self-check says, as not judged.
|
||||
JudgesRoot bool
|
||||
}
|
||||
|
||||
// RootField is a user resource's field saying the account must never become root without a person
|
||||
// (novox/hq ADR 0266).
|
||||
const RootField = "root"
|
||||
|
||||
// rootInto composes a user's `root` for the node-engine: taken away when it asserts nothing (empty — a
|
||||
// machine where agents run as the operator) or when the engine is older than the field and parses
|
||||
// strictly; kept as "never" otherwise.
|
||||
func rootInto(resource map[string]any, with Rendering) {
|
||||
if resource["type"] != "user" {
|
||||
return
|
||||
}
|
||||
value, has := resource[RootField]
|
||||
if !has {
|
||||
return
|
||||
}
|
||||
if s, _ := value.(string); s == "" || !with.JudgesRoot {
|
||||
delete(resource, RootField)
|
||||
}
|
||||
}
|
||||
|
||||
// machinePort is where a module's port lives on this machine, or the port itself when the mesh has
|
||||
@@ -1005,9 +980,6 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
// How it is ready, in the node-engine's words: its endpoint as the port this machine
|
||||
// published it on — or not sent at all to an engine older than the field (ADR 0240).
|
||||
healthInto(copied, m, with)
|
||||
// And a user's `root` (novox/hq ADR 0266): sent only when it asserts something, to an engine
|
||||
// that judges it.
|
||||
rootInto(copied, with)
|
||||
// The account's environment and every module's shell code, where this module holds the
|
||||
// seat that places them (novox/hq ADR 0203, ADR 0204). Gathered from every module on
|
||||
// the node, as the jails are, and **last of every placeholder pass**: shell code is a
|
||||
|
||||
@@ -78,47 +78,9 @@ func machineFacts(r Resolution, names map[string]string, meshRange string) map[s
|
||||
out["account"] = r.Account
|
||||
out["account-home"] = accountHomeOf(r.Account, r.AccountHome)
|
||||
}
|
||||
// The account agents run as here, and whether it must never become root (novox/hq ADR 0266). The agent
|
||||
// account where the node names one; the operator account otherwise, so a module writing the agent's
|
||||
// home names one fact on every machine. `agent-root` is "never" only for an account of the agents' own:
|
||||
// the user resource naming it then asks the node-engine to judge it, and on a machine where agents run
|
||||
// as the operator it is empty, asserting nothing — the operator's account may become root there.
|
||||
if agent, home := r.agentAccount(); agent != "" {
|
||||
out["agent-account"] = agent
|
||||
out["agent-home"] = home
|
||||
out["agent-root"] = ""
|
||||
if r.AgentAccount != "" {
|
||||
out["agent-root"] = RootNever
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RootNever is what a user resource's `root` says of an account that must never become root without a
|
||||
// person (novox/hq ADR 0266); the node-engine judges it.
|
||||
const RootNever = "never"
|
||||
|
||||
// agentAccount is the account agents run as on this machine and its home: the agent account when the node
|
||||
// names one (novox/hq ADR 0266), else the operator account; empty when neither is known.
|
||||
func (r Resolution) agentAccount() (string, string) {
|
||||
if r.AgentAccount != "" {
|
||||
return r.AgentAccount, agentHomeOf(r.AgentAccount, r.AgentAccountHome)
|
||||
}
|
||||
if r.Account != "" {
|
||||
return r.Account, accountHomeOf(r.Account, r.AccountHome)
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// agentHomeOf is where the agent account's home is: what was stored, or /home/<account>. Never /root: the
|
||||
// agent account is never root.
|
||||
func agentHomeOf(account, home string) string {
|
||||
if home != "" {
|
||||
return home
|
||||
}
|
||||
return "/home/" + account
|
||||
}
|
||||
|
||||
// accountHomeOf is where an account's home is: what was stored, or the derived default — /root for
|
||||
// root, /home/<account> otherwise. The one place the default is written, so a fact and the store
|
||||
// cannot disagree about it.
|
||||
@@ -143,10 +105,8 @@ func machineInto(resource map[string]any, facts map[string]string, module string
|
||||
// (novox/hq to-be 29), the same reason its content names ${machine:address}. And the name a
|
||||
// `user` shape sets the login shell of, and the user a user-scoped unit or a process runs as:
|
||||
// the shell module makes the operator's account its holder's login shell, and the desktop's
|
||||
// watchers run as that account (novox/hq ADR 0176, ADR 0177) — neither can name the person. And a
|
||||
// user's `root`: the agent's module declares the account agents run as with ${machine:agent-root},
|
||||
// "never" only where that account is the agents' own (novox/hq ADR 0266).
|
||||
for _, field := range []string{"path", "owner", "content", "name", "user", "root"} {
|
||||
// watchers run as that account (novox/hq ADR 0176, ADR 0177) — neither can name the person.
|
||||
for _, field := range []string{"path", "owner", "content", "name", "user"} {
|
||||
s, ok := resource[field].(string)
|
||||
if !ok {
|
||||
continue
|
||||
|
||||
@@ -2,7 +2,6 @@ package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -45,33 +44,6 @@ type Placement struct {
|
||||
|
||||
var ownerShape = regexp.MustCompile(`^[0-9]+:[0-9]+$`)
|
||||
|
||||
// systemTrees are where no placement and no access may be: the machine's own system, and the node-engine's
|
||||
// and the tool runner's state (novox/hq ADR 0266). A placed directory is created and chowned by the
|
||||
// node-engine as root, and an access is mounted into a container: a place at /etc, owned by an account a
|
||||
// caller names, hands that account the machine. Refused at or below each of these.
|
||||
var systemTrees = []string{"/etc", "/usr", "/boot", "/root", "/proc", "/sys", "/dev", "/run", "/bin", "/sbin",
|
||||
"/lib", "/lib64", "/var/lib/mesh", "/var/lib/mesh-host", "/var/lib/mesh-bus-conf"}
|
||||
|
||||
// systemRoots are directories a placement may be below but never be: each holds the whole machine's, or
|
||||
// every module's or every person's, directories.
|
||||
var systemRoots = []string{"/", "/var", "/var/lib", "/home", "/mnt", "/srv", "/opt", "/storage", "/data", "/tmp", "/var/tmp"}
|
||||
|
||||
// systemPath says why a path is the machine's own and no placement's, or "".
|
||||
func systemPath(path string) string {
|
||||
clean := filepath.Clean(path)
|
||||
for _, root := range systemRoots {
|
||||
if clean == root {
|
||||
return clean + " is a whole tree of the machine's"
|
||||
}
|
||||
}
|
||||
for _, tree := range systemTrees {
|
||||
if clean == tree || strings.HasPrefix(clean, tree+"/") {
|
||||
return clean + " is in " + tree + ", the machine's own or the mesh's state"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// accessRef is how a module names one of its accesses: ${access:<id>}.
|
||||
var accessRef = regexp.MustCompile(`\$\{access:([a-z0-9][a-z0-9-]*)\}`)
|
||||
|
||||
@@ -131,11 +103,7 @@ func Places(m Manifest, layers []Layer) (map[string]Placement, error) {
|
||||
if !strings.HasPrefix(p.Path, "/") {
|
||||
return nil, fmt.Errorf("%s places %q at %q, which is not an absolute path", m.Module, id, p.Path)
|
||||
}
|
||||
p.Path = filepath.Clean(p.Path)
|
||||
if why := systemPath(p.Path); why != "" {
|
||||
return nil, fmt.Errorf("%s places %q at %s: %s, and the node-engine would create and own it as "+
|
||||
"root (novox/hq ADR 0266)", m.Module, id, p.Path, why)
|
||||
}
|
||||
p.Path = strings.TrimRight(p.Path, "/")
|
||||
out[id] = p
|
||||
}
|
||||
}
|
||||
@@ -179,12 +147,7 @@ func AccessPlaces(m Manifest, layers []Layer) (map[string]string, error) {
|
||||
return nil, fmt.Errorf("%s places the access %q at %v, which is not an absolute path",
|
||||
m.Module, id, body)
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
if why := systemPath(path); why != "" {
|
||||
return nil, fmt.Errorf("%s places the access %q at %s: %s, and an access is mounted into the "+
|
||||
"module's container (novox/hq ADR 0266)", m.Module, id, path, why)
|
||||
}
|
||||
out[id] = path
|
||||
out[id] = strings.TrimRight(path, "/")
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -209,68 +209,6 @@ func deepCopy(in map[string]any) map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
// settingsHoldOneLine refuses a line break or a NUL in any string of any setting, for every module, at any
|
||||
// depth: a key or a value, in an object or a list (novox/hq ADR 0266). A value is substituted into env and
|
||||
// configuration files the node-engine writes as root (an app's .env, a logind drop-in), and a line break
|
||||
// there is a directive of the caller's own; a NUL ends a string early wherever C reads it. Judged where a
|
||||
// setting is kept and again where it is composed, so a stored value with one costs its module its place
|
||||
// and says which key. One shape is let through: PEM blocks alone (a certificate authority's root handed to a
|
||||
// provider), whose lines are base64 between BEGIN and END. Anything else that must hold lines is the
|
||||
// module's own file, not a setting.
|
||||
func settingsHoldOneLine(module string, layers []Layer) error {
|
||||
for _, layer := range layers {
|
||||
for _, key := range sortedKeysAny(layer.Values) {
|
||||
if at := lineBreakIn(layer.Values[key], key); at != "" {
|
||||
return fmt.Errorf("%s: the setting %s in %q holds a line break or a NUL, which a file it is written "+
|
||||
"into would read as a directive of its own (novox/hq ADR 0266); a setting is one line",
|
||||
module, at, layer.From)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pemShape is the one value with lines a setting may hold: PEM blocks and nothing else — a certificate
|
||||
// authority's root the operator hands a provider is one. Its lines are base64 between BEGIN and END: no
|
||||
// space, quote, dot or underscore, so no path, option or command — at most a padded line an env file would
|
||||
// read as an empty assignment, which names no program.
|
||||
var pemShape = regexp.MustCompile(`^(-----BEGIN [A-Z0-9 ]+-----\n([A-Za-z0-9+/]{1,76}={0,2}\n)+-----END [A-Z0-9 ]+-----\n?)+$`)
|
||||
|
||||
// lineBreakIn is the path of the first string under v holding \n, \r or NUL, or "".
|
||||
func lineBreakIn(v any, at string) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if strings.ContainsAny(t, "\n\r\x00") && !pemShape.MatchString(t) {
|
||||
return at
|
||||
}
|
||||
case map[string]any:
|
||||
for _, k := range sortedKeysAny(t) {
|
||||
if strings.ContainsAny(k, "\n\r\x00") {
|
||||
return at + "." + strings.ToValidUTF8(strings.NewReplacer("\n", "\\n", "\r", "\\r", "\x00", "\\0").Replace(k), "?")
|
||||
}
|
||||
if found := lineBreakIn(t[k], at+"."+k); found != "" {
|
||||
return found
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for i, e := range t {
|
||||
if found := lineBreakIn(e, fmt.Sprintf("%s[%d]", at, i)); found != "" {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sortedKeysAny(m map[string]any) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// UnusedSettings names settings that reach nothing.
|
||||
//
|
||||
// Somebody who sets a key on a module with nothing mergeable, or misspells one, has changed
|
||||
@@ -467,9 +405,6 @@ var networkName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
|
||||
// refused where it is stored (SetSettings, with UnusedSettings) and said where a plan is read,
|
||||
// and never costs a module its place.
|
||||
func JudgeSettings(m Manifest, layers []Layer, adopted bool) error {
|
||||
if err := settingsHoldOneLine(m.Module, layers); err != nil {
|
||||
return err
|
||||
}
|
||||
// With no layers too: a definition may ask for a setting nobody made — an access placed by
|
||||
// nobody, a file's ${setting:…} nothing sets — and that is the same statement, missing.
|
||||
if _, err := GivenPorts(m, layers); err != nil {
|
||||
|
||||
@@ -29,9 +29,11 @@ const MountsSeat = "node-mounts"
|
||||
func nfsServerVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "exports", Description: "Every share this machine exports: its name, its path, read-write or " +
|
||||
"read-only, the owner every client is mapped to (uid and gid), the clients it is exported to (the " +
|
||||
"private network's range), and whether the kernel holds it now. Also the exports found that are " +
|
||||
"not the mesh's: a dataset's sharenfs property, a line in /etc/exports.",
|
||||
"read-only, the owner every client is mapped to (uid and gid), each node it is exported to with that " +
|
||||
"node's private address and access (only the nodes the server grants it to and that ask for it), " +
|
||||
"what is granted and not asked for or asked for and not granted, and whether the kernel holds it " +
|
||||
"now. Also the exports found that are not the mesh's: a dataset's sharenfs property, a line in " +
|
||||
"/etc/exports.",
|
||||
Input: schema(map[string]string{}, nil),
|
||||
Replaces: []string{"exportfs -v", "cat /etc/exports", "zfs get sharenfs"}},
|
||||
{Name: "clients", Description: "Which machines have mounted which share now, as the NFS server " +
|
||||
@@ -39,11 +41,12 @@ func nfsServerVerbs() []Verb {
|
||||
Input: schema(map[string]string{}, nil),
|
||||
Replaces: []string{"ss -tn sport = :2049", "cat /proc/fs/nfsd/clients/*/info"}},
|
||||
{Name: "test", Description: "Whether this machine exports one share for the mesh now, and whether its " +
|
||||
"NFS service is up; with an address, also whether that address is inside the private network's " +
|
||||
"range the share is exported to. What a machine mounting the share asks before it mounts.",
|
||||
"NFS service is up; with an address, also whether the share is exported to that address: a node's " +
|
||||
"own private address, when the server grants it the share and the node asks for it. What a machine " +
|
||||
"mounting the share asks before it mounts.",
|
||||
Input: schema(map[string]string{
|
||||
"share": "the share, by its name",
|
||||
"address": "a client's address on the private network (optional)",
|
||||
"address": "a node's private address, to ask whether the share is exported to it (optional)",
|
||||
}, []string{"share"}),
|
||||
Replaces: []string{"showmount -e"}},
|
||||
{Name: "reload", Description: "Have the kernel read this machine's export files again now (exportfs " +
|
||||
|
||||
@@ -159,3 +159,21 @@ func TestReloadSaysWhatItDoes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A share is exported to each node the server grants it to and that asks for it, at that node's own
|
||||
// address (novox/hq ADR 0263 rule 5) — never to the private network's whole range. The verbs that
|
||||
// describe the export say so, and do not promise the range.
|
||||
func TestTheExportVerbsDescribePerNodeAddresses(t *testing.T) {
|
||||
s, _ := SeatNamed(NFSServerSeat)
|
||||
for _, v := range s.Serves {
|
||||
if v.Name != "exports" && v.Name != "test" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(v.Description, "private network's range") {
|
||||
t.Errorf("%s still describes the export as the private network's range: %s", v.Name, v.Description)
|
||||
}
|
||||
if !strings.Contains(v.Description, "address") {
|
||||
t.Errorf("%s does not say the export is to each node's address: %s", v.Name, v.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,9 +237,9 @@ var ControllerVerbs = []Verb{
|
||||
"node": "the machine that runs the module",
|
||||
"module": "the module's name",
|
||||
}, []string{"node", "module"})},
|
||||
{Name: "token", Description: "Refused through a verb since novox/hq ADR 0266: the one-time token a machine " +
|
||||
"joins with is answered to its caller, and whoever may call a verb includes agents. Issue it at the " +
|
||||
"controller's terminal: `mesh-controller token issue --new <name> --overlay-key <public half>`.",
|
||||
{Name: "token", Description: "Issue a one-time token for a machine to join with. Give the public half of the " +
|
||||
"tunnel key the machine made (`nox-mesh-host key`): the machine is given its address and made a peer of " +
|
||||
"the hub, and joins through the tunnel. The token is shown once, in the answer.",
|
||||
Input: schema(map[string]string{
|
||||
"node": "a machine the mesh already has a record for",
|
||||
"new": "or the name of a machine to create the record for",
|
||||
@@ -267,14 +267,10 @@ var ControllerVerbs = []Verb{
|
||||
"list": "\"preferences\": every module's preferences — key, default and why — and the value on each " +
|
||||
"machine it is assigned to with where it comes from; module and node narrow it (novox/hq ADR 0262)",
|
||||
}, nil, "clear", "replace", "history")},
|
||||
{Name: "command", Description: "Run one reading command line of the controller's own, as you would type it at " +
|
||||
"its shell — `node show ace`, `module list`, `plans`, `conditions show <key>` — and answer what it " +
|
||||
"printed. The generic verb beside the named ones (novox/hq ADR 0154), and since ADR 0266 it only reads: " +
|
||||
"status, version, help, seats, healers, hand-acts, durations, collection, images, artifacts, data, builds, " +
|
||||
"queue, plan, plans (not stop/close/go), doctor (not run), conditions list/show/history, node list/show, " +
|
||||
"module list, settings show/preferences, retire list, cleanup list, delivery plan/walks, bus, mirrors (not " +
|
||||
"--record/--confirm). What writes has its named verb; what sets a key, issues a credential or a token, or " +
|
||||
"accepts, recovers or exports a secret is the controller's terminal's alone.",
|
||||
{Name: "command", Description: "Run one command line of the controller's own, as you would type it at its " +
|
||||
"shell — `node account g14 jochen`, `node show ace`, `module list` — and answer what it printed. The " +
|
||||
"generic verb beside the named ones (novox/hq ADR 0154): everything the binary can do, without a verb " +
|
||||
"per command. Any node may call any tool (ADR 0175), so nothing is held back here.",
|
||||
Input: schema(map[string]string{
|
||||
"command": "the command line, as the controller's binary takes it; quotes group a word with spaces",
|
||||
}, []string{"command"})},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -31,9 +31,6 @@ type ResourceHealth struct {
|
||||
// Account is the account whose own service manager runs it, or the account a resource of kind account
|
||||
// is (novox/hq ADR 0254).
|
||||
Account string `json:"account,omitempty"`
|
||||
// Root is "never" on an account verdict that judged whether the account can become root without a
|
||||
// person (novox/hq ADR 0266).
|
||||
Root string `json:"root,omitempty"`
|
||||
}
|
||||
|
||||
// NodeHealth is a machine's newest statement, as kept.
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
-- A node names the account its agents run as (novox/hq ADR 0266).
|
||||
--
|
||||
-- On the control node every agent session ran as the operator's account, which may become root without
|
||||
-- a password: any agent there could become root without a person. The decision is an account of the
|
||||
-- agents' own, without sudo, beside the operator's, who keeps theirs. Stated by the operator at the
|
||||
-- controller's terminal, like the operator account (migration 0036), and never by a verb or a setting,
|
||||
-- so no agent can change which account it is.
|
||||
--
|
||||
-- Empty rather than null, as the operator account is: empty is a real state, "agents run as the
|
||||
-- operator's account here" — a workstation's today. The home is stored only when it is not
|
||||
-- /home/<account>; empty means derive it.
|
||||
alter table node add column agent_account text not null default '';
|
||||
alter table node add column agent_account_home text not null default '';
|
||||
+2
-118
@@ -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,
|
||||
|
||||
@@ -420,20 +420,6 @@ const LivenessContract = 1
|
||||
// because an older one parses strictly and would refuse the whole declaration for it.
|
||||
const ReadinessContract = 2
|
||||
|
||||
// RootContract is the statement of an engine that also judges a user's declared `root` (novox/hq ADR 0266):
|
||||
// whether an account declared never to become root without a person can — uid 0, a group that grants root,
|
||||
// a sudo rule, a secret of the mesh it may read. Only to such an engine is the field sent: an older one
|
||||
// parses strictly and would refuse the whole declaration for it.
|
||||
const RootContract = 3
|
||||
|
||||
// ReasonRoot starts the reason of an account verdict that found a way to root (ADR 0266); the node-engine's
|
||||
// own words (mesh-host internal/accounts ReasonRoot).
|
||||
const ReasonRoot = "can become root without a person"
|
||||
|
||||
// RootNever is the value of a user's `root`, and of a verdict's Root, that the account must never become
|
||||
// root without a person (ADR 0266).
|
||||
const RootNever = "never"
|
||||
|
||||
// Health is one statement of a machine's long-running resources (to-be 48 §4): in every report, as the
|
||||
// event HealthSubject between reports on each change, and again every minute while one is not healthy.
|
||||
// The node-engine's own (mesh-host internal/link Health); a test on each side holds the field names.
|
||||
@@ -556,10 +542,6 @@ type ResourceHealth struct {
|
||||
// manager, and the account itself for a resource of kind KindAccount (novox/hq ADR 0254). Empty from an
|
||||
// engine older than that, and for anything the machine's own manager or runtime runs.
|
||||
Account string `json:"account,omitempty"`
|
||||
// Root is "never" on a verdict of kind KindAccount whose account is declared never to become root
|
||||
// without a person (novox/hq ADR 0266): the engine judged that too, and a healthy verdict says it cannot.
|
||||
// Empty from an engine older than RootContract, and on every other verdict.
|
||||
Root string `json:"root,omitempty"`
|
||||
}
|
||||
|
||||
// HealthSaid is the health event's body: the machine and its statement. The machine is read from the
|
||||
|
||||
-20
@@ -383,25 +383,8 @@ type User struct {
|
||||
// has it not. On the account rather than on the unit, because it is the account's: two units of
|
||||
// one account cannot disagree about it, and undeclaring one of them must not stop the other.
|
||||
Linger *bool `json:"linger,omitempty"`
|
||||
|
||||
// Root says whether this account may become root without a person (novox/hq ADR 0266). "never"
|
||||
// is the agents' own account: the login an agent session runs as on a machine where it must not
|
||||
// reach root by itself. Empty asserts nothing, as Shell's does.
|
||||
//
|
||||
// **A statement the engine judges, never one it acts on.** The apply gives an account it creates
|
||||
// no password, no sudo rule and no group beyond those declared, as it always has, and takes none
|
||||
// away from one it finds: a sudo rule or a group granted by hand is a person's to remove, and a
|
||||
// declaration that silently stripped them would be the mesh deciding what a person's machine
|
||||
// grants. What "never" adds is the look: on every look the engine reads whether the account can
|
||||
// become root by itself — by its uid, a group that grants root, any sudo rule, or a secret the mesh
|
||||
// placed that it can read — and says it unhealthy while it can (internal/accounts), so the
|
||||
// controller can tell a machine where it holds from one where it does not.
|
||||
Root string `json:"root,omitempty"`
|
||||
}
|
||||
|
||||
// RootNever is the one value Root takes besides empty: the account never becomes root without a person.
|
||||
const RootNever = "never"
|
||||
|
||||
// Network is a named network on this machine.
|
||||
//
|
||||
// **A name and nothing else.** Not a driver, a subnet or a gateway: each of those is something a
|
||||
@@ -495,9 +478,6 @@ func (u *User) validate(where string, _ bool) []string {
|
||||
if u.Home != "" && !strings.HasPrefix(u.Home, "/") {
|
||||
problems = append(problems, where+": a home directory is an absolute path")
|
||||
}
|
||||
if u.Root != "" && u.Root != RootNever {
|
||||
problems = append(problems, fmt.Sprintf("%s: root is %q or absent, and %q is neither", where, RootNever, u.Root))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
|
||||
Vendored
+2
-2
@@ -75,7 +75,7 @@ github.com/nats-io/nkeys
|
||||
# github.com/nats-io/nuid v1.0.1
|
||||
## explicit
|
||||
github.com/nats-io/nuid
|
||||
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261008195316-9a85dffc11a2
|
||||
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac
|
||||
## explicit; go 1.26.0
|
||||
github.com/novox/mesh-host/internal/declaration
|
||||
github.com/novox/mesh-host/validate
|
||||
@@ -133,4 +133,4 @@ golang.org/x/text/width
|
||||
# golang.org/x/time v0.15.0
|
||||
## explicit; go 1.25.0
|
||||
golang.org/x/time/rate
|
||||
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261008195316-9a85dffc11a2
|
||||
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac
|
||||
|
||||
Reference in New Issue
Block a user