Merge pull request 'Kinded benches, verbs named by their caller, proofs and records (hq ADR 0259 §3)' (#154) from feat/asks-answered-on-any-channel into main
This commit was merged in pull request #154.
This commit is contained in:
@@ -48,7 +48,8 @@ const agentAccountProbe = "DA"
|
||||
//
|
||||
// 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) {
|
||||
// now is the judging clock, threaded so a caller judging several things at one instant judges them all at it.
|
||||
func agentConfined(ctx context.Context, inv *inventory.Inventory, node string, now time.Time) (named, confined bool, why string, err error) {
|
||||
n, err := inv.NodeByName(ctx, node)
|
||||
if err != nil {
|
||||
return false, false, "", err
|
||||
@@ -61,7 +62,7 @@ func agentConfined(ctx context.Context, inv *inventory.Inventory, node string) (
|
||||
if err != nil {
|
||||
return true, false, "", err
|
||||
}
|
||||
confined, why = judgedConfined(n.AgentAccount, h, had, time.Now())
|
||||
confined, why = judgedConfined(n.AgentAccount, h, had, now)
|
||||
return true, confined, why, nil
|
||||
}
|
||||
|
||||
@@ -228,7 +229,7 @@ func agentAccountLines(ctx context.Context, inv *inventory.Inventory, n inventor
|
||||
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)
|
||||
_, confined, why, err := agentConfined(ctx, inv, n.Name, time.Now())
|
||||
if err != nil {
|
||||
return []string{fmt.Sprintf(" agents run as %s (home %s); whether it can become root could NOT be read: %v",
|
||||
n.AgentAccount, n.AgentHome(), err)}
|
||||
|
||||
@@ -106,10 +106,10 @@ func TestTheSelfCheckSaysAnAgentAccountThatCanBecomeRoot(t *testing.T) {
|
||||
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 {
|
||||
if named, confined, why, err := agentConfined(ctx, inv, "anchor", time.Now()); err != nil || !named || !confined {
|
||||
t.Fatalf("agentConfined on anchor: %v %v %q %v", named, confined, why, err)
|
||||
}
|
||||
if named, _, why, err := agentConfined(ctx, inv, "laptop"); err != nil || named ||
|
||||
if named, _, why, err := agentConfined(ctx, inv, "laptop", time.Now()); err != nil || named ||
|
||||
!strings.Contains(why, "operator account") {
|
||||
t.Fatalf("agentConfined on a machine naming none: %v %q %v", named, why, err)
|
||||
}
|
||||
@@ -246,7 +246,7 @@ func TestASearchStillRunningAfterARestartIsNotUrgent(t *testing.T) {
|
||||
if found := say(link.StateUnknown, running); len(found) != 0 {
|
||||
t.Fatalf("a search first seen now was raised: %+v", found)
|
||||
}
|
||||
if _, confined, why, _ := agentConfined(ctx, inv, "anchor"); confined || !strings.Contains(why, "not judged") {
|
||||
if _, confined, why, _ := agentConfined(ctx, inv, "anchor", time.Now()); confined || !strings.Contains(why, "not judged") {
|
||||
t.Fatalf("an account whose search runs was read as confined: %q", why)
|
||||
}
|
||||
// The engine restarted again and again, each statement's own since fresh: the controller's clock runs on.
|
||||
|
||||
@@ -53,9 +53,26 @@ func assertBusObjects(ctx context.Context, inv *inventory.Inventory, r broker.Ra
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// And the work queues of seats that name their caller or their kind, with each holder's worker
|
||||
// (novox/hq ADR 0259 §3): an ask queues until the router takes it, a channel's work until that kind
|
||||
// takes it.
|
||||
trafficStreams, trafficWorkers, err := seatTrafficObjects(ctx, inv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Every one tried, and every failure named: one module's consumer the bus refuses is no reason
|
||||
// the modules after it in the list hear nothing (novox/hq issue 208, where this runs on each send).
|
||||
var failed []error
|
||||
for _, s := range trafficStreams {
|
||||
if err := r.EnsureStream(s); err != nil {
|
||||
failed = append(failed, fmt.Errorf("the work queue %s: %w", s.Name, err))
|
||||
}
|
||||
}
|
||||
for _, c := range trafficWorkers {
|
||||
if err := r.EnsureConsumer(c); err != nil {
|
||||
failed = append(failed, fmt.Errorf("the worker %s on %s: %w", c.Name, c.Stream, err))
|
||||
}
|
||||
}
|
||||
for _, c := range consumers {
|
||||
if err := r.EnsureConsumer(c.Consumer); err != nil {
|
||||
failed = append(failed, fmt.Errorf("how %s on %s hears what it consumes: %w", c.Module, c.Node, err))
|
||||
@@ -130,6 +147,37 @@ func moduleConsumers(ctx context.Context, inv *inventory.Inventory) ([]broker.Mo
|
||||
return broker.ConsumersOf(users), nil
|
||||
}
|
||||
|
||||
// seatTrafficObjects is the work queues and workers of seats that name their caller or their kind, from
|
||||
// the records the user list is composed from.
|
||||
func seatTrafficObjects(ctx context.Context, inv *inventory.Inventory) ([]broker.Stream, []broker.Consumer, error) {
|
||||
records, err := inv.BusRecords(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
users, err := broker.Users(records)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
streams, workers := broker.SeatTrafficObjects(users)
|
||||
// And the queue of every such seat the catalogue declares, held or not: work queues from registration,
|
||||
// so what is submitted before a holder is assigned waits for it (the correctness review of 2026-10-08).
|
||||
declared, err := inv.DeclaredTrafficSeats(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
have := map[string]bool{}
|
||||
for _, s := range streams {
|
||||
have[s.Name] = true
|
||||
}
|
||||
for _, s := range broker.TrafficQueues(declared) {
|
||||
if !have[s.Name] {
|
||||
streams = append(streams, s)
|
||||
have[s.Name] = true
|
||||
}
|
||||
}
|
||||
return streams, workers, nil
|
||||
}
|
||||
|
||||
// moduleConsumerCount is how many modules hear what they consume, for the raise's one line.
|
||||
func moduleConsumerCount(ctx context.Context, inv *inventory.Inventory) (int, error) {
|
||||
consumers, err := moduleConsumers(ctx, inv)
|
||||
|
||||
@@ -126,6 +126,11 @@ var probeRegistry = []probe{
|
||||
{ID: agentAccountProbe, Asserts: "every machine that names an agent account has it judged, on its node-engine's " +
|
||||
"newest statement, unable to become root without a person", From: "ADR 0266, ADR 0259 §8",
|
||||
Kind: kindAgentCanBecomeRoot, Phase: 1, run: probeAgentAccounts},
|
||||
// Root where the trusted parties run (novox/hq ADR 0259 §8): while an agent can become root there without a
|
||||
// person, an answer proven there proves nothing.
|
||||
{ID: "D-root", Asserts: "no agent can become root without a person on a machine where the router or a channel " +
|
||||
"proving its sender runs: not by its own account, and not through a tool that runs its command as an account " +
|
||||
"that can", From: "ADR 0259 §8", Kind: kindRootNotFree, Phase: 2, run: probeAgentRoot},
|
||||
{ID: "DW", Asserts: "the watchdogs of the signals table ran within three of their intervals",
|
||||
From: "ADR 0227 rule 6: the watchers are watched", Kind: "watchdogs-silent", Phase: 1, run: probeWatchdogs},
|
||||
// The core's health definitions (novox/hq to-be 45 §8, ADR 0236): what a core component's new build is
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// Who can become root where the trusted parties run (novox/hq ADR 0259 §8, as reviewed on 2026-10-09).
|
||||
//
|
||||
// The router and every channel proving its sender run as accounts of their own, so that no agent reads what
|
||||
// they hold or speaks as them. **Root on their machine undoes all of it**, and so does an agent running as the
|
||||
// operator's account there. A machine is **root-free** — an answer proven there may authorise — only when all
|
||||
// of these are measured, now, and hold:
|
||||
//
|
||||
// 1. the machine names an account agents run as (novox/hq ADR 0266), so no agent runs as the operator's
|
||||
// account, which may become root;
|
||||
// 2. its node-engine — running as root, which no agent controls — judged that account unable to become root
|
||||
// without a person, in a statement heard within the last 15 minutes (agentConfined, judgedConfined);
|
||||
// 3. the login shell's `execute` is not served there (novox/hq ADR 0268): its holder's setting withholds it
|
||||
// **and** the bus was asked and heard no `execute` answered there. `execute` runs commands as the machine's
|
||||
// runtime account, which the mesh's acting tools give passwordless sudo; that account is taken to become
|
||||
// root, always, so no measure of it is asked.
|
||||
//
|
||||
// **Nothing else is a pass.** A machine that names no agent account, an unknown machine, a store or bus that
|
||||
// could not be read, a verdict stale or absent — each is not root-free, and says why. The sudo module's own
|
||||
// measure is no longer part of this judgement: it ran in the machine's runtime, as the very account an agent
|
||||
// could become, so it could not be believed.
|
||||
//
|
||||
// The one judgement (judgeRoot) is read two ways: the self-check raises `root-not-free` on every machine where
|
||||
// the router or a module of its own account runs and the judgement fails; and the `root-free` verb answers it
|
||||
// live, to the router, which honours a verified sender only on its pass. A machine holding the operator's
|
||||
// graphical session, where a messaging client's desktop app may run, is not judged here: the operator accepted
|
||||
// that gap for now (hq issue 344).
|
||||
|
||||
// kindRootNotFree is the condition a trusted party's machine that is not root-free raises. Its own key, apart
|
||||
// from ADR 0266's agent-can-become-root (Token agent-root, from DA): the two judge different things — DA the
|
||||
// agent account alone, this the whole of root-free — and one key from two probes flapped between them (the
|
||||
// confirmation review of 2026-10-09).
|
||||
const kindRootNotFree = "root-not-free"
|
||||
|
||||
// routerSeat is the seat the router holds: where it runs counts as a trusted party's machine.
|
||||
const routerSeat = "operator-channel"
|
||||
|
||||
const (
|
||||
loginShellSeat = "node-login-shell"
|
||||
// loginShellVerb is the seat's verb that runs a command, and the name of the setting its holder withholds
|
||||
// it by (novox/hq ADR 0268).
|
||||
loginShellVerb = "execute"
|
||||
// executeServes is the one value of that setting that serves the verb; anything else withholds it.
|
||||
executeServes = "serve"
|
||||
)
|
||||
|
||||
// loginShellServed judges whether the login shell's execute is served on a machine, failing closed (novox/hq
|
||||
// ADR 0268): served while the holder's setting there is `serve` (or the holder has no such setting and claims
|
||||
// the verb), or while the bus heard the verb answered there, or while the bus could not be asked. why says
|
||||
// which, in words.
|
||||
func loginShellServed(holder string, claims bool, setting *any, heard, asked bool) (bool, string) {
|
||||
var why []string
|
||||
switch {
|
||||
case setting != nil:
|
||||
if v, _ := (*setting).(string); v == executeServes {
|
||||
why = append(why, holder+"'s execute setting there is "+executeServes)
|
||||
}
|
||||
case claims:
|
||||
why = append(why, holder+" claims execute and has no setting that withholds it")
|
||||
}
|
||||
if heard {
|
||||
why = append(why, "the bus hears execute answered there")
|
||||
} else if !asked {
|
||||
why = append(why, "the bus could not be asked whether execute is answered there")
|
||||
}
|
||||
return len(why) > 0, strings.Join(why, "; ")
|
||||
}
|
||||
|
||||
// rootFacts is what the judgement reads of one machine.
|
||||
type rootFacts struct {
|
||||
Machine string
|
||||
// Unread is every read that failed, in words: any one is a fail.
|
||||
Unread []string
|
||||
// AgentNamed is whether the machine names an agent account; Confined whether its node-engine judged it
|
||||
// unable to become root, freshly; ConfinedWhy the judgement's words either way.
|
||||
AgentNamed bool
|
||||
Confined bool
|
||||
ConfinedWhy string
|
||||
// Execute is whether the login shell's execute is served there; ExecuteWhy why, in words.
|
||||
Execute bool
|
||||
ExecuteWhy string
|
||||
// SearchPending is the agent account unjudged only because the node-engine's first setuid search runs,
|
||||
// within its bound (ADR 0266's quiet window).
|
||||
SearchPending bool
|
||||
}
|
||||
|
||||
// rootVerdict is the judgement on one machine, as the root-free verb answers it.
|
||||
type rootVerdict struct {
|
||||
Machine string `json:"machine"`
|
||||
Free bool `json:"free"`
|
||||
Why string `json:"why"`
|
||||
Judged time.Time `json:"judged"`
|
||||
// Quiet is a machine not free only because its first setuid search still runs, within its bound: the
|
||||
// self-check raises nothing for it then (ADR 0266's quiet window). It is never free for it.
|
||||
Quiet bool `json:"quiet,omitempty"`
|
||||
}
|
||||
|
||||
// judgeRoot is the one judgement: free only when nothing failed to read, an agent account is named and judged
|
||||
// confined, and execute is not served.
|
||||
func judgeRoot(f rootFacts, now time.Time) rootVerdict {
|
||||
v := rootVerdict{Machine: f.Machine, Judged: now.UTC()}
|
||||
var not []string
|
||||
if len(f.Unread) > 0 {
|
||||
not = append(not, "not measured: "+strings.Join(f.Unread, "; "))
|
||||
}
|
||||
switch {
|
||||
case f.ConfinedWhy == "":
|
||||
// Not read (said above), or nothing said of it: never a pass.
|
||||
if len(f.Unread) == 0 {
|
||||
not = append(not, "whether agents there can become root was not judged")
|
||||
}
|
||||
case !f.AgentNamed:
|
||||
not = append(not, "agents run as the operator's account there, which may become root ("+f.ConfinedWhy+")")
|
||||
case !f.Confined:
|
||||
not = append(not, f.ConfinedWhy)
|
||||
}
|
||||
if f.Execute {
|
||||
not = append(not, "the login shell runs any command an agent gives it as the machine's runtime account, "+
|
||||
"which can become root ("+orNoneKnown(f.ExecuteWhy)+")")
|
||||
}
|
||||
if len(not) > 0 {
|
||||
v.Why = strings.Join(not, "; ")
|
||||
// The one failure is the agent account not judged yet, because its first search runs.
|
||||
v.Quiet = len(not) == 1 && f.SearchPending && f.AgentNamed && !f.Confined && len(f.Unread) == 0 && !f.Execute
|
||||
return v
|
||||
}
|
||||
v.Free = true
|
||||
v.Why = f.ConfinedWhy + "; the login shell's execute is not served there"
|
||||
return v
|
||||
}
|
||||
|
||||
// rootReader reads the facts of machines live: the catalogue's placements, the node-engine's verdicts and the
|
||||
// bus's discovery, each once per reader.
|
||||
type rootReader struct {
|
||||
entries []inventory.Entry
|
||||
read error
|
||||
heard map[string]map[string]map[string]bool
|
||||
asked error
|
||||
// confined is agentConfined; settings the login shell holder's settings on a machine. Replaceable in a test.
|
||||
confined func(ctx context.Context, node string, now time.Time) (named, confined bool, why string, err error)
|
||||
// quiet says the one thing keeping a machine's agent account unjudged is the node-engine's first setuid
|
||||
// search, within its bound (ADR 0266, searchStillRunning). Read by the self-check alone, to raise nothing
|
||||
// then; nil reads no quiet. It never makes a machine root-free.
|
||||
quiet func(ctx context.Context, node string, now time.Time) bool
|
||||
settings func(ctx context.Context, node, module string) ([]catalogue.Layer, error)
|
||||
}
|
||||
|
||||
func newRootReader(ctx context.Context, inv *inventory.Inventory, conn *nats.Conn) *rootReader {
|
||||
r := &rootReader{}
|
||||
r.entries, r.read = inv.Catalogued(ctx)
|
||||
if conn == nil {
|
||||
r.asked = fmt.Errorf("this process holds no connection to the bus")
|
||||
} else {
|
||||
r.heard, r.asked = discoverSeatVerbs(ctx, conn)
|
||||
}
|
||||
r.confined = func(ctx context.Context, node string, now time.Time) (bool, bool, string, error) {
|
||||
return agentConfined(ctx, inv, node, now)
|
||||
}
|
||||
r.settings = inv.SettingsFor
|
||||
return r
|
||||
}
|
||||
|
||||
// facts reads one machine, at now.
|
||||
func (r *rootReader) facts(ctx context.Context, machine string, now time.Time) rootFacts {
|
||||
f := rootFacts{Machine: machine}
|
||||
if r.read != nil {
|
||||
f.Unread = append(f.Unread, "the catalogue's placements could not be read: "+r.read.Error())
|
||||
}
|
||||
named, confined, why, err := r.confined(ctx, machine, now)
|
||||
if err != nil {
|
||||
f.Unread = append(f.Unread, "the account agents run as could not be read: "+err.Error())
|
||||
} else {
|
||||
f.AgentNamed, f.Confined, f.ConfinedWhy = named, confined, why
|
||||
}
|
||||
f.Execute, f.ExecuteWhy = r.executeServed(ctx, machine)
|
||||
if r.quiet != nil && f.AgentNamed && !f.Confined {
|
||||
f.SearchPending = r.quiet(ctx, machine, now)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// executeServed is whether the login shell's execute is served on a machine, failing closed: the bus not
|
||||
// asked, the placements not read, or a holder's setting not read, is served.
|
||||
func (r *rootReader) executeServed(ctx context.Context, machine string) (bool, string) {
|
||||
heard := r.heard[loginShellSeat][loginShellVerb][machine]
|
||||
asked := r.asked == nil
|
||||
var whys []string
|
||||
served := false
|
||||
holders := 0
|
||||
for _, e := range r.entries {
|
||||
if !e.Manifest.ClaimsSeat(loginShellSeat) || !slices.Contains(e.On, machine) {
|
||||
continue
|
||||
}
|
||||
holders++
|
||||
var setting *any
|
||||
if _, declared := e.Manifest.Settings[loginShellVerb]; declared {
|
||||
layers, err := r.settings(ctx, machine, e.Manifest.Module)
|
||||
if err != nil {
|
||||
served = true
|
||||
whys = append(whys, e.Manifest.Module+"'s setting there could not be read: "+err.Error())
|
||||
continue
|
||||
}
|
||||
for _, s := range catalogue.Effective(e.Manifest, layers) {
|
||||
if s.Key == loginShellVerb {
|
||||
v := s.Value
|
||||
setting = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
if s, why := loginShellServed(e.Manifest.Module, claimServes(e.Manifest, loginShellSeat, loginShellVerb),
|
||||
setting, heard, asked); s {
|
||||
served = true
|
||||
whys = append(whys, why)
|
||||
}
|
||||
}
|
||||
if holders == 0 {
|
||||
// Nobody is assigned to serve it; the bus must still hear nobody answering it.
|
||||
if s, why := loginShellServed("no holder", false, nil, heard, asked); s {
|
||||
served = true
|
||||
whys = append(whys, why)
|
||||
}
|
||||
}
|
||||
if r.read != nil {
|
||||
served = true
|
||||
whys = append(whys, "who holds the login shell there could not be read")
|
||||
}
|
||||
return served, strings.Join(whys, "; ")
|
||||
}
|
||||
|
||||
// claimServes says whether a manifest's claim of a seat names a verb among those it serves.
|
||||
func claimServes(m catalogue.Manifest, seat, verb string) bool {
|
||||
for _, c := range m.Claims {
|
||||
if c.Name == seat && slices.Contains(c.Serves, verb) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// judgeRootFree is the root-free verb's answer: each named machine judged now. It never fails: what could not
|
||||
// be read is a machine not free, saying so.
|
||||
func judgeRootFree(ctx context.Context, r *rootReader, machines []string, now time.Time) []rootVerdict {
|
||||
out := make([]rootVerdict, 0, len(machines))
|
||||
for _, m := range machines {
|
||||
out = append(out, judgeRoot(r.facts(ctx, m, now), now))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// trustedMachines are the machines where the router or a module of its own account runs, each with those
|
||||
// modules.
|
||||
func trustedMachines(entries []inventory.Entry) map[string][]string {
|
||||
trusted := map[string][]string{}
|
||||
for _, e := range entries {
|
||||
for _, node := range e.On {
|
||||
if e.Manifest.RunsAs != "" || e.Manifest.ClaimsSeat(routerSeat) {
|
||||
trusted[node] = append(trusted[node], e.Manifest.Module)
|
||||
}
|
||||
}
|
||||
}
|
||||
return trusted
|
||||
}
|
||||
|
||||
// agentRootObservation is the condition of a trusted party's machine that is not root-free.
|
||||
func agentRootObservation(v rootVerdict, trusted []string) conditions.Observation {
|
||||
trusted = append([]string(nil), trusted...)
|
||||
sort.Strings(trusted)
|
||||
return conditions.Observation{Scope: conditions.ScopeMachine, ID: v.Machine, Token: kindRootNotFree,
|
||||
Machine: v.Machine, Kind: kindRootNotFree, Severity: conditions.Urgent,
|
||||
Summary: fmt.Sprintf("%s is not root-free, where %s run: until it is, the router approves nothing proven "+
|
||||
"there (novox/hq ADR 0259 §8): %s", v.Machine, strings.Join(trusted, ", "), v.Why),
|
||||
Headline: "Phone answers held on " + v.Machine,
|
||||
Needs: "give the programs working for you on " + v.Machine + " an account that cannot become root.",
|
||||
Explanation: "The modules that prove your answers from your phone run on " + v.Machine + ", and the mesh " +
|
||||
"cannot show that a program working for you there is unable to become root or to act as you. Until " +
|
||||
"it can, answers from your phone can only acknowledge.",
|
||||
Resolved: "Answers from your phone can approve again on " + v.Machine}
|
||||
}
|
||||
|
||||
// probeAgentRoot is the probe: every trusted party's machine, judged by the one judgement.
|
||||
func probeAgentRoot(ctx context.Context, d *doctor) ([]conditions.Observation, error) {
|
||||
var conn *nats.Conn
|
||||
if d.js != nil {
|
||||
conn = d.js.Conn()
|
||||
}
|
||||
inv := d.open.inventory
|
||||
r := newRootReader(ctx, inv, conn)
|
||||
if r.read != nil {
|
||||
return nil, r.read
|
||||
}
|
||||
// The self-check alone reads ADR 0266's quiet window: nothing raised while a machine's first setuid search
|
||||
// runs, within its bound. The root-free verb never reads it, so the machine still answers not free.
|
||||
r.quiet = func(ctx context.Context, node string, now time.Time) bool {
|
||||
n, err := inv.NodeByName(ctx, node)
|
||||
if err != nil || n.AgentAccount == "" {
|
||||
return false
|
||||
}
|
||||
h, had, err := inv.HealthOf(ctx, node)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
quiet, err := searchStillRunning(ctx, inv, node, n.AgentAccount, h, had, now)
|
||||
return err == nil && quiet
|
||||
}
|
||||
return rootObservations(ctx, r, trustedMachines(r.entries), time.Now()), nil
|
||||
}
|
||||
|
||||
// rootObservations judges the machines and says each that fails.
|
||||
func rootObservations(ctx context.Context, r *rootReader, trusted map[string][]string, now time.Time) []conditions.Observation {
|
||||
var machines []string
|
||||
for m := range trusted {
|
||||
machines = append(machines, m)
|
||||
}
|
||||
sort.Strings(machines)
|
||||
var out []conditions.Observation
|
||||
for _, v := range judgeRootFree(ctx, r, machines, now) {
|
||||
if !v.Free && !v.Quiet {
|
||||
out = append(out, agentRootObservation(v, trusted[v.Machine]))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rootClock is the clock the root-free verb judges by.
|
||||
var rootClock = time.Now
|
||||
|
||||
// rootFreeAnswer is the root-free verb: the named machines, each judged now by the serving controller. Only it
|
||||
// answers: a process that is not serving says so, and a caller reads that as no machine free.
|
||||
func rootFreeAnswer(ctx context.Context, machines string, now time.Time) (any, error) {
|
||||
d := doctorFrom
|
||||
if d == nil || d.open == nil || d.open.inventory == nil {
|
||||
return nil, fmt.Errorf("this controller is not serving, so it judges no machine root-free: ask again, and " +
|
||||
"the serving controller answers")
|
||||
}
|
||||
var names []string
|
||||
for _, m := range strings.Split(machines, ",") {
|
||||
if m = strings.TrimSpace(m); m != "" && !slices.Contains(names, m) {
|
||||
names = append(names, m)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("root-free judges the machines named, and none was")
|
||||
}
|
||||
var conn *nats.Conn
|
||||
if d.js != nil {
|
||||
conn = d.js.Conn()
|
||||
}
|
||||
return map[string]any{"machines": judgeRootFree(ctx, newRootReader(ctx, d.open.inventory, conn), names, now)}, nil
|
||||
}
|
||||
|
||||
// rootFreeNow is the machines judged root-free, for composing a push's memberships: only those that pass.
|
||||
func rootFreeNow(ctx context.Context, r *rootReader, machines []string, now time.Time) map[string]bool {
|
||||
free := map[string]bool{}
|
||||
for _, v := range judgeRootFree(ctx, r, machines, now) {
|
||||
if v.Free {
|
||||
free[v.Machine] = true
|
||||
}
|
||||
}
|
||||
return free
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
var rootNow = time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// A machine is root-free only on a positive measure of each thing (the review of 2026-10-09, H2/H3): every
|
||||
// read succeeded, an agent account is named and judged confined by the node-engine, execute is not served.
|
||||
func TestRootFreeIsAPositiveMeasureAndNothingElse(t *testing.T) {
|
||||
pass := rootFacts{Machine: "anchor", AgentNamed: true, Confined: true,
|
||||
ConfinedWhy: "the agent account agents cannot become root without a person (judged 2026-10-09 12:00)"}
|
||||
if v := judgeRoot(pass, rootNow); !v.Free || v.Machine != "anchor" || !v.Judged.Equal(rootNow) {
|
||||
t.Fatalf("the one pass: %+v", v)
|
||||
}
|
||||
fails := map[string]func(*rootFacts){
|
||||
"a read failed": func(f *rootFacts) { f.Unread = []string{"the store did not answer"} },
|
||||
"no agent account named": func(f *rootFacts) { f.AgentNamed, f.Confined = false, false },
|
||||
"not confined": func(f *rootFacts) { f.Confined = false },
|
||||
"nothing said of it": func(f *rootFacts) { f.ConfinedWhy = "" },
|
||||
"execute served": func(f *rootFacts) { f.Execute, f.ExecuteWhy = true, "the bus hears execute answered there" },
|
||||
"confined, but agent read": func(f *rootFacts) { f.Unread, f.ConfinedWhy = []string{"x"}, "" },
|
||||
}
|
||||
for name, mutate := range fails {
|
||||
f := pass
|
||||
mutate(&f)
|
||||
if v := judgeRoot(f, rootNow); v.Free || v.Why == "" {
|
||||
t.Errorf("%s: judged %+v", name, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The reader fails closed on every case the review named: the agent account read only where the coding-agent
|
||||
// module runs (old :225), no account to measure taken for a pass (old :246), and a measure that did not answer
|
||||
// taken for "not root" (old :114, :123).
|
||||
func TestTheRootReaderFailsClosed(t *testing.T) {
|
||||
shell := catalogue.Manifest{Module: "zsh", Claims: []catalogue.Claim{{Name: loginShellSeat, Serves: []string{"execute"}}},
|
||||
Settings: map[string]catalogue.SettingDeclaration{"execute": {Default: "withhold"}}}
|
||||
reader := func() *rootReader {
|
||||
return &rootReader{
|
||||
entries: []inventory.Entry{{Manifest: shell, On: []string{"anchor"}}},
|
||||
heard: map[string]map[string]map[string]bool{},
|
||||
confined: func(context.Context, string, time.Time) (bool, bool, string, error) {
|
||||
return true, true, "the agent account agents cannot become root without a person", nil
|
||||
},
|
||||
settings: func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, nil },
|
||||
}
|
||||
}
|
||||
ctx := context.Background()
|
||||
if v := judgeRootFree(ctx, reader(), []string{"anchor"}, rootNow)[0]; !v.Free {
|
||||
t.Fatalf("the control: agents confined, execute withheld and unheard, everything read: %+v", v)
|
||||
}
|
||||
cases := map[string]func(*rootReader){
|
||||
"no agent account named, no coding-agent module there": func(r *rootReader) {
|
||||
r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) {
|
||||
return false, false, "anchor names no agent account: agents run as the operator account (ops)", nil
|
||||
}
|
||||
},
|
||||
"the node-engine's verdict not read": func(r *rootReader) {
|
||||
r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) {
|
||||
return false, false, "", errors.New("the store did not answer")
|
||||
}
|
||||
},
|
||||
"a stale verdict": func(r *rootReader) {
|
||||
r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) {
|
||||
return true, false, "the agent account agents is not judged: the machine's newest statement was heard at …", nil
|
||||
}
|
||||
},
|
||||
"the bus not asked": func(r *rootReader) { r.asked = errors.New("no bus") },
|
||||
"the placements not read": func(r *rootReader) { r.read = errors.New("no store") },
|
||||
"the holder's setting not read": func(r *rootReader) {
|
||||
r.settings = func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, errors.New("no store") }
|
||||
},
|
||||
"execute heard on the bus": func(r *rootReader) {
|
||||
r.heard = map[string]map[string]map[string]bool{loginShellSeat: {"execute": {"anchor": true}}}
|
||||
},
|
||||
"a holder that serves execute": func(r *rootReader) {
|
||||
r.entries[0].Manifest.Settings = nil
|
||||
},
|
||||
}
|
||||
for name, mutate := range cases {
|
||||
r := reader()
|
||||
mutate(r)
|
||||
if v := judgeRootFree(ctx, r, []string{"anchor"}, rootNow)[0]; v.Free {
|
||||
t.Errorf("%s: judged free: %+v", name, v)
|
||||
}
|
||||
}
|
||||
// A machine with no login shell holder at all: still the bus must hear none.
|
||||
r := reader()
|
||||
r.entries = nil
|
||||
r.heard = map[string]map[string]map[string]bool{loginShellSeat: {"execute": {"anchor": true}}}
|
||||
if v := judgeRootFree(ctx, r, []string{"anchor"}, rootNow)[0]; v.Free {
|
||||
t.Errorf("execute answered by a module nobody assigned: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// The probe says agent-root, by the same judgement, on each machine where the router or a module of its own
|
||||
// account runs and that is not root-free; urgent and in plain words; nothing where every one is free.
|
||||
func TestTheProbeSaysEachMachineThatIsNotRootFree(t *testing.T) {
|
||||
entries := []inventory.Entry{
|
||||
{Manifest: catalogue.Manifest{Module: "telegram", RunsAs: "telegram"}, On: []string{"anchor"}},
|
||||
{Manifest: catalogue.Manifest{Module: "messenger", RunsAs: "messenger",
|
||||
Claims: []catalogue.Claim{{Name: routerSeat}}}, On: []string{"anchor"}},
|
||||
{Manifest: catalogue.Manifest{Module: "xorg", Claims: []catalogue.Claim{{Name: catalogue.DisplayServerSeat}}},
|
||||
On: []string{"laptop"}},
|
||||
}
|
||||
trusted := trustedMachines(entries)
|
||||
if len(trusted["anchor"]) != 2 || len(trusted["laptop"]) != 0 {
|
||||
t.Fatalf("trusted %v", trusted)
|
||||
}
|
||||
r := &rootReader{entries: entries, heard: map[string]map[string]map[string]bool{},
|
||||
confined: func(_ context.Context, node string, _ time.Time) (bool, bool, string, error) {
|
||||
return false, false, node + " names no agent account: agents run as the operator account (ops)", nil
|
||||
},
|
||||
settings: func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, nil }}
|
||||
got := rootObservations(context.Background(), r, trusted, rootNow)
|
||||
if len(got) != 1 || got[0].Machine != "anchor" || got[0].Kind != kindRootNotFree || got[0].Severity != conditions.Urgent ||
|
||||
!strings.Contains(got[0].Summary, "messenger, telegram") || !strings.Contains(got[0].Summary, "names no agent account") {
|
||||
t.Fatalf("said %+v", got)
|
||||
}
|
||||
o := got[0]
|
||||
if why, ok := conditions.PlainWords(conditions.Words{Headline: o.Headline, Explanation: o.Explanation,
|
||||
Needs: o.Needs, Resolved: o.Resolved}, o.Machine); !ok {
|
||||
t.Errorf("not plain: %s", why)
|
||||
}
|
||||
r.confined = func(context.Context, string, time.Time) (bool, bool, string, error) {
|
||||
return true, true, "confined", nil
|
||||
}
|
||||
if got := rootObservations(context.Background(), r, trusted, rootNow); len(got) != 0 {
|
||||
t.Errorf("said of a free machine: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0268: the login shell counts only where its execute is served, and fails closed.
|
||||
func TestTheLoginShellCountsOnlyWhereExecuteIsServed(t *testing.T) {
|
||||
val := func(v any) *any { return &v }
|
||||
cases := []struct {
|
||||
name string
|
||||
claims bool
|
||||
setting *any
|
||||
heard, asked bool
|
||||
served bool
|
||||
saysInTheWhys string
|
||||
}{
|
||||
{"withheld by the setting and silent on the bus", true, val("withhold"), false, true, false, ""},
|
||||
{"withheld by the setting, the machine not yet pushed", true, val("withhold"), true, true, true, "the bus hears"},
|
||||
{"the setting serves", true, val("serve"), false, true, true, "setting there is serve"},
|
||||
{"a wrong value withholds, as the holder does", true, val("Serve"), false, true, false, ""},
|
||||
{"the setting withholds, the bus could not be asked", true, val("withhold"), false, false, true, "could not be asked"},
|
||||
{"a holder with no such setting that claims execute", true, nil, false, true, true, "claims execute"},
|
||||
{"a holder with no such setting that does not claim it, heard all the same", false, nil, true, true, true, "the bus hears"},
|
||||
{"a holder with no such setting that does not claim it, silent", false, nil, false, true, false, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
served, why := loginShellServed("zsh", c.claims, c.setting, c.heard, c.asked)
|
||||
if served != c.served || (c.saysInTheWhys != "" && !strings.Contains(why, c.saysInTheWhys)) {
|
||||
t.Errorf("%s: served %v (%q), want %v saying %q", c.name, served, why, c.served, c.saysInTheWhys)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The root-free verb is the serving controller's alone, answered in its process and never as a command; a
|
||||
// controller not serving answers an error, which the router reads as no machine free.
|
||||
func TestRootFreeIsAnsweredOnlyByTheServingController(t *testing.T) {
|
||||
was := doctorFrom
|
||||
doctorFrom = nil
|
||||
t.Cleanup(func() { doctorFrom = was })
|
||||
if _, err := rootFreeAnswer(context.Background(), "anchor", rootNow); err == nil {
|
||||
t.Error("a controller not serving judged a machine")
|
||||
}
|
||||
if !inProcess["root-free"] {
|
||||
t.Error("root-free is not answered in the serving process")
|
||||
}
|
||||
if _, err := argvFor("root-free", map[string]any{"machines": "anchor"}); err == nil {
|
||||
t.Error("root-free ran as a command")
|
||||
}
|
||||
// The router names its machines as a list (its contract with this verb); one text separated by commas is
|
||||
// the same; anything else in the list is refused.
|
||||
for _, given := range []any{[]any{"anchor", "relay"}, "anchor, relay"} {
|
||||
a, err := readArguments("root-free", map[string]any{"machines": given})
|
||||
if err != nil || a.given["machines"] != "anchor,relay" && a.given["machines"] != "anchor, relay" {
|
||||
t.Errorf("root-free given %v read %v (%v)", given, a, err)
|
||||
}
|
||||
}
|
||||
if _, err := readArguments("root-free", map[string]any{"machines": []any{"anchor", 7}}); err == nil {
|
||||
t.Error("root-free took a number for a machine")
|
||||
}
|
||||
if _, err := readArguments("status", map[string]any{"machines": []any{"anchor"}}); err == nil {
|
||||
t.Error("a verb that takes no list took one")
|
||||
}
|
||||
}
|
||||
|
||||
// The confirmation review of 2026-10-09: ADR 0266's quiet window (#175) keeps the self-check from raising
|
||||
// agent-can-become-root while the node-engine's first setuid search runs. It must not make root-free answer free:
|
||||
// root-free needs a complete, fresh verdict. A verdict still waiting for the search is "not judged" to
|
||||
// agentConfined, so the machine is not root-free, whatever the quiet says — and the same statement, complete
|
||||
// and healthy, is the control.
|
||||
func TestAMachineWaitingForItsFirstSetuidSearchIsNotRootFree(t *testing.T) {
|
||||
now := rootNow
|
||||
statement := func(state, reason string) inventory.NodeHealth {
|
||||
return inventory.NodeHealth{Node: "anchor", Contract: link.RootContract, SaidAt: now, HeardAt: now,
|
||||
Resources: []inventory.ResourceHealth{{Module: "claude-code", Resource: "agent", Kind: link.KindAccount,
|
||||
Target: "agents", State: state, Reason: reason, Root: link.RootNever}}}
|
||||
}
|
||||
judged := func(h inventory.NodeHealth) rootVerdict {
|
||||
confined, why := judgedConfined("agents", h, true, now)
|
||||
return judgeRoot(rootFacts{Machine: "anchor", AgentNamed: true, Confined: confined, ConfinedWhy: why}, now)
|
||||
}
|
||||
if v := judged(statement(link.StateHealthy, "")); !v.Free {
|
||||
t.Fatalf("the control: a complete healthy verdict, fresh: %+v", v)
|
||||
}
|
||||
pending := statement(link.StateUnknown, link.ReasonRootPending+": the search runs")
|
||||
if rootVerdictKind("agents", pending, true, now) != verdictPending {
|
||||
t.Fatal("the statement is not one the quiet window counts as waiting for the search")
|
||||
}
|
||||
if v := judged(pending); v.Free {
|
||||
t.Errorf("a machine whose first setuid search is pending was judged root-free: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// The confirmation review of 2026-10-09, on #154 beside ADR 0266: D-root keeps ADR 0266's quiet window — nothing
|
||||
// raised while the one thing unjudged is the first setuid search, within its bound — while the root-free verb
|
||||
// still answers the machine not free; and D-root's condition has a key of its own, apart from DA's.
|
||||
func TestRootNotFreeIsQuietWhileTheFirstSearchRunsAndKeyedApartFromDA(t *testing.T) {
|
||||
entries := []inventory.Entry{{Manifest: catalogue.Manifest{Module: "telegram", RunsAs: "telegram"}, On: []string{"anchor"}}}
|
||||
r := &rootReader{entries: entries, heard: map[string]map[string]map[string]bool{},
|
||||
confined: func(context.Context, string, time.Time) (bool, bool, string, error) {
|
||||
return true, false, "the agent account agents is not judged: the search for setuid programs runs", nil
|
||||
},
|
||||
settings: func(context.Context, string, string) ([]catalogue.Layer, error) { return nil, nil },
|
||||
quiet: func(context.Context, string, time.Time) bool { return true }}
|
||||
trusted := trustedMachines(entries)
|
||||
if got := rootObservations(context.Background(), r, trusted, rootNow); len(got) != 0 {
|
||||
t.Errorf("raised while the first search runs: %+v", got)
|
||||
}
|
||||
if v := judgeRootFree(context.Background(), r, []string{"anchor"}, rootNow)[0]; v.Free || !v.Quiet {
|
||||
t.Errorf("root-free while the first search runs: %+v", v)
|
||||
}
|
||||
// Quiet hides nothing else: the login shell served as well is said.
|
||||
r.heard = map[string]map[string]map[string]bool{loginShellSeat: {"execute": {"anchor": true}}}
|
||||
if got := rootObservations(context.Background(), r, trusted, rootNow); len(got) != 1 {
|
||||
t.Errorf("a second failure was kept quiet: %+v", got)
|
||||
}
|
||||
// Past its bound, said.
|
||||
r.heard, r.quiet = map[string]map[string]map[string]bool{}, func(context.Context, string, time.Time) bool { return false }
|
||||
got := rootObservations(context.Background(), r, trusted, rootNow)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("a search past its bound was not said: %+v", got)
|
||||
}
|
||||
// One key per judgement: DA's is machine.<m>.agent-root, this one its own.
|
||||
da := conditions.Observation{Scope: conditions.ScopeMachine, ID: "anchor", Token: "agent-root", Machine: "anchor"}
|
||||
if got[0].Key() == da.Key() {
|
||||
t.Errorf("D-root and DA share the key %s", da.Key())
|
||||
}
|
||||
}
|
||||
@@ -641,31 +641,8 @@ const (
|
||||
// discoverHolders asks the bus's discovery who serves what, and answers seat → machine for every
|
||||
// endpoint a seat's verb is served on.
|
||||
func discoverHolders(ctx context.Context, conn *nats.Conn) (map[string]map[string]bool, error) {
|
||||
inbox := conn.NewRespInbox()
|
||||
sub, err := conn.SubscribeSync(inbox)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
if err := conn.PublishRequest("$SRV.INFO", inbox, nil); err != nil {
|
||||
return nil, fmt.Errorf("asking the bus who serves what: %w", err)
|
||||
}
|
||||
out := map[string]map[string]bool{}
|
||||
deadline := time.Now().Add(discoveryPatience)
|
||||
for time.Now().Before(deadline) {
|
||||
wait, cancel := context.WithTimeout(ctx, discoveryQuiet)
|
||||
msg, err := sub.NextMsgWithContext(wait)
|
||||
cancel()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
break
|
||||
}
|
||||
var info micro.Info
|
||||
if json.Unmarshal(msg.Data, &info) != nil {
|
||||
continue
|
||||
}
|
||||
err := discoverServices(ctx, conn, func(info micro.Info) {
|
||||
for _, e := range info.Endpoints {
|
||||
seat, node := e.Metadata["seat"], e.Metadata["node"]
|
||||
if seat == "" {
|
||||
@@ -684,8 +661,69 @@ func discoverHolders(ctx context.Context, conn *nats.Conn) (map[string]map[strin
|
||||
out[info.Name] = map[string]bool{}
|
||||
}
|
||||
out[info.Name][info.ID] = true
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// discoverSeatVerbs asks the bus's discovery the same, one level finer: seat → verb → machine, for every
|
||||
// seat verb answered (an endpoint's `tool` is its verb). A seat held where a verb is withheld (novox/hq ADR
|
||||
// 0268) shows the seat and not that verb.
|
||||
func discoverSeatVerbs(ctx context.Context, conn *nats.Conn) (map[string]map[string]map[string]bool, error) {
|
||||
out := map[string]map[string]map[string]bool{}
|
||||
err := discoverServices(ctx, conn, func(info micro.Info) {
|
||||
for _, e := range info.Endpoints {
|
||||
seat, verb, node := e.Metadata["seat"], e.Metadata["tool"], e.Metadata["node"]
|
||||
if seat == "" || verb == "" {
|
||||
continue
|
||||
}
|
||||
if node == "" {
|
||||
node = info.ID
|
||||
}
|
||||
if out[seat] == nil {
|
||||
out[seat] = map[string]map[string]bool{}
|
||||
}
|
||||
if out[seat][verb] == nil {
|
||||
out[seat][verb] = map[string]bool{}
|
||||
}
|
||||
out[seat][verb][node] = true
|
||||
}
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// discoverServices asks the bus's discovery once and hands every service's answer to visit, waiting
|
||||
// discoveryQuiet after the last and discoveryPatience at the most.
|
||||
func discoverServices(ctx context.Context, conn *nats.Conn, visit func(micro.Info)) error {
|
||||
if conn == nil {
|
||||
return errors.New("the controller holds no connection to the bus")
|
||||
}
|
||||
return out, nil
|
||||
inbox := conn.NewRespInbox()
|
||||
sub, err := conn.SubscribeSync(inbox)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
if err := conn.PublishRequest("$SRV.INFO", inbox, nil); err != nil {
|
||||
return fmt.Errorf("asking the bus who serves what: %w", err)
|
||||
}
|
||||
deadline := time.Now().Add(discoveryPatience)
|
||||
for time.Now().Before(deadline) {
|
||||
wait, cancel := context.WithTimeout(ctx, discoveryQuiet)
|
||||
msg, err := sub.NextMsgWithContext(wait)
|
||||
cancel()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
break
|
||||
}
|
||||
var info micro.Info
|
||||
if json.Unmarshal(msg.Data, &info) != nil {
|
||||
continue
|
||||
}
|
||||
visit(info)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// probeArchives is D4: every archive the mesh keeps is held by its manifest in the artifact store.
|
||||
|
||||
@@ -1250,11 +1250,14 @@ func issueMemberships(ctx context.Context, open *stores, server *link.Server, se
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
where := broker.PlacementsOf(records, records.Interchangeable)
|
||||
bus, ok := server.Bus().(link.OverNATS)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// Which machines are root-free now (novox/hq ADR 0259 §8): a channel's verified sender is composed for the
|
||||
// router only from one, beside a router on one. Judged once per push, by the root-free verb's judgement.
|
||||
records.RootFree = rootFreeNow(ctx, newRootReader(ctx, open.inventory, bus.Conn), records.Nodes, time.Now())
|
||||
where := broker.PlacementsOf(records, records.Interchangeable)
|
||||
// **Every declared state's bucket, before the memberships that name it** (novox/hq ADR 0201). The
|
||||
// raise at start asserts them too, but a module registered and assigned since would otherwise have
|
||||
// its bucket only after the control plane next restarts — found the first time a module declared
|
||||
|
||||
@@ -135,6 +135,12 @@ func handOver(ctx context.Context, seatName, to string, adding bool) error {
|
||||
if !ok || nodeName == "" || module == "" {
|
||||
return fmt.Errorf("the new holder is named <node>/<module>, not %q", to)
|
||||
}
|
||||
// A kinded bench is held once per kind, by the claims themselves (novox/hq ADR 0234 §2, ADR 0259): the
|
||||
// record of who holds a seat has no kind, so a handover would name one holder for every kind.
|
||||
if catalogue.KindedBenches[seatName] {
|
||||
return fmt.Errorf("%s is a kinded bench: each kind is held by the module claiming it, and is not handed "+
|
||||
"over by `seat` — assign the module that claims the kind, or unassign the one that does", seatName)
|
||||
}
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
@@ -62,3 +64,13 @@ func TestAClaimOutsideTheSetIsShownNotHidden(t *testing.T) {
|
||||
t.Fatalf("a claim outside the set was not shown: %+v", outside)
|
||||
}
|
||||
}
|
||||
|
||||
// A kinded bench is not handed over by `seat`: each kind is held by its claim (novox/hq ADR 0259).
|
||||
func TestAKindedBenchIsNotHandedOver(t *testing.T) {
|
||||
for _, bench := range []string{"channel", "intake"} {
|
||||
err := handOver(context.Background(), bench, "anchor/telegram", false)
|
||||
if err == nil || !strings.Contains(err.Error(), "is a kinded bench") {
|
||||
t.Errorf("%s: %v", bench, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,14 @@ func declaredArguments(v catalogue.Verb) (names []string, switches map[string]bo
|
||||
return names, switches
|
||||
}
|
||||
|
||||
// isList says a verb's argument is declared a list of text (catalogue's listed): given as a JSON array, it is
|
||||
// read as its items joined by commas, as the same argument given as one text would be.
|
||||
func isList(v catalogue.Verb, name string) bool {
|
||||
props, _ := v.Input["properties"].(map[string]any)
|
||||
p, _ := props[name].(map[string]any)
|
||||
return p != nil && p["type"] == "array"
|
||||
}
|
||||
|
||||
// readArguments refuses what the verb does not take, before anything is composed.
|
||||
func readArguments(verb string, args map[string]any) (*verbArguments, error) {
|
||||
v, known := controllerVerb(verb)
|
||||
@@ -150,6 +158,19 @@ func readArguments(verb string, args map[string]any) (*verbArguments, error) {
|
||||
return nil, fmt.Errorf("%s: %q is text, not true or false", verb, k)
|
||||
}
|
||||
value = fmt.Sprint(x)
|
||||
case []any:
|
||||
if !isList(v, k) {
|
||||
return nil, fmt.Errorf("%s: %q is text, and was given a list", verb, k)
|
||||
}
|
||||
items := make([]string, 0, len(x))
|
||||
for _, item := range x {
|
||||
text, ok := item.(string)
|
||||
if !ok || strings.TrimSpace(text) == "" || strings.Contains(text, ",") {
|
||||
return nil, fmt.Errorf("%s: %q is a list of names, and holds %v", verb, k, item)
|
||||
}
|
||||
items = append(items, strings.TrimSpace(text))
|
||||
}
|
||||
value = strings.Join(items, ",")
|
||||
default:
|
||||
return nil, fmt.Errorf("%s: %q is text, and was given %T", verb, k, x)
|
||||
}
|
||||
@@ -282,6 +303,8 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
return nil, errors.New("tools is answered from the records, not by a command")
|
||||
case "dead-letters":
|
||||
return nil, errors.New("dead-letters is answered by the serving controller, on its own connection, not by a command")
|
||||
case "root-free":
|
||||
return nil, errors.New("root-free is judged by the serving controller, on its own connection, not by a command")
|
||||
case "status":
|
||||
return []string{"status", "--json"}, nil
|
||||
case "nodes":
|
||||
@@ -1092,6 +1115,9 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) {
|
||||
if verb == "dead-letters" {
|
||||
return deadLettersAnswer(ctx, a)
|
||||
}
|
||||
if verb == "root-free" {
|
||||
return rootFreeAnswer(ctx, a.given["machines"], rootClock())
|
||||
}
|
||||
if verb == "doctor" {
|
||||
// From the serving controller, which runs the self-check and hears the signals
|
||||
// (novox/hq to-be 45 §4): the last verdict at once, or a run now.
|
||||
@@ -1182,7 +1208,10 @@ func actsOnAPlan(args map[string]any) bool {
|
||||
var inProcess = map[string]bool{"tools": true, "calls": true, "doctor": true,
|
||||
// What a consumer gave up on, read and changed on the serving controller's own connection (novox/hq
|
||||
// issue 330).
|
||||
"dead-letters": true}
|
||||
"dead-letters": true,
|
||||
// Whether a machine is root-free, judged live on the serving controller's store and connection (novox/hq ADR
|
||||
// 0259 §8): the router asks it before an approval.
|
||||
"root-free": true}
|
||||
|
||||
// answersFirst is a command line whose caller is answered before it runs: a push, by its verb or
|
||||
// through `command`. A push sends the machine holding the bus first when its user list changed, the
|
||||
|
||||
Reference in New Issue
Block a user