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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)...)
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Vendored
+1
-1
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user