Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ef52011c9 | ||
|
|
f444e8599b | ||
|
|
93a50c0fd5 | ||
|
|
a62e5e05de | ||
|
|
b4c67282ab |
@@ -53,9 +53,26 @@ func assertBusObjects(ctx context.Context, inv *inventory.Inventory, r broker.Ra
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// And the work queues of seats that name their caller or their kind, with each holder's worker
|
||||||
|
// (novox/hq ADR 0259 §3): an ask queues until the router takes it, a channel's work until that kind
|
||||||
|
// takes it.
|
||||||
|
trafficStreams, trafficWorkers, err := seatTrafficObjects(ctx, inv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
// Every one tried, and every failure named: one module's consumer the bus refuses is no reason
|
// Every one tried, and every failure named: one module's consumer the bus refuses is no reason
|
||||||
// the modules after it in the list hear nothing (novox/hq issue 208, where this runs on each send).
|
// the modules after it in the list hear nothing (novox/hq issue 208, where this runs on each send).
|
||||||
var failed []error
|
var failed []error
|
||||||
|
for _, s := range trafficStreams {
|
||||||
|
if err := r.EnsureStream(s); err != nil {
|
||||||
|
failed = append(failed, fmt.Errorf("the work queue %s: %w", s.Name, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range trafficWorkers {
|
||||||
|
if err := r.EnsureConsumer(c); err != nil {
|
||||||
|
failed = append(failed, fmt.Errorf("the worker %s on %s: %w", c.Name, c.Stream, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, c := range consumers {
|
for _, c := range consumers {
|
||||||
if err := r.EnsureConsumer(c.Consumer); err != nil {
|
if err := r.EnsureConsumer(c.Consumer); err != nil {
|
||||||
failed = append(failed, fmt.Errorf("how %s on %s hears what it consumes: %w", c.Module, c.Node, err))
|
failed = append(failed, fmt.Errorf("how %s on %s hears what it consumes: %w", c.Module, c.Node, err))
|
||||||
@@ -130,6 +147,37 @@ func moduleConsumers(ctx context.Context, inv *inventory.Inventory) ([]broker.Mo
|
|||||||
return broker.ConsumersOf(users), nil
|
return broker.ConsumersOf(users), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seatTrafficObjects is the work queues and workers of seats that name their caller or their kind, from
|
||||||
|
// the records the user list is composed from.
|
||||||
|
func seatTrafficObjects(ctx context.Context, inv *inventory.Inventory) ([]broker.Stream, []broker.Consumer, error) {
|
||||||
|
records, err := inv.BusRecords(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
users, err := broker.Users(records)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
streams, workers := broker.SeatTrafficObjects(users)
|
||||||
|
// And the queue of every such seat the catalogue declares, held or not: work queues from registration,
|
||||||
|
// so what is submitted before a holder is assigned waits for it (the correctness review of 2026-10-08).
|
||||||
|
declared, err := inv.DeclaredTrafficSeats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
have := map[string]bool{}
|
||||||
|
for _, s := range streams {
|
||||||
|
have[s.Name] = true
|
||||||
|
}
|
||||||
|
for _, s := range broker.TrafficQueues(declared) {
|
||||||
|
if !have[s.Name] {
|
||||||
|
streams = append(streams, s)
|
||||||
|
have[s.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return streams, workers, nil
|
||||||
|
}
|
||||||
|
|
||||||
// moduleConsumerCount is how many modules hear what they consume, for the raise's one line.
|
// moduleConsumerCount is how many modules hear what they consume, for the raise's one line.
|
||||||
func moduleConsumerCount(ctx context.Context, inv *inventory.Inventory) (int, error) {
|
func moduleConsumerCount(ctx context.Context, inv *inventory.Inventory) (int, error) {
|
||||||
consumers, err := moduleConsumers(ctx, inv)
|
consumers, err := moduleConsumers(ctx, inv)
|
||||||
|
|||||||
@@ -121,6 +121,11 @@ var probeRegistry = []probe{
|
|||||||
{ID: probeReconnectsID, Asserts: "no user of the bus had its connection dropped more than twelve times in the " +
|
{ID: probeReconnectsID, Asserts: "no user of the bus had its connection dropped more than twelve times in the " +
|
||||||
"last hour: the bus module's nats_closed_connections", From: "issue 327", Kind: kindBusReconnects,
|
"last hour: the bus module's nats_closed_connections", From: "issue 327", Kind: kindBusReconnects,
|
||||||
Phase: 1, run: probeReconnects},
|
Phase: 1, run: probeReconnects},
|
||||||
|
// 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: kindAgentRoot, Phase: 2, run: probeAgentRoot},
|
||||||
{ID: "DW", Asserts: "the watchdogs of the signals table ran within three of their intervals",
|
{ID: "DW", Asserts: "the watchdogs of the signals table ran within three of their intervals",
|
||||||
From: "ADR 0227 rule 6: the watchers are watched", Kind: "watchdogs-silent", Phase: 1, run: probeWatchdogs},
|
From: "ADR 0227 rule 6: the watchers are watched", Kind: "watchdogs-silent", Phase: 1, run: probeWatchdogs},
|
||||||
// The core's health definitions (novox/hq to-be 45 §8, ADR 0236): what a core component's new build is
|
// The core's health definitions (novox/hq to-be 45 §8, ADR 0236): what a core component's new build is
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The self-check's probe of who can become root where the trusted parties run (novox/hq ADR 0259 §8, rule 3).
|
||||||
|
//
|
||||||
|
// 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.** So on every machine where a module
|
||||||
|
// of its own account runs, this probe asks two questions, and raises an urgent condition while either is yes:
|
||||||
|
//
|
||||||
|
// 1. can an account an agent runs as become root without a person there — passwordless sudo, or a group
|
||||||
|
// that is root by another name (docker, disk)? The agent's account is the one the coding-agent module on
|
||||||
|
// that machine names (`agent_account`), and the operator's account while it names none;
|
||||||
|
// 2. can an agent run a command it chooses, through the mesh's own tools, as an account that can — the login
|
||||||
|
// shell's `execute` runs as the machine's runtime account, which the mesh's acting tools give passwordless
|
||||||
|
// sudo? **Only where `execute` is served** (novox/hq ADR 0268): `execute` is an optional verb its holder
|
||||||
|
// withholds on a machine whose `execute` setting is not `serve`. The holder's seat being held there says
|
||||||
|
// nothing; whether the verb is served does. It counts as served while either says so — the holder's
|
||||||
|
// setting as the controller resolves it for that machine (a withheld value not yet pushed is still served),
|
||||||
|
// or the bus's discovery hearing the verb answered there — so a withheld setting the machine has not acted
|
||||||
|
// on yet, or a holder answering against its setting, is never taken for closed.
|
||||||
|
//
|
||||||
|
// The measurement is the sudo module's on that machine (`sudo_escalation`). **What cannot be measured is not a
|
||||||
|
// pass**: a machine it does not run on, or that does not answer, raises the same urgent condition, saying it
|
||||||
|
// was not measured — the router reads the condition, and a probe that merely failed to run would leave it
|
||||||
|
// approving there (hq ADR 0259 §8, as amended on 2026-10-09).
|
||||||
|
|
||||||
|
// kindAgentRoot is the condition an agent able to become root where a trusted party runs raises.
|
||||||
|
const kindAgentRoot = "agent-root"
|
||||||
|
|
||||||
|
// The tools the probe asks, of the modules on each machine.
|
||||||
|
const (
|
||||||
|
agentModule = "claude-code"
|
||||||
|
agentStatusTool = "claude_code_status"
|
||||||
|
sudoModule = "sudo"
|
||||||
|
sudoEscalation = "sudo_escalation"
|
||||||
|
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, for the condition.
|
||||||
|
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, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// escalation is the sudo module's answer for one account.
|
||||||
|
type escalation struct {
|
||||||
|
Account string `json:"account"`
|
||||||
|
Root bool `json:"root_without_a_person"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentRootFacts is what one machine says, as the probe reads it.
|
||||||
|
type agentRootFacts struct {
|
||||||
|
Machine string
|
||||||
|
Trusted []string // the modules of their own account on it
|
||||||
|
Agent string // the account agents run as there; "" when none run there
|
||||||
|
AgentNamed bool // the coding-agent module named it, rather than it being taken for the operator's
|
||||||
|
Runtime string // the account the machine's runtime runs as
|
||||||
|
LoginShell bool // the login shell's execute is served there, running commands as the runtime's account
|
||||||
|
// LoginShellWhy is why execute counts as served there, in words: its holder's setting, the bus, or both.
|
||||||
|
LoginShellWhy string
|
||||||
|
Answers map[string]escalation
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentRootObservations judges one machine's facts: an urgent condition while an agent can become root there
|
||||||
|
// without a person, one way or the other, naming which.
|
||||||
|
func agentRootObservations(f agentRootFacts) []conditions.Observation {
|
||||||
|
var ways []string
|
||||||
|
if f.Agent != "" {
|
||||||
|
if e := f.Answers[f.Agent]; e.Root {
|
||||||
|
named := "the operator's account, which agents run as while the coding-agent module names no other"
|
||||||
|
if f.AgentNamed {
|
||||||
|
named = "the account agents run as"
|
||||||
|
}
|
||||||
|
ways = append(ways, fmt.Sprintf("%s (%s) can become root without a person: %s", f.Agent, named, e.Why))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.LoginShell && f.Runtime != "" {
|
||||||
|
if e := f.Answers[f.Runtime]; e.Root {
|
||||||
|
served := ""
|
||||||
|
if f.LoginShellWhy != "" {
|
||||||
|
served = " (" + f.LoginShellWhy + ")"
|
||||||
|
}
|
||||||
|
ways = append(ways, fmt.Sprintf("the login shell runs any command an agent gives it as %s%s, which can "+
|
||||||
|
"become root without a person: %s", f.Runtime, served, e.Why))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ways) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sort.Strings(f.Trusted)
|
||||||
|
return []conditions.Observation{{Scope: conditions.ScopeMachine, ID: f.Machine, Token: kindAgentRoot,
|
||||||
|
Machine: f.Machine, Kind: kindAgentRoot, Severity: conditions.Urgent,
|
||||||
|
Summary: fmt.Sprintf("an agent can become root on %s without a person, where %s run as accounts of their "+
|
||||||
|
"own; until it cannot, the router approves nothing proven there (novox/hq ADR 0259 §8): %s",
|
||||||
|
f.Machine, strings.Join(f.Trusted, ", "), strings.Join(ways, "; ")),
|
||||||
|
Headline: "Root without you on " + f.Machine,
|
||||||
|
Needs: "choose how programs working for you on " + f.Machine + " stop becoming root without asking you.",
|
||||||
|
Explanation: "The modules that prove your answers from your phone run on " + f.Machine + ", and a program " +
|
||||||
|
"working for you there can become root without asking you, so it could answer in your name. Until " +
|
||||||
|
"that changes, answers from your phone can only acknowledge.",
|
||||||
|
Resolved: "Nothing on " + f.Machine + " becomes root without you any more"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeAgentRoot is the probe: every machine a module of its own account runs on, judged.
|
||||||
|
func probeAgentRoot(ctx context.Context, d *doctor) ([]conditions.Observation, error) {
|
||||||
|
inv := d.open.inventory
|
||||||
|
entries, err := inv.Catalogued(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
facts := map[string]*agentRootFacts{}
|
||||||
|
agentOn, sudoOn := map[string]bool{}, map[string]bool{}
|
||||||
|
for _, e := range entries {
|
||||||
|
for _, node := range e.On {
|
||||||
|
switch {
|
||||||
|
case e.Manifest.RunsAs != "":
|
||||||
|
f := facts[node]
|
||||||
|
if f == nil {
|
||||||
|
f = &agentRootFacts{Machine: node, Answers: map[string]escalation{}}
|
||||||
|
facts[node] = f
|
||||||
|
}
|
||||||
|
f.Trusted = append(f.Trusted, e.Manifest.Module)
|
||||||
|
case e.Manifest.Module == agentModule:
|
||||||
|
agentOn[node] = true
|
||||||
|
case e.Manifest.Module == sudoModule:
|
||||||
|
sudoOn[node] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
machines := make([]string, 0, len(facts))
|
||||||
|
for m := range facts {
|
||||||
|
machines = append(machines, m)
|
||||||
|
}
|
||||||
|
sort.Strings(machines)
|
||||||
|
var out []conditions.Observation
|
||||||
|
var unread []string
|
||||||
|
if len(machines) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// Whether the login shell's execute is served where a trusted party runs (novox/hq ADR 0268): the
|
||||||
|
// holder's setting for that machine, and what the bus hears answered there. A discovery that could not be
|
||||||
|
// asked leaves only the setting, which is said: the probe then cannot show the verb withheld.
|
||||||
|
// A discovery that could not be asked counts execute as served (loginShellServed), and says so.
|
||||||
|
heard, derr := discoverSeatVerbs(ctx, d.js.Conn())
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.Manifest.ClaimsSeat(loginShellSeat) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, node := range e.On {
|
||||||
|
f := facts[node]
|
||||||
|
if f == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var setting *any
|
||||||
|
if _, declared := e.Manifest.Settings[loginShellVerb]; declared {
|
||||||
|
layers, err := inv.SettingsFor(ctx, node, e.Manifest.Module)
|
||||||
|
if err != nil {
|
||||||
|
f.LoginShell, f.LoginShellWhy = true, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.LoginShell, f.LoginShellWhy = loginShellServed(e.Manifest.Module, claimServes(e.Manifest, loginShellSeat, loginShellVerb),
|
||||||
|
setting, heard[loginShellSeat][loginShellVerb][node], derr == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range machines {
|
||||||
|
f := facts[m]
|
||||||
|
record, err := inv.NodeByName(ctx, m)
|
||||||
|
if err != nil {
|
||||||
|
unread = append(unread, m+": "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f.Runtime = record.Account
|
||||||
|
if agentOn[m] {
|
||||||
|
f.Agent = record.Account
|
||||||
|
if a, err := link.AskModuleToolOn(ctx, d.js.Conn(), agentModule, agentStatusTool, m, map[string]any{}, 10*time.Second); err == nil && a.Error == "" {
|
||||||
|
var status struct {
|
||||||
|
AgentAccount string `json:"agent_account"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(a.Result, &status) == nil && status.AgentAccount != "" {
|
||||||
|
f.Agent, f.AgentNamed = status.AgentAccount, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sudoOn[m] {
|
||||||
|
unread = append(unread, m+": the sudo module is not assigned there, so who can become root is not measured")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var accounts []string
|
||||||
|
for _, a := range []string{f.Agent, f.Runtime} {
|
||||||
|
if a != "" && !slices.Contains(accounts, a) {
|
||||||
|
accounts = append(accounts, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(accounts) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a, err := link.AskModuleToolOn(ctx, d.js.Conn(), sudoModule, sudoEscalation, m, map[string]any{"accounts": accounts}, 15*time.Second)
|
||||||
|
if err == nil && a.Error != "" {
|
||||||
|
err = fmt.Errorf("%s", a.Error)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
unread = append(unread, m+": "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var answers []escalation
|
||||||
|
if err := json.Unmarshal(a.Result, &answers); err != nil {
|
||||||
|
unread = append(unread, m+": the sudo module's answer could not be read: "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range answers {
|
||||||
|
f.Answers[e.Account] = e
|
||||||
|
}
|
||||||
|
out = append(out, agentRootObservations(*f)...)
|
||||||
|
}
|
||||||
|
// Each machine whose measure failed is said as not measured, the same condition: never a pass.
|
||||||
|
for _, m := range machines {
|
||||||
|
var why []string
|
||||||
|
for _, u := range unread {
|
||||||
|
if strings.HasPrefix(u, m+": ") {
|
||||||
|
why = append(why, strings.TrimPrefix(u, m+": "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(why) > 0 {
|
||||||
|
out = append(out, agentRootUnmeasured(*facts[m], strings.Join(why, "; ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentRootUnmeasured is the condition of a machine where a trusted party runs and who can become root was
|
||||||
|
// not measured: urgent, like a measured yes, because the router believes a proof only where it is a no.
|
||||||
|
func agentRootUnmeasured(f agentRootFacts, why string) conditions.Observation {
|
||||||
|
trusted := append([]string(nil), f.Trusted...)
|
||||||
|
sort.Strings(trusted)
|
||||||
|
return conditions.Observation{Scope: conditions.ScopeMachine, ID: f.Machine, Token: kindAgentRoot,
|
||||||
|
Machine: f.Machine, Kind: kindAgentRoot, Severity: conditions.Urgent,
|
||||||
|
Summary: fmt.Sprintf("whether an agent can become root on %s without a person is not measured, where %s "+
|
||||||
|
"run as accounts of their own; until it is, the router approves nothing proven there (novox/hq ADR "+
|
||||||
|
"0259 §8): %s", f.Machine, strings.Join(trusted, ", "), why),
|
||||||
|
Headline: "Root not checked on " + f.Machine,
|
||||||
|
Needs: "let the mesh check who can become root on " + f.Machine + ": assign the sudo module there, or bring it back.",
|
||||||
|
Explanation: "The modules that prove your answers from your phone run on " + f.Machine + ", and the mesh " +
|
||||||
|
"could not check whether a program working for you there can become root without asking you. Until " +
|
||||||
|
"it can, answers from your phone can only acknowledge.",
|
||||||
|
Resolved: "The mesh checks who can become root on " + f.Machine + " again"}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/conditions"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq ADR 0259 §8, rule 3: the probe fails today — agents run as the operator's account, which has
|
||||||
|
// passwordless sudo, and the login shell runs their commands as it — and passes only once neither holds.
|
||||||
|
func TestAnAgentAbleToBecomeRootWhereTheRouterRunsIsSaid(t *testing.T) {
|
||||||
|
root := escalation{Root: true, Why: "(ALL : ALL) NOPASSWD: ALL"}
|
||||||
|
none := escalation{Why: "no rule lets it without a password"}
|
||||||
|
base := func() agentRootFacts {
|
||||||
|
return agentRootFacts{Machine: "anchor", Trusted: []string{"telegram", "messenger"}, Runtime: "ops",
|
||||||
|
Answers: map[string]escalation{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
today := base()
|
||||||
|
today.Agent, today.LoginShell = "ops", true
|
||||||
|
today.Answers["ops"] = root
|
||||||
|
got := agentRootObservations(today)
|
||||||
|
if len(got) != 1 || got[0].Severity != conditions.Urgent || got[0].Kind != kindAgentRoot ||
|
||||||
|
!strings.Contains(got[0].Summary, "the operator's account") || !strings.Contains(got[0].Summary, "the login shell") {
|
||||||
|
t.Fatalf("today: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
agentsMoved := base()
|
||||||
|
agentsMoved.Agent, agentsMoved.AgentNamed, agentsMoved.LoginShell = "agents", true, true
|
||||||
|
agentsMoved.Answers["agents"], agentsMoved.Answers["ops"] = none, root
|
||||||
|
if got := agentRootObservations(agentsMoved); len(got) != 1 || strings.Contains(got[0].Summary, "agents (") ||
|
||||||
|
!strings.Contains(got[0].Summary, "the login shell") {
|
||||||
|
t.Errorf("agents of their own account, the login shell still the runtime's: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
closed := base()
|
||||||
|
closed.Agent, closed.AgentNamed = "agents", true
|
||||||
|
closed.Answers["agents"], closed.Answers["ops"] = none, root
|
||||||
|
if got := agentRootObservations(closed); len(got) != 0 {
|
||||||
|
t.Errorf("agents of their own account and no login shell there: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
noAgents := base()
|
||||||
|
noAgents.Answers["ops"] = root
|
||||||
|
if got := agentRootObservations(noAgents); len(got) != 0 {
|
||||||
|
t.Errorf("no agent runs there and no login shell is held: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Its words are plain, as every condition's are (novox/hq ADR 0253).
|
||||||
|
func TestTheRootConditionIsSaidInPlainWords(t *testing.T) {
|
||||||
|
f := agentRootFacts{Machine: "anchor", Trusted: []string{"telegram"}, Runtime: "ops", Agent: "ops",
|
||||||
|
Answers: map[string]escalation{"ops": {Root: true, Why: "x"}}}
|
||||||
|
o := agentRootObservations(f)[0]
|
||||||
|
if why, ok := conditions.PlainWords(conditions.Words{Headline: o.Headline, Explanation: o.Explanation,
|
||||||
|
Needs: o.Needs, Resolved: o.Resolved}, "anchor"); !ok {
|
||||||
|
t.Errorf("not plain: %s", why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0268: the login shell counts only where its execute is served, and fails closed — the holder's
|
||||||
|
// setting resolving to anything but serve and the bus hearing no execute there is the one way it is withheld.
|
||||||
|
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 control-node with execute withheld and agents of their own account: nothing is said.
|
||||||
|
f := agentRootFacts{Machine: "anchor", Trusted: []string{"telegram"}, Runtime: "ops", Agent: "agents", AgentNamed: true,
|
||||||
|
Answers: map[string]escalation{"ops": {Root: true, Why: "NOPASSWD"}, "agents": {Why: "none"}}}
|
||||||
|
f.LoginShell, f.LoginShellWhy = loginShellServed("zsh", true, val("withhold"), false, true)
|
||||||
|
if got := agentRootObservations(f); len(got) != 0 {
|
||||||
|
t.Errorf("execute withheld and agents confined: %+v", got)
|
||||||
|
}
|
||||||
|
// Withheld in the setting, still answered on the bus: said, with why.
|
||||||
|
f.LoginShell, f.LoginShellWhy = loginShellServed("zsh", true, val("withhold"), true, true)
|
||||||
|
if got := agentRootObservations(f); len(got) != 1 || !strings.Contains(got[0].Summary, "the bus hears execute answered there") {
|
||||||
|
t.Errorf("execute still answered: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hq ADR 0259 §8 as amended on 2026-10-09: a machine where a trusted party runs and who can become root is not
|
||||||
|
// measured raises the same urgent condition, so the router, which reads the condition, approves nothing there.
|
||||||
|
func TestRootNotMeasuredWhereTheRouterRunsIsSaidAndNeverAPass(t *testing.T) {
|
||||||
|
o := agentRootUnmeasured(agentRootFacts{Machine: "anchor", Trusted: []string{"telegram", "messenger"}},
|
||||||
|
"the sudo module is not assigned there, so who can become root is not measured")
|
||||||
|
if o.Kind != kindAgentRoot || o.Severity != conditions.Urgent || o.Machine != "anchor" ||
|
||||||
|
!strings.Contains(o.Summary, "not measured") || !strings.Contains(o.Summary, "messenger, telegram") {
|
||||||
|
t.Errorf("%+v", o)
|
||||||
|
}
|
||||||
|
if why, ok := conditions.PlainWords(conditions.Words{Headline: o.Headline, Explanation: o.Explanation,
|
||||||
|
Needs: o.Needs, Resolved: o.Resolved}, "anchor"); !ok {
|
||||||
|
t.Errorf("not plain: %s", why)
|
||||||
|
}
|
||||||
|
// The same key as a measured yes, so the router's one rule reads both.
|
||||||
|
measured := agentRootObservations(agentRootFacts{Machine: "anchor", Trusted: []string{"telegram"}, Runtime: "ops",
|
||||||
|
Agent: "ops", Answers: map[string]escalation{"ops": {Root: true, Why: "x"}}})[0]
|
||||||
|
if o.Key() != measured.Key() {
|
||||||
|
t.Errorf("keys differ: %s, %s", o.Key(), measured.Key())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -637,31 +637,8 @@ const (
|
|||||||
// discoverHolders asks the bus's discovery who serves what, and answers seat → machine for every
|
// discoverHolders asks the bus's discovery who serves what, and answers seat → machine for every
|
||||||
// endpoint a seat's verb is served on.
|
// endpoint a seat's verb is served on.
|
||||||
func discoverHolders(ctx context.Context, conn *nats.Conn) (map[string]map[string]bool, error) {
|
func discoverHolders(ctx context.Context, conn *nats.Conn) (map[string]map[string]bool, error) {
|
||||||
inbox := conn.NewRespInbox()
|
|
||||||
sub, err := conn.SubscribeSync(inbox)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer func() { _ = sub.Unsubscribe() }()
|
|
||||||
if err := conn.PublishRequest("$SRV.INFO", inbox, nil); err != nil {
|
|
||||||
return nil, fmt.Errorf("asking the bus who serves what: %w", err)
|
|
||||||
}
|
|
||||||
out := map[string]map[string]bool{}
|
out := map[string]map[string]bool{}
|
||||||
deadline := time.Now().Add(discoveryPatience)
|
err := discoverServices(ctx, conn, func(info micro.Info) {
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
wait, cancel := context.WithTimeout(ctx, discoveryQuiet)
|
|
||||||
msg, err := sub.NextMsgWithContext(wait)
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
var info micro.Info
|
|
||||||
if json.Unmarshal(msg.Data, &info) != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, e := range info.Endpoints {
|
for _, e := range info.Endpoints {
|
||||||
seat, node := e.Metadata["seat"], e.Metadata["node"]
|
seat, node := e.Metadata["seat"], e.Metadata["node"]
|
||||||
if seat == "" {
|
if seat == "" {
|
||||||
@@ -680,8 +657,69 @@ func discoverHolders(ctx context.Context, conn *nats.Conn) (map[string]map[strin
|
|||||||
out[info.Name] = map[string]bool{}
|
out[info.Name] = map[string]bool{}
|
||||||
}
|
}
|
||||||
out[info.Name][info.ID] = true
|
out[info.Name][info.ID] = true
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoverSeatVerbs asks the bus's discovery the same, one level finer: seat → verb → machine, for every
|
||||||
|
// seat verb answered (an endpoint's `tool` is its verb). A seat held where a verb is withheld (novox/hq ADR
|
||||||
|
// 0268) shows the seat and not that verb.
|
||||||
|
func discoverSeatVerbs(ctx context.Context, conn *nats.Conn) (map[string]map[string]map[string]bool, error) {
|
||||||
|
out := map[string]map[string]map[string]bool{}
|
||||||
|
err := discoverServices(ctx, conn, func(info micro.Info) {
|
||||||
|
for _, e := range info.Endpoints {
|
||||||
|
seat, verb, node := e.Metadata["seat"], e.Metadata["tool"], e.Metadata["node"]
|
||||||
|
if seat == "" || verb == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if node == "" {
|
||||||
|
node = info.ID
|
||||||
|
}
|
||||||
|
if out[seat] == nil {
|
||||||
|
out[seat] = map[string]map[string]bool{}
|
||||||
|
}
|
||||||
|
if out[seat][verb] == nil {
|
||||||
|
out[seat][verb] = map[string]bool{}
|
||||||
|
}
|
||||||
|
out[seat][verb][node] = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoverServices asks the bus's discovery once and hands every service's answer to visit, waiting
|
||||||
|
// discoveryQuiet after the last and discoveryPatience at the most.
|
||||||
|
func discoverServices(ctx context.Context, conn *nats.Conn, visit func(micro.Info)) error {
|
||||||
|
if conn == nil {
|
||||||
|
return errors.New("the controller holds no connection to the bus")
|
||||||
}
|
}
|
||||||
return out, nil
|
inbox := conn.NewRespInbox()
|
||||||
|
sub, err := conn.SubscribeSync(inbox)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = sub.Unsubscribe() }()
|
||||||
|
if err := conn.PublishRequest("$SRV.INFO", inbox, nil); err != nil {
|
||||||
|
return fmt.Errorf("asking the bus who serves what: %w", err)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(discoveryPatience)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
wait, cancel := context.WithTimeout(ctx, discoveryQuiet)
|
||||||
|
msg, err := sub.NextMsgWithContext(wait)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var info micro.Info
|
||||||
|
if json.Unmarshal(msg.Data, &info) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
visit(info)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// probeArchives is D4: every archive the mesh keeps is held by its manifest in the artifact store.
|
// probeArchives is D4: every archive the mesh keeps is held by its manifest in the artifact store.
|
||||||
|
|||||||
@@ -135,6 +135,12 @@ func handOver(ctx context.Context, seatName, to string, adding bool) error {
|
|||||||
if !ok || nodeName == "" || module == "" {
|
if !ok || nodeName == "" || module == "" {
|
||||||
return fmt.Errorf("the new holder is named <node>/<module>, not %q", to)
|
return fmt.Errorf("the new holder is named <node>/<module>, not %q", to)
|
||||||
}
|
}
|
||||||
|
// A kinded bench is held once per kind, by the claims themselves (novox/hq ADR 0234 §2, ADR 0259): the
|
||||||
|
// record of who holds a seat has no kind, so a handover would name one holder for every kind.
|
||||||
|
if catalogue.KindedBenches[seatName] {
|
||||||
|
return fmt.Errorf("%s is a kinded bench: each kind is held by the module claiming it, and is not handed "+
|
||||||
|
"over by `seat` — assign the module that claims the kind, or unassign the one that does", seatName)
|
||||||
|
}
|
||||||
open, err := openStores(ctx)
|
open, err := openStores(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
@@ -62,3 +64,13 @@ func TestAClaimOutsideTheSetIsShownNotHidden(t *testing.T) {
|
|||||||
t.Fatalf("a claim outside the set was not shown: %+v", outside)
|
t.Fatalf("a claim outside the set was not shown: %+v", outside)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A kinded bench is not handed over by `seat`: each kind is held by its claim (novox/hq ADR 0259).
|
||||||
|
func TestAKindedBenchIsNotHandedOver(t *testing.T) {
|
||||||
|
for _, bench := range []string{"channel", "intake"} {
|
||||||
|
err := handOver(context.Background(), bench, "anchor/telegram", false)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "is a kinded bench") {
|
||||||
|
t.Errorf("%s: %v", bench, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -126,7 +126,9 @@ func ConsumerFor(p Principal) (Consumer, bool) {
|
|||||||
// A module that reacts to anything — a module's events or a role's (novox/hq ADR 0121). Watching
|
// A module that reacts to anything — a module's events or a role's (novox/hq ADR 0121). Watching
|
||||||
// a role was missing here, so the one module that does it got no consumer at all: it started,
|
// a role was missing here, so the one module that does it got no consumer at all: it started,
|
||||||
// connected, and its graph stayed empty with nothing anywhere reporting why.
|
// connected, and its graph stayed empty with nothing anywhere reporting why.
|
||||||
if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0) {
|
// And one that hears its own answers on a seat it uses (novox/hq ADR 0259 §3): an asker's warrants.
|
||||||
|
hearsItsOwn := len(SeatTrafficOf(p.Module, nil, p.Uses, nil).Subscribe) > 0
|
||||||
|
if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0 && !hearsItsOwn) {
|
||||||
return Consumer{}, false
|
return Consumer{}, false
|
||||||
}
|
}
|
||||||
perms, err := PermissionsFor(p)
|
perms, err := PermissionsFor(p)
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ type Membership struct {
|
|||||||
// and refuses, with the reason, what is not on it — the bus enforces only the union over every
|
// and refuses, with the reason, what is not on it — the bus enforces only the union over every
|
||||||
// module on the machine.
|
// module on the machine.
|
||||||
State []StateIssued `json:"state,omitempty"`
|
State []StateIssued `json:"state,omitempty"`
|
||||||
|
// SeatTraffic is what this module's code may submit, say, hear, take, ask, answer and read on seats
|
||||||
|
// that name their caller or their kind (novox/hq ADR 0259 §3). The runtime carrying the module
|
||||||
|
// publishes, takes and answers for it only what is listed here: the bus enforces only the union
|
||||||
|
// over every module on the machine, so one module's code reaching another's name or kind through
|
||||||
|
// the runtime is the runtime's to refuse.
|
||||||
|
SeatTraffic *SeatTraffic `json:"seat-traffic,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Served is one address a tool is answered on.
|
// Served is one address a tool is answered on.
|
||||||
@@ -78,6 +84,8 @@ type Placements struct {
|
|||||||
// Interchangeable is each module whose definition says its instances are the same anywhere,
|
// Interchangeable is each module whose definition says its instances are the same anywhere,
|
||||||
// so the module's plain subject is issued to all of them in one queue.
|
// so the module's plain subject is issued to all of them in one queue.
|
||||||
Interchangeable map[string]bool
|
Interchangeable map[string]bool
|
||||||
|
// Kinds is every kind held of a kinded bench, by whom and with what capabilities (ADR 0259 §5).
|
||||||
|
Kinds []KindHeld
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnswersForTheModule says whether an instance of a module on one machine is issued the module's
|
// AnswersForTheModule says whether an instance of a module on one machine is issued the module's
|
||||||
@@ -109,6 +117,20 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.State = stateIssuedFor(d, node)
|
m.State = stateIssuedFor(d, node)
|
||||||
|
t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches)
|
||||||
|
for _, s := range append(append([]Seat{}, d.Uses...), d.Watches...) {
|
||||||
|
if !s.Kinded {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, k := range where.Kinds {
|
||||||
|
if k.Seat == s.Name && !kindListed(t.Kinds, k) {
|
||||||
|
t.Kinds = append(t.Kinds, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(t.Publish)+len(t.Subscribe)+len(t.Answers)+len(t.Workers)+len(t.Records)+len(t.Kinds) > 0 {
|
||||||
|
m.SeatTraffic = &t
|
||||||
|
}
|
||||||
if len(d.Invokes) > 0 {
|
if len(d.Invokes) > 0 {
|
||||||
m.Reaches = map[string][]string{}
|
m.Reaches = map[string][]string{}
|
||||||
for _, t := range d.Invokes {
|
for _, t := range d.Invokes {
|
||||||
@@ -145,5 +167,48 @@ func PlacementsOf(r Records, interchangeable map[string]bool) Placements {
|
|||||||
for _, nodes := range p.Nodes {
|
for _, nodes := range p.Nodes {
|
||||||
sort.Strings(nodes)
|
sort.Strings(nodes)
|
||||||
}
|
}
|
||||||
|
for node, declared := range r.Assigned {
|
||||||
|
for _, d := range declared {
|
||||||
|
for _, s := range d.Holds {
|
||||||
|
if s.Kinded && s.Kind != "" {
|
||||||
|
p.Kinds = append(p.Kinds, KindHeld{Seat: s.Name, Kind: s.Kind, Module: d.Module, Node: node,
|
||||||
|
Capabilities: placedCapabilities(s.Capabilities, d.RunsAs)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(p.Kinds, func(i, j int) bool {
|
||||||
|
a, b := p.Kinds[i], p.Kinds[j]
|
||||||
|
if a.Seat != b.Seat {
|
||||||
|
return a.Seat < b.Seat
|
||||||
|
}
|
||||||
|
if a.Kind != b.Kind {
|
||||||
|
return a.Kind < b.Kind
|
||||||
|
}
|
||||||
|
return a.Node < b.Node
|
||||||
|
})
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// placedCapabilities is what a kind's claim promises, as far as its placement lets the router believe it
|
||||||
|
// (novox/hq ADR 0259 §8): `verified-sender` only from a holder that runs as an account of its own, on a bus
|
||||||
|
// account of its own — never one the machine's runtime carries as the operator's account.
|
||||||
|
func placedCapabilities(declared []string, runsAs string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, c := range declared {
|
||||||
|
if c == "verified-sender" && runsAs == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func kindListed(list []KindHeld, k KindHeld) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if x.Seat == k.Seat && x.Kind == k.Kind && x.Module == k.Module && x.Node == k.Node {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
+53
-1
@@ -63,6 +63,23 @@ type Seat struct {
|
|||||||
Emits []string
|
Emits []string
|
||||||
Serves []string
|
Serves []string
|
||||||
Versions []string // protocol versions served beside the current one; empty for v1 only
|
Versions []string // protocol versions served beside the current one; empty for v1 only
|
||||||
|
|
||||||
|
// Kinded says the seat is a kinded bench (novox/hq ADR 0234 §2, ADR 0259 §3): each holder claims one
|
||||||
|
// kind, and its verbs' subjects carry it. Kind is the kind this principal's claim names, for a seat it
|
||||||
|
// holds.
|
||||||
|
Kinded bool
|
||||||
|
Kind string
|
||||||
|
// ByCaller are the accepts and emits whose last token names the calling module (ADR 0259 §3).
|
||||||
|
ByCaller []string
|
||||||
|
// Proofs are the seat's proof verbs: core request and reply, never on a stream (ADR 0259 §3).
|
||||||
|
Proofs []string
|
||||||
|
// Records are the holder's buckets, by their full name, each user reads under its own name.
|
||||||
|
Records []string
|
||||||
|
// Capabilities are what this principal's claim of a kinded bench promises (ADR 0234 §2).
|
||||||
|
Capabilities []string
|
||||||
|
// DeclaredBy is the module that declares the seat. On a kinded bench it alone submits work to a kind
|
||||||
|
// and answers its proofs (novox/hq ADR 0259 §8): the router, not any user or watcher of the bench.
|
||||||
|
DeclaredBy string
|
||||||
}
|
}
|
||||||
|
|
||||||
// A Principal is one user of the bus. Its permissions are derived from what it declares and
|
// A Principal is one user of the bus. Its permissions are derived from what it declares and
|
||||||
@@ -530,6 +547,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
// 2b. Events of a role it watches, under the seat's own namespace. Subscribe only: watching a
|
// 2b. Events of a role it watches, under the seat's own namespace. Subscribe only: watching a
|
||||||
// role is hearing what it announced, not taking part in it.
|
// role is hearing what it announced, not taking part in it.
|
||||||
for _, w := range p.Watches {
|
for _, w := range p.Watches {
|
||||||
|
if w.Kinded {
|
||||||
|
continue // composed by SeatTrafficOf below
|
||||||
|
}
|
||||||
for _, e := range w.Emits {
|
for _, e := range w.Emits {
|
||||||
sub = append(sub, seatSubject(w, "event", e))
|
sub = append(sub, seatSubject(w, "event", e))
|
||||||
}
|
}
|
||||||
@@ -548,6 +568,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
|
|
||||||
// 3. Seats it holds: full participation.
|
// 3. Seats it holds: full participation.
|
||||||
for _, s := range p.Holds {
|
for _, s := range p.Holds {
|
||||||
|
if s.isNewTraffic() {
|
||||||
|
// Composed by SeatTrafficOf below, worker and all; only its tools are served here.
|
||||||
|
for _, t := range s.Serves {
|
||||||
|
sub = append(sub, seatToolSubject(s, t, p.Node))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
// Taking work from the role's queue: the worker consumer every holder shares (asked
|
// Taking work from the role's queue: the worker consumer every holder shares (asked
|
||||||
// about, pulled from, acknowledged), on the seat's own stream (novox/hq ADR 0190). A
|
// about, pulled from, acknowledged), on the seat's own stream (novox/hq ADR 0190). A
|
||||||
// holder pulls — asks the consumer for its next message, answered on its own inbox —
|
// holder pulls — asks the consumer for its next message, answered on its own inbox —
|
||||||
@@ -590,7 +617,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
// seat's inbound subject and watch other modules' traffic, nor publish its outbound
|
// seat's inbound subject and watch other modules' traffic, nor publish its outbound
|
||||||
// events and lie about outcomes (design 29 §2).
|
// events and lie about outcomes (design 29 §2).
|
||||||
for _, s := range p.Uses {
|
for _, s := range p.Uses {
|
||||||
for _, a := range s.Accepts {
|
for _, a := range plainVerbs(s, s.Accepts) {
|
||||||
pub = append(pub, seatSubject(s, "accept", a))
|
pub = append(pub, seatSubject(s, "accept", a))
|
||||||
}
|
}
|
||||||
for _, t := range s.Serves {
|
for _, t := range s.Serves {
|
||||||
@@ -603,6 +630,12 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
pub = append(pub, stateGrants(stateAccess{Module: p.Module, Node: p.Node, Keeps: p.State,
|
pub = append(pub, stateGrants(stateAccess{Module: p.Module, Node: p.Node, Keeps: p.State,
|
||||||
PerMachine: p.PerMachine, Reads: p.Reads, KeyedReads: p.KeyedReads})...)
|
PerMachine: p.PerMachine, Reads: p.Reads, KeyedReads: p.KeyedReads})...)
|
||||||
|
|
||||||
|
// 6. Its traffic on seats that name their caller or their kind, ask proofs or keep records
|
||||||
|
// (novox/hq ADR 0259 §3).
|
||||||
|
tp, ts := SeatTrafficOf(p.Module, p.Holds, p.Uses, p.Watches).grants()
|
||||||
|
pub = append(pub, tp...)
|
||||||
|
sub = append(sub, ts...)
|
||||||
|
|
||||||
case KindNodeTools:
|
case KindNodeTools:
|
||||||
// **One process serves what every module on the machine would have served for itself**
|
// **One process serves what every module on the machine would have served for itself**
|
||||||
// (novox/hq ADR 0175). Each carried module's whole tool namespace — the same grant that
|
// (novox/hq ADR 0175). Each carried module's whole tool namespace — the same grant that
|
||||||
@@ -680,6 +713,25 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
pub = append(pub, stateGrants(stateAccess{Module: d.Module, Node: p.Node, Keeps: stateNames(d.State),
|
pub = append(pub, stateGrants(stateAccess{Module: d.Module, Node: p.Node, Keeps: stateNames(d.State),
|
||||||
PerMachine: perMachineNames(d.State), Reads: d.Reads, KeyedReads: d.KeyedReads})...)
|
PerMachine: perMachineNames(d.State), Reads: d.Reads, KeyedReads: d.KeyedReads})...)
|
||||||
}
|
}
|
||||||
|
// **Never the traffic of a trusted holder** (novox/hq ADR 0259 §8): the machine's runtime runs as the
|
||||||
|
// operator's account, which every agent runs as, so a module saying warrants or speaking for a kind
|
||||||
|
// that proves its sender is never composed into it — refused here, naming it, whatever registration
|
||||||
|
// let through.
|
||||||
|
for _, d := range p.Carries {
|
||||||
|
if why := trustedTraffic(d); why != "" {
|
||||||
|
return Permissions{}, fmt.Errorf("%s on %s is carried by the machine's runtime, and %s: it runs "+
|
||||||
|
"as an account of its own, never the runtime's (novox/hq ADR 0259)", d.Module, p.Node, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// **And the seat traffic of the modules it carries** (novox/hq ADR 0259 §3): a bundle reaches the
|
||||||
|
// bus only through its runtime, so the runtime is granted the union. That one module's code does
|
||||||
|
// not publish under another's name or kind through it is the runtime's to keep, from the seat
|
||||||
|
// traffic each module's membership lists.
|
||||||
|
for _, d := range p.Carries {
|
||||||
|
tp, ts := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches).grants()
|
||||||
|
pub = append(pub, tp...)
|
||||||
|
sub = append(sub, ts...)
|
||||||
|
}
|
||||||
sub = unique(sub)
|
sub = unique(sub)
|
||||||
pub = unique(pub)
|
pub = unique(pub)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
package broker
|
||||||
|
|
||||||
|
import "sort"
|
||||||
|
|
||||||
|
// What a module may say and take on the seats it holds, uses and watches, beyond tools (novox/hq ADR
|
||||||
|
// 0259 §3, to-be 46 §10).
|
||||||
|
//
|
||||||
|
// Three rules are added to the ones a seat always had, each a subject whose last token names who may
|
||||||
|
// publish it, granted to that publisher alone — the way a node seat's event about a machine carries the
|
||||||
|
// machine (ADR 0219):
|
||||||
|
//
|
||||||
|
// - **A verb named by its caller** (`by-caller`). A user of the seat submits that accept, and hears that
|
||||||
|
// event, under its own module's name and no other: `accept.ask.<module>`, `event.decided.<module>`. The
|
||||||
|
// holder takes every caller's accept and says the event to any caller. So an ask's asker is a fact the
|
||||||
|
// server enforces, and a warrant reaches only the asker it is for.
|
||||||
|
// - **A kinded bench** (ADR 0234 §2). A holder claims one kind and takes its own kind's accepts, says its
|
||||||
|
// own kind's events and asks its own kind's proofs, and nothing of another kind; a user submits to any
|
||||||
|
// kind. Each kind has its own worker on the seat's queue, so a holder that is away keeps its work and
|
||||||
|
// holds up no other kind.
|
||||||
|
// - **A proof** (`proofs`): core request and reply on `mesh.seat.<seat>.proof.<verb>.<kind>`. No stream's
|
||||||
|
// subjects cover it, so what travels there — a code typed by the operator — is never persisted. A kinded
|
||||||
|
// holder asks with its own kind; the modules that watch the seat answer.
|
||||||
|
//
|
||||||
|
// And one read: **a holder's records**, a bucket the seat names, read by each user under its own name only
|
||||||
|
// (`$KV.<bucket>.<module>.>`), so an asker reads the state of its own asks and no other asker's.
|
||||||
|
|
||||||
|
// Worker is one durable consumer a holder pulls a seat's work from.
|
||||||
|
type Worker struct {
|
||||||
|
Stream string
|
||||||
|
Consumer string
|
||||||
|
Filter string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeatTraffic is one module's seat traffic beyond tools. Publish and Subscribe are subject patterns, in the
|
||||||
|
// server's wildcards; the runtime that carries the module checks a bundle's request against them, since
|
||||||
|
// the runtime's own principal holds the union of every module it carries.
|
||||||
|
type SeatTraffic struct {
|
||||||
|
// Publish is what it submits (accepts of seats it uses), says (events of seats it holds) and asks
|
||||||
|
// (proofs of seats it holds a kind of).
|
||||||
|
Publish []string `json:"publish,omitempty"`
|
||||||
|
// Subscribe is what it hears: events of seats it uses that are named by caller, events of seats it
|
||||||
|
// watches, and accepts of seats it holds.
|
||||||
|
Subscribe []string `json:"subscribe,omitempty"`
|
||||||
|
// Answers is the proof subjects it answers, as a watcher of a kinded seat.
|
||||||
|
Answers []string `json:"answers,omitempty"`
|
||||||
|
// Workers are the work queues it takes from, as a holder.
|
||||||
|
Workers []Worker `json:"workers,omitempty"`
|
||||||
|
// Records is the direct-get subjects of the records it reads under its own name.
|
||||||
|
Records []string `json:"records,omitempty"`
|
||||||
|
// Kinds are the holders of every kinded bench it uses or watches, with what each promises: the one
|
||||||
|
// account of which channel is which, and what it can carry, that the router judges an answer by. The
|
||||||
|
// controller's, from the claims, never a channel's word (novox/hq ADR 0259 §5).
|
||||||
|
Kinds []KindHeld `json:"kinds,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KindHeld is one kind of a kinded bench and who holds it.
|
||||||
|
type KindHeld struct {
|
||||||
|
Seat string `json:"seat"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Module string `json:"module"`
|
||||||
|
Node string `json:"node"`
|
||||||
|
Capabilities []string `json:"capabilities,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KindedBenches are the seats that may be kinded (ADR 0234 §2): making another is a decision, recorded.
|
||||||
|
var KindedBenches = map[string]bool{"channel": true, "intake": true}
|
||||||
|
|
||||||
|
// WorkerName is the worker a seat's holders pull from: one for the seat, or one per kind on a kinded bench.
|
||||||
|
func WorkerName(seat, kind string) string {
|
||||||
|
if kind == "" {
|
||||||
|
return "SEAT_" + upperSnake(seat) + "_worker"
|
||||||
|
}
|
||||||
|
return "SEAT_" + upperSnake(seat) + "_" + upperSnake(kind) + "_worker"
|
||||||
|
}
|
||||||
|
|
||||||
|
func namesVerb(list []string, s string) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if x == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeatTrafficOf derives one module's seat traffic from the seats it holds, uses and watches. Only seats
|
||||||
|
// carrying one of the rules above are read: every other seat is composed as it always was.
|
||||||
|
func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic {
|
||||||
|
var t SeatTraffic
|
||||||
|
for _, s := range holds {
|
||||||
|
if !s.isNewTraffic() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kind := ""
|
||||||
|
if s.Kinded {
|
||||||
|
kind = s.Kind
|
||||||
|
if kind == "" || !safeSubject.MatchString(kind) {
|
||||||
|
// A kinded claim without a usable kind is refused at registration; here it is granted
|
||||||
|
// nothing, which is the same answer at the last place it could be asked.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(s.Accepts) > 0 {
|
||||||
|
stream := seatStreamName(s.Name)
|
||||||
|
filter := "mesh.seat." + s.Name + ".accept.>"
|
||||||
|
if kind != "" {
|
||||||
|
filter = "mesh.seat." + s.Name + ".accept.*." + kind
|
||||||
|
}
|
||||||
|
t.Workers = append(t.Workers, Worker{Stream: stream, Consumer: WorkerName(s.Name, kind), Filter: filter})
|
||||||
|
}
|
||||||
|
for _, a := range s.Accepts {
|
||||||
|
switch {
|
||||||
|
case kind != "":
|
||||||
|
t.Subscribe = append(t.Subscribe, seatSubject(s, "accept", a+"."+kind))
|
||||||
|
case namesVerb(s.ByCaller, a):
|
||||||
|
t.Subscribe = append(t.Subscribe, seatSubject(s, "accept", a+".*"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, e := range s.Emits {
|
||||||
|
switch {
|
||||||
|
case kind != "":
|
||||||
|
t.Publish = append(t.Publish, seatSubject(s, "event", e+"."+kind))
|
||||||
|
case namesVerb(s.ByCaller, e):
|
||||||
|
t.Publish = append(t.Publish, seatSubject(s, "event", e+".*"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kind != "" {
|
||||||
|
for _, v := range s.Proofs {
|
||||||
|
t.Publish = append(t.Publish, seatSubject(s, "proof", v+"."+kind))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range uses {
|
||||||
|
if !s.isNewTraffic() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, a := range s.Accepts {
|
||||||
|
switch {
|
||||||
|
case namesVerb(s.ByCaller, a):
|
||||||
|
t.Publish = append(t.Publish, seatSubject(s, "accept", a+"."+module))
|
||||||
|
case s.Kinded && module == s.DeclaredBy:
|
||||||
|
// Work for a kind is put on its queue by the bench's own router, and by no other user.
|
||||||
|
t.Publish = append(t.Publish, seatSubject(s, "accept", a+".*"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, e := range s.Emits {
|
||||||
|
if namesVerb(s.ByCaller, e) {
|
||||||
|
t.Subscribe = append(t.Subscribe, seatSubject(s, "event", e+"."+module))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, b := range s.Records {
|
||||||
|
if !safeSubject.MatchString(b) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Records = append(t.Records, "$JS.API.DIRECT.GET.KV_"+b+".$KV."+b+"."+module+".>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, w := range watches {
|
||||||
|
if w.Kinded {
|
||||||
|
for _, e := range w.Emits {
|
||||||
|
t.Subscribe = append(t.Subscribe, seatSubject(w, "event", e+".*"))
|
||||||
|
}
|
||||||
|
if module == w.DeclaredBy {
|
||||||
|
// A code is answered by the bench's own router, and by no other watcher.
|
||||||
|
for _, v := range w.Proofs {
|
||||||
|
t.Answers = append(t.Answers, seatSubject(w, "proof", v+".*"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Publish = unique(t.Publish)
|
||||||
|
t.Subscribe = unique(t.Subscribe)
|
||||||
|
t.Answers = unique(t.Answers)
|
||||||
|
t.Records = unique(t.Records)
|
||||||
|
sort.Slice(t.Workers, func(i, j int) bool { return t.Workers[i].Consumer < t.Workers[j].Consumer })
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// grants is the bus permissions seat traffic needs: the subjects themselves, and the JetStream API a
|
||||||
|
// worker is pulled and acknowledged through and a record is read through.
|
||||||
|
func (t SeatTraffic) grants() (pub, sub []string) {
|
||||||
|
pub = append(pub, t.Publish...)
|
||||||
|
sub = append(sub, t.Subscribe...)
|
||||||
|
sub = append(sub, t.Answers...)
|
||||||
|
for _, w := range t.Workers {
|
||||||
|
pub = append(pub,
|
||||||
|
"$JS.API.CONSUMER.INFO."+w.Stream+"."+w.Consumer,
|
||||||
|
"$JS.API.CONSUMER.MSG.NEXT."+w.Stream+"."+w.Consumer,
|
||||||
|
"$JS.ACK."+w.Stream+"."+w.Consumer+".>")
|
||||||
|
}
|
||||||
|
pub = append(pub, t.Records...)
|
||||||
|
return pub, sub
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNewTraffic says whether a seat carries any of the rules above, so a seat that carries none is
|
||||||
|
// composed exactly as before them.
|
||||||
|
func (s Seat) isNewTraffic() bool {
|
||||||
|
return s.Kinded || len(s.ByCaller) > 0 || len(s.Proofs) > 0 || len(s.Records) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// plainVerbs is a seat's accepts or emits with those the rules above compose taken out: a verb named by
|
||||||
|
// its caller and every verb of a kinded bench are composed by SeatTrafficOf and nowhere else.
|
||||||
|
func plainVerbs(s Seat, verbs []string) []string {
|
||||||
|
if s.Kinded {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, v := range verbs {
|
||||||
|
if !namesVerb(s.ByCaller, v) {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeatTrafficObjects is the work queues and workers the seat traffic of every composed user implies
|
||||||
|
// (novox/hq ADR 0259 §3): a queue for each seat a holder takes work from, and each holder's worker on it —
|
||||||
|
// one per kind on a kinded bench, filtered to that kind, so the kinds never take each other's work. Only
|
||||||
|
// seats carrying the rules above; the mesh's own seats' queues are RaiseSeats'.
|
||||||
|
func SeatTrafficObjects(users []Principal) ([]Stream, []Consumer) {
|
||||||
|
streams := map[string]Stream{}
|
||||||
|
consumers := map[string]Consumer{}
|
||||||
|
add := func(module string, holds []Seat) {
|
||||||
|
for _, w := range SeatTrafficOf(module, holds, nil, nil).Workers {
|
||||||
|
seat := ""
|
||||||
|
for _, s := range holds {
|
||||||
|
if seatStreamName(s.Name) == w.Stream {
|
||||||
|
seat = s.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
streams[w.Stream] = Stream{
|
||||||
|
Name: w.Stream,
|
||||||
|
Subjects: []string{"mesh.seat." + seat + ".accept.>"},
|
||||||
|
Retention: RetentionWorkQueue,
|
||||||
|
MaxAge: 7 * 24 * 60 * 60,
|
||||||
|
Why: "work submitted to the " + seat + " seat; its holders take it, each kind its own, and it queues while nobody does",
|
||||||
|
}
|
||||||
|
consumers[w.Consumer] = Consumer{
|
||||||
|
Name: w.Consumer,
|
||||||
|
Stream: w.Stream,
|
||||||
|
Filters: []string{w.Filter},
|
||||||
|
AckWaitSeconds: 60,
|
||||||
|
// No bound on redelivery: a channel away for a day keeps its work, offered again later and
|
||||||
|
// later by its holder's runtime (novox/hq ADR 0259; the correctness review of 2026-10-08).
|
||||||
|
MaxDeliver: 0,
|
||||||
|
Why: module + " holds " + seat + "; it pulls one ask at a time and acknowledges once it has " +
|
||||||
|
"recorded it, so a crash redelivers rather than loses",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range users {
|
||||||
|
switch p.Kind {
|
||||||
|
case KindModule:
|
||||||
|
add(p.Module, p.Holds)
|
||||||
|
case KindNodeTools:
|
||||||
|
for _, d := range p.Carries {
|
||||||
|
add(d.Module, d.Holds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var ss []Stream
|
||||||
|
for _, s := range streams {
|
||||||
|
ss = append(ss, s)
|
||||||
|
}
|
||||||
|
sort.Slice(ss, func(i, j int) bool { return ss[i].Name < ss[j].Name })
|
||||||
|
var cs []Consumer
|
||||||
|
for _, c := range consumers {
|
||||||
|
cs = append(cs, c)
|
||||||
|
}
|
||||||
|
sort.Slice(cs, func(i, j int) bool { return cs[i].Name < cs[j].Name })
|
||||||
|
return ss, cs
|
||||||
|
}
|
||||||
|
|
||||||
|
// trustedTraffic is why a module's seat traffic is the trusted holder's (novox/hq ADR 0259 §8), or "": it
|
||||||
|
// says a seat's event to one caller each (a warrant), or holds a kind of a kinded bench that proves its sender.
|
||||||
|
func trustedTraffic(d Declared) string {
|
||||||
|
for _, s := range d.Holds {
|
||||||
|
for _, e := range s.Emits {
|
||||||
|
if namesVerb(s.ByCaller, e) {
|
||||||
|
return "it says " + s.Name + "'s " + e + " to one caller each"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Kinded && namesVerb(s.Capabilities, "verified-sender") {
|
||||||
|
return "it holds " + s.Name + " of kind " + s.Kind + ", which proves its sender"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrafficQueues is the work queue of every seat naming its caller or its kind that accepts work, held or not
|
||||||
|
// (novox/hq ADR 0259 §3): what is submitted before a holder is assigned waits for it.
|
||||||
|
func TrafficQueues(seats []Seat) []Stream {
|
||||||
|
var out []Stream
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, s := range seats {
|
||||||
|
if len(s.Accepts) == 0 || !s.isNewTraffic() || seen[s.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[s.Name] = true
|
||||||
|
out = append(out, Stream{Name: seatStreamName(s.Name), Subjects: []string{"mesh.seat." + s.Name + ".accept.>"},
|
||||||
|
Retention: RetentionWorkQueue, MaxAge: 7 * 24 * 60 * 60,
|
||||||
|
Why: "work submitted to the " + s.Name + " seat; its holders take it, each kind its own, and it queues while nobody does"})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
package broker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The seats of novox/hq ADR 0259 §3, as the messenger declares them.
|
||||||
|
func operatorChannel() Seat {
|
||||||
|
return Seat{Name: "operator-channel", Scope: "mesh", Accepts: []string{"ask", "cancel"},
|
||||||
|
Emits: []string{"decided"}, Serves: []string{"open", "history", "notify"},
|
||||||
|
ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"messenger_asks"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func channelSeat(kind string) Seat {
|
||||||
|
return Seat{Name: "channel", Scope: "mesh", Accepts: []string{"show", "edit", "send"}, Kinded: true, Kind: kind,
|
||||||
|
DeclaredBy: "messenger"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func intakeSeat(kind string) Seat {
|
||||||
|
return Seat{Name: "intake", Scope: "mesh", Emits: []string{"choice", "link"}, Proofs: []string{"code"},
|
||||||
|
Kinded: true, Kind: kind, DeclaredBy: "messenger"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowed(patterns []string, subject string) bool {
|
||||||
|
for _, p := range patterns {
|
||||||
|
if subjectMatches(p, subject) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func perms(t *testing.T, p Principal) Permissions {
|
||||||
|
t.Helper()
|
||||||
|
got, err := PermissionsFor(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAskerAsksAndHearsUnderItsOwnNameOnly(t *testing.T) {
|
||||||
|
asker := Principal{Kind: KindModule, Node: "anchor", Module: "mesh-delivery", Uses: []Seat{operatorChannel()}}
|
||||||
|
got := perms(t, asker)
|
||||||
|
for _, s := range []string{
|
||||||
|
"mesh.seat.operator-channel.accept.ask.mesh-delivery",
|
||||||
|
"mesh.seat.operator-channel.accept.cancel.mesh-delivery",
|
||||||
|
"$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.mesh-delivery.a1",
|
||||||
|
} {
|
||||||
|
if !allowed(got.Publish, s) {
|
||||||
|
t.Errorf("an asker may not publish %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range []string{
|
||||||
|
"mesh.seat.operator-channel.accept.ask.mesh-controller",
|
||||||
|
"mesh.seat.operator-channel.accept.ask.*",
|
||||||
|
"mesh.seat.operator-channel.event.decided.mesh-delivery",
|
||||||
|
"$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.mesh-controller.a1",
|
||||||
|
"$KV.messenger_asks.mesh-delivery.a1",
|
||||||
|
} {
|
||||||
|
if allowed(got.Publish, s) {
|
||||||
|
t.Errorf("an asker may publish %s, which is not its own to submit", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowed(got.Subscribe, "mesh.seat.operator-channel.event.decided.mesh-delivery") {
|
||||||
|
t.Error("an asker does not hear its own warrants")
|
||||||
|
}
|
||||||
|
if allowed(got.Subscribe, "mesh.seat.operator-channel.event.decided.mesh-controller") {
|
||||||
|
t.Error("an asker hears another asker's warrants")
|
||||||
|
}
|
||||||
|
// And its own consumer carries its warrants, so a restart catches up.
|
||||||
|
c, ok := ConsumerFor(asker)
|
||||||
|
if !ok || !allowed(c.Filters, "mesh.seat.operator-channel.event.decided.mesh-delivery") {
|
||||||
|
t.Errorf("the asker's consumer does not carry its warrants: %v", c.Filters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlyTheHolderPublishesAWarrant(t *testing.T) {
|
||||||
|
users, err := Users(Records{
|
||||||
|
Nodes: []string{"anchor"},
|
||||||
|
Assigned: map[string][]Declared{"anchor": {
|
||||||
|
{Module: "messenger", Holds: []Seat{operatorChannel()}, Uses: []Seat{channelSeat("")},
|
||||||
|
Watches: []Seat{{Name: "intake", Emits: []string{"choice", "link"}, Kinded: true, Proofs: []string{"code"}, DeclaredBy: "messenger"}}},
|
||||||
|
{Module: "mesh-delivery", Uses: []Seat{operatorChannel()}},
|
||||||
|
{Module: "telegram", Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, u := range users {
|
||||||
|
got := perms(t, u)
|
||||||
|
says := allowed(got.Publish, "mesh.seat.operator-channel.event.decided.mesh-delivery")
|
||||||
|
if says != (u.Module == "messenger") {
|
||||||
|
t.Errorf("%s %s publish a warrant", u.Username(), map[bool]string{true: "may", false: "may not"}[says])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheHolderTakesEveryCallersAskThroughItsWorker(t *testing.T) {
|
||||||
|
got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "messenger", Holds: []Seat{operatorChannel()}})
|
||||||
|
if !allowed(got.Subscribe, "mesh.seat.operator-channel.accept.ask.mesh-delivery") {
|
||||||
|
t.Error("the router does not take an ask")
|
||||||
|
}
|
||||||
|
for _, s := range []string{
|
||||||
|
"$JS.API.CONSUMER.MSG.NEXT.SEAT_OPERATOR_CHANNEL.SEAT_OPERATOR_CHANNEL_worker",
|
||||||
|
"$JS.ACK.SEAT_OPERATOR_CHANNEL.SEAT_OPERATOR_CHANNEL_worker.x",
|
||||||
|
"mesh.seat.operator-channel.event.decided.mesh-controller",
|
||||||
|
} {
|
||||||
|
if !allowed(got.Publish, s) {
|
||||||
|
t.Errorf("the router may not publish %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allowed(got.Publish, "mesh.seat.operator-channel.accept.ask.messenger") {
|
||||||
|
t.Error("the holder may ask its own seat without using it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAKindedHolderReachesItsOwnKindAndNoOther(t *testing.T) {
|
||||||
|
got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "telegram",
|
||||||
|
Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}})
|
||||||
|
for _, s := range []string{
|
||||||
|
"mesh.seat.intake.event.choice.telegram", "mesh.seat.intake.event.link.telegram",
|
||||||
|
"mesh.seat.intake.proof.code.telegram",
|
||||||
|
"$JS.API.CONSUMER.MSG.NEXT.SEAT_CHANNEL.SEAT_CHANNEL_TELEGRAM_worker",
|
||||||
|
} {
|
||||||
|
if !allowed(got.Publish, s) {
|
||||||
|
t.Errorf("telegram may not publish %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range []string{
|
||||||
|
"mesh.seat.intake.event.choice.desktop", "mesh.seat.intake.proof.code.desktop",
|
||||||
|
"mesh.seat.channel.accept.show.telegram",
|
||||||
|
"$JS.API.CONSUMER.MSG.NEXT.SEAT_CHANNEL.SEAT_CHANNEL_DESKTOP_worker",
|
||||||
|
"mesh.seat.operator-channel.event.decided.mesh-delivery",
|
||||||
|
} {
|
||||||
|
if allowed(got.Publish, s) {
|
||||||
|
t.Errorf("telegram may publish %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowed(got.Subscribe, "mesh.seat.channel.accept.show.telegram") ||
|
||||||
|
allowed(got.Subscribe, "mesh.seat.channel.accept.show.desktop") {
|
||||||
|
t.Error("telegram does not take exactly its own kind's work")
|
||||||
|
}
|
||||||
|
if allowed(got.Subscribe, "mesh.seat.intake.proof.code.telegram") {
|
||||||
|
t.Error("a channel answers its own proofs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheWatcherAnswersProofsAndHearsEveryKind(t *testing.T) {
|
||||||
|
got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "messenger",
|
||||||
|
Uses: []Seat{channelSeat("")},
|
||||||
|
Watches: []Seat{{Name: "intake", Emits: []string{"choice"}, Kinded: true, Proofs: []string{"code"}, DeclaredBy: "messenger"}}})
|
||||||
|
for _, s := range []string{"mesh.seat.intake.event.choice.telegram", "mesh.seat.intake.proof.code.desktop"} {
|
||||||
|
if !allowed(got.Subscribe, s) {
|
||||||
|
t.Errorf("the router does not hear %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowed(got.Publish, "mesh.seat.channel.accept.show.telegram") {
|
||||||
|
t.Error("the router cannot send a channel its work")
|
||||||
|
}
|
||||||
|
if allowed(got.Publish, "mesh.seat.intake.event.choice.telegram") || allowed(got.Publish, "mesh.seat.intake.proof.code.telegram") {
|
||||||
|
t.Error("the router may say a channel's answer or proof")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoStreamKeepsAProof(t *testing.T) {
|
||||||
|
users, _ := Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": {
|
||||||
|
{Module: "telegram", Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}},
|
||||||
|
{Module: "messenger", Holds: []Seat{operatorChannel()}},
|
||||||
|
}}})
|
||||||
|
streams, _ := SeatTrafficObjects(users)
|
||||||
|
streams = append(streams, MeshStreams()...)
|
||||||
|
for _, s := range streams {
|
||||||
|
for _, subject := range s.Subjects {
|
||||||
|
if subjectMatches(subject, "mesh.seat.intake.proof.code.telegram") {
|
||||||
|
t.Errorf("%s keeps a proof (%s)", s.Name, subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEachKindHasAWorkerOfItsOwn(t *testing.T) {
|
||||||
|
users, _ := Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": {
|
||||||
|
{Module: "telegram", Holds: []Seat{channelSeat("telegram")}},
|
||||||
|
{Module: "desk-channel", Holds: []Seat{channelSeat("desktop")}},
|
||||||
|
{Module: "messenger", Holds: []Seat{operatorChannel()}},
|
||||||
|
}}})
|
||||||
|
streams, workers := SeatTrafficObjects(users)
|
||||||
|
names := map[string]string{}
|
||||||
|
for _, w := range workers {
|
||||||
|
names[w.Name] = strings.Join(w.Filters, ",")
|
||||||
|
}
|
||||||
|
want := map[string]string{
|
||||||
|
"SEAT_CHANNEL_TELEGRAM_worker": "mesh.seat.channel.accept.*.telegram",
|
||||||
|
"SEAT_CHANNEL_DESKTOP_worker": "mesh.seat.channel.accept.*.desktop",
|
||||||
|
"SEAT_OPERATOR_CHANNEL_worker": "mesh.seat.operator-channel.accept.>",
|
||||||
|
}
|
||||||
|
for n, f := range want {
|
||||||
|
if names[n] != f {
|
||||||
|
t.Errorf("worker %s filters %q, want %q", n, names[n], f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(streams) != 2 {
|
||||||
|
t.Errorf("want the queues of channel and operator-channel, got %v", streams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheRuntimeIsGrantedTheUnionAndTheMembershipEachModulesShare(t *testing.T) {
|
||||||
|
telegram := Declared{Module: "telegram", Holds: []Seat{channelSeat("telegram"), intakeSeat("telegram")}}
|
||||||
|
desk := Declared{Module: "desk-channel", Holds: []Seat{channelSeat("desktop"), intakeSeat("desktop")}}
|
||||||
|
got := perms(t, Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule, Carries: []Declared{telegram, desk}})
|
||||||
|
for _, s := range []string{"mesh.seat.intake.event.choice.telegram", "mesh.seat.intake.event.choice.desktop"} {
|
||||||
|
if !allowed(got.Publish, s) {
|
||||||
|
t.Errorf("the runtime may not publish %s for a module it carries", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m := MembershipFor("anchor", telegram, Placements{})
|
||||||
|
if m.SeatTraffic == nil || !allowed(m.SeatTraffic.Publish, "mesh.seat.intake.event.choice.telegram") ||
|
||||||
|
allowed(m.SeatTraffic.Publish, "mesh.seat.intake.event.choice.desktop") {
|
||||||
|
t.Errorf("telegram's membership does not list exactly its own kind: %+v", m.SeatTraffic)
|
||||||
|
}
|
||||||
|
if plain := MembershipFor("anchor", Declared{Module: "plain"}, Placements{}); plain.SeatTraffic != nil {
|
||||||
|
t.Error("a module with no such seat is given seat traffic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASeatWithoutTheNewRulesIsComposedAsBefore(t *testing.T) {
|
||||||
|
old := Seat{Name: "node-build-agent", Scope: "node", Accepts: []string{"build"}, Emits: []string{"built"}}
|
||||||
|
got := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "builder", Holds: []Seat{old}})
|
||||||
|
for _, s := range []string{"mesh.seat.node-build-agent.event.built",
|
||||||
|
"$JS.API.CONSUMER.MSG.NEXT.SEAT_NODE_BUILD_AGENT.SEAT_NODE_BUILD_AGENT_worker"} {
|
||||||
|
if !allowed(got.Publish, s) {
|
||||||
|
t.Errorf("an old seat's holder lost %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowed(got.Subscribe, "mesh.seat.node-build-agent.accept.build") {
|
||||||
|
t.Error("an old seat's holder lost its accept")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The router learns which channel is which, and what each promises, from the controller's membership:
|
||||||
|
// the claims, never a channel's word (ADR 0259 §5).
|
||||||
|
func TestTheRoutersMembershipNamesEveryKindAndItsCapabilities(t *testing.T) {
|
||||||
|
tg := channelSeat("telegram")
|
||||||
|
tg.Capabilities = []string{"choice", "verified-sender"}
|
||||||
|
desk := channelSeat("desktop")
|
||||||
|
desk.Capabilities = []string{"choice"}
|
||||||
|
router := Declared{Module: "messenger", Holds: []Seat{operatorChannel()}, Uses: []Seat{channelSeat("")}}
|
||||||
|
records := Records{Nodes: []string{"anchor", "laptop"}, Assigned: map[string][]Declared{
|
||||||
|
"anchor": {router, {Module: "telegram", Holds: []Seat{tg}, RunsAs: "telegram"}},
|
||||||
|
"laptop": {{Module: "desk-channel", Holds: []Seat{desk}}},
|
||||||
|
}}
|
||||||
|
where := PlacementsOf(records, nil)
|
||||||
|
m := MembershipFor("anchor", router, where)
|
||||||
|
if m.SeatTraffic == nil || len(m.SeatTraffic.Kinds) != 2 {
|
||||||
|
t.Fatalf("the router is not told the kinds: %+v", m.SeatTraffic)
|
||||||
|
}
|
||||||
|
byKind := map[string]KindHeld{}
|
||||||
|
for _, k := range m.SeatTraffic.Kinds {
|
||||||
|
byKind[k.Kind] = k
|
||||||
|
}
|
||||||
|
if k := byKind["telegram"]; k.Module != "telegram" || k.Node != "anchor" || !namesVerb(k.Capabilities, "verified-sender") {
|
||||||
|
t.Errorf("telegram is %+v", k)
|
||||||
|
}
|
||||||
|
if k := byKind["desktop"]; k.Module != "desk-channel" || namesVerb(k.Capabilities, "verified-sender") {
|
||||||
|
t.Errorf("the desk is %+v", k)
|
||||||
|
}
|
||||||
|
if other := MembershipFor("anchor", Declared{Module: "mesh-delivery", Uses: []Seat{operatorChannel()}}, where); other.SeatTraffic != nil && len(other.SeatTraffic.Kinds) > 0 {
|
||||||
|
t.Error("an asker is told the channels")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0259 §8: only the bench's own router answers its proofs and puts work on a kind's queue.
|
||||||
|
func TestOnlyTheBenchsRouterAnswersProofsAndSubmitsWork(t *testing.T) {
|
||||||
|
other := perms(t, Principal{Kind: KindModule, Node: "anchor", Module: "eavesdropper",
|
||||||
|
Uses: []Seat{channelSeat("")},
|
||||||
|
Watches: []Seat{{Name: "intake", Emits: []string{"choice"}, Kinded: true, Proofs: []string{"code"}, DeclaredBy: "messenger"}}})
|
||||||
|
if allowed(other.Subscribe, "mesh.seat.intake.proof.code.telegram") {
|
||||||
|
t.Error("a watcher that is not the router answers codes")
|
||||||
|
}
|
||||||
|
if allowed(other.Publish, "mesh.seat.channel.accept.show.telegram") {
|
||||||
|
t.Error("a user that is not the router puts work on a kind's queue")
|
||||||
|
}
|
||||||
|
if !allowed(other.Subscribe, "mesh.seat.intake.event.choice.telegram") {
|
||||||
|
t.Error("a watcher no longer hears the bench's events")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0259 §8: the machine's runtime runs as the operator's account; it never carries a module
|
||||||
|
// that says warrants or speaks for a kind proving its sender, and such a module has its own account.
|
||||||
|
func TestTheMachinesRuntimeNeverCarriesATrustedHolder(t *testing.T) {
|
||||||
|
tg := channelSeat("telegram")
|
||||||
|
tg.Capabilities = []string{"choice", "verified-sender"}
|
||||||
|
for name, d := range map[string]Declared{
|
||||||
|
"the router": {Module: "messenger", Holds: []Seat{operatorChannel()}},
|
||||||
|
"a verified channel": {Module: "telegram", Holds: []Seat{tg}},
|
||||||
|
} {
|
||||||
|
if _, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule,
|
||||||
|
Carries: []Declared{d}}); err == nil || !strings.Contains(err.Error(), "an account of its own") {
|
||||||
|
t.Errorf("%s was composed into the machine's runtime: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
desk := channelSeat("desktop")
|
||||||
|
desk.Capabilities = []string{"choice"}
|
||||||
|
if _, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule,
|
||||||
|
Carries: []Declared{{Module: "desk-channel", Holds: []Seat{desk}}}}); err != nil {
|
||||||
|
t.Errorf("a channel proving nothing was refused: %v", err)
|
||||||
|
}
|
||||||
|
users, err := Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": {
|
||||||
|
{Module: RuntimeModule}, {Module: "telegram", Holds: []Seat{tg}, RunsAs: "telegram"},
|
||||||
|
{Module: "messenger", Holds: []Seat{operatorChannel()}, RunsAs: "messenger"},
|
||||||
|
}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, u := range users {
|
||||||
|
if u.Kind == KindNodeTools {
|
||||||
|
for _, d := range u.Carries {
|
||||||
|
if d.RunsAs != "" {
|
||||||
|
t.Errorf("the machine's runtime carries %s", d.Module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := PermissionsFor(u); err != nil {
|
||||||
|
t.Errorf("the runtime could not be composed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0259 §8: verified-sender reaches the router only from a holder of its own account.
|
||||||
|
func TestVerifiedSenderIsBelievedOnlyFromAHolderOfItsOwnAccount(t *testing.T) {
|
||||||
|
if got := placedCapabilities([]string{"choice", "verified-sender"}, ""); namesVerb(got, "verified-sender") {
|
||||||
|
t.Errorf("a carried holder keeps verified-sender: %v", got)
|
||||||
|
}
|
||||||
|
if got := placedCapabilities([]string{"choice", "verified-sender"}, "telegram"); !namesVerb(got, "verified-sender") {
|
||||||
|
t.Errorf("a holder of its own account lost verified-sender: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,9 @@ type Declared struct {
|
|||||||
// Checks are the module's own tools its health asks, each `<module>.<tool>` (novox/hq ADR 0240, to-be
|
// Checks are the module's own tools its health asks, each `<module>.<tool>` (novox/hq ADR 0240, to-be
|
||||||
// 48 §3): the machine's node-engine asks them of its own node tools, and is granted that and no more.
|
// 48 §3): the machine's node-engine asks them of its own node tools, and is granted that and no more.
|
||||||
Checks []string
|
Checks []string
|
||||||
|
// RunsAs is the account the module runs as in a runtime of its own (novox/hq ADR 0259 §8): it is never
|
||||||
|
// carried by the machine's runtime, and reaches the bus on its own account.
|
||||||
|
RunsAs string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Records is what composing a user list needs to know about the mesh, and nothing more.
|
// Records is what composing a user list needs to know about the mesh, and nothing more.
|
||||||
@@ -120,10 +123,13 @@ func Users(r Records) ([]Principal, error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if runtimeHere {
|
if runtimeHere {
|
||||||
out = append(out, Principal{
|
var carried []Declared
|
||||||
Kind: KindNodeTools, Node: node, Module: RuntimeModule,
|
for _, d := range r.Assigned[node] {
|
||||||
Carries: append([]Declared(nil), r.Assigned[node]...),
|
if d.RunsAs == "" {
|
||||||
})
|
carried = append(carried, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, Principal{Kind: KindNodeTools, Node: node, Module: RuntimeModule, Carries: carried})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, node := range sortedCopy(r.Enrolling) {
|
for _, node := range sortedCopy(r.Enrolling) {
|
||||||
|
|||||||
@@ -1073,9 +1073,23 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
}
|
}
|
||||||
owner[fmt.Sprint(process["id"])] = RuntimeModule
|
owner[fmt.Sprint(process["id"])] = RuntimeModule
|
||||||
out = append(out, process)
|
out = append(out, process)
|
||||||
// What each module's bundles are given is read as the account the runtime runs as.
|
// And a runtime of its own for each module of its own account, after it (novox/hq ADR 0259 §8).
|
||||||
|
owns, err := r.ownRuntimes(with)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, p := range owns {
|
||||||
|
id := fmt.Sprint(p["id"])
|
||||||
|
owner[id] = strings.TrimSuffix(id, "."+OwnRuntimeID())
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
// What each module's bundles are given is read as the account the runtime runs as — not what a
|
||||||
|
// module of its own account is given, which its own account reads.
|
||||||
words := map[string]map[string]string{}
|
words := map[string]map[string]string{}
|
||||||
for _, m := range r.Modules {
|
for _, m := range r.Modules {
|
||||||
|
if m.RunsAs != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
w, err := bundleWords(m, with)
|
w, err := bundleWords(m, with)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The router of novox/hq ADR 0259: it declares the operator's seat and the two kinded benches.
|
||||||
|
func router() Manifest {
|
||||||
|
return Manifest{Module: "messenger", Tools: []string{"open", "history", "notify"},
|
||||||
|
State: []StateDeclaration{{Name: "asks"}}, RunsAs: "messenger", SecretsOwner: "messenger",
|
||||||
|
DefinesSeats: []SeatDeclaration{
|
||||||
|
{Name: "operator-channel", Scope: ScopeMesh, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"},
|
||||||
|
ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"asks"},
|
||||||
|
Serves: []Verb{{Name: "open"}, {Name: "history"}, {Name: "notify"}}},
|
||||||
|
{Name: "channel", Scope: ScopeMesh, Kinded: true, Accepts: []string{"show", "edit", "send"}},
|
||||||
|
{Name: "intake", Scope: ScopeMesh, Kinded: true, Emits: []string{"choice", "link"}, Proofs: []string{"code"}},
|
||||||
|
},
|
||||||
|
Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh}},
|
||||||
|
Uses: []string{"channel"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func aChannel(module, kind string) Manifest {
|
||||||
|
return Manifest{Module: module, Claims: []Claim{
|
||||||
|
{Name: "channel", Scope: ScopeMesh, Kind: kind}, {Name: "intake", Scope: ScopeMesh, Kind: kind}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTwoChannelsOfDifferentKindsHoldTheBenches(t *testing.T) {
|
||||||
|
shelf := Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"),
|
||||||
|
"desk-channel": aChannel("desk-channel", "desktop")}
|
||||||
|
if got := problemsFor(t, shelf); got != "" {
|
||||||
|
t.Fatalf("two kinds were refused: %s", got)
|
||||||
|
}
|
||||||
|
for _, m := range shelf {
|
||||||
|
if got := declaredSeatProblems(m); len(got) > 0 {
|
||||||
|
t.Fatalf("%s: %v", m.Module, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASecondClaimOfOneKindIsRefused(t *testing.T) {
|
||||||
|
got := problemsFor(t, Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"),
|
||||||
|
"telegram-two": aChannel("telegram-two", "telegram")})
|
||||||
|
if !strings.Contains(got, `of kind "telegram", which telegram already claims`) {
|
||||||
|
t.Fatalf("a second holder of one kind stood: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAKindedBenchNeedsAKindAndNoOtherSeatTakesOne(t *testing.T) {
|
||||||
|
got := problemsFor(t, Shelf{"messenger": router(), "nameless": aChannel("nameless", "")})
|
||||||
|
if !strings.Contains(got, "claims the kinded bench channel and names no kind") {
|
||||||
|
t.Fatalf("a claim without a kind stood: %s", got)
|
||||||
|
}
|
||||||
|
odd := Manifest{Module: "odd", Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh, Kind: "telegram"}}}
|
||||||
|
got = problemsFor(t, Shelf{"messenger": router(), "odd": odd})
|
||||||
|
if !strings.Contains(got, "only a kinded bench takes a kind") {
|
||||||
|
t.Fatalf("a kind on a seat that is not kinded stood: %s", got)
|
||||||
|
}
|
||||||
|
dotted := problemsFor(t, Shelf{"messenger": router(), "dotted": aChannel("dotted", "a.b")})
|
||||||
|
if !strings.Contains(dotted, "not a usable name") {
|
||||||
|
t.Fatalf("a kind that would widen a subject stood: %s", dotted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlyChannelAndIntakeAreKinded(t *testing.T) {
|
||||||
|
m := Manifest{Module: "x", DefinesSeats: []SeatDeclaration{{Name: "pager", Kinded: true, Accepts: []string{"page"}}}}
|
||||||
|
if got := strings.Join(declaredSeatProblems(m), "; "); !strings.Contains(got, "only channel and intake are kinded") {
|
||||||
|
t.Fatalf("another kinded bench was declared: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheNewRulesAreHeldToWhatTheSeatSays(t *testing.T) {
|
||||||
|
m := Manifest{Module: "x", DefinesSeats: []SeatDeclaration{{Name: "thing", Accepts: []string{"do"},
|
||||||
|
ByCaller: []string{"undo"}, Proofs: []string{"code"}, Records: []string{"nothing"}}}}
|
||||||
|
got := strings.Join(declaredSeatProblems(m), "; ")
|
||||||
|
for _, want := range []string{"names thing.undo by its caller, which the seat neither accepts nor emits",
|
||||||
|
"declares proofs on thing, which is not kinded", `read its records "nothing", which it keeps no state of`} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("not refused: %q in %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two kinds on one machine are two holders, and one kind on two machines is a second claimant.
|
||||||
|
func TestEachKindIsItsOwnHolderWhenResolved(t *testing.T) {
|
||||||
|
modules := []Manifest{aChannel("telegram", "telegram"), aChannel("desk-channel", "desktop")}
|
||||||
|
held, problems := checkClaims(modules, Node{Name: "anchor"}, nil, nil)
|
||||||
|
if len(problems) > 0 || len(held) != 4 {
|
||||||
|
t.Fatalf("two kinds on one machine: held %v, problems %v", held, problems)
|
||||||
|
}
|
||||||
|
_, problems = checkClaims([]Manifest{aChannel("telegram", "telegram")}, Node{Name: "home"}, held, nil)
|
||||||
|
if len(problems) == 0 {
|
||||||
|
t.Fatal("one kind held on two machines was not refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A channel's capabilities come from the fixed vocabulary, and only a kinded claim carries any.
|
||||||
|
func TestCapabilitiesAreTheVocabularysAndOnlyOnAKindedClaim(t *testing.T) {
|
||||||
|
good := aChannel("telegram", "telegram")
|
||||||
|
good.Claims[0].Capabilities = []string{"deliver", "choice", "verified-sender", "max-length:4096"}
|
||||||
|
good.RunsAs = "telegram"
|
||||||
|
if got := problemsFor(t, Shelf{"messenger": router(), "telegram": good}); got != "" {
|
||||||
|
t.Fatalf("the vocabulary was refused: %s", got)
|
||||||
|
}
|
||||||
|
bad := aChannel("telegram", "telegram")
|
||||||
|
bad.Claims[0].Capabilities = []string{"trusted", "max-length:lots"}
|
||||||
|
got := problemsFor(t, Shelf{"messenger": router(), "telegram": bad})
|
||||||
|
for _, w := range []string{`"trusted"`, `"max-length:lots"`} {
|
||||||
|
if !strings.Contains(got, w+", which channel-capabilities/1 does not have") {
|
||||||
|
t.Errorf("%s was not refused: %s", w, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
odd := Manifest{Module: "odd", Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh, Capabilities: []string{"deliver"}}}}
|
||||||
|
if got := problemsFor(t, Shelf{"messenger": router(), "odd": odd}); !strings.Contains(got, "only a kinded bench's claim carries them") {
|
||||||
|
t.Errorf("capabilities on a seat that is not kinded stood: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0259 §8: a module saying warrants, or speaking for a kind that proves its sender, runs as an
|
||||||
|
// account of its own — never carried by the machine's runtime, which runs as the operator's account.
|
||||||
|
func TestATrustedHolderMustRunAsAnAccountOfItsOwn(t *testing.T) {
|
||||||
|
r := router()
|
||||||
|
r.RunsAs = ""
|
||||||
|
if got := problemsFor(t, Shelf{"messenger": r}); !strings.Contains(got, "messenger must run as an account of its own") {
|
||||||
|
t.Errorf("a router on the machine's runtime stood: %s", got)
|
||||||
|
}
|
||||||
|
tg := aChannel("telegram", "telegram")
|
||||||
|
tg.Claims[0].Capabilities = []string{"choice", "verified-sender"}
|
||||||
|
if got := problemsFor(t, Shelf{"messenger": router(), "telegram": tg}); !strings.Contains(got, "telegram must run as an account of its own") {
|
||||||
|
t.Errorf("a verified channel on the machine's runtime stood: %s", got)
|
||||||
|
}
|
||||||
|
desk := aChannel("desk-channel", "desktop")
|
||||||
|
desk.Claims[0].Capabilities = []string{"choice"}
|
||||||
|
if got := problemsFor(t, Shelf{"messenger": router(), "desk-channel": desk}); got != "" {
|
||||||
|
t.Errorf("a channel proving nothing was held to it: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunsAsIsAnAccountOfTheModulesOwn(t *testing.T) {
|
||||||
|
ok := Manifest{Module: "telegram", RunsAs: "telegram", SecretsOwner: "telegram",
|
||||||
|
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/telegram/broker"}},
|
||||||
|
Resources: []map[string]any{{"id": "account", "type": "user", "name": "telegram"}}}
|
||||||
|
if got := RunsAsProblems(ok); len(got) != 0 {
|
||||||
|
t.Fatalf("a sound runs-as was refused: %v", got)
|
||||||
|
}
|
||||||
|
for want, change := range map[string]func(*Manifest){
|
||||||
|
"never root": func(m *Manifest) { m.RunsAs, m.SecretsOwner = "root", "root" },
|
||||||
|
"not an account name": func(m *Manifest) { m.RunsAs = "${machine:account}" },
|
||||||
|
"which it does not make": func(m *Manifest) { m.Resources = nil },
|
||||||
|
"declares no own secret": func(m *Manifest) { m.OwnSecrets = nil },
|
||||||
|
"they are the account's own": func(m *Manifest) { m.SecretsOwner = "" },
|
||||||
|
} {
|
||||||
|
m := ok
|
||||||
|
m.Resources = append([]map[string]any(nil), ok.Resources...)
|
||||||
|
m.OwnSecrets = OwnSecrets{"broker": {Path: "/x"}}
|
||||||
|
change(&m)
|
||||||
|
if got := strings.Join(RunsAsProblems(m), "; "); !strings.Contains(got, want) {
|
||||||
|
t.Errorf("want %q, got %q", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,13 @@ type Claim struct {
|
|||||||
// text/template over one piece — its fields and `module` — in the tool's own grammar (novox/hq ADR
|
// text/template over one piece — its fields and `module` — in the tool's own grammar (novox/hq ADR
|
||||||
// 0255). The data is the mesh's, the format the holder's, as a module's facts template is.
|
// 0255). The data is the mesh's, the format the holder's, as a module's facts template is.
|
||||||
Renders map[string]string `json:"renders,omitempty"`
|
Renders map[string]string `json:"renders,omitempty"`
|
||||||
|
// Kind is the kind this module holds a kinded bench as (novox/hq ADR 0234 §2, ADR 0259): `telegram`,
|
||||||
|
// `desktop`. Refused on any other seat, and a second claim of one kind is refused.
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
|
// Capabilities are what a channel of this kind promises, from the fixed vocabulary
|
||||||
|
// channel-capabilities/1 (novox/hq ADR 0234 §2): the router judges an answer by these, read from the
|
||||||
|
// controller's record of this claim and never from the channel.
|
||||||
|
Capabilities []string `json:"capabilities,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServesFor is what this claim offers a seat's protocol: the verbs it names, else the module's
|
// ServesFor is what this claim offers a seat's protocol: the verbs it names, else the module's
|
||||||
@@ -640,6 +647,13 @@ type Manifest struct {
|
|||||||
// cannot use.
|
// cannot use.
|
||||||
SecretsOwner string `json:"secrets-owner,omitempty"`
|
SecretsOwner string `json:"secrets-owner,omitempty"`
|
||||||
|
|
||||||
|
// RunsAs is the account this module's tools bundle runs as, in a runtime of its own on a bus account of
|
||||||
|
// its own (novox/hq ADR 0259 §8): never the machine's runtime, which runs as the operator's account and
|
||||||
|
// carries every module on the machine. The account is one the module makes (a `user` resource of that
|
||||||
|
// name), owns its secrets (`secrets-owner`), and is neither root nor the operator's. Required of a module
|
||||||
|
// that says a warrant, or speaks for a channel kind that proves its sender.
|
||||||
|
RunsAs string `json:"runs-as,omitempty"`
|
||||||
|
|
||||||
// Keeps is where this module wants every operator-sealed secret in the mesh written — the
|
// Keeps is where this module wants every operator-sealed secret in the mesh written — the
|
||||||
// vault's field, and so far nobody else's (novox/hq ADR 0085, amended).
|
// vault's field, and so far nobody else's (novox/hq ADR 0085, amended).
|
||||||
//
|
//
|
||||||
@@ -1620,6 +1634,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
// prefix. Whether a seat anybody names exists, and whether a holder answers for it, are
|
// prefix. Whether a seat anybody names exists, and whether a holder answers for it, are
|
||||||
// facts about the catalogue and are checked at registration (CatalogueProblems).
|
// facts about the catalogue and are checked at registration (CatalogueProblems).
|
||||||
problems = append(problems, declaredSeatProblems(m)...)
|
problems = append(problems, declaredSeatProblems(m)...)
|
||||||
|
problems = append(problems, RunsAsProblems(m)...)
|
||||||
if m.Computed != "" && len(m.Resources) > 0 {
|
if m.Computed != "" && len(m.Resources) > 0 {
|
||||||
// One or the other. A module that both ships files and has them computed would leave
|
// One or the other. A module that both ships files and has them computed would leave
|
||||||
// nobody able to say where a given file came from.
|
// nobody able to say where a given file came from.
|
||||||
|
|||||||
@@ -115,6 +115,16 @@ type Held struct {
|
|||||||
Node string
|
Node string
|
||||||
Module string
|
Module string
|
||||||
Site string
|
Site string
|
||||||
|
// Kind is the kind a kinded bench is held as (novox/hq ADR 0234 §2): each kind is its own holder.
|
||||||
|
Kind string
|
||||||
|
}
|
||||||
|
|
||||||
|
// heldKey is what one holder holds: the seat, and its kind on a kinded bench.
|
||||||
|
func heldKey(claim, kind string) string {
|
||||||
|
if kind == "" {
|
||||||
|
return canonicalSeat(claim)
|
||||||
|
}
|
||||||
|
return canonicalSeat(claim) + "/" + kind
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolution is what a node should run, and why.
|
// Resolution is what a node should run, and why.
|
||||||
@@ -864,7 +874,7 @@ func checkClaims(modules []Manifest, node Node, elsewhere []Held, holdings []Hel
|
|||||||
// **One seat under either of its names** (novox/hq ADR 0122): a manifest registered before
|
// **One seat under either of its names** (novox/hq ADR 0122): a manifest registered before
|
||||||
// a rename claims the former name, and one written after it the current — two claimants of
|
// a rename claims the former name, and one written after it the current — two claimants of
|
||||||
// one seat, compared by the seat they resolve to and not by how each spelled it.
|
// one seat, compared by the seat they resolve to and not by how each spelled it.
|
||||||
seat := canonicalSeat(c.Name)
|
seat := heldKey(c.Name, c.Kind)
|
||||||
if other, taken := byScope[scope][seat]; taken {
|
if other, taken := byScope[scope][seat]; taken {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s and %s both claim %q, and only one thing may hold it per %s",
|
"%s and %s both claim %q, and only one thing may hold it per %s",
|
||||||
@@ -873,14 +883,14 @@ func checkClaims(modules []Manifest, node Node, elsewhere []Held, holdings []Hel
|
|||||||
}
|
}
|
||||||
byScope[scope][seat] = m.Module
|
byScope[scope][seat] = m.Module
|
||||||
held = append(held, Held{Claim: c.Name, Scope: scope, Node: node.Name,
|
held = append(held, Held{Claim: c.Name, Scope: scope, Node: node.Name,
|
||||||
Module: m.Module, Site: node.Site})
|
Module: m.Module, Site: node.Site, Kind: c.Kind})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// And against the rest of the mesh, for the scopes that reach past this machine.
|
// And against the rest of the mesh, for the scopes that reach past this machine.
|
||||||
for _, h := range held {
|
for _, h := range held {
|
||||||
for _, e := range elsewhere {
|
for _, e := range elsewhere {
|
||||||
if e.Node == node.Name || canonicalSeat(e.Claim) != canonicalSeat(h.Claim) || e.Scope != h.Scope {
|
if e.Node == node.Name || heldKey(e.Claim, e.Kind) != heldKey(h.Claim, h.Kind) || e.Scope != h.Scope {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch h.Scope {
|
switch h.Scope {
|
||||||
|
|||||||
@@ -165,6 +165,10 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
|
|||||||
if with.Adopted && m.Filtering != nil {
|
if with.Adopted && m.Filtering != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if m.RunsAs != "" {
|
||||||
|
// Served by a runtime of its own, on its own account (ownRuntimes): never the machine's.
|
||||||
|
continue
|
||||||
|
}
|
||||||
words, err := bundleWords(m, with)
|
words, err := bundleWords(m, with)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -223,6 +227,84 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
|
|||||||
return process, nil
|
return process, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OwnRuntimeID names the process a module of its own account is served by (novox/hq ADR 0259 §8).
|
||||||
|
func OwnRuntimeID() string { return "own-runtime" }
|
||||||
|
|
||||||
|
// ownRuntimes are the processes the modules of their own account are served by (novox/hq ADR 0259 §8): each
|
||||||
|
// the machine's runtime program — the same build, run from the same source — serving that one module alone,
|
||||||
|
// as the module's own account, on the module's own bus credential. Never the machine's runtime, which runs
|
||||||
|
// as the operator's account and carries every module on the machine.
|
||||||
|
func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) {
|
||||||
|
var own []Manifest
|
||||||
|
for _, m := range r.Modules {
|
||||||
|
if m.RunsAs != "" && !(with.Adopted && m.Filtering != nil) {
|
||||||
|
own = append(own, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(own) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var runtime *Manifest
|
||||||
|
for i := range r.Modules {
|
||||||
|
if r.Modules[i].Module == RuntimeModule {
|
||||||
|
runtime = &r.Modules[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if runtime == nil || len(runtime.Bundles) != 1 || runtime.Bundles[0].Binary == "" {
|
||||||
|
return nil, fmt.Errorf("%s runs as its own account in a runtime of its own, and %s is not here to run it "+
|
||||||
|
"from: assign %s to %s first (novox/hq ADR 0259)", own[0].Module, RuntimeModule, RuntimeModule, r.Node)
|
||||||
|
}
|
||||||
|
program := runtime.Bundles[0]
|
||||||
|
var out []map[string]any
|
||||||
|
for _, m := range own {
|
||||||
|
credential, declared := m.OwnSecrets["broker"]
|
||||||
|
if !declared {
|
||||||
|
return nil, fmt.Errorf("%s runs as its own account and declares no own secret broker", m.Module)
|
||||||
|
}
|
||||||
|
var served, restartOn []string
|
||||||
|
for _, b := range m.Bundles {
|
||||||
|
for _, load := range b.Loads {
|
||||||
|
if launcher, has := b.Launchers[load]; has {
|
||||||
|
load = launcher
|
||||||
|
}
|
||||||
|
served = append(served, m.Module+"="+BundlePath(m.Module, b.Name)+"/"+load)
|
||||||
|
}
|
||||||
|
if len(b.Loads) > 0 {
|
||||||
|
restartOn = append(restartOn, m.Module+"."+BundleID(b.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(served) == 0 {
|
||||||
|
return nil, fmt.Errorf("%s runs as its own account and its build produced no bundle to serve", m.Module)
|
||||||
|
}
|
||||||
|
sort.Strings(served)
|
||||||
|
restartOn = append(restartOn, m.Module+"."+NeedID("broker"))
|
||||||
|
sort.Strings(restartOn)
|
||||||
|
env := map[string]string{RuntimeToolModules: strings.Join(served, ","), RuntimeBrokerFile: credential.Path}
|
||||||
|
words, err := bundleWords(m, with)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(words) > 0 {
|
||||||
|
body, err := json.Marshal(map[string]map[string]string{m.Module: words})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
env[RuntimeToolEnv] = string(body)
|
||||||
|
}
|
||||||
|
process := map[string]any{
|
||||||
|
"id": m.Module + "." + OwnRuntimeID(), "type": "process", "name": m.Module + "-runtime",
|
||||||
|
"source": program.Source, "digest": program.Digest,
|
||||||
|
"run": []any{"./" + program.Binary}, "env": env, "restart-on": toAny(restartOn),
|
||||||
|
"user": m.RunsAs,
|
||||||
|
}
|
||||||
|
if err := artifactsInto(process, RuntimeModule, with); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, process)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func toAny(in []string) []any {
|
func toAny(in []string) []any {
|
||||||
out := make([]any, 0, len(in))
|
out := make([]any, 0, len(in))
|
||||||
for _, s := range in {
|
for _, s := range in {
|
||||||
|
|||||||
@@ -457,3 +457,49 @@ func TestAGoToolsBundleIsServedByItsBinary(t *testing.T) {
|
|||||||
t.Error("a Go bundle loading a file it does not contain was admitted")
|
t.Error("a Go bundle loading a file it does not contain was admitted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0259 §8: a module of its own account is served by a runtime of its own — the machine's
|
||||||
|
// runtime program, as that account, on that module's own credential — and never by the machine's runtime,
|
||||||
|
// which runs as the operator's account and is given none of its words.
|
||||||
|
func TestAModuleOfItsOwnAccountIsServedByARuntimeOfItsOwn(t *testing.T) {
|
||||||
|
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||||
|
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}, "telegram": {"broker": "own"}}}
|
||||||
|
goRuntime := Manifest{Module: RuntimeModule, Version: "1",
|
||||||
|
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}},
|
||||||
|
Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "go",
|
||||||
|
System: "arch", From: "cmd/node-tools"}}}}
|
||||||
|
goRuntime, err := goRuntime.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle,
|
||||||
|
Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
telegram := aToolsModule(t, "telegram", "tools/index.js")
|
||||||
|
telegram.RunsAs, telegram.SecretsOwner = "telegram", "telegram"
|
||||||
|
telegram.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/telegram/broker"}}
|
||||||
|
out, err := Resolution{Node: "anchor", Account: "ops",
|
||||||
|
Modules: []Manifest{aToolsModule(t, "nftables", "tools/index.js"), telegram, goRuntime}}.Declaration(with)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
machine := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
||||||
|
if served := machine["env"].(map[string]string)[RuntimeToolModules]; strings.Contains(served, "telegram") || !strings.Contains(served, "nftables") {
|
||||||
|
t.Errorf("the machine's runtime serves %q", served)
|
||||||
|
}
|
||||||
|
own := fileNamed(out, "telegram."+OwnRuntimeID())
|
||||||
|
if own == nil {
|
||||||
|
t.Fatalf("telegram has no runtime of its own: %v", ids(out))
|
||||||
|
}
|
||||||
|
env := own["env"].(map[string]string)
|
||||||
|
if own["user"] != "telegram" || fmt.Sprint(own["run"]) != "[./node-tools]" ||
|
||||||
|
env[RuntimeBrokerFile] != "/var/lib/telegram/broker" ||
|
||||||
|
env[RuntimeToolModules] != "telegram="+BundleRoot+"/telegram/tools/tools/index.js" {
|
||||||
|
t.Errorf("its own runtime: user %v run %v env %v", own["user"], own["run"], env)
|
||||||
|
}
|
||||||
|
if _, told := env[RuntimeOperatorAccount]; told {
|
||||||
|
t.Error("a runtime of a module's own account is told the operator's account")
|
||||||
|
}
|
||||||
|
// Without the machine's runtime to run it from, it is refused in words.
|
||||||
|
if _, err := (Resolution{Node: "anchor", Modules: []Manifest{telegram}}).ownRuntimes(with); err == nil {
|
||||||
|
t.Error("a module of its own account composed without a runtime program")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package catalogue
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -51,6 +52,35 @@ type SeatDeclaration struct {
|
|||||||
// owns its own, which is why a seat is also the answer for a module that needs retention
|
// owns its own, which is why a seat is also the answer for a module that needs retention
|
||||||
// its events cannot have.
|
// its events cannot have.
|
||||||
RetainSeconds int `json:"retain-seconds,omitempty"`
|
RetainSeconds int `json:"retain-seconds,omitempty"`
|
||||||
|
|
||||||
|
// Kinded makes the seat a kinded bench (novox/hq ADR 0234 §2, ADR 0259 §3): its holders are different
|
||||||
|
// modules, each claiming one kind, and each verb's subject carries the kind. Only the benches in
|
||||||
|
// KindedBenches may be kinded; making another is a decision, recorded.
|
||||||
|
Kinded bool `json:"kinded,omitempty"`
|
||||||
|
// ByCaller are accepts and emits whose subject's last token names the calling module (ADR 0259 §3): a
|
||||||
|
// user submits such an accept, and hears such an event, under its own name and no other.
|
||||||
|
ByCaller []string `json:"by-caller,omitempty"`
|
||||||
|
// Proofs are verbs carried as core request and reply, never on a stream: what travels on them (a code
|
||||||
|
// the operator typed) is never kept (ADR 0259 §3). On a kinded bench a holder asks with its own kind and
|
||||||
|
// the modules watching the seat answer.
|
||||||
|
Proofs []string `json:"proofs,omitempty"`
|
||||||
|
// Records are state buckets of the declaring module that each user reads under its own name — the
|
||||||
|
// keys `<user>.…` and no other (ADR 0259 §3).
|
||||||
|
Records []string `json:"records,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KindedBenches are the seats that may be kinded (novox/hq ADR 0234 §2): `channel` sends to the operator,
|
||||||
|
// `intake` takes what the operator answers. Another is a decision, recorded, as ADR 0223 asks of a bench.
|
||||||
|
var KindedBenches = map[string]bool{"channel": true, "intake": true}
|
||||||
|
|
||||||
|
// NamedByCaller says whether one of the seat's verbs is named by its caller.
|
||||||
|
func (s SeatDeclaration) NamedByCaller(verb string) bool {
|
||||||
|
for _, v := range s.ByCaller {
|
||||||
|
if v == verb {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// At is this declaration's scope, with the default applied. Mesh by default, because a seat
|
// At is this declaration's scope, with the default applied. Mesh by default, because a seat
|
||||||
@@ -132,6 +162,7 @@ func declaredSeatProblems(m Manifest) []string {
|
|||||||
"%s declares %s.%s, which is not a usable verb", m.Module, s.Name, v))
|
"%s declares %s.%s, which is not a usable verb", m.Module, s.Name, v))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
problems = append(problems, trafficProblems(m, s)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, u := range m.Uses {
|
for _, u := range m.Uses {
|
||||||
@@ -142,6 +173,56 @@ func declaredSeatProblems(m Manifest) []string {
|
|||||||
return problems
|
return problems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// trafficProblems is what one declaration of the rules of ADR 0259 §3 can be judged on alone.
|
||||||
|
func trafficProblems(m Manifest, s SeatDeclaration) []string {
|
||||||
|
var problems []string
|
||||||
|
if s.Kinded && !KindedBenches[s.Name] {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s declares %s as a kinded bench; only channel and intake are kinded, and another is a "+
|
||||||
|
"decision, recorded (novox/hq ADR 0234)", m.Module, s.Name))
|
||||||
|
}
|
||||||
|
if s.Kinded && len(s.ByCaller) > 0 {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s declares %s kinded and names verbs by their caller; a kinded bench's subjects carry the kind",
|
||||||
|
m.Module, s.Name))
|
||||||
|
}
|
||||||
|
for _, v := range s.ByCaller {
|
||||||
|
inAccepts, inEmits := false, false
|
||||||
|
for _, a := range s.Accepts {
|
||||||
|
inAccepts = inAccepts || a == v
|
||||||
|
}
|
||||||
|
for _, e := range s.Emits {
|
||||||
|
inEmits = inEmits || e == v
|
||||||
|
}
|
||||||
|
if !inAccepts && !inEmits {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s names %s.%s by its caller, which the seat neither accepts nor emits", m.Module, s.Name, v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range s.Proofs {
|
||||||
|
if !name.MatchString(v) || strings.Contains(v, ".") {
|
||||||
|
problems = append(problems, fmt.Sprintf("%s declares the proof %s.%s, which is not a usable verb",
|
||||||
|
m.Module, s.Name, v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(s.Proofs) > 0 && !s.Kinded {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s declares proofs on %s, which is not kinded; a proof is asked by a holder of a kind",
|
||||||
|
m.Module, s.Name))
|
||||||
|
}
|
||||||
|
for _, r := range s.Records {
|
||||||
|
kept := false
|
||||||
|
for _, st := range m.State {
|
||||||
|
kept = kept || st.Name == r
|
||||||
|
}
|
||||||
|
if !kept {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s says %s's users read its records %q, which it keeps no state of", m.Module, s.Name, r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
// A Shelf is every manifest the mesh has registered, by module name.
|
// A Shelf is every manifest the mesh has registered, by module name.
|
||||||
type Shelf map[string]Manifest
|
type Shelf map[string]Manifest
|
||||||
|
|
||||||
@@ -182,8 +263,19 @@ func CatalogueProblems(shelf Shelf) []string {
|
|||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Who claims each kind of a kinded bench, so a second claim of one kind is refused (ADR 0234 §2).
|
||||||
|
kindsTaken := map[string]string{}
|
||||||
|
|
||||||
for _, module := range shelfOrder(shelf) {
|
for _, module := range shelfOrder(shelf) {
|
||||||
m := shelf[module]
|
m := shelf[module]
|
||||||
|
for _, c := range m.Claims {
|
||||||
|
if c.Kind != "" {
|
||||||
|
if _, isModuleSeat := declared[c.Name]; !isModuleSeat {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s claims %s of kind %q, and only a kinded bench takes a kind", module, c.Name, c.Kind))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A `uses` naming nothing is where ADR 0110's guarantee lands under a derived set: the
|
// A `uses` naming nothing is where ADR 0110's guarantee lands under a derived set: the
|
||||||
// same refusal, at the same moment, from a set nobody maintains by hand.
|
// same refusal, at the same moment, from a set nobody maintains by hand.
|
||||||
@@ -214,6 +306,7 @@ func CatalogueProblems(shelf Shelf) []string {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
problems = append(problems, kindProblems(module, c, s, kindsTaken)...)
|
||||||
if c.At() != s.At() {
|
if c.At() != s.At() {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s claims %s at scope %q, and %s declares it at %s",
|
"%s claims %s at scope %q, and %s declares it at %s",
|
||||||
@@ -228,6 +321,16 @@ func CatalogueProblems(shelf Shelf) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// **A trusted holder runs as its own account** (novox/hq ADR 0259 §8): a module saying warrants, or
|
||||||
|
// speaking for a kind that proves its sender, is never carried by a machine's runtime.
|
||||||
|
for _, module := range shelfOrder(shelf) {
|
||||||
|
m := shelf[module]
|
||||||
|
if why := TrustedHolding(m, declared); why != "" && m.RunsAs == "" {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s must run as an account of its own (runs-as): %s, and the machine's runtime runs as the "+
|
||||||
|
"operator's account, which every agent runs as (novox/hq ADR 0259)", module, why))
|
||||||
|
}
|
||||||
|
}
|
||||||
// A read of a module's state that module does not keep (novox/hq ADR 0201) — said only where the
|
// A read of a module's state that module does not keep (novox/hq ADR 0201) — said only where the
|
||||||
// owner is on the shelf, as a consumer may be installed before its emitter.
|
// owner is on the shelf, as a consumer may be installed before its emitter.
|
||||||
var manifests []Manifest
|
var manifests []Manifest
|
||||||
@@ -239,6 +342,63 @@ func CatalogueProblems(shelf Shelf) []string {
|
|||||||
return problems
|
return problems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChannelCapabilities is the fixed vocabulary `channel-capabilities/1` (novox/hq ADR 0234 §2): a word
|
||||||
|
// outside it is refused. `max-length:<N>` takes a number.
|
||||||
|
var ChannelCapabilities = map[string]bool{
|
||||||
|
"deliver": true, "reaches-away": true, "loud": true, "silent": true, "edit": true,
|
||||||
|
"reaches-when-mesh-down": true, "private": true,
|
||||||
|
"choice": true, "reply": true, "threads": true, "operator-first": true,
|
||||||
|
"verified-sender": true, "exact-render": true, "code-factor": true, "key-factor": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxLength = regexp.MustCompile(`^max-length:[1-9][0-9]{0,6}$`)
|
||||||
|
|
||||||
|
// capabilityProblems are the words of a claim outside the vocabulary, and capabilities on a claim of a
|
||||||
|
// seat that is not kinded.
|
||||||
|
func capabilityProblems(module string, c Claim, kinded bool) []string {
|
||||||
|
if len(c.Capabilities) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !kinded {
|
||||||
|
return []string{fmt.Sprintf("%s claims %s with capabilities, and only a kinded bench's claim carries them",
|
||||||
|
module, c.Name)}
|
||||||
|
}
|
||||||
|
var problems []string
|
||||||
|
for _, w := range c.Capabilities {
|
||||||
|
if !ChannelCapabilities[w] && !maxLength.MatchString(w) {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s claims %s with the capability %q, which channel-capabilities/1 does not have", module, c.Name, w))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
// kindProblems is a claim judged against a declared seat's kind: a kinded bench takes one claim per kind,
|
||||||
|
// a usable name; any other seat takes none.
|
||||||
|
func kindProblems(module string, c Claim, s SeatDeclaration, taken map[string]string) []string {
|
||||||
|
if problems := capabilityProblems(module, c, s.Kinded); len(problems) > 0 {
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case !s.Kinded && c.Kind != "":
|
||||||
|
return []string{fmt.Sprintf("%s claims %s of kind %q, and only a kinded bench takes a kind",
|
||||||
|
module, c.Name, c.Kind)}
|
||||||
|
case !s.Kinded:
|
||||||
|
return nil
|
||||||
|
case c.Kind == "":
|
||||||
|
return []string{fmt.Sprintf("%s claims the kinded bench %s and names no kind", module, c.Name)}
|
||||||
|
case !name.MatchString(c.Kind) || strings.Contains(c.Kind, "."):
|
||||||
|
return []string{fmt.Sprintf("%s claims %s of kind %q, which is not a usable name", module, c.Name, c.Kind)}
|
||||||
|
}
|
||||||
|
key := c.Name + "/" + c.Kind
|
||||||
|
if first, ok := taken[key]; ok && first != module {
|
||||||
|
return []string{fmt.Sprintf("%s claims %s of kind %q, which %s already claims; a kind has one holder",
|
||||||
|
module, c.Name, c.Kind, first)}
|
||||||
|
}
|
||||||
|
taken[key] = module
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// unserved is what a seat's protocol promises and the claimant does not answer. Only the tools
|
// unserved is what a seat's protocol promises and the claimant does not answer. Only the tools
|
||||||
// are checked: `accepts` and `emits` are wired by the runtime from the declaration, while a tool
|
// are checked: `accepts` and `emits` are wired by the runtime from the declaration, while a tool
|
||||||
// is code the module either has or has not written — under the claim's serves, or among its own.
|
// is code the module either has or has not written — under the claim's serves, or among its own.
|
||||||
@@ -266,3 +426,65 @@ func shelfOrder(shelf Shelf) []string {
|
|||||||
sort.Strings(out)
|
sort.Strings(out)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var accountName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,30}$`)
|
||||||
|
|
||||||
|
// RunsAsProblems is what one manifest's `runs-as` is held to (novox/hq ADR 0259 §8): an account of the
|
||||||
|
// module's own making — a `user` resource of that name — that owns its secrets, with a bus account of its own,
|
||||||
|
// and that is neither root nor the operator's.
|
||||||
|
func RunsAsProblems(m Manifest) []string {
|
||||||
|
if m.RunsAs == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var problems []string
|
||||||
|
say := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) }
|
||||||
|
switch {
|
||||||
|
case !accountName.MatchString(m.RunsAs):
|
||||||
|
say("%s runs as %q, which is not an account name of the module's own", m.Module, m.RunsAs)
|
||||||
|
return problems
|
||||||
|
case m.RunsAs == "root":
|
||||||
|
say("%s runs as root; a module of its own account runs as an account it makes, never root", m.Module)
|
||||||
|
}
|
||||||
|
made := false
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if fmt.Sprint(r["type"]) == "user" && fmt.Sprint(r["name"]) == m.RunsAs {
|
||||||
|
made = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !made {
|
||||||
|
say("%s runs as %s, which it does not make: a user resource named %s", m.Module, m.RunsAs, m.RunsAs)
|
||||||
|
}
|
||||||
|
if _, has := m.OwnSecrets["broker"]; !has {
|
||||||
|
say("%s runs as its own account and declares no own secret broker: its runtime reaches the bus on an "+
|
||||||
|
"account of its own", m.Module)
|
||||||
|
}
|
||||||
|
if m.SecretsOwner != m.RunsAs {
|
||||||
|
say("%s runs as %s, and its secrets belong to %q: they are the account's own", m.Module, m.RunsAs, m.SecretsOwner)
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrustedHolding is why a module must run as its own account (novox/hq ADR 0259 §8), or "": it holds a seat
|
||||||
|
// whose events it says to one caller each (a warrant), or speaks for a kind of a kinded bench that proves
|
||||||
|
// its sender. Neither may be carried by the machine's runtime, which runs as the operator's account.
|
||||||
|
func TrustedHolding(m Manifest, declared map[string]SeatDeclaration) string {
|
||||||
|
for _, c := range m.Claims {
|
||||||
|
s, ok := declared[c.Name]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range s.Emits {
|
||||||
|
if s.NamedByCaller(e) {
|
||||||
|
return fmt.Sprintf("it holds %s, whose %s it says to one caller each", c.Name, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Kinded {
|
||||||
|
for _, capability := range c.Capabilities {
|
||||||
|
if capability == "verified-sender" {
|
||||||
|
return fmt.Sprintf("it holds %s of kind %s, which proves its sender", c.Name, c.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,9 +34,12 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) {
|
|||||||
// that seat promises. **Across the whole catalogue, not one manifest**: a seat is declared by
|
// that seat promises. **Across the whole catalogue, not one manifest**: a seat is declared by
|
||||||
// one module and held by another, which is the whole reason a seat exists (ADR 0118).
|
// one module and held by another, which is the whole reason a seat exists (ADR 0118).
|
||||||
seats := map[string]catalogue.SeatDeclaration{}
|
seats := map[string]catalogue.SeatDeclaration{}
|
||||||
|
// And who declared each, so a seat's records are named as the declaring module's buckets (ADR 0259).
|
||||||
|
declarers := map[string]string{}
|
||||||
for _, m := range declared {
|
for _, m := range declared {
|
||||||
for _, s := range m.DefinesSeats {
|
for _, s := range m.DefinesSeats {
|
||||||
seats[s.Name] = s
|
seats[s.Name] = s
|
||||||
|
declarers[s.Name] = m.Module
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// And the mesh's own, which carry protocol too (novox/hq ADR 0121). Added after the modules'
|
// And the mesh's own, which carry protocol too (novox/hq ADR 0121). Added after the modules'
|
||||||
@@ -78,7 +81,7 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) {
|
|||||||
"%s is assigned to %s and is not in the catalogue, so what it may say cannot "+
|
"%s is assigned to %s and is not in the catalogue, so what it may say cannot "+
|
||||||
"be derived", module, n.Name)
|
"be derived", module, n.Name)
|
||||||
}
|
}
|
||||||
d := declaredFor(m, seats)
|
d := declaredFor(m, seats, declarers)
|
||||||
// And the state offered to it as a seat's holder by the modules beside it (novox/hq ADR 0255).
|
// And the state offered to it as a seat's holder by the modules beside it (novox/hq ADR 0255).
|
||||||
// For this machine's key alone (novox/hq ADR 0260): a bar shows its own machine's draw.
|
// For this machine's key alone (novox/hq ADR 0260): a bar shows its own machine's draw.
|
||||||
for _, sr := range catalogue.ReadsGranted(m, onMachine(declared, modules), n.Name) {
|
for _, sr := range catalogue.ReadsGranted(m, onMachine(declared, modules), n.Name) {
|
||||||
@@ -121,7 +124,7 @@ func onMachine(declared map[string]catalogue.Manifest, modules []string) []catal
|
|||||||
|
|
||||||
// declaredFor is one module's manifest as the composer needs it: what it says about itself, and the
|
// declaredFor is one module's manifest as the composer needs it: what it says about itself, and the
|
||||||
// protocol of every seat it holds or uses.
|
// protocol of every seat it holds or uses.
|
||||||
func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaration) broker.Declared {
|
func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaration, declarers map[string]string) broker.Declared {
|
||||||
// A consumed name is a module's event unless it names a seat, and only somebody holding the seat
|
// A consumed name is a module's event unless it names a seat, and only somebody holding the seat
|
||||||
// set can tell (novox/hq ADR 0121). Split here, because the composer cannot look at a name and
|
// set can tell (novox/hq ADR 0121). Split here, because the composer cannot look at a name and
|
||||||
// know — and a role's event read as a module's is a subscription to a namespace nobody owns.
|
// know — and a role's event read as a module's is a subscription to a namespace nobody owns.
|
||||||
@@ -132,6 +135,16 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
|
|||||||
if named {
|
if named {
|
||||||
// A seat's event when the seat says it; else the event of the module of that name — a seat and
|
// A seat's event when the seat says it; else the event of the module of that name — a seat and
|
||||||
// the module holding it may share a name (mesh-delivery, novox/hq ADR 0239).
|
// the module holding it may share a name (mesh-delivery, novox/hq ADR 0239).
|
||||||
|
if s, isASeat := seats[emitter]; isASeat && s.Kinded {
|
||||||
|
// A kinded bench's event is named `<event>` or `<event>.*` and heard from every kind; its
|
||||||
|
// proofs are answered by whoever watches it (ADR 0259 §3).
|
||||||
|
ev := strings.TrimSuffix(event, ".*")
|
||||||
|
if catalogue.SeatSays(s.Emits, ev) {
|
||||||
|
watches = append(watches, broker.Seat{Name: s.Name, Scope: s.Scope, Emits: []string{ev},
|
||||||
|
Kinded: true, Proofs: s.Proofs, DeclaredBy: declarers[s.Name]})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
if s, isASeat := seats[emitter]; isASeat && catalogue.SeatSays(s.Emits, event) {
|
if s, isASeat := seats[emitter]; isASeat && catalogue.SeatSays(s.Emits, event) {
|
||||||
watches = append(watches, broker.Seat{Name: s.Name, Emits: []string{event}})
|
watches = append(watches, broker.Seat{Name: s.Name, Emits: []string{event}})
|
||||||
continue
|
continue
|
||||||
@@ -155,6 +168,8 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
|
|||||||
Reads: m.Reads,
|
Reads: m.Reads,
|
||||||
// And the tools its health asks (novox/hq ADR 0240): the machine's node-engine is granted them.
|
// And the tools its health asks (novox/hq ADR 0240): the machine's node-engine is granted them.
|
||||||
Checks: catalogue.HealthChecks(m),
|
Checks: catalogue.HealthChecks(m),
|
||||||
|
// And whether it runs as an account of its own (novox/hq ADR 0259 §8).
|
||||||
|
RunsAs: m.RunsAs,
|
||||||
}
|
}
|
||||||
// Whether it can be given an account at all: delivered as its own secret named broker, so one
|
// Whether it can be given an account at all: delivered as its own secret named broker, so one
|
||||||
// that declares none has nowhere to read it (novox/hq issue 195).
|
// that declares none has nowhere to read it (novox/hq issue 195).
|
||||||
@@ -168,12 +183,15 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
|
|||||||
// Every seat with a protocol, the mesh's own included. One that says only who does a job is
|
// Every seat with a protocol, the mesh's own included. One that says only who does a job is
|
||||||
// not here and grants nothing, which is most of them.
|
// not here and grants nothing, which is most of them.
|
||||||
if s, hasAProtocol := seats[c.Name]; hasAProtocol {
|
if s, hasAProtocol := seats[c.Name]; hasAProtocol {
|
||||||
d.Holds = append(d.Holds, asSeat(s))
|
held := asSeat(s, declarers[s.Name])
|
||||||
|
held.Kind = c.Kind
|
||||||
|
held.Capabilities = c.Capabilities
|
||||||
|
d.Holds = append(d.Holds, held)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, name := range m.Uses {
|
for _, name := range m.Uses {
|
||||||
if s, declaredSomewhere := seats[name]; declaredSomewhere {
|
if s, declaredSomewhere := seats[name]; declaredSomewhere {
|
||||||
d.Uses = append(d.Uses, asSeat(s))
|
d.Uses = append(d.Uses, asSeat(s, declarers[s.Name]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return d
|
return d
|
||||||
@@ -204,9 +222,17 @@ func (i *Inventory) DeclaredBuckets(ctx context.Context) ([]broker.Bucket, error
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func asSeat(s catalogue.SeatDeclaration) broker.Seat {
|
func asSeat(s catalogue.SeatDeclaration, declarer string) broker.Seat {
|
||||||
return broker.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits,
|
seat := broker.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits,
|
||||||
Serves: catalogue.VerbNames(s.Serves)}
|
Serves: catalogue.VerbNames(s.Serves), Kinded: s.Kinded, ByCaller: s.ByCaller, Proofs: s.Proofs,
|
||||||
|
DeclaredBy: declarer}
|
||||||
|
// A seat's records are its declaring module's buckets, named as the bus holds them (ADR 0259 §3).
|
||||||
|
for _, r := range s.Records {
|
||||||
|
if declarer != "" {
|
||||||
|
seat.Records = append(seat.Records, broker.BucketName(declarer, r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seat
|
||||||
}
|
}
|
||||||
|
|
||||||
// MeshSeats are the mesh's own seats that carry a protocol, as the bus needs them: what to make a work
|
// MeshSeats are the mesh's own seats that carry a protocol, as the bus needs them: what to make a work
|
||||||
@@ -277,3 +303,22 @@ func heldHere(claimed []broker.Seat, holdings []catalogue.Held, node, module str
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeclaredTrafficSeats is every seat any registered module declares that names its caller or its kind
|
||||||
|
// (novox/hq ADR 0259 §3), as the bus needs it: assigned or not, so its work queue exists from registration.
|
||||||
|
func (i *Inventory) DeclaredTrafficSeats(ctx context.Context) ([]broker.Seat, error) {
|
||||||
|
declared, err := i.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot read the catalogue: %w", err)
|
||||||
|
}
|
||||||
|
var out []broker.Seat
|
||||||
|
for _, m := range declared {
|
||||||
|
for _, s := range m.DefinesSeats {
|
||||||
|
seat := asSeat(s, m.Module)
|
||||||
|
if seat.Kinded || len(seat.ByCaller) > 0 {
|
||||||
|
out = append(out, seat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user