Merge pull request 'Mail seats: a declared seat carries needs; a secret given is told once (issues 802, 835)' (#345) from feat/mail-seats into main

This commit was merged in pull request #345.
This commit is contained in:
2026-10-15 17:44:20 +00:00
19 changed files with 643 additions and 65 deletions
+86 -7
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"time"
@@ -253,8 +254,9 @@ func askAtDesk(ctx context.Context, node, module, name, desk string) error {
return err
}
defer open.Close()
askedBy := link.Caller()
d := deskGive{
askedBy: link.Caller(),
askedBy: askedBy,
open: func(node, module, name, desk string) (int64, error) {
return open.inventory.OpenSecretAsk(ctx, node, module, name, askedByName(link.Caller()), desk)
},
@@ -291,7 +293,9 @@ func askAtDesk(ctx context.Context, node, module, name, desk string) error {
return err
})
},
announce: func(node, module, name, how string) error { return announceSecretGiven(ctx, node, module, name, how) },
announce: func(node, module, name, how string) error {
return announceSecretGiven(ctx, node, module, name, how, askedBy)
},
}
words, err := d.give(node, module, name, desk)
if err != nil {
@@ -342,10 +346,85 @@ func secretNameWords(name string) string {
return strings.Join(strings.FieldsFunc(name, func(r rune) bool { return r == '-' || r == '_' || r == '.' }), " ")
}
// announceSecretGiven raises it on this controller's keeper.
func announceSecretGiven(ctx context.Context, node, module, name, how string) error {
return withKeeper(ctx, func(k *conditions.Keeper) error {
_, err := k.Observe(ctx, secretGivenObservation(node, module, name, how, time.Now()))
return err
// tellWithin is how long the operator channel's `tell` is given to answer: well under a human's patience,
// and short because nothing in the call waits on the operator — it asks nothing (novox/hq ADR 0380).
const tellWithin = 10 * time.Second
// askOperatorChannel asks the operator channel's seat (the holder issue 797 gives it) its `tell` verb,
// through the controller's grant (novox/hq ADR 0380 §8), over this controller's own bus connection; a seam
// a test replaces whole. The holder's own refusal is an error naming it; an absent holder or a refused
// grant answer as link.ErrNothingServes or link.ErrGrantRefused (checked with errors.Is), so the caller can
// tell the two apart from any other failure.
var askOperatorChannel = func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
var result json.RawMessage
err := onTheBus(func(conn *nats.Conn) error {
answer, err := link.AskMeshSeatTool(ctx, conn, "operator-channel", "tell", args, tellWithin)
if err != nil {
return err
}
if answer.Error != "" {
return fmt.Errorf("operator-channel.tell refused: %s", answer.Error)
}
result = answer.Result
return nil
})
return result, err
}
// announceSecretGiven tells the operator, once, that a module's own secret was given (novox/hq ADR 0380):
// one plain message through the operator channel's `tell`, naming the secret, the module, the machine,
// where and when it was given and who asked — asking nothing, and opening no condition. **The controller's
// change waits for that verb at run time, not at merge** (clause 9): while nothing on the bus yet serves it,
// or the controller's own grant is refused, the secret-given condition is raised exactly as before ADR 0380
// — any other failure (the holder itself refusing, or answering something unreadable) is the announcement's
// own failure, said to the caller as it always was.
func announceSecretGiven(ctx context.Context, node, module, name, how, askedBy string) error {
at := time.Now()
body := map[string]any{
"headline": fmt.Sprintf("A password was entered for %s's %s on %s", module, secretNameWords(name), node),
"subject": node,
"lines": []string{
fmt.Sprintf("%s at %s, asked by %s.", how, at.Local().Format("15:04"), askedByName(askedBy)),
"If that was not you, give it again yourself at the controller's terminal.",
},
}
_, told := askOperatorChannel(ctx, body)
switch {
case told == nil:
clearSecretGivenConditions(ctx)
return nil
case errors.Is(told, link.ErrNothingServes), errors.Is(told, link.ErrGrantRefused):
log.Printf("secret-given: %s's %s on %s could not be told through the operator channel (%v); the "+
"secret-given condition is raised as it was before novox/hq ADR 0380 (its clause 9)", module, name,
node, told)
return withKeeper(ctx, func(k *conditions.Keeper) error {
_, err := k.Observe(ctx, secretGivenObservation(node, module, name, how, at))
return err
})
default:
return told
}
}
// clearSecretGivenConditions retires every open secret-given condition once a plain message has replaced
// it (novox/hq ADR 0380 §10): called after the first time a secret-given message is told successfully. No
// resolved line is sent — the condition is simply gone — and why is kept in the keeper's own history.
func clearSecretGivenConditions(ctx context.Context) {
if err := withKeeper(ctx, func(k *conditions.Keeper) error {
open, err := k.Open(ctx)
if err != nil {
return err
}
for _, c := range open {
if c.Kind != kindSecretGiven {
continue
}
if _, err := k.ClearSaying(ctx, c.Key, "retired by novox/hq ADR 0380", ""); err != nil {
return err
}
}
return nil
}); err != nil {
log.Printf("secret-given: the open secret-given conditions, retired by ADR 0380, could not be cleared: %v", err)
}
}
+169
View File
@@ -556,3 +556,172 @@ func TestASecretAskIsNeverASecretRead(t *testing.T) {
}
}
}
// askOperatorChannelFake replaces askOperatorChannel for the test and records every call it was given.
func askOperatorChannelFake(t *testing.T, answer func(args map[string]any) (json.RawMessage, error)) *[]map[string]any {
t.Helper()
var calls []map[string]any
was := askOperatorChannel
askOperatorChannel = func(_ context.Context, args map[string]any) (json.RawMessage, error) {
calls = append(calls, args)
return answer(args)
}
t.Cleanup(func() { askOperatorChannel = was })
return &calls
}
// announceWithRealTell wires a deskGive's announce to the real announceSecretGiven, as askAtDesk does,
// rather than the fakes the tests above use to look at what `give` does around it.
func announceWithRealTell(ctx context.Context, askedBy string) func(node, module, name, how string) error {
return func(node, module, name, how string) error {
return announceSecretGiven(ctx, node, module, name, how, askedBy)
}
}
// novox/hq ADR 0380 clauses 1-2: a secret given at the desk is told to the operator once, in a plain
// message through the operator channel's `tell`, naming the secret, the module, where and when and who
// asked, and opens no condition — the kind the review of 2026-10-09 (M4) raised is retired.
func TestASecretGivenAtTheDeskIsToldOnceAndOpensNoCondition(t *testing.T) {
ctx := context.Background()
k, _ := withConditionsInMemory(t)
calls := askOperatorChannelFake(t, func(map[string]any) (json.RawMessage, error) { return nil, nil })
d, _, _, _ := aDesk(t, sealedTo(t, typed))
d.announce = announceWithRealTell(ctx, "mail-seats-835@novox")
if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err != nil {
t.Fatal(err)
}
if len(*calls) != 1 {
t.Fatalf("told the operator %d times, want exactly 1: %v", len(*calls), *calls)
}
call := (*calls)[0]
if call["headline"] != "A password was entered for telegram's telegram token on anchor" {
t.Errorf("headline %v", call["headline"])
}
if call["subject"] != "anchor" {
t.Errorf("subject %v", call["subject"])
}
lines, _ := call["lines"].([]string)
if len(lines) != 2 || !strings.Contains(lines[0], "at the desk on laptop at") ||
!strings.Contains(lines[0], "asked by mail-seats-835@novox") ||
lines[1] != "If that was not you, give it again yourself at the controller's terminal." {
t.Errorf("lines %v", lines)
}
open, err := k.Open(ctx)
if err != nil {
t.Fatal(err)
}
if len(open) != 0 {
t.Errorf("a condition was raised though the operator was told: %+v", open)
}
}
// novox/hq ADR 0380 §9: until the operator channel's `tell` is delivered — nothing yet serves it, or the
// controller's own grant is refused — the secret-given condition is raised exactly as before.
func TestASecretGivenAtTheDeskRaisesTheConditionWhileNothingServesTell(t *testing.T) {
ctx := context.Background()
for _, failure := range []error{link.ErrNothingServes, link.ErrGrantRefused} {
k, _ := withConditionsInMemory(t)
askOperatorChannelFake(t, func(map[string]any) (json.RawMessage, error) { return nil, failure })
d, _, _, _ := aDesk(t, sealedTo(t, typed))
d.announce = announceWithRealTell(ctx, "mesh-mail-835, novox")
if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err != nil {
t.Fatal(err)
}
open, err := k.Open(ctx)
if err != nil {
t.Fatal(err)
}
if len(open) != 1 || open[0].Kind != kindSecretGiven {
t.Fatalf("%v: the condition was not raised as before: %+v", failure, open)
}
}
}
// novox/hq ADR 0380 §10: once the operator channel is told successfully, every secret-given condition
// still open from before this change is retired, without a resolved line of its own.
func TestASuccessfulTellRetiresEveryOpenSecretGivenCondition(t *testing.T) {
ctx := context.Background()
k, _ := withConditionsInMemory(t)
for _, module := range []string{"telegram", "mesh-mail"} {
if _, err := k.Observe(ctx, secretGivenObservation("anchor", module, "a-secret", "at the desk on laptop",
time.Now())); err != nil {
t.Fatal(err)
}
}
askOperatorChannelFake(t, func(map[string]any) (json.RawMessage, error) { return nil, nil })
if err := announceSecretGiven(ctx, "anchor", "telegram", "telegram-token", "at the desk on laptop", "me"); err != nil {
t.Fatal(err)
}
open, err := k.Open(ctx)
if err != nil {
t.Fatal(err)
}
if len(open) != 0 {
t.Fatalf("the open secret-given conditions were not retired: %+v", open)
}
}
// novox/hq ADR 0380 clause 7: a trusted party's secret given at the controller's terminal is announced
// before it is kept, and not kept when the announcement fails. It still fails when telling the operator
// AND raising the fallback condition both fail — nothing silently keeps a trusted party's value unheard.
func TestATrustedPartysSecretAtTheTerminalIsNotKeptWhenTellAndTheConditionBothFail(t *testing.T) {
ctx := context.Background()
askOperatorChannelFake(t, func(map[string]any) (json.RawMessage, error) { return nil, link.ErrNothingServes })
// conditionsFrom is left nil and there is no bus to dial here, so the fallback condition cannot be
// raised either: both of ADR 0380's ways to tell the operator fail.
kept := false
if _, _, err := keepGiven(true,
func() error {
return announceSecretGiven(ctx, "anchor", "telegram", "telegram-token", "at the controller's terminal", "me")
},
func() (bool, error) { kept = true; return false, nil }); err == nil {
t.Error("a trusted party's secret was kept though telling the operator and raising the condition both failed")
}
if kept {
t.Error("the value was kept despite the failure")
}
}
// Review round 2 of mesh-controller (feat/mail-seats, head 5f24a47d): ADR 0380 §9's fallback — raising the
// secret-given condition — answers only link.ErrNothingServes and link.ErrGrantRefused. Any other failure of
// the operator channel's `tell` (the holder itself refusing, or answering something unreadable) is the
// announcement's own failure, surfaced to the caller, and opens no condition.
func TestAnOperatorChannelFailureOtherThanNothingServesOrGrantRefusedSurfacesAndRaisesNoCondition(t *testing.T) {
ctx := context.Background()
k, _ := withConditionsInMemory(t)
refused := errors.New("operator-channel.tell refused: too many lines")
askOperatorChannelFake(t, func(map[string]any) (json.RawMessage, error) { return nil, refused })
if err := announceSecretGiven(ctx, "anchor", "telegram", "telegram-token", "at the desk on laptop", "me"); err != refused {
t.Fatalf("announceSecretGiven = %v, want the refusal itself surfaced", err)
}
open, err := k.Open(ctx)
if err != nil {
t.Fatal(err)
}
if len(open) != 0 {
t.Errorf("a condition was raised though the failure was neither ErrNothingServes nor ErrGrantRefused: %+v", open)
}
}
// Review round 2 of mesh-controller: a secret accepted at the controller's terminal writes a hand act naming
// who asked, where and when it was given — and never the value (novox/hq ADR 0380 §6).
func TestASecretAcceptedAtTheTerminalWritesAHandActNamingWhoWhereAndNeverTheValue(t *testing.T) {
act := terminalGiveHandAct("anchor", "telegram", "telegram-token", "mail-seats-835@novox")
if act.Verb != "secret accept" || act.Cause != "given-at-the-terminal" {
t.Fatalf("act %+v", act)
}
if !strings.Contains(act.Why, "at the controller's terminal") {
t.Errorf("the act does not name where: %q", act.Why)
}
if !strings.Contains(act.Why, "asked by mail-seats-835@novox") {
t.Errorf("the act does not name who: %q", act.Why)
}
if strings.Contains(act.Why, typed) {
t.Error("the act carries the value")
}
// When is RecordHandAct's own job (it stamps At with time.Now when the act leaves none, and By with the
// caller when the act leaves that empty too): this act leaves both for it to fill.
if act.By != "" || !act.At.IsZero() {
t.Errorf("the act sets its own who/when instead of leaving them to RecordHandAct: %+v", act)
}
}
+26 -1
View File
@@ -12,8 +12,11 @@ import (
"strings"
"time"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
"github.com/novox/mesh-controller/internal/secrets"
)
@@ -151,8 +154,11 @@ func secretCommand(ctx context.Context, args []string) error {
if err != nil {
return err
}
askedBy := link.Caller()
untilStart, unannounced, err := keepGiven(trusted,
func() error { return announceSecretGiven(ctx, node, module, name, "at the controller's terminal") },
func() error {
return announceSecretGiven(ctx, node, module, name, "at the controller's terminal", askedBy)
},
func() (bool, error) { return open.inventory.AcceptGivenSecret(ctx, node, module, name, value) })
if err != nil {
if trusted && unannounced != nil {
@@ -164,6 +170,15 @@ func secretCommand(ctx context.Context, args []string) error {
if unannounced != nil {
fmt.Printf(" this change could NOT be announced on the operator's channels: %v\n", unannounced)
}
// The giving is recorded, never the value (novox/hq ADR 0380 §6): the desk already logs its own act; the
// terminal's own secret accept did not until now.
act := terminalGiveHandAct(node, module, name, askedBy)
if err := onTheBus(func(conn *nats.Conn) error {
_, err := link.RecordHandAct(ctx, conn, act)
return err
}); err != nil {
fmt.Printf(" this act could NOT be recorded in the hand-act log, and is done anyway: %v\n", err)
}
// Not printed back, and there is nowhere it could be printed from: it is sealed to that
// machine and the mesh cannot read it again.
fmt.Printf("%s on %s now holds %q, sealed to that machine.\n", module, node, name)
@@ -541,6 +556,16 @@ func whoAsked() string {
return "the mesh"
}
// terminalGiveHandAct is the hand-act log's entry for a secret accepted at the controller's terminal
// (novox/hq ADR 0380 §6): who asked, where and that it happened — never the value, which the act never
// carries a field for.
func terminalGiveHandAct(node, module, name, askedBy string) link.HandAct {
return link.HandAct{Verb: "secret accept", Args: []string{node, module, name},
Why: fmt.Sprintf("the operator gave %s for %s on %s at the controller's terminal, asked by %s", name, module,
node, askedByName(askedBy)),
Cause: "given-at-the-terminal"}
}
// keepGiven keeps a value given at the controller's terminal, and announces it on the operator's channels
// (the confirmation review of 2026-10-09, N1-give). **A trusted party's — a module running as an account of its
// own: the router, a verified channel — is announced before it is kept, and not kept when the announcement
+54 -1
View File
@@ -35,7 +35,7 @@ func TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose(t *testing.T)
carriedPub := subjectsIn(accounts, "publish")
carriedSub := subjectsIn(accounts, "subscribe")
if diff := missing(want.Publish, carriedPub); len(diff) > 0 {
if diff := requiredPublishGap(t, want.Publish, carriedPub); len(diff) > 0 {
t.Errorf("the installer's user list does not let the controller publish %v — it would come up "+
"and be refused on the first thing it tried", diff)
}
@@ -62,6 +62,59 @@ func TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose(t *testing.T)
}
}
// requiredPublishGap is what in want is missing from carried, leaving out a grant DegradableGrants names
// and saying why in the test log instead (novox/hq issue 835, the forge check of mesh-controller#345): the
// merge check reads mesh-host at the commit the machines run, which a change to mesh-host's examples alone
// never moves, so the carried list can lag the controller's derived one without the controller refusing to
// come up.
func requiredPublishGap(t *testing.T, want, carried []string) []string {
t.Helper()
diff := missing(want, carried)
var required []string
for _, subject := range diff {
if reason, ok := degradableReason(subject); ok {
t.Logf("the installer's user list does not let the controller publish %s, but that falls back "+
"rather than refuses: %s", subject, reason)
continue
}
required = append(required, subject)
}
return required
}
// degradableReason says whether a subject is one of DegradableGrants, and why.
func degradableReason(subject string) (string, bool) {
for _, g := range DegradableGrants {
if g.Subject == subject {
return g.Reason, true
}
}
return "", false
}
// TestADegradableGrantMissingFromTheInstallersListPasses is the forge check's own case (mesh-controller#345,
// novox/hq issue 835): an installer list that lacks operator-channel.tell alone, because mesh-host has not
// yet built with the grant, still passes — the controller falls back to the secret-given condition rather
// than coming up refused.
func TestADegradableGrantMissingFromTheInstallersListPasses(t *testing.T) {
want := []string{"mesh.seat.operator-channel.tool.tell", "mesh.seat.node-service-manager.tool.restart.*"}
carried := []string{"mesh.seat.node-service-manager.tool.restart.*"}
if diff := requiredPublishGap(t, want, carried); len(diff) > 0 {
t.Errorf("a degradable grant missing from the installer's list must not fail the genesis test, got %v", diff)
}
}
// TestANonDegradableGrantMissingFromTheInstallersListStillFails proves the exemption is named, not general:
// every other grant the controller derives stays required.
func TestANonDegradableGrantMissingFromTheInstallersListStillFails(t *testing.T) {
want := []string{"mesh.seat.operator-channel.tool.tell", "mesh.seat.node-service-manager.tool.restart.*"}
carried := []string{"mesh.seat.operator-channel.tool.tell"}
diff := requiredPublishGap(t, want, carried)
if len(diff) != 1 || diff[0] != "mesh.seat.node-service-manager.tool.restart.*" {
t.Errorf("a missing grant that is not degradable must still fail the genesis test, got %v", diff)
}
}
// theCarriedAccounts is the accounts file the installer's template writes at genesis.
func theCarriedAccounts(t *testing.T) string {
t.Helper()
+32
View File
@@ -279,6 +279,34 @@ var VerbsTheControllerAsksTheDeliveryOwner = []SeatVerb{{Seat: "mesh-delivery",
var VerbsTheControllerActsOnAWarrant = []SeatVerb{{Seat: "node-service-manager", Verb: "restart"},
{Seat: ControllerSeat, Verb: "plans"}}
// VerbsTheControllerAsksTheOperatorChannel are the operator channel's verbs the controller calls (novox/hq
// ADR 0380): `tell`, one plain message with no condition behind it — a module's own secret given, named,
// where and when and by whom, asking nothing. A mesh seat's verb is flat: no machine in the subject.
var VerbsTheControllerAsksTheOperatorChannel = []SeatVerb{{Seat: "operator-channel", Verb: "tell"}}
// DegradableGrant is a publish grant whose absence from the installer's carried first-user list is not a
// bug in the template: a fallback covers the refusal.
type DegradableGrant struct {
Subject string
Reason string
}
// DegradableGrants are the only publish grants TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose
// may find missing from the installer's carried list without failing (novox/hq issue 835, the forge check
// of mesh-controller#345): a change that only edits mesh-host's examples can never satisfy a check that
// reads the mesh-host commit the machines actually run, so the carried list lags behind the controller's
// derived one until mesh-host's own module next builds. operator-channel.tell is the one grant the
// controller already has a fallback for: refused (no holder, or the bus refuses it), the controller raises
// the secret-given condition exactly as before (ADR 0380 §9), so an older carried list still comes up and
// works. Every other grant stays required — missing one of those is a controller that comes up and is
// refused on the first thing it tries, with no fallback that covers it.
var DegradableGrants = []DegradableGrant{
{
Subject: "mesh.seat." + VerbsTheControllerAsksTheOperatorChannel[0].Seat + ".tool." + VerbsTheControllerAsksTheOperatorChannel[0].Verb,
Reason: "refused: the controller raises the secret-given condition as before (ADR 0380 §9)",
},
}
// perMachineEvents are a node-scoped seat's events about the holder itself, whose last token is the
// holder's machine (novox/hq ADR 0219): `paused.<node>`, the build agent saying whether it takes work.
var perMachineEvents = map[string]bool{"paused.*": true}
@@ -500,6 +528,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
for _, v := range VerbsTheControllerAsksForASecret {
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
}
// And the operator channel, a mesh seat, asked flat: one message when a secret is given (ADR 0380).
for _, v := range VerbsTheControllerAsksTheOperatorChannel {
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb)
}
// And the delivery's owner, a mesh seat, asked on its flat subjects (ADR 0239).
for _, v := range VerbsTheControllerAsksTheDeliveryOwner {
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb)
+1 -1
View File
@@ -24,7 +24,7 @@ accounts {
jetstream: enabled
users = [
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*"] }
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] }
subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] }
allow_responses: { max: 1, ttl: "1m" }
} }
@@ -34,7 +34,7 @@ func TestAContributionDependsOnTheSeatThatPlacesIt(t *testing.T) {
"an empty environment": {empty, nil},
}
for name, c := range cases {
got := DependsOn(c.m)
got := DependsOn(nil, c.m)
if len(got) == 0 {
got = nil
}
@@ -118,7 +118,7 @@ func TestCodeForAPowerMomentDependsOnThePowerSeat(t *testing.T) {
for _, moment := range powerMoments {
m := mod("laptop", nil, nil, nil)
m.Shell = []ShellCode{{For: moment, Slot: "normal", Code: "true"}}
if got := DependsOn(m); !reflect.DeepEqual(got, []string{PowerSeat}) {
if got := DependsOn(nil, m); !reflect.DeepEqual(got, []string{PowerSeat}) {
t.Errorf("code for %s depends on %v, want %s", moment, got, PowerSeat)
}
if p := m.shellProblems(); len(p) != 0 {
+3 -3
View File
@@ -197,9 +197,9 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) {
}
// What keeps something irreplaceable depends on the machine's backup holder — it backs it up or
// watches its array; valuable data alone is backed up where a holder is, and refused nowhere.
if !slices.Contains(DependsOn(media), BackupSeat) || !slices.Contains(DependsOn(pg), BackupSeat) ||
slices.Contains(DependsOn(agent), BackupSeat) {
t.Fatalf("depends: media %v, pg %v, agent %v", DependsOn(media), DependsOn(pg), DependsOn(agent))
if !slices.Contains(DependsOn(nil, media), BackupSeat) || !slices.Contains(DependsOn(nil, pg), BackupSeat) ||
slices.Contains(DependsOn(nil, agent), BackupSeat) {
t.Fatalf("depends: media %v, pg %v, agent %v", DependsOn(nil, media), DependsOn(nil, pg), DependsOn(nil, agent))
}
if it, _ := media.DataItem("films"); it.OwnedByModule() || it.Protection() != "redundancy" {
t.Fatalf("an operator's path reads %+v", it)
@@ -14,7 +14,7 @@ func TestTheResolverConfigSeatIsGone(t *testing.T) {
t.Error("node-resolver-config is still in the mesh's set; the uplink's holder writes the resolver file")
}
// An uplink's holder needs no seat beside it for the file: it is its own.
if got := DependsOn(mod("networkmanager", nil, nil, nil, Claim{Name: "node-uplink"})); len(got) != 0 {
if got := DependsOn(nil, mod("networkmanager", nil, nil, nil, Claim{Name: "node-uplink"})); len(got) != 0 {
t.Errorf("an uplink holder with nothing declared depends on %v", got)
}
}
@@ -95,7 +95,7 @@ func TestAContributionDependsOnItsSeat(t *testing.T) {
{Seat: HotkeysSeat, Kind: "trigger", Content: "x"},
{Seat: DisplaySessionSeat, Kind: "config", Content: "y"},
}}
if got, want := DependsOn(m), []string{DisplaySessionSeat, HotkeysSeat}; !reflect.DeepEqual(got, want) {
if got, want := DependsOn(nil, m), []string{DisplaySessionSeat, HotkeysSeat}; !reflect.DeepEqual(got, want) {
t.Errorf("depends on %v, want %v", got, want)
}
catalogue := map[string]Manifest{"laptop": m, "triggerhappy": hotkeysHolder()}
+2 -2
View File
@@ -333,9 +333,9 @@ func TestAnOfferedBlockMakesNoDependencyOnTheBar(t *testing.T) {
block(`{"bar":"bottom","place":"status","shows":"battery"}`),
{Seat: HotkeysSeat, Kind: "trigger", Content: "x"},
}}
for _, seat := range DependsOn(power) {
for _, seat := range DependsOn(nil, power) {
if seat == BarSeat {
t.Fatalf("a block made the power module depend on the bar: %v", DependsOn(power))
t.Fatalf("a block made the power module depend on the bar: %v", DependsOn(nil, power))
}
}
// And on a machine with no bar it is placed nowhere, and nothing fails.
+38 -11
View File
@@ -6,10 +6,12 @@ import (
"strings"
)
// A module depends on the node seats that apply its resources (novox/hq ADR 0207) and on the
// seats it contributes to (novox/hq ADR 0210). A third source — what a seat it holds needs beside it
// (novox/hq ADR 0220) — had one user, node-resolver-config needing node-uplink, and went with that
// seat when the resolver file became the uplink's own (novox/hq ADR 0223).
// A module depends on the node seats that apply its resources (novox/hq ADR 0207), on the
// seats it contributes to (novox/hq ADR 0210), and on the seats a seat it holds needs beside it
// (novox/hq ADR 0220). The third source once served only compiled seats and had one user,
// node-resolver-config needing node-uplink, which went with that seat when the resolver file became
// the uplink's own (novox/hq ADR 0223) — and now serves module-declared seats instead: mail-notifier
// needing node-notifier, and the like (novox/hq ADR 0378 §8).
//
// Some of what a module declares is applied through software on the machine that is itself a
// module: a service through the service manager, a package through the package manager, a container
@@ -76,13 +78,16 @@ func isFoundation(m Manifest) bool {
return foundationModules[m.Module] || m.Computed != ""
}
// DependsOn is every seat a module needs held on its node, derived from the resource types it
// declares itself (novox/hq ADR 0207 §2), sorted.
// DependsOn is every seat a module needs held on its node: derived from the resource types it
// declares itself (novox/hq ADR 0207 §2), from what it contributes to (ADR 0210), and from what a
// node seat it claims needs beside it — read from whichever manifest in `catalogue` declares that
// seat (novox/hq ADR 0220, ADR 0378 §8). Sorted.
//
// **The module's own `resources` only.** What the controller composes around a module — its
// filter, jails, certificates, kept files, bundles, the guard — is the mesh's, put there because the
// module is assigned, and depending on it would make the module answer for the mesh's choices.
func DependsOn(m Manifest) []string {
// **The module's own `resources` only**, for the first source. What the controller composes around
// a module — its filter, jails, certificates, kept files, bundles, the guard — is the mesh's, put
// there because the module is assigned, and depending on it would make the module answer for the
// mesh's choices.
func DependsOn(catalogue map[string]Manifest, m Manifest) []string {
if isFoundation(m) {
return nil
}
@@ -95,6 +100,9 @@ func DependsOn(m Manifest) []string {
for _, seat := range contributedTo(m) {
seen[seat] = true
}
for _, seat := range neededBesideClaims(catalogue, m) {
seen[seat] = true
}
out := make([]string, 0, len(seen))
for s := range seen {
out = append(out, s)
@@ -135,6 +143,25 @@ func contributedTo(m Manifest) []string {
return out
}
// neededBesideClaims is every seat a node seat the module claims needs held on the same node
// (novox/hq ADR 0220, ADR 0378 §8): a module-declared seat's needs are read from whichever manifest
// in the catalogue declares that seat, never written again in the claimant's own manifest. Derived
// from the claim, as a resource's seat is derived from its type, so a module that claims the seat
// cannot leave the dependency out. A compiled seat carries no needs (novox/hq ADR 0378 §8).
func neededBesideClaims(catalogue map[string]Manifest, m Manifest) []string {
var out []string
for _, seat := range nodeSeatsClaimed(m) {
for _, candidate := range catalogue {
for _, s := range candidate.DefinesSeats {
if s.Name == seat {
out = append(out, s.Needs...)
}
}
}
}
return out
}
// claimsSeat is whether a module claims a node seat, by its current name or one it used to have
// (ADR 0122), so a rename leaves the dependency met.
func claimsSeat(m Manifest, seat string) bool {
@@ -205,7 +232,7 @@ func UnheldDependencies(catalogue map[string]Manifest, node string, set []Manife
if judged != nil && !judged[m.Module] {
continue
}
for _, seat := range DependsOn(m) {
for _, seat := range DependsOn(catalogue, m) {
if held[seat] {
continue
}
+33 -3
View File
@@ -51,7 +51,7 @@ func TestADependencyIsDerivedFromEachResourceTypeThatAppliesThroughASeat(t *test
"action": nil, "network": nil, "access": nil,
}
for kind, want := range cases {
got := DependsOn(withResources(mod("m", nil, nil, nil), res(kind, "x")))
got := DependsOn(nil, withResources(mod("m", nil, nil, nil), res(kind, "x")))
if len(got) == 0 {
got = nil
}
@@ -59,7 +59,7 @@ func TestADependencyIsDerivedFromEachResourceTypeThatAppliesThroughASeat(t *test
t.Errorf("a %s resource depends on %v, want %v", kind, got, want)
}
}
all := DependsOn(withResources(mod("m", nil, nil, nil),
all := DependsOn(nil, withResources(mod("m", nil, nil, nil),
res("container", "a"), res("service", "b"), res("package", "c"), res("package", "d")))
if want := []string{ContainerRuntimeSeat, PackageManagerSeat, ServiceManagerSeat}; !reflect.DeepEqual(all, want) {
t.Errorf("a module of every kind depends on %v, want each seat once: %v", all, want)
@@ -207,7 +207,7 @@ func TestTheFoundationsDeclarationsAreNotJudged(t *testing.T) {
cat := shelf(append(coreThree(), network, host)...)
for _, m := range []Manifest{network, host} {
if d := DependsOn(m); len(d) != 0 {
if d := DependsOn(cat, m); len(d) != 0 {
t.Errorf("%s, the foundation's, depends on %v", m.Module, d)
}
}
@@ -223,6 +223,36 @@ func TestTheFoundationsDeclarationsAreNotJudged(t *testing.T) {
}
}
// A module-declared seat's needs are judged exactly as a compiled seat's once were (novox/hq ADR
// 0220, ADR 0378 §8): "mail" declares the node seat "mail-notifier" needing node-notifier, the
// mesh's own (desktop notifications); "notifier" claims mail-notifier; "dunst" claims node-notifier,
// as it does in the real catalogue.
func mailNotifierAndDunst() map[string]Manifest {
return shelf(
Manifest{Module: "mail", DefinesSeats: []SeatDeclaration{{
Name: "mail-notifier", Scope: ScopeNode, Needs: []string{NotifierSeat},
}}},
mod("notifier", nil, nil, nil, Claim{Name: "mail-notifier"}),
mod("dunst", nil, nil, nil, Claim{Name: NotifierSeat}),
)
}
func TestAModuleDeclaredSeatsNeedsAreJudgedLikeACompiledSeatsOnceWere(t *testing.T) {
cat := mailNotifierAndDunst()
_, err := AssignRefusal(cat, "workstation", nil, []string{"notifier"})
var refusal *Refusal
if !errors.As(err, &refusal) || !strings.Contains(err.Error(), "node-notifier") {
t.Fatalf("notifier assigned without dunst was not refused naming node-notifier: %v", err)
}
if _, err := AssignRefusal(cat, "workstation", []string{"dunst"}, []string{"notifier"}); err != nil {
t.Errorf("notifier beside dunst was refused: %v", err)
}
if err := UnassignRefusal(cat, "workstation", []string{"dunst", "notifier"}, []string{"dunst"}); err == nil ||
!strings.Contains(err.Error(), "node-notifier") {
t.Errorf("taking dunst from under notifier was not refused: %v", err)
}
}
func TestUnassigningTheLastHolderWhileDependentsRemainIsRefused(t *testing.T) {
sshd := withResources(mod("sshd", nil, nil, nil), res("service", "sshd"))
cat := shelf(append(coreThree(), sshd)...)
+61
View File
@@ -71,6 +71,12 @@ type SeatDeclaration struct {
// mesh's own seats name theirs by: a node seat only, each absolute and literal, and none another seat
// names. A module's seat never steps back.
Paths []string `json:"paths,omitempty"`
// Needs is every node seat this seat's holder needs held on its own node (novox/hq ADR 0220, ADR
// 0378 §8): a role whose holder is only right while another role is filled beside it. A module
// claiming this seat depends on each, exactly as a module declaring a service depends on the
// service manager (ADR 0207) — derived from the claim, never written again in the claimant's own
// manifest. Only a node seat may carry needs, and a compiled seat carries none.
Needs []string `json:"needs,omitempty"`
}
// KindedBenches are the seats that may be kinded (novox/hq ADR 0234 §2): `channel` sends to the operator,
@@ -168,6 +174,7 @@ func declaredSeatProblems(m Manifest) []string {
}
problems = append(problems, trafficProblems(m, s)...)
problems = append(problems, declaredSeatPathProblems(m, s)...)
problems = append(problems, declaredSeatNeedsProblems(m, s)...)
}
for _, u := range m.Uses {
@@ -228,6 +235,30 @@ func trafficProblems(m Manifest, s SeatDeclaration) []string {
return problems
}
// declaredSeatNeedsProblems is what one declaration's needs can be judged on alone (novox/hq ADR
// 0378 §8): only a node seat is ever held beside another on the same node, so only a node seat may
// carry needs; and a seat needing itself names nothing that was not already true of holding it.
// Whether each named need itself exists as a node seat somewhere in the catalogue is CatalogueProblems'
// to say, once every manifest is known.
func declaredSeatNeedsProblems(m Manifest, s SeatDeclaration) []string {
var problems []string
if len(s.Needs) == 0 {
return problems
}
if s.At() != ScopeNode {
problems = append(problems, fmt.Sprintf(
"%s declares %s needing %s, and only a node seat needs another held beside it "+
"(novox/hq ADR 0378 §8)", m.Module, s.Name, strings.Join(s.Needs, ", ")))
}
for _, need := range s.Needs {
if need == s.Name {
problems = append(problems, fmt.Sprintf(
"%s declares %s needing itself (novox/hq ADR 0378 §8)", m.Module, s.Name))
}
}
return problems
}
// A Shelf is every manifest the mesh has registered, by module name.
type Shelf map[string]Manifest
@@ -280,6 +311,36 @@ func CatalogueProblems(shelf Shelf) []string {
return ok
}
// atNode says whether a seat, compiled or declared, is a node seat — judged here because only
// the whole catalogue knows a declared one's scope (novox/hq ADR 0378 §8).
atNode := func(seat string) bool {
if s, isMesh := SeatNamed(seat); isMesh {
return s.Scope == ScopeNode
}
if s, ok := declared[seat]; ok {
return s.At() == ScopeNode
}
return false
}
// A module-declared seat's needs name node seats that hold beside it (novox/hq ADR 0378 §8):
// whether one exists at all, compiled or declared by any module, only the whole catalogue can
// say — a single manifest's own problems (declaredSeatProblems) judge its needs no further.
for _, module := range shelfOrder(shelf) {
for _, s := range shelf[module].DefinesSeats {
for _, need := range s.Needs {
if !exists(need) {
problems = append(problems, fmt.Sprintf(
"%s declares %s needing %s, "+UndeclaredSeat, module, s.Name, need))
} else if !atNode(need) {
problems = append(problems, fmt.Sprintf(
"%s declares %s needing %s, which is not a node seat (novox/hq ADR 0378 §8)",
module, s.Name, need))
}
}
}
}
// Which seats name each path (novox/hq ADR 0287): a module's seat naming one another seat names loses.
named, pathProblems := pathSeats(shelfOrder(shelf), shelf, declaredBy)
problems = append(problems, pathProblems...)
+47
View File
@@ -116,6 +116,53 @@ func TestTheMeshsOwnSeatsAreStillClaimable(t *testing.T) {
}
}
// Only a node seat may carry needs (novox/hq ADR 0378 §8): a per-mesh seat's holder is not on any
// one node to depend on anything beside it there.
func TestOnlyANodeSeatMayCarryNeeds(t *testing.T) {
m := Manifest{Module: "mail", DefinesSeats: []SeatDeclaration{{
Name: "mail-notifier", Scope: ScopeMesh, Needs: []string{"node-notifier"}}}}
got := strings.Join(declaredSeatProblems(m), "; ")
if !strings.Contains(got, "needing") || !strings.Contains(got, "node seat") {
t.Fatalf("a mesh-scope seat's needs were accepted: %q", got)
}
}
// A seat needing itself names nothing (novox/hq ADR 0378 §8).
func TestASeatCannotNeedItself(t *testing.T) {
m := Manifest{Module: "mail", DefinesSeats: []SeatDeclaration{{
Name: "mail-notifier", Scope: ScopeNode, Needs: []string{"mail-notifier"}}}}
got := strings.Join(declaredSeatProblems(m), "; ")
if !strings.Contains(got, "needing itself") {
t.Fatalf("a seat needing itself was accepted: %q", got)
}
}
// A need naming nothing node-scope — undeclared, or declared at another scope — is the whole
// catalogue's to say (novox/hq ADR 0378 §8).
func TestANeedMustNameANodeSeatSomewhereInTheCatalogue(t *testing.T) {
mail := Manifest{Module: "mail", DefinesSeats: []SeatDeclaration{{
Name: "mail-notifier", Scope: ScopeNode, Needs: []string{"desk-beeper"}}}}
// Nobody declares desk-beeper at all.
got := problemsFor(t, Shelf{"mail": mail})
if !strings.Contains(got, "desk-beeper") || !strings.Contains(got, "no module declares") {
t.Fatalf("needing an undeclared seat was accepted: %q", got)
}
// Declared, but at mesh scope: still not a node seat to hold beside it.
notNode := Manifest{Module: "dunst", DefinesSeats: []SeatDeclaration{{Name: "desk-beeper", Scope: ScopeMesh}}}
got = problemsFor(t, Shelf{"mail": mail, "dunst": notNode})
if !strings.Contains(got, "desk-beeper") || !strings.Contains(got, "not a node seat") {
t.Fatalf("needing a mesh-scope seat was accepted: %q", got)
}
// Declared at node scope: accepted.
dunst := Manifest{Module: "dunst", DefinesSeats: []SeatDeclaration{{Name: "desk-beeper", Scope: ScopeNode}}}
if got := problemsFor(t, Shelf{"mail": mail, "dunst": dunst}); got != "" {
t.Fatalf("needing a node seat another module declares was refused: %q", got)
}
}
// A refusal that reorders itself between runs is a refusal nobody can diff.
func TestTheProblemsAreStable(t *testing.T) {
shelf := Shelf{"telegram": telegram(), "shop": {Module: "shop", Uses: []string{"nope"}},
+2 -2
View File
@@ -641,8 +641,8 @@ func AskMeshSeatTool(ctx context.Context, conn *nats.Conn, seat, verb string, ar
reply, err = r.msg, r.err
case why := <-refused:
cancel()
return Answer{}, fmt.Errorf("the bus refused the controller asking %s.%s — its grants do not name %s: %v",
seat, verb, subject, why)
return Answer{}, fmt.Errorf("%w: the bus refused the controller asking %s.%s — its grants do not name %s: %v",
ErrGrantRefused, seat, verb, subject, why)
}
switch {
case errors.Is(err, nats.ErrNoResponders):
+5
View File
@@ -167,6 +167,11 @@ const (
// is older than the tool.
var ErrNothingServes = errors.New("nothing serves it")
// ErrGrantRefused is the bus refusing this principal's own call: its grants do not name the subject
// asked. Distinct from ErrNothingServes (nobody there to answer) — this is the asker's own grant, not
// the holder's absence (novox/hq ADR 0380 §9).
var ErrGrantRefused = errors.New("the bus refused the ask")
// ModuleToolOn is where one machine's instance of a module answers a tool: the module's tool subject
// with the machine as its last token — the subject the node tools bind beside the shared one.
func ModuleToolOn(module, tool, node string) string { return ToolSubject(module, tool) + "." + node }
+1 -1
View File
@@ -8,7 +8,7 @@ private addresses. That is acceptable while mesh-controller is private; if it ev
a second place to clean besides the catalogue itself.
mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile
mesh-host 7a41e120635b3f7b6cb94cca8b3fc4093e4e6110 examples/foundation-first-node.lock
mesh-host c3b2fce0a29f0969dedf517c1f3e1570d64a8e74 examples/foundation-first-node.lock
mesh-sdk 5933eb757d443293631be2bed27a96288c2348d8 conformance/events/module-event.json
To move them, from this repository's root, with the two repositories checked out beside it:
+79 -29
View File
@@ -28,11 +28,12 @@
// image, and this file is applied on a machine with no mesh to ask about anything (ADR 0006). The
// registry prefix is the lab's; the digests are upstream's.
//
// The store's data is a NAMED VOLUME, not a directory on the machine. A directory the host
// creates is owned by root, and the database runs as somebody else inside the container — so it
// could not write, and the container crash-looped. A named volume lets the image set up its own
// ownership, and outlives the container, which is what you want for the thing holding the mesh's
// state.
// **The store is raised where the `postgres` module keeps it** (novox/hq ADR 0338): the container
// `postgres`, on the module's pgvector image, its data in the directory /var/lib/mesh-store owned by
// the image's postgres user, its superuser password read from the module's own-secret file — so the
// store step adopts it as it runs. Pinned upstream by the module's own digest, not through the lab's
// registry prefix, because the two must be the same reference (mesh-host internal/bootstrap holds
// them to each other against the module's manifest).
{
"declaration": 1,
@@ -83,17 +84,28 @@
"boot": "enabled",
"restart-on": ["base-filter"]
},
// The store's data is a directory owned by the image's postgres user (999), made before the
// container starts: a directory the runtime made would be root's, and the database could not
// write it. Measured on the module's image 2026-10-13: it initialises a new store on a directory
// made so (novox/hq ADR 0338 §5).
{
"id": "store-data",
"type": "directory",
"path": "/var/lib/mesh-store",
"mode": "0700",
"owner": "999:70"
},
{
"id": "store",
"type": "container",
"name": "mesh-store",
"image": "192.0.2.250:5000/postgres@sha256:7abf537131b66ed5af448d90653abf1679b0c7e9a1f07efdd4c3108a401b259a",
"name": "postgres",
"image": "pgvector/pgvector@sha256:cf134a767f474095eeba57e0117be8e568e011a63f33fbf252f14c9b760f8e6f",
"env": {
"POSTGRES_PASSWORD": "bootstrap",
"PGDATA": "/var/lib/postgresql/data/pgdata"
},
"ports": ["5432:5432"],
"volumes": ["mesh-store-data:/var/lib/postgresql/data"]
"volumes": ["/var/lib/mesh-store:/var/lib/postgresql/data"]
},
// Over TCP, not the socket. While the store initialises it runs a temporary server on the
// socket ONLY, then stops it and starts the real one — so a socket check passes, the action
@@ -102,21 +114,21 @@
{
"id": "store-ready",
"type": "action",
"in": "mesh-store",
"in": "postgres",
"command": ["sh", "-c", "for i in $(seq 1 180); do pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1 && exit 0; sleep 1; done; echo 'the store did not answer within 180s; its own last words follow'; pg_isready -h 127.0.0.1 -U postgres; tail -n 20 /var/lib/postgresql/data/log/*.log 2>/dev/null; exit 1"],
"verify": ["pg_isready", "-h", "127.0.0.1", "-U", "postgres"]
},
{
"id": "inventory-database",
"type": "action",
"in": "mesh-store",
"in": "postgres",
"command": ["sh", "-c", "psql -U postgres -c 'CREATE DATABASE inventory'"],
"verify": ["sh", "-c", "psql -U postgres -lqt | cut -d'|' -f1 | grep -qw inventory"]
},
{
"id": "identity-database",
"type": "action",
"in": "mesh-store",
"in": "postgres",
"command": ["sh", "-c", "psql -U postgres -c 'CREATE DATABASE identity'"],
"verify": ["sh", "-c", "psql -U postgres -lqt | cut -d'|' -f1 | grep -qw identity"]
},
@@ -126,20 +138,44 @@
{
"id": "licences-database",
"type": "action",
"in": "mesh-store",
"in": "postgres",
"command": ["sh", "-c", "psql -U postgres -c 'CREATE DATABASE licences'"],
"verify": ["sh", "-c", "psql -U postgres -lqt | cut -d'|' -f1 | grep -qw licences"]
},
{
"id": "context-schemas",
"type": "action",
"command": ["docker", "run", "--rm", "--network", "container:mesh-store",
"command": ["docker", "run", "--rm", "--network", "container:postgres",
"-e", "MESH_STORE_INVENTORY=postgres://postgres:bootstrap@127.0.0.1:5432/inventory?sslmode=disable",
"-e", "MESH_STORE_IDENTITY=postgres://postgres:bootstrap@127.0.0.1:5432/identity?sslmode=disable",
"-e", "MESH_STORE_LICENCES=postgres://postgres:bootstrap@127.0.0.1:5432/licences?sslmode=disable",
"192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
"migrate"],
"verify": ["sh", "-c", "docker exec mesh-store psql -U postgres -d inventory -tAc \"select to_regclass('public.node')\" | grep -qx node && docker exec mesh-store psql -U postgres -d identity -tAc \"select to_regclass('public.signing_key')\" | grep -qx signing_key && docker exec mesh-store psql -U postgres -d licences -tAc \"select to_regclass('public.licence')\" | grep -qx licence"]
"verify": ["sh", "-c", "docker exec postgres psql -U postgres -d inventory -tAc \"select to_regclass('public.node')\" | grep -qx node && docker exec postgres psql -U postgres -d identity -tAc \"select to_regclass('public.signing_key')\" | grep -qx signing_key && docker exec postgres psql -U postgres -d licences -tAc \"select to_regclass('public.licence')\" | grep -qx licence"]
},
// **The bus is raised where the `nats` module keeps it** (novox/hq ADR 0333 §1): the container
// `mesh-broker-nats`, its configuration and user list in /var/lib/nats-module/conf, its streams in
// /var/lib/mesh-broker-nats and its certificate in /var/lib/mesh-broker-tls — the names and paths the
// catalogue's module declares. So adopting it as that module at the broker step keeps it: the
// module's built image takes the place of the upstream one below, once, on the same streams, users
// and certificate, and nothing else moves. mesh-host's internal/bootstrap holds the two to each other
// against the module's own manifest (busLayout), at the broker step and in its tests.
//
// Directories on the machine, not volumes the runtime names: the module mounts these paths, and the
// control plane, once it runs as the mesh's process, reads the certificate at its path on the machine.
// The certificate's directory is readable by everyone because the certificate is public and the control
// plane runs as nobody; its key is written 0600 by the program that makes it.
{
"id": "bus-tls-dir",
"type": "directory",
"path": "/var/lib/mesh-broker-tls",
"mode": "0755"
},
{
"id": "bus-data-dir",
"type": "directory",
"path": "/var/lib/mesh-broker-nats",
"mode": "0700"
},
{
"id": "bus-certificate",
@@ -151,49 +187,63 @@
// it, and asking nothing of the image it writes into. Self-signed on purpose: a host pins
// this server's exact certificate (novox/hq ADR 0004), and at this moment there is no mesh
// to ask an authority of.
// `--user 0:0` because the volume is root's and this image runs as nobody, which is right
// for the long-running control plane and wrong for a one-shot writing into a fresh volume.
"command": ["docker", "run", "--rm", "--user", "0:0", "-v", "mesh-broker-tls:/tls",
// `--user 0:0` because the directory is root's and this image runs as nobody, which is right
// for the long-running control plane and wrong for a one-shot writing into a fresh directory.
"command": ["docker", "run", "--rm", "--user", "0:0", "-v", "/var/lib/mesh-broker-tls:/tls",
"192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
"broker", "certificate", "--into", "/tls"],
"verify": ["docker", "run", "--rm", "--user", "0:0", "-v", "mesh-broker-tls:/tls",
"verify": ["docker", "run", "--rm", "--user", "0:0", "-v", "/var/lib/mesh-broker-tls:/tls",
"192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
"broker", "certificate", "--check", "--into", "/tls"]
},
// **Seeds, written only where nothing is** (create-once). The bus's configuration and user list are
// the bundle's until the bus is adopted, and the module's from then on (ADR 0333 §4): an installer
// run again after the adoption applies this bundle again, and an ordinary file would put the
// bundle's one user back over every user the mesh composed, and leave the node unable to reach its
// bus. Until the adoption the installer places the composed list over the seed (enrol.go).
{
"id": "bus-conf-dir",
"type": "directory",
"path": "/var/lib/mesh-bus-conf",
"path": "/var/lib/nats-module/conf",
"mode": "0700"
},
{
"id": "bus-conf",
"type": "file",
"path": "/var/lib/mesh-bus-conf/nats.conf",
"path": "/var/lib/nats-module/conf/nats.conf",
"mode": "0644",
"content": "port: 4222\nhttp: 127.0.0.1:8222\n\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n}\n\njetstream {\n store_dir: \"/data\"\n}\n\ninclude accounts.conf\n"
"create-once": true,
"content": "port: 4222\nhttp: 0.0.0.0:8222\n\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n}\n\njetstream {\n store_dir: \"/data\"\n}\n\ninclude accounts.conf\n"
},
{
"id": "bus-accounts",
"type": "file",
"path": "/var/lib/mesh-bus-conf/accounts.conf",
"path": "/var/lib/nats-module/conf/accounts.conf",
"mode": "0600",
"content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"$JS.ACK.DEAD_LETTER_NOTICES.controller.>\", \"$JS.API.>\", \"$KV.mesh-controller_asked.>\", \"$KV.mesh-controller_calls.>\", \"$KV.mesh-controller_hand-acts.>\", \"$KV.mesh-controller_conditions.>\", \"$KV.mesh-controller_condition-history.>\", \"$KV.mesh-controller_lease.>\", \"$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>\", \"$KV.SEAT_NODE_BUILD_AGENT_cancelled.>\", \"_INBOX.enrol.>\", \"mesh.assignment.>\", \"mesh.mod.*.tool.>\", \"mesh.node.>\", \"mesh.seat.mesh-build-machine.accept.>\", \"mesh.seat.node-build-agent.accept.>\", \"mesh.seat.mesh-build-machine.tool.>\", \"mesh.seat.node-build-agent.tool.>\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.refused\", \"mesh.seat.mesh-controller.event.rolled-back\", \"mesh.seat.mesh-controller.event.checked\", \"mesh.seat.mesh-controller.event.plan-moved\", \"mesh.seat.mesh-controller.event.condition-raised\", \"mesh.seat.mesh-controller.event.condition-changed\", \"mesh.seat.mesh-controller.event.condition-cleared\", \"mesh.seat.mesh-controller.event.doctor-heartbeat\", \"mesh.seat.mesh-controller.event.secret-replaced\", \"mesh.seat.mesh-controller.event.healer-acted\", \"$SRV.INFO\", \"mesh.seat.node-intrusion-prevention.tool.banned.*\", \"mesh.seat.node-backup.tool.backed-up.*\", \"mesh.seat.node-backup.tool.now.*\", \"mesh.seat.mesh-delivery.tool.stalled\", \"mesh.seat.mesh-delivery.tool.close\", \"mesh.seat.node-launcher.tool.secret.*\", \"mesh.seat.mesh-delivery.tool.release\", \"mesh.seat.mesh-delivery.tool.stop\", \"mesh.seat.mesh-delivery.tool.times\", \"mesh.seat.mesh-delivery.tool.groups\", \"mesh.seat.node-service-manager.tool.restart.*\", \"mesh.seat.mesh-controller.tool.plans\", \"mesh.events.dead.>\", \"mesh.again.>\"] }\n subscribe: { allow: [\"$JS.API.>\", \"$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>\", \"$JS.EVENT.ADVISORY.CONSUMER.DELETED.>\", \"_DELIVER.controller\", \"_DELIVER.controller.>\", \"_INBOX.controller.>\", \"mesh.control.>\", \"mesh.mod.*.event.provisioner.failing\", \"mesh.mod.*.event.provisioner.recovered\", \"mesh.mod.*.event.provisioner.retirement\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.mod.gitea.event.pull.updated\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.seat.mesh-build-machine.event.built\", \"mesh.seat.node-build-agent.event.built\", \"mesh.seat.operator-channel.event.decided.mesh-controller\", \"mesh.seat.mesh-controller.tool.>\", \"$SRV.PING\", \"$SRV.INFO\", \"$SRV.PING.mesh-controller\", \"$SRV.PING.mesh-controller.>\", \"$SRV.INFO.mesh-controller\", \"$SRV.INFO.mesh-controller.>\", \"$SRV.STATS\", \"$SRV.STATS.mesh-controller\", \"$SRV.STATS.mesh-controller.>\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n}\n"
"create-once": true,
"content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"$JS.ACK.DEAD_LETTER_NOTICES.controller.>\", \"$JS.API.>\", \"$KV.mesh-controller_asked.>\", \"$KV.mesh-controller_calls.>\", \"$KV.mesh-controller_hand-acts.>\", \"$KV.mesh-controller_conditions.>\", \"$KV.mesh-controller_condition-history.>\", \"$KV.mesh-controller_lease.>\", \"$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>\", \"$KV.SEAT_NODE_BUILD_AGENT_cancelled.>\", \"_INBOX.enrol.>\", \"mesh.assignment.>\", \"mesh.mod.*.tool.>\", \"mesh.node.>\", \"mesh.seat.mesh-build-machine.accept.>\", \"mesh.seat.node-build-agent.accept.>\", \"mesh.seat.mesh-build-machine.tool.>\", \"mesh.seat.node-build-agent.tool.>\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.refused\", \"mesh.seat.mesh-controller.event.rolled-back\", \"mesh.seat.mesh-controller.event.checked\", \"mesh.seat.mesh-controller.event.plan-moved\", \"mesh.seat.mesh-controller.event.condition-raised\", \"mesh.seat.mesh-controller.event.condition-changed\", \"mesh.seat.mesh-controller.event.condition-cleared\", \"mesh.seat.mesh-controller.event.doctor-heartbeat\", \"mesh.seat.mesh-controller.event.secret-replaced\", \"mesh.seat.mesh-controller.event.healer-acted\", \"$SRV.INFO\", \"mesh.seat.node-intrusion-prevention.tool.banned.*\", \"mesh.seat.node-backup.tool.backed-up.*\", \"mesh.seat.node-backup.tool.now.*\", \"mesh.seat.mesh-delivery.tool.stalled\", \"mesh.seat.mesh-delivery.tool.close\", \"mesh.seat.node-launcher.tool.secret.*\", \"mesh.seat.mesh-delivery.tool.release\", \"mesh.seat.mesh-delivery.tool.stop\", \"mesh.seat.mesh-delivery.tool.times\", \"mesh.seat.mesh-delivery.tool.groups\", \"mesh.seat.node-service-manager.tool.restart.*\", \"mesh.seat.operator-channel.tool.tell\", \"mesh.seat.mesh-controller.tool.plans\", \"mesh.events.dead.>\", \"mesh.again.>\"] }\n subscribe: { allow: [\"$JS.API.>\", \"$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>\", \"$JS.EVENT.ADVISORY.CONSUMER.DELETED.>\", \"_DELIVER.controller\", \"_DELIVER.controller.>\", \"_INBOX.controller.>\", \"mesh.control.>\", \"mesh.mod.*.event.provisioner.failing\", \"mesh.mod.*.event.provisioner.recovered\", \"mesh.mod.*.event.provisioner.retirement\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.mod.gitea.event.pull.updated\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.seat.mesh-build-machine.event.built\", \"mesh.seat.node-build-agent.event.built\", \"mesh.seat.operator-channel.event.decided.mesh-controller\", \"mesh.seat.mesh-controller.tool.>\", \"$SRV.PING\", \"$SRV.INFO\", \"$SRV.PING.mesh-controller\", \"$SRV.PING.mesh-controller.>\", \"$SRV.INFO.mesh-controller\", \"$SRV.INFO.mesh-controller.>\", \"$SRV.STATS\", \"$SRV.STATS.mesh-controller\", \"$SRV.STATS.mesh-controller.>\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n}\n"
},
// The upstream server, until the broker step adopts it: the `nats` module's own image, built by the
// mesh, takes its place there once, on these same directories (ADR 0333 §3), as the bus's planned
// step (ADR 0236). The release is the one the module's image is built on (its NATS_BASE, 2.11.17),
// pinned upstream by that same digest: the adoption changes no server release, so it can be undone
// by putting the old image back and is said reversible. The published port is the
// node's bus port, 5671 unless given, which every enrolment token names; the installer gives the
// module the setting that maps its declared 4222 to it (ADR 0333 §2).
{
"id": "broker",
"type": "container",
"name": "mesh-broker",
"image": "192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927",
"name": "mesh-broker-nats",
"image": "nats@sha256:e4bf19f15fd3218814a4e3c9e0064e1334bd8aa20d5984b9f1a0afd084f8cc00",
"ports": ["5671:4222", "127.0.0.1:8222:8222"],
"volumes": ["mesh-broker-data:/data", "mesh-broker-tls:/tls:ro", "/var/lib/mesh-bus-conf:/etc/nats:ro"],
"volumes": ["/var/lib/mesh-broker-nats:/data", "/var/lib/nats-module/conf:/etc/nats:ro", "/var/lib/mesh-broker-tls:/tls:ro"],
"args": ["-c", "/etc/nats/nats.conf", "-js"]
},
{
"id": "broker-ready",
"type": "action",
"command": ["sh", "-c", "for i in $(seq 1 60); do docker run --rm --network host --entrypoint sh 192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927 -c 'nc -z 127.0.0.1 5671' >/dev/null 2>&1 && exit 0; sleep 1; done; exit 1"],
"verify": ["sh", "-c", "docker run --rm --network host --entrypoint sh 192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927 -c 'nc -z 127.0.0.1 5671'"]
"command": ["sh", "-c", "for i in $(seq 1 60); do docker run --rm --network host --entrypoint sh nats@sha256:e4bf19f15fd3218814a4e3c9e0064e1334bd8aa20d5984b9f1a0afd084f8cc00 -c 'nc -z 127.0.0.1 5671' >/dev/null 2>&1 && exit 0; sleep 1; done; exit 1"],
"verify": ["sh", "-c", "docker run --rm --network host --entrypoint sh nats@sha256:e4bf19f15fd3218814a4e3c9e0064e1334bd8aa20d5984b9f1a0afd084f8cc00 -c 'nc -z 127.0.0.1 5671'"]
},
{
"id": "control-plane",
@@ -202,7 +252,7 @@
"image": "192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
"network": "host",
"args": ["serve"],
"volumes": ["mesh-broker-tls:/broker-tls:ro"],
"volumes": ["/var/lib/mesh-broker-tls:/broker-tls:ro"],
"env": {
"MESH_STORE_INVENTORY": "postgres://postgres:bootstrap@127.0.0.1:5432/inventory?sslmode=disable",
"MESH_STORE_IDENTITY": "postgres://postgres:bootstrap@127.0.0.1:5432/identity?sslmode=disable",