Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00bcdccdc7 | ||
|
|
747734687e | ||
|
|
0c8c9ffae9 | ||
|
|
1c7c385839 | ||
|
|
65ff6159ad | ||
|
|
e6e1e3bc89 | ||
|
|
07e59c535e | ||
|
|
ba97297f66 | ||
|
|
e6b00e2e51 | ||
|
|
fa19a2d718 | ||
|
|
3e5086a2f6 | ||
|
|
ed331eb972 | ||
|
|
be59f29f46 | ||
|
|
5689553406 |
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -179,7 +180,21 @@ func (a *actor) release() {
|
||||
// holderOf is this process as the lease's holder.
|
||||
func holderOf(instance string) lease.Holder {
|
||||
host, _ := os.Hostname()
|
||||
return lease.Holder{Instance: instance, Host: host, Build: version}
|
||||
build := runningBuild()
|
||||
if build == "" {
|
||||
build = version
|
||||
}
|
||||
return lease.Holder{Instance: instance, Host: host, Build: build}
|
||||
}
|
||||
|
||||
// RunningBuildVar is where the declaration tells this process which build it is (module.json, the
|
||||
// controller process's env): the version its bundle is delivered as, `${version}` composed by the
|
||||
// catalogue from the bundle's digest. Empty for a process placed by hand.
|
||||
const RunningBuildVar = "MESH_CONTROLLER_VERSION"
|
||||
|
||||
// runningBuild is the version of the build this process is, or empty when the declaration did not say.
|
||||
func runningBuild() string {
|
||||
return strings.TrimSpace(os.Getenv(RunningBuildVar))
|
||||
}
|
||||
|
||||
// serveUnderTheLease takes the lease for the serving controller, waiting while another holds it, and
|
||||
|
||||
@@ -167,6 +167,11 @@ type asker struct {
|
||||
routerRecord func(ctx context.Context, id string) (*asks.Warrant, error)
|
||||
// routerHere says whether a router holds the seat and takes asks under the asker's name; nil is yes.
|
||||
routerHere func(ctx context.Context) (bool, error)
|
||||
// grantHeld says whether the bus holds the controller's grant to ask: the user list the bus's machine was
|
||||
// last sent is the one the mesh composes now (novox/hq issue 353). The grant is composed from the router's
|
||||
// assignment and reaches the bus only when that machine is next pushed, so between `assign` and `push`
|
||||
// the record says a router is here and the bus refuses every ask. why says what to do; nil is yes.
|
||||
grantHeld func(ctx context.Context) (held bool, why string, err error)
|
||||
// channels is what the channels are now, as a fingerprint: who holds which kind, promising what.
|
||||
channels func(ctx context.Context) string
|
||||
// raise keeps the asker's own condition (sourceAsker): which conditions needing the operator could not be
|
||||
@@ -176,6 +181,7 @@ type asker struct {
|
||||
logf func(string, ...any)
|
||||
|
||||
saidNoRouter bool
|
||||
saidNoGrant bool
|
||||
|
||||
mu sync.Mutex
|
||||
nudged chan struct{}
|
||||
@@ -258,6 +264,30 @@ func (a *asker) reconcile(ctx context.Context) error {
|
||||
}
|
||||
a.saidNoRouter = false
|
||||
}
|
||||
if a.grantHeld != nil {
|
||||
held, why, err := a.grantHeld(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !held {
|
||||
if !a.saidNoGrant {
|
||||
a.logf("the bus does not hold the controller's grant to ask yet: %s; the operator is asked nothing until it does", why)
|
||||
a.saidNoGrant = true
|
||||
}
|
||||
open, err := a.open(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var unasked []conditions.Condition
|
||||
for _, c := range open {
|
||||
if wants(c, now) {
|
||||
unasked = append(unasked, c)
|
||||
}
|
||||
}
|
||||
return a.sayUnasked(ctx, unasked, "the bus does not hold the controller's grant to ask yet: "+why)
|
||||
}
|
||||
a.saidNoGrant = false
|
||||
}
|
||||
channels := ""
|
||||
if a.channels != nil {
|
||||
channels = a.channels(ctx)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
@@ -654,3 +656,145 @@ func TestAnAcknowledgementNeverSharesAnAskWithAnApproval(t *testing.T) {
|
||||
t.Errorf("the approval kept through a silence was not performed: %v", r.called)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq issue 353: the controller's grant to ask is composed from the router's assignment and reaches the
|
||||
// bus only when its machine is pushed. Between the two, the bus refuses every ask (measured 2026-10-09, 17:54 to
|
||||
// 17:56 local: seven refusals of mesh.seat.operator-channel.accept.ask.mesh-controller). So nothing is asked
|
||||
// while the bus's user list is behind, it is said once, the conditions that need the operator are raised as
|
||||
// undelivered with what to do, and the asks go out once the bus holds the grant.
|
||||
func TestNothingIsAskedWhileTheBusLacksTheControllersGrant(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
var said []string
|
||||
r.a.logf = func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }
|
||||
var raised [][]conditions.Observation
|
||||
r.a.raise = func(_ context.Context, obs []conditions.Observation) error {
|
||||
raised = append(raised, obs)
|
||||
return nil
|
||||
}
|
||||
held := false
|
||||
r.a.grantHeld = func(context.Context) (bool, string, error) {
|
||||
return held, "the bus's user list on anchor is behind what the mesh composes; `push anchor` carries it", nil
|
||||
}
|
||||
r.open = []conditions.Condition{heldCondition()}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
_ = r.a.reconcile(context.Background())
|
||||
if len(r.asksSent(t)) != 0 {
|
||||
t.Error("asked while the bus lacks the grant")
|
||||
}
|
||||
n := 0
|
||||
for _, s := range said {
|
||||
if strings.Contains(s, "does not hold the controller's grant") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("said %d times: %q", n, said)
|
||||
}
|
||||
if len(raised) == 0 || len(raised[len(raised)-1]) != 1 ||
|
||||
!strings.Contains(raised[len(raised)-1][0].Summary, "`push anchor` carries it") ||
|
||||
raised[len(raised)-1][0].Kind != "asks-undelivered" {
|
||||
t.Fatalf("not said as a condition with what to do: %+v", raised)
|
||||
}
|
||||
held = true
|
||||
_ = r.a.reconcile(context.Background())
|
||||
if sent := r.asksSent(t); len(sent) != 1 || sent[0].About != heldCondition().Key {
|
||||
t.Errorf("not asked once the bus holds the grant: %+v", sent)
|
||||
}
|
||||
if last := raised[len(raised)-1]; len(last) != 0 {
|
||||
t.Errorf("the undelivered condition was not cleared: %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
// hq issue 353, the review: the bus holds the controller's grant to ask only when both facts hold — the mesh
|
||||
// composes the controller's own grant, and the bus's module says it is in force on the running server — and
|
||||
// each fact not known is not held, said with why.
|
||||
func TestTheGrantIsHeldOnlyWhenComposedAndInForceOnTheBus(t *testing.T) {
|
||||
yes := func(context.Context) (bool, string, error) { return true, "", nil }
|
||||
no := func(why string) grantJudge {
|
||||
return func(context.Context) (bool, string, error) { return false, why, nil }
|
||||
}
|
||||
broken := func(context.Context) (bool, string, error) { return false, "", errors.New("the store is away") }
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
composed, inForce grantJudge
|
||||
held bool
|
||||
says string
|
||||
}{
|
||||
{"composed and in force", yes, yes, true, ""},
|
||||
{"not composed", no("no router is assigned"), yes, false, "no router is assigned"},
|
||||
{"composed, written, not reloaded", yes, no("the server has not reloaded"), false, "has not reloaded"},
|
||||
{"the composition unknown", broken, yes, false, "could not be worked out: the store is away"},
|
||||
{"the bus unknown", yes, broken, false, "could not be worked out: the store is away"},
|
||||
{"nothing judges the bus", yes, nil, false, "not judged"},
|
||||
} {
|
||||
held, why, err := grantHeldBy(c.composed, c.inForce, time.Now)(context.Background())
|
||||
if err != nil || held != c.held || !strings.Contains(why, c.says) {
|
||||
t.Errorf("%s: held %v (%q, %v)", c.name, held, why, err)
|
||||
}
|
||||
}
|
||||
// Remembered for grantLookEvery, then judged again.
|
||||
now := time.Date(2026, 10, 9, 18, 0, 0, 0, time.UTC)
|
||||
inForce := false
|
||||
judge := grantHeldBy(yes, func(context.Context) (bool, string, error) { return inForce, "not yet", nil },
|
||||
func() time.Time { return now })
|
||||
if held, _, _ := judge(context.Background()); held {
|
||||
t.Fatal("held before the bus enforces it")
|
||||
}
|
||||
inForce = true
|
||||
if held, _, _ := judge(context.Background()); held {
|
||||
t.Error("judged again inside grantLookEvery")
|
||||
}
|
||||
now = now.Add(grantLookEvery)
|
||||
if held, _, _ := judge(context.Background()); !held {
|
||||
t.Error("not judged again after grantLookEvery")
|
||||
}
|
||||
}
|
||||
|
||||
// The controller's own grant, as composed: present while a router that takes asks is assigned, absent
|
||||
// otherwise — whatever else in the user list changes.
|
||||
func TestTheControllersOwnGrantToAskIsReadFromTheComposition(t *testing.T) {
|
||||
router := broker.Declared{Module: "messenger", Holds: []broker.Seat{{Name: broker.AsksSeat, Scope: "mesh",
|
||||
Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"}, ByCaller: []string{"ask", "cancel", "decided"}}}}
|
||||
other := broker.Declared{Module: "dunst"}
|
||||
with := broker.Records{Nodes: []string{"anchor"}, Assigned: map[string][]broker.Declared{"anchor": {router, other}}}
|
||||
without := broker.Records{Nodes: []string{"anchor"}, Assigned: map[string][]broker.Declared{"anchor": {other}}}
|
||||
if may, err := controllerMayAsk(with); err != nil || !may {
|
||||
t.Errorf("with a router assigned: %v %v", may, err)
|
||||
}
|
||||
if may, err := controllerMayAsk(without); err != nil || may {
|
||||
t.Errorf("with no router assigned: %v %v", may, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The bus module's answer: in force only when it says so in so many words.
|
||||
func TestTheBusModulesAnswerIsInForceOnlyWhenItSaysSo(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
answer string
|
||||
want bool
|
||||
says string
|
||||
}{
|
||||
{`{"allowed":true,"in_force":true}`, true, ""},
|
||||
{`{"allowed":true,"in_force":false,"why":"the server has not reloaded since it was written"}`, false, "not reloaded"},
|
||||
{`{"allowed":false,"in_force":false}`, false, "not in force"},
|
||||
{`{"allowed":true}`, false, "older than that answer"},
|
||||
{`{"allowed":false,"in_force":true}`, false, "not allowed"},
|
||||
{`not json`, false, "could not be read"},
|
||||
} {
|
||||
if got, why := grantInForce(json.RawMessage(c.answer)); got != c.want || !strings.Contains(why, c.says) {
|
||||
t.Errorf("%s: %v %q", c.answer, got, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The serving asker is wired to the real judgement: with records and a bus it cannot read, the grant is not
|
||||
// held, and so nothing is asked (a stub saying yes, or no judgement at all, would ask).
|
||||
func TestTheServingAskerJudgesTheGrantForReal(t *testing.T) {
|
||||
a := servingAsker(&stores{}, nil, nil, nil)
|
||||
if a.grantHeld == nil {
|
||||
t.Fatal("the serving asker does not judge the bus's grant")
|
||||
}
|
||||
held, why, err := a.grantHeld(context.Background())
|
||||
if err != nil || held || !strings.Contains(why, "could not be worked out") {
|
||||
t.Errorf("held %v (%q, %v) with nothing to read", held, why, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
@@ -234,6 +235,163 @@ func routerHereIn(inv *inventory.Inventory) func(ctx context.Context) (bool, err
|
||||
}
|
||||
}
|
||||
|
||||
// grantHeldIn says whether the bus holds the controller's grant to ask (novox/hq issue 353), judged on the two
|
||||
// facts that make it so, and failing closed on each:
|
||||
//
|
||||
// 1. **composed**: the user list the mesh composes now grants the controller itself to publish its ask
|
||||
// (controllerMayAsk) — the controller's own grant, not whether any user of the list changed;
|
||||
// 2. **in force**: the bus's own module, on the machine holding the bus, says the user list there allows it
|
||||
// and the server reloaded after that list was written (the nats module's `nats_user_can`, `in_force`) — a
|
||||
// list sent is not a list the bus enforces until it reloads.
|
||||
//
|
||||
// Anything that cannot be worked out is not held, with why, so the conditions that need the operator are said
|
||||
// as undelivered rather than asked into a refusal.
|
||||
func grantHeldIn(open *stores, conn *nats.Conn) func(ctx context.Context) (bool, string, error) {
|
||||
return grantHeldBy(composedGrantIn(open), busGrantIn(open, conn), time.Now)
|
||||
}
|
||||
|
||||
// grantJudge is one of the two facts: whether it holds, and why not.
|
||||
type grantJudge func(ctx context.Context) (bool, string, error)
|
||||
|
||||
// grantHeldBy is the judgement over the two facts, remembered for grantLookEvery: composing the list resolves
|
||||
// the bus's machine whole, and asking the bus's module is a call.
|
||||
func grantHeldBy(composed, inForce grantJudge, now func() time.Time) func(ctx context.Context) (bool, string, error) {
|
||||
var mu sync.Mutex
|
||||
var at time.Time
|
||||
var held bool
|
||||
var why string
|
||||
return func(ctx context.Context) (bool, string, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !at.IsZero() && now().Sub(at) < grantLookEvery {
|
||||
return held, why, nil
|
||||
}
|
||||
held, why = judgeGrant(ctx, composed, inForce)
|
||||
at = now()
|
||||
return held, why, nil
|
||||
}
|
||||
}
|
||||
|
||||
// judgeGrant is held only when both facts hold; an error is a fact not known, never a yes.
|
||||
func judgeGrant(ctx context.Context, composed, inForce grantJudge) (bool, string) {
|
||||
for _, fact := range []struct {
|
||||
judge grantJudge
|
||||
what string
|
||||
}{{composed, "whether the mesh composes the controller a grant to ask"}, {inForce, "whether the bus enforces it"}} {
|
||||
if fact.judge == nil {
|
||||
return false, fact.what + " is not judged here"
|
||||
}
|
||||
ok, why, err := fact.judge(ctx)
|
||||
if err != nil {
|
||||
return false, fact.what + " could not be worked out: " + err.Error()
|
||||
}
|
||||
if !ok {
|
||||
return false, why
|
||||
}
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// controllerMayAsk says whether the controller's own grant, as composed from these records, lets it publish its
|
||||
// ask (ADR 0259 §3: composed while a router that takes asks under its asker's name is assigned).
|
||||
func controllerMayAsk(records broker.Records) (bool, error) {
|
||||
users, err := broker.Users(records)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.Kind != broker.KindController {
|
||||
continue
|
||||
}
|
||||
perms, err := broker.PermissionsFor(u)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return broker.MayPublish(perms, asks.AskSubject(askerName)), nil
|
||||
}
|
||||
return false, errors.New("the composition holds no controller")
|
||||
}
|
||||
|
||||
// composedGrantIn is the first fact, from the mesh's records.
|
||||
func composedGrantIn(open *stores) grantJudge {
|
||||
return func(ctx context.Context) (bool, string, error) {
|
||||
if open == nil || open.inventory == nil {
|
||||
return false, "", errors.New("the mesh's records are not open")
|
||||
}
|
||||
records, err := open.inventory.BusRecords(ctx)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
may, err := controllerMayAsk(records)
|
||||
if err != nil || may {
|
||||
return may, "", err
|
||||
}
|
||||
return false, "the mesh composes the controller no grant to ask: no router that takes asks under its " +
|
||||
"asker's name is assigned", nil
|
||||
}
|
||||
}
|
||||
|
||||
// busGrantIn is the second fact, asked of the bus's own module on the machine holding the bus.
|
||||
func busGrantIn(open *stores, conn *nats.Conn) grantJudge {
|
||||
return func(ctx context.Context) (bool, string, error) {
|
||||
if open == nil || open.inventory == nil || conn == nil {
|
||||
return false, "", errors.New("the mesh's records or the bus are not open")
|
||||
}
|
||||
holders, err := seatHolders(ctx, open.inventory)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
h, held := holders[theBrokerSeat]
|
||||
if !held || h.Node == "" || h.Module == "" {
|
||||
return false, "no module holds the bus, so nothing says what it enforces", nil
|
||||
}
|
||||
a, err := link.AskModuleToolOn(ctx, conn, h.Module, busUserCanTool, h.Node, map[string]any{
|
||||
"user": broker.ControllerName, "action": "publish", "subject": asks.AskSubject(askerName)}, 15*time.Second)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if a.Error != "" {
|
||||
return false, "", errors.New(a.Error)
|
||||
}
|
||||
inForce, why := grantInForce(a.Result)
|
||||
if !inForce {
|
||||
why = fmt.Sprintf("the bus on %s does not enforce the controller's grant to ask yet: %s; `push %s` "+
|
||||
"carries it, and the bus reloads it in place", h.Node, why, h.Node)
|
||||
}
|
||||
return inForce, why, nil
|
||||
}
|
||||
}
|
||||
|
||||
// busUserCanTool is the bus module's answer to whether a user may do something, and whether it is in force.
|
||||
const busUserCanTool = "nats_user_can"
|
||||
|
||||
// grantInForce reads the bus module's answer: in force only when it says so, in so many words. An answer
|
||||
// without `in_force` is a bus module older than the question, and is not a yes.
|
||||
func grantInForce(raw json.RawMessage) (bool, string) {
|
||||
var answer struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
InForce *bool `json:"in_force"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||
return false, "the bus module's answer could not be read"
|
||||
}
|
||||
switch {
|
||||
case answer.InForce == nil:
|
||||
return false, "the bus module does not say whether a grant is in force (it is older than that answer)"
|
||||
case !*answer.InForce && answer.Why != "":
|
||||
return false, answer.Why
|
||||
case !*answer.InForce:
|
||||
return false, "the bus module says it is not in force"
|
||||
case !answer.Allowed:
|
||||
return false, "the bus module says it is in force and not allowed"
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// grantLookEvery is how often the bus's user list is judged against the one its machine was last sent.
|
||||
const grantLookEvery = 30 * time.Second
|
||||
|
||||
// channelsIn is what the channels are now, as a fingerprint: each module claiming a kind of the channel
|
||||
// bench, where, promising what, and whether of its own account. An ask the router refused is asked again
|
||||
// once this changes.
|
||||
@@ -269,7 +427,19 @@ func startAsking(ctx context.Context, open *stores, server *link.Server, conn *n
|
||||
fmt.Printf("the operator cannot be asked: %v\n", err)
|
||||
return
|
||||
}
|
||||
a := &asker{
|
||||
a := servingAsker(open, conn, js, keeper)
|
||||
if err := server.Decides(a); err != nil {
|
||||
fmt.Printf("the operator's answers cannot be heard, so nothing is asked: %v\n", err)
|
||||
return
|
||||
}
|
||||
askerFrom = a
|
||||
go a.keep(ctx)
|
||||
}
|
||||
|
||||
// servingAsker is the serving controller's asker, wired to the mesh: what it reads, publishes, performs and
|
||||
// records, and the judgements it asks by — the bus's grant to ask among them (novox/hq issue 353).
|
||||
func servingAsker(open *stores, conn *nats.Conn, js jetstream.JetStream, keeper *conditions.Keeper) *asker {
|
||||
return &asker{
|
||||
open: keeper.Open,
|
||||
silence: func(ctx context.Context, key string, d time.Duration, by, why string) error {
|
||||
_, err := keeper.Silence(ctx, key, d, by, why)
|
||||
@@ -287,6 +457,7 @@ func startAsking(ctx context.Context, open *stores, server *link.Server, conn *n
|
||||
},
|
||||
routerRecord: routerRecordOf(conn, open.inventory),
|
||||
routerHere: routerHereIn(open.inventory),
|
||||
grantHeld: grantHeldIn(open, conn),
|
||||
channels: channelsIn(open.inventory),
|
||||
raise: func(ctx context.Context, obs []conditions.Observation) error {
|
||||
return keeper.Reconcile(ctx, sourceAsker, obs)
|
||||
@@ -294,10 +465,4 @@ func startAsking(ctx context.Context, open *stores, server *link.Server, conn *n
|
||||
now: time.Now,
|
||||
logf: func(format string, args ...any) { fmt.Printf(format+"\n", args...) },
|
||||
}
|
||||
if err := server.Decides(a); err != nil {
|
||||
fmt.Printf("the operator's answers cannot be heard, so nothing is asked: %v\n", err)
|
||||
return
|
||||
}
|
||||
askerFrom = a
|
||||
go a.keep(ctx)
|
||||
}
|
||||
|
||||
@@ -119,6 +119,13 @@ func TestTheBusMachineIsBehindByItsUserListAlone(t *testing.T) {
|
||||
if userListBehind("", "") {
|
||||
t.Fatal("a machine sent no list reads as behind")
|
||||
}
|
||||
// A list that could not be composed is behind: not known is never "not behind" (the review of hq issue 353).
|
||||
if !listBehind("", errors.New("the plan cannot be worked out"), digestOf([]byte(list))) {
|
||||
t.Fatal("a list nobody could compose reads as current")
|
||||
}
|
||||
if listBehind(list, nil, digestOf([]byte(list))) || !listBehind(list, nil, "") {
|
||||
t.Fatal("listBehind does not read the digest as userListBehind does")
|
||||
}
|
||||
}
|
||||
|
||||
// The machine holding the bus goes first: its declaration carries the user list the new grants are
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/secrets"
|
||||
)
|
||||
|
||||
// A module's own secret given at the operator's desk (novox/hq ADR 0259 §10).
|
||||
//
|
||||
// **The value never passes through whoever asked for it.** An agent, or the operator at the mesh MCP
|
||||
// server, calls `give` with the machine, the module, the secret's name and the desk — never a value. The
|
||||
// controller makes a sealing keypair for this one call, asks the desk's `node-launcher.secret` to prompt the
|
||||
// operator without showing what is typed, and is answered with what was typed **sealed to that key**: no
|
||||
// plaintext on the bus, in a runtime's log or in any call's record. It opens it here, seals it to the
|
||||
// module's machine exactly as `secret accept` does, and forgets it. What it answers says only that the
|
||||
// value was taken, or why not.
|
||||
//
|
||||
// **What remains** (ADR 0234's accepted residual risk): on an X11 desk any program of the operator's
|
||||
// account can read the keys as they are typed. And a program that calls the desk's prompt itself, with a
|
||||
// key of its own, is answered with what the operator typed into a prompt they did not ask for — as it could
|
||||
// draw a window of its own. The prompt says who asks and for what, so the operator types only into a
|
||||
// prompt they started.
|
||||
|
||||
// deskPromptWithin is how long the prompt waits for the operator: below the runtime's thirty seconds for
|
||||
// one call, as the launcher's menu is.
|
||||
const deskPromptWithin = 25
|
||||
|
||||
// deskGive is the desk path, its four reaches given so a test needs no store and no bus.
|
||||
type deskGive struct {
|
||||
// declares refuses a module or a secret the mesh would refuse, before anybody is asked to type.
|
||||
declares func(module, name string) error
|
||||
// known refuses a machine the mesh does not know, before anybody is asked to type; nil knows every one
|
||||
// (a test that does not look).
|
||||
known func(machine string) error
|
||||
// trusted says a module runs as an account of its own: its secret is never taken at a desk (below). Nil is
|
||||
// never (a test that does not look).
|
||||
trusted func(module string) (bool, error)
|
||||
// ask asks one machine's node-launcher.secret and answers its result, or the holder's refusal.
|
||||
ask func(machine string, args map[string]any) (json.RawMessage, error)
|
||||
// accept seals the value as `secret accept` does, and says whether it lives until the module's start.
|
||||
accept func(value string) (untilStart bool, err error)
|
||||
// record writes the act in the hand-act log.
|
||||
record func(link.HandAct) error
|
||||
// announce raises the condition that says a module's own secret was given (secretGivenObservation), on
|
||||
// every channel; nil announces nothing (a test that does not look).
|
||||
announce func(node, module, name, how string) error
|
||||
}
|
||||
|
||||
// errNothingGiven is a prompt dismissed, or not answered in time: nothing changes.
|
||||
var errNothingGiven = errors.New("nothing was given")
|
||||
|
||||
// give asks the desk for the value and seals it; it answers the words said to the caller.
|
||||
func (d deskGive) give(node, module, name, desk string) (string, error) {
|
||||
for what, v := range map[string]string{"the machine": node, "the module": module, "the secret": name, "the desk": desk} {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return "", fmt.Errorf("%s is not named", what)
|
||||
}
|
||||
}
|
||||
if d.known != nil {
|
||||
for what, machine := range map[string]string{"the machine the secret is for": node, "the desk": desk} {
|
||||
if err := d.known(machine); err != nil {
|
||||
return "", fmt.Errorf("nobody was asked to type anything: %s, %s, is not a machine this mesh knows: %w",
|
||||
what, machine, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := d.declares(module, name); err != nil {
|
||||
return "", fmt.Errorf("nobody was asked to type anything: %w", err)
|
||||
}
|
||||
// **A trusted party's secret is not taken at a desk** (the confirmation review of 2026-10-09, N1-give). The
|
||||
// prompt's answer comes back over the desk machine's bus, on a subject that machine's runtime answers — and
|
||||
// on a desk machine agents run as the operator, who holds that runtime's credential. An agent could answer
|
||||
// first, with a bot token of its own sealed to this call's key, and the channel the operator's answers are
|
||||
// proven on would be the agent's. So the value of a module running as its own account is typed at the
|
||||
// controller's terminal, where no bus carries it.
|
||||
if d.trusted != nil {
|
||||
trusted, err := d.trusted(module)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("nobody was asked to type anything: whether %s is a trusted party could not be read: %w", module, err)
|
||||
}
|
||||
if trusted {
|
||||
return "", fmt.Errorf("nobody was asked to type anything: %s runs as an account of its own and proves the "+
|
||||
"operator's answers, so its %s is given at the controller's terminal alone — there, run `mesh-controller "+
|
||||
"secret accept %s %s %s` and type it at its prompt. A desk's prompt is answered over the desk machine's "+
|
||||
"bus, where an agent may answer first (novox/hq ADR 0259 §10)", module, name, node, module, name)
|
||||
}
|
||||
}
|
||||
public, private, err := secrets.Keypair()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no key could be made to take the value: %w", err)
|
||||
}
|
||||
// By name, never by words: the holder writes the prompt from these, and says the controller asks, which
|
||||
// the bus alone makes true (broker.ControllerOnly).
|
||||
raw, err := d.ask(desk, map[string]any{
|
||||
"module": module,
|
||||
"secret": name,
|
||||
"node": node,
|
||||
"seal_to": public,
|
||||
"timeout_seconds": deskPromptWithin,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the desk on %s could not be asked: %w", desk, err)
|
||||
}
|
||||
var answer struct {
|
||||
Sealed string `json:"sealed"`
|
||||
Cancelled bool `json:"cancelled"`
|
||||
TimedOut bool `json:"timed_out"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||
return "", fmt.Errorf("the desk on %s answered something that is not the prompt's answer", desk)
|
||||
}
|
||||
switch {
|
||||
case answer.TimedOut:
|
||||
return "", fmt.Errorf("%w: the prompt on %s was not answered within %d seconds", errNothingGiven, desk, deskPromptWithin)
|
||||
case answer.Cancelled:
|
||||
return "", fmt.Errorf("%w: the prompt on %s was dismissed", errNothingGiven, desk)
|
||||
case answer.Sealed == "":
|
||||
return "", fmt.Errorf("the desk on %s answered no sealed value", desk)
|
||||
}
|
||||
opened, err := secrets.Open(private, answer.Sealed)
|
||||
if err != nil {
|
||||
// Never the value, never what failed to open: only that it was not sealed to this call.
|
||||
return "", fmt.Errorf("the desk on %s answered a value not sealed to this call; nothing was taken", desk)
|
||||
}
|
||||
value := asSupplied(string(opened))
|
||||
for i := range opened {
|
||||
opened[i] = 0
|
||||
}
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "", fmt.Errorf("%w: the prompt on %s was answered empty", errNothingGiven, desk)
|
||||
}
|
||||
untilStart, err := d.accept(value)
|
||||
value = ""
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
act := link.HandAct{Verb: "secret accept", Args: []string{node, module, name, "--at-desk", desk},
|
||||
Why: fmt.Sprintf("the operator gave %s for %s on %s at the desk on %s", name, module, node, desk),
|
||||
Cause: "given-at-the-desk"}
|
||||
recorded := ""
|
||||
if err := d.record(act); err != nil {
|
||||
recorded = fmt.Sprintf("\n this act could NOT be recorded in the hand-act log, and is done anyway: %v", err)
|
||||
}
|
||||
if d.announce != nil {
|
||||
if err := d.announce(node, module, name, "at the desk on "+desk); err != nil {
|
||||
recorded += fmt.Sprintf("\n this change could NOT be announced on the operator's channels: %v", err)
|
||||
}
|
||||
}
|
||||
words := fmt.Sprintf("%s on %s now holds %q, given at the desk on %s and sealed to %s; the mesh cannot read it "+
|
||||
"back.\n run `push %s` to send it", module, node, name, desk, node, node)
|
||||
if untilStart {
|
||||
words += fmt.Sprintf("\n it lives until %s next starts well under the mesh, and is then replaced with a value "+
|
||||
"the mesh makes (ADR 0228)", module)
|
||||
}
|
||||
return words + recorded, nil
|
||||
}
|
||||
|
||||
// giveAtDesk is `secret accept <node> <module> <name> --at-desk <machine>`: the desk path, on this
|
||||
// controller's stores and bus.
|
||||
func giveAtDesk(ctx context.Context, node, module, name, desk string) error {
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer open.Close()
|
||||
d := deskGive{
|
||||
declares: func(module, name string) error { return open.inventory.DeclaresOwnSecret(ctx, module, name) },
|
||||
known: func(machine string) error {
|
||||
_, err := open.inventory.NodeByName(ctx, machine)
|
||||
return err
|
||||
},
|
||||
trusted: func(module string) (bool, error) { return open.inventory.RunsAsItsOwnAccount(ctx, module) },
|
||||
ask: func(machine string, args map[string]any) (json.RawMessage, error) {
|
||||
var result json.RawMessage
|
||||
err := onTheBus(func(conn *nats.Conn) error {
|
||||
answer, err := link.AskSeatTool(ctx, conn, "node-launcher", "secret", machine, args,
|
||||
time.Duration(deskPromptWithin+5)*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if answer.Error != "" {
|
||||
return errors.New(answer.Error)
|
||||
}
|
||||
result = answer.Result
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
},
|
||||
accept: func(value string) (bool, error) {
|
||||
return open.inventory.AcceptGivenSecret(ctx, node, module, name, value)
|
||||
},
|
||||
record: func(act link.HandAct) error {
|
||||
return onTheBus(func(conn *nats.Conn) error {
|
||||
_, err := link.RecordHandAct(ctx, conn, act)
|
||||
return err
|
||||
})
|
||||
},
|
||||
announce: func(node, module, name, how string) error { return announceSecretGiven(ctx, node, module, name, how) },
|
||||
}
|
||||
words, err := d.give(node, module, name, desk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(words)
|
||||
return nil
|
||||
}
|
||||
|
||||
// kindSecretGiven is the condition every value given for a module's own secret raises (the review of 2026-10-09,
|
||||
// M4): on every channel, so a bot token changed by somebody else — a channel that now answers for them — is
|
||||
// heard of. It stays until the operator silences or clears it.
|
||||
const kindSecretGiven = "secret-given"
|
||||
|
||||
// secretGivenObservation is that condition: which secret, of which module on which machine, how and when.
|
||||
func secretGivenObservation(node, module, name, how string, at time.Time) conditions.Observation {
|
||||
key := node + "." + module + "." + name
|
||||
return conditions.Observation{Scope: conditions.ScopeMachine, ID: key, Token: kindSecretGiven, Kind: kindSecretGiven,
|
||||
Machine: node, Severity: conditions.Urgent, Source: kindSecretGiven,
|
||||
Summary: fmt.Sprintf("%s of %s on %s was given %s at %s", name, module, node, how,
|
||||
at.Local().Format("2006-01-02 15:04")),
|
||||
Headline: "Secret of " + module + " changed",
|
||||
Explanation: fmt.Sprintf("The secret %s of %s on %s was given %s at %s. If you did not do this, "+
|
||||
"somebody else holds what %s acts with.", name, module, node, how, at.Local().Format("15:04"), module),
|
||||
Needs: "silence this if you gave it; if you did not, give the secret again yourself and unlink what it serves.",
|
||||
Resolved: "You saw that " + name + " of " + module + " was changed",
|
||||
Actions: []conditions.Action{conditions.SilenceAction(conditions.Key(conditions.ScopeMachine, key, kindSecretGiven))}}
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/secrets"
|
||||
)
|
||||
|
||||
const typed = "123456789:AAEhBP0av28P4XFQnIuR-o-7Xnz1kkUzW3g"
|
||||
|
||||
// aDesk is the desk path with a prompt the test answers as the operator would, and what it was asked kept.
|
||||
func aDesk(t *testing.T, answer func(args map[string]any) (json.RawMessage, error)) (deskGive, *[]string, *[]link.HandAct, *[]map[string]any) {
|
||||
t.Helper()
|
||||
var accepted []string
|
||||
var acts []link.HandAct
|
||||
var asked []map[string]any
|
||||
return deskGive{
|
||||
declares: func(module, name string) error {
|
||||
if module != "telegram" || name != "telegram-token" {
|
||||
return errors.New(module + " does not declare " + name + " as an own secret")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
ask: func(machine string, args map[string]any) (json.RawMessage, error) {
|
||||
asked = append(asked, args)
|
||||
return answer(args)
|
||||
},
|
||||
accept: func(value string) (bool, error) { accepted = append(accepted, value); return false, nil },
|
||||
record: func(a link.HandAct) error { acts = append(acts, a); return nil },
|
||||
}, &accepted, &acts, &asked
|
||||
}
|
||||
|
||||
func sealedTo(t *testing.T, value string) func(args map[string]any) (json.RawMessage, error) {
|
||||
return func(args map[string]any) (json.RawMessage, error) {
|
||||
sealed, err := secrets.Seal(args["seal_to"].(string), []byte(value+"\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"sealed": sealed})
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0259 §10: the value typed at the desk is sealed as `secret accept` seals it, and is in no
|
||||
// answer, no prompt argument and no act recorded.
|
||||
func TestASecretGivenAtTheDeskIsSealedAndSaidNowhere(t *testing.T) {
|
||||
d, accepted, acts, asked := aDesk(t, sealedTo(t, typed))
|
||||
words, err := d.give("anchor", "telegram", "telegram-token", "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*accepted) != 1 || (*accepted)[0] != typed {
|
||||
t.Fatalf("the value sealed is not what was typed, its line ending taken off")
|
||||
}
|
||||
if len(*acts) != 1 || (*acts)[0].Verb != "secret accept" || (*acts)[0].Cause != "given-at-the-desk" ||
|
||||
!strings.Contains((*acts)[0].Why, "at the desk on laptop") {
|
||||
t.Errorf("the act: %+v", *acts)
|
||||
}
|
||||
raw, _ := json.Marshal(struct {
|
||||
Words string
|
||||
Acts []link.HandAct
|
||||
Asked []map[string]any
|
||||
}{words, *acts, *asked})
|
||||
if strings.Contains(string(raw), typed) || strings.Contains(string(raw), "AAEhBP0") {
|
||||
t.Fatal("the value appears in what was said, asked or recorded")
|
||||
}
|
||||
if !strings.Contains(words, "push anchor") || !strings.Contains(words, "given at the desk on laptop") {
|
||||
t.Errorf("%q", words)
|
||||
}
|
||||
if p := (*asked)[0]; p["seal_to"] == "" || p["timeout_seconds"] != deskPromptWithin {
|
||||
t.Errorf("the prompt was asked %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingIsAskedForASecretTheMeshWouldRefuse(t *testing.T) {
|
||||
for _, c := range [][2]string{{"telegram", "chat-id"}, {"nobody", "telegram-token"}} {
|
||||
d, accepted, _, asked := aDesk(t, sealedTo(t, typed))
|
||||
if _, err := d.give("anchor", c[0], c[1], "laptop"); err == nil || !strings.Contains(err.Error(), "nobody was asked") {
|
||||
t.Errorf("%v: %v", c, err)
|
||||
}
|
||||
if len(*asked) != 0 || len(*accepted) != 0 {
|
||||
t.Errorf("%v: the operator was asked anyway", c)
|
||||
}
|
||||
}
|
||||
d, _, _, _ := aDesk(t, sealedTo(t, typed))
|
||||
if _, err := d.give("anchor", "telegram", "telegram-token", ""); err == nil {
|
||||
t.Error("no desk was refused nowhere")
|
||||
}
|
||||
}
|
||||
|
||||
func TestADismissedEmptyLateOrForeignAnswerTakesNothing(t *testing.T) {
|
||||
for want, answer := range map[string]func(map[string]any) (json.RawMessage, error){
|
||||
"not answered within 25 seconds": func(map[string]any) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"cancelled":true,"timed_out":true}`), nil
|
||||
},
|
||||
"was dismissed": func(map[string]any) (json.RawMessage, error) { return json.RawMessage(`{"cancelled":true}`), nil },
|
||||
"answered empty": sealedTo(t, " "),
|
||||
"not sealed to this call": func(map[string]any) (json.RawMessage, error) {
|
||||
other, _, _ := secrets.Keypair()
|
||||
sealed, _ := secrets.Seal(other, []byte(typed))
|
||||
raw, _ := json.Marshal(map[string]any{"sealed": sealed})
|
||||
return raw, nil
|
||||
},
|
||||
"could not be asked": func(map[string]any) (json.RawMessage, error) { return nil, errors.New("no session answers") },
|
||||
} {
|
||||
d, accepted, acts, _ := aDesk(t, answer)
|
||||
_, err := d.give("anchor", "telegram", "telegram-token", "laptop")
|
||||
if err == nil || !strings.Contains(err.Error(), want) || strings.Contains(err.Error(), typed) {
|
||||
t.Errorf("want %q, got %v", want, err)
|
||||
}
|
||||
if len(*accepted) != 0 || len(*acts) != 0 {
|
||||
t.Errorf("%s: something was taken or recorded", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheGiveVerbRunsTheDeskPathAndTheControllerMayAskTheDesk(t *testing.T) {
|
||||
argv, err := argvFor("give", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token", "at": "laptop"})
|
||||
if err != nil || strings.Join(argv, " ") != "secret accept anchor telegram telegram-token --at-desk laptop" {
|
||||
t.Fatalf("%v %v", argv, err)
|
||||
}
|
||||
if _, err := argvFor("give", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token"}); err == nil {
|
||||
t.Error("give without a desk was taken")
|
||||
}
|
||||
perms, err := broker.PermissionsFor(broker.Principal{Kind: broker.KindController})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, p := range perms.Publish {
|
||||
found = found || p == "mesh.seat.node-launcher.tool.secret.*"
|
||||
}
|
||||
if !found {
|
||||
t.Error("the controller may not ask the desk's prompt")
|
||||
}
|
||||
}
|
||||
|
||||
// The review of 2026-10-09 (M4): the desk's prompt says who asks in words the caller does not choose — the
|
||||
// controller, which the bus alone lets ask it — and what for, from names the controller checked; the prompt
|
||||
// carries no free text of the caller's.
|
||||
func TestThePromptIsAskedByNameNeverByWordsTheCallerChose(t *testing.T) {
|
||||
d, _, _, asked := aDesk(t, sealedTo(t, typed))
|
||||
if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := (*asked)[0]
|
||||
if p["module"] != "telegram" || p["secret"] != "telegram-token" || p["node"] != "anchor" {
|
||||
t.Errorf("the prompt was not asked by name: %v", p)
|
||||
}
|
||||
for _, free := range []string{"prompt", "message"} {
|
||||
if _, there := p[free]; there {
|
||||
t.Errorf("the prompt carries the caller's %s: %v", free, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every value given for a module's own secret is announced as a condition, on every channel (the review of
|
||||
// 2026-10-09, M4): a bot token changed by somebody else is a channel that now answers for them.
|
||||
func TestAValueGivenAtTheDeskIsAnnounced(t *testing.T) {
|
||||
d, _, _, _ := aDesk(t, sealedTo(t, typed))
|
||||
var said []string
|
||||
d.announce = func(node, module, name, how string) error {
|
||||
said = append(said, node+" "+module+" "+name+" "+how)
|
||||
return nil
|
||||
}
|
||||
if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(said) != 1 || !strings.Contains(said[0], "anchor telegram telegram-token") || !strings.Contains(said[0], "laptop") {
|
||||
t.Fatalf("announced %v", said)
|
||||
}
|
||||
o := secretGivenObservation("anchor", "telegram", "telegram-token", "at the desk on laptop", time.Date(2026, 10, 9, 12, 3, 0, 0, time.UTC))
|
||||
if o.Severity != conditions.Urgent || !strings.Contains(o.Explanation, "telegram-token") ||
|
||||
len(o.Actions) == 0 || o.Key() == "" {
|
||||
t.Errorf("the announcement %+v", o)
|
||||
}
|
||||
if strings.Contains(o.Summary+o.Explanation+o.Said, typed) {
|
||||
t.Error("the announcement carries the value")
|
||||
}
|
||||
}
|
||||
|
||||
// The bus lets the controller alone ask the desk's prompt (the review of 2026-10-09, M4): the runtime, which
|
||||
// carries every agent's calls, and a person granted every tool are denied it, however wide their grant.
|
||||
func TestOnlyTheControllerMayAskTheDesksPrompt(t *testing.T) {
|
||||
for _, p := range []broker.Principal{
|
||||
{Kind: broker.KindNodeTools, Node: "laptop"},
|
||||
{Kind: broker.KindPerson, Module: "operator", Invokes: []string{"*"}},
|
||||
{Kind: broker.KindModule, Node: "laptop", Module: "lab", Invokes: []string{"seat:node-launcher.secret"}},
|
||||
} {
|
||||
perms, err := broker.PermissionsFor(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, subject := range []string{"mesh.seat.node-launcher.tool.secret.laptop", "mesh.seat.node-launcher.tool.secret",
|
||||
"mesh.mod.rofi.tool.node-launcher.secret", "mesh.mod.rofi.tool.node-launcher.secret.laptop"} {
|
||||
if broker.MayPublish(perms, subject) {
|
||||
t.Errorf("%s may publish %s", p.Username(), subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
perms, _ := broker.PermissionsFor(broker.Principal{Kind: broker.KindController})
|
||||
if !broker.MayPublish(perms, "mesh.seat.node-launcher.tool.secret.laptop") {
|
||||
t.Error("the controller may not ask the desk's prompt")
|
||||
}
|
||||
}
|
||||
|
||||
// A value for a secret comes from the terminal or the desk, never through a verb (the review of 2026-10-09,
|
||||
// M4): `secret accept` with a value, run for a verb, is refused before anything is read.
|
||||
func TestASecretValueIsNeverAcceptedThroughAVerb(t *testing.T) {
|
||||
t.Setenv(verbVar, "mesh-controller.command")
|
||||
for _, args := range [][]string{
|
||||
{"accept", "anchor", "telegram", "telegram-token", "--from", "/dev/null"},
|
||||
{"accept", "anchor", "app", "db", "--from", "/dev/null", "--provider", "store"},
|
||||
} {
|
||||
err := secretCommand(context.Background(), args)
|
||||
if err == nil || !strings.Contains(err.Error(), "never through a verb") {
|
||||
t.Errorf("%v: %v", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The `give` verb's own line passes the terminal-only rule of ADR 0266, and no other `secret accept` does: a
|
||||
// value, a file, a provider or an extra word is still the terminal's alone.
|
||||
func TestOnlyTheGiveLinePassesTheTerminalRuleForSecrets(t *testing.T) {
|
||||
if err := terminalOnly([]string{"secret", "accept", "anchor", "telegram", "telegram-token", "--at-desk", "laptop"}); err != nil {
|
||||
t.Errorf("give's line refused: %v", err)
|
||||
}
|
||||
for _, argv := range [][]string{
|
||||
{"secret", "accept", "anchor", "telegram", "telegram-token"},
|
||||
{"secret", "accept", "anchor", "telegram", "telegram-token", "--from", "/tmp/x"},
|
||||
{"secret", "accept", "anchor", "telegram", "telegram-token", "--at-desk", "laptop", "--local"},
|
||||
{"secret", "accept", "anchor", "telegram", "--provider", "--at-desk", "laptop"},
|
||||
{"secret", "export", "anchor", "telegram", "telegram-token", "--at-desk", "laptop"},
|
||||
} {
|
||||
if err := terminalOnly(argv); err == nil {
|
||||
t.Errorf("%v passed the terminal rule", argv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The confirmation review of 2026-10-09, N1-give: a desk's prompt is answered over the desk machine's bus, and
|
||||
// on a desk machine agents run as the operator, who holds its runtime's credential — so a trusted party's
|
||||
// secret (a module running as an account of its own: the Telegram bot's token) is never taken at a desk.
|
||||
// Refused before anybody is asked to type, whoever called, naming the terminal's line.
|
||||
func TestATrustedPartysSecretIsNeverTakenAtADesk(t *testing.T) {
|
||||
d, accepted, acts, asked := aDesk(t, sealedTo(t, typed))
|
||||
d.trusted = func(module string) (bool, error) { return module == "telegram", nil }
|
||||
_, err := d.give("anchor", "telegram", "telegram-token", "laptop")
|
||||
if err == nil || !strings.Contains(err.Error(), "controller's terminal alone") ||
|
||||
!strings.Contains(err.Error(), "secret accept anchor telegram telegram-token") {
|
||||
t.Fatalf("a trusted party's secret was taken at the desk, or refused without the line: %v", err)
|
||||
}
|
||||
if len(*asked)+len(*accepted)+len(*acts) != 0 {
|
||||
t.Errorf("asked %v, accepted %d, recorded %v", *asked, len(*accepted), *acts)
|
||||
}
|
||||
d.trusted = func(string) (bool, error) { return false, errors.New("the store did not answer") }
|
||||
if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err == nil || len(*asked) != 0 {
|
||||
t.Errorf("a module not known to be untrusted was asked at the desk: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// And who may answer the desk's prompt at all: only the runtime of the machine it is asked on, carrying the
|
||||
// launcher that holds the seat there — never the controller, another machine's runtime, or a module's own
|
||||
// account (the confirmation review of 2026-10-09, N1-give).
|
||||
func TestOnlyTheDeskMachinesLauncherMayAnswerItsPrompt(t *testing.T) {
|
||||
launcher := broker.Declared{Module: "rofi", Holds: []broker.Seat{{Name: "node-launcher", Scope: "node",
|
||||
Serves: []string{"run", "secret"}}}}
|
||||
subject := "mesh.seat.node-launcher.tool.secret.laptop"
|
||||
for _, c := range []struct {
|
||||
p broker.Principal
|
||||
answers bool
|
||||
}{
|
||||
{broker.Principal{Kind: broker.KindNodeTools, Node: "laptop", Module: broker.RuntimeModule, Carries: []broker.Declared{launcher}}, true},
|
||||
{broker.Principal{Kind: broker.KindNodeTools, Node: "anchor", Module: broker.RuntimeModule, Carries: []broker.Declared{launcher}}, false},
|
||||
{broker.Principal{Kind: broker.KindController}, false},
|
||||
{broker.Principal{Kind: broker.KindModule, Node: "laptop", Module: "lab"}, false},
|
||||
{broker.Principal{Kind: broker.KindNode, Node: "laptop"}, false},
|
||||
} {
|
||||
perms, err := broker.PermissionsFor(c.p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := broker.MaySubscribe(perms, subject); got != c.answers {
|
||||
t.Errorf("%s may answer %s: %v, want %v", c.p.Username(), subject, got, c.answers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// N1-give at the controller's terminal (the confirmation review of 2026-10-09): a trusted party's secret is
|
||||
// announced before it is kept, and not kept when the announcement fails; another module's is kept first and
|
||||
// a failed announcement is said, not undone.
|
||||
func TestATrustedPartysSecretGivenAtTheTerminalIsAnnouncedBeforeItIsKept(t *testing.T) {
|
||||
var order []string
|
||||
announce := func(fail bool) func() error {
|
||||
return func() error {
|
||||
order = append(order, "announce")
|
||||
if fail {
|
||||
return errors.New("no channel")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
keep := func() (bool, error) { order = append(order, "keep"); return false, nil }
|
||||
|
||||
order = nil
|
||||
if _, unannounced, err := keepGiven(true, announce(false), keep); err != nil || unannounced != nil ||
|
||||
strings.Join(order, ",") != "announce,keep" {
|
||||
t.Errorf("trusted: %v %v, order %v; want announced, then kept", unannounced, err, order)
|
||||
}
|
||||
order = nil
|
||||
if _, _, err := keepGiven(true, announce(true), keep); err == nil || strings.Join(order, ",") != "announce" {
|
||||
t.Errorf("trusted, announcement failed: %v, order %v; want refused and nothing kept", err, order)
|
||||
}
|
||||
order = nil
|
||||
if _, unannounced, err := keepGiven(false, announce(true), keep); err != nil || unannounced == nil ||
|
||||
strings.Join(order, ",") != "keep,announce" {
|
||||
t.Errorf("not trusted: %v %v, order %v; want kept, then the failed announcement said", unannounced, err, order)
|
||||
}
|
||||
order = nil
|
||||
failing := func() (bool, error) { order = append(order, "keep"); return false, errors.New("store away") }
|
||||
if _, _, err := keepGiven(false, announce(false), failing); err == nil || strings.Join(order, ",") != "keep" {
|
||||
t.Errorf("not trusted, keep failed: %v, order %v; want refused and nothing announced", err, order)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine the mesh does not know, as the secret's or as the desk, is refused before anybody is asked to type.
|
||||
func TestAGiveNamingAMachineTheMeshDoesNotKnowAsksNobody(t *testing.T) {
|
||||
for _, unknown := range []string{"elsewhere", "nodesk"} {
|
||||
d, accepted, acts, asked := aDesk(t, func(map[string]any) (json.RawMessage, error) {
|
||||
t.Fatal("the desk was asked")
|
||||
return nil, nil
|
||||
})
|
||||
d.known = func(machine string) error {
|
||||
if machine == unknown {
|
||||
return errors.New("no node " + machine)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
node, desk := "anchor", "laptop"
|
||||
if unknown == "elsewhere" {
|
||||
node = unknown
|
||||
} else {
|
||||
desk = unknown
|
||||
}
|
||||
_, err := d.give(node, "telegram", "telegram-token", desk)
|
||||
if err == nil || !strings.Contains(err.Error(), "nobody was asked") || !strings.Contains(err.Error(), unknown) {
|
||||
t.Errorf("%s: %v", unknown, err)
|
||||
}
|
||||
if len(*accepted)+len(*acts)+len(*asked) != 0 {
|
||||
t.Errorf("%s: something happened: %v %v %v", unknown, *accepted, *acts, *asked)
|
||||
}
|
||||
}
|
||||
}
|
||||
+153
-1
@@ -87,6 +87,9 @@ const (
|
||||
healthWaiting
|
||||
healthNotYet
|
||||
healthBroken
|
||||
// healthSuperseded is a judging that cannot go on: the machine was sent another build of the module
|
||||
// after the gate's send (novox/hq issue 352). No verdict on the build judged, and nothing put back.
|
||||
healthSuperseded
|
||||
)
|
||||
|
||||
// served is what one machine's node tools answered the bus's discovery with.
|
||||
@@ -122,6 +125,39 @@ type gateFacts struct {
|
||||
// groupsAdded is, per module, whether the move judged puts an account in a group its previous build did
|
||||
// not (issue 318 review): the only move whose wait for a new login is excused.
|
||||
groupsAdded map[string]bool
|
||||
// sent is, per machine, the declaration the gate's own send carried there (novox/hq issue 352): a
|
||||
// report is held against it, never against the send made last. sentBuilds is what each machine was
|
||||
// last sent of every module, and judged the commit of each module this gate judges: a machine last
|
||||
// sent another build of the module is not running the build judged.
|
||||
sent map[string]inventory.SentDeclaration
|
||||
sentBuilds map[string]map[string]string
|
||||
commits map[string]string
|
||||
}
|
||||
|
||||
// reportedOn says a machine's last report is on what the gate sent it (novox/hq issue 352): on that
|
||||
// declaration, or one it was sent after it — or, for a gate kept before sends were kept on it, on the
|
||||
// declaration last sent. On 2026-10-09 a release's gate read the control node's report against a newer
|
||||
// send another plan had just made there, and failed three builds the machine had reported healthy as
|
||||
// "has not reported on what it was sent".
|
||||
func (f gateFacts) reportedOn(machine string, r inventory.Reported) bool {
|
||||
if sent, kept := f.sent[machine]; kept {
|
||||
return sent.ReportsOn(r)
|
||||
}
|
||||
return r.Current
|
||||
}
|
||||
|
||||
// supersededOn says the machine was last sent another build of the module than the one this gate judges
|
||||
// (novox/hq issue 352): the judging cannot go on, whatever the machine reports. On 2026-10-09 a controller
|
||||
// put back by one gate judged another gate's newer controller build passed on the same machine, reading
|
||||
// the put-back build's health as the newer one's.
|
||||
func (f gateFacts) supersededOn(module, machine string) (string, bool) {
|
||||
judged, known := f.commits[module]
|
||||
sent, has := f.sentBuilds[machine][module]
|
||||
if !known || !has || judged == "" || sent == "" || sameCommit(sent, judged) {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf("%s was sent %s %s after this gate's %s: the build judged no longer runs there, and "+
|
||||
"this judging is superseded by that send's", machine, module, short(sent), short(judged)), true
|
||||
}
|
||||
|
||||
// gatherGateFacts reads what a judging needs, from the store, the bus and this controller's memory. A
|
||||
@@ -199,9 +235,12 @@ func judgeHealth(module, component string, m catalogue.Manifest, machine string,
|
||||
return healthBroken, fmt.Sprintf("the witness on %s judged the %s %s and %s: %s", machine, r.Component,
|
||||
short(r.From), r.Outcome, r.Why)
|
||||
}
|
||||
if why, superseded := f.supersededOn(module, machine); superseded {
|
||||
return healthSuperseded, why
|
||||
}
|
||||
r, said := f.reports[machine]
|
||||
switch {
|
||||
case !said || r.At == nil || !r.Current:
|
||||
case !said || r.At == nil || !f.reportedOn(machine, r):
|
||||
return healthNotYet, fmt.Sprintf("%s has not reported on what it was sent", machine)
|
||||
case r.Outcome == inventory.OutcomeFailed || r.Outcome == inventory.OutcomeRefused:
|
||||
return healthBroken, fmt.Sprintf("%s %s what it was sent", machine, r.Outcome)
|
||||
@@ -438,6 +477,21 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
|
||||
return "", err
|
||||
}
|
||||
facts.groupsAdded = movesAddingGroups(ctx, open.inventory, g, pairs, shelf)
|
||||
facts.sent = g.Sent
|
||||
facts.commits, facts.sentBuilds = judgedCommits(g, pairs), map[string]map[string]string{}
|
||||
// A module this gate put back at once (putBackBroken) was sent its earlier build by the gate itself:
|
||||
// not another send, and not a judging superseded.
|
||||
for _, m := range g.Returned {
|
||||
delete(facts.commits, m)
|
||||
}
|
||||
for _, j := range pairs {
|
||||
if _, read := facts.sentBuilds[j.node]; read {
|
||||
continue
|
||||
}
|
||||
if builds, known, err := open.inventory.SentBuilds(ctx, j.node); err == nil && known {
|
||||
facts.sentBuilds[j.node] = builds
|
||||
}
|
||||
}
|
||||
// **What is wrong with a machine itself is the machine's** (novox/hq issue 281): read once for each
|
||||
// machine judged, apart from what is wrong with a module there, and never pinned on the module the
|
||||
// gate happens to be kept on.
|
||||
@@ -474,6 +528,13 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
|
||||
if _, seen := reading[j.module]; !seen {
|
||||
modules = append(modules, j.module)
|
||||
}
|
||||
if h == healthSuperseded {
|
||||
// Decided at once (novox/hq issue 352): nothing of this gate's can be judged on a machine that
|
||||
// was sent another build of it, and nothing is put back — the later send is what runs there.
|
||||
g.Failing, g.Last = nil, ""
|
||||
decide(g, inventory.GateSuperseded, said, now)
|
||||
return g.Verdict, nil
|
||||
}
|
||||
if h == healthBroken && !slices.Contains(g.Broken, j.module) {
|
||||
g.Broken = append(g.Broken, j.module)
|
||||
if g.BrokenWhy == "" {
|
||||
@@ -577,6 +638,40 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
|
||||
return g.Verdict, nil
|
||||
}
|
||||
|
||||
// judgedCommits is the commit of each module a gate judges: the gate's own To for its module, and each
|
||||
// carried move's. Pure.
|
||||
func judgedCommits(g *inventory.PlanGate, pairs []judged) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, c := range g.Carried {
|
||||
if c.To != "" {
|
||||
out[c.Module] = c.To
|
||||
}
|
||||
}
|
||||
if g.To != "" {
|
||||
for _, j := range pairs {
|
||||
if _, has := out[j.module]; !has && !slices.ContainsFunc(g.Carried, func(c inventory.CarriedMove) bool { return c.Module == j.module }) {
|
||||
out[j.module] = g.To
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sentNow is what each machine was just sent, read after a send for the gate to keep (novox/hq issue
|
||||
// 352): a machine whose send is not on record is left out, and its report is read as before.
|
||||
func sentNow(ctx context.Context, inv *inventory.Inventory, machines []string) map[string]inventory.SentDeclaration {
|
||||
out := map[string]inventory.SentDeclaration{}
|
||||
for _, n := range machines {
|
||||
if s, found, err := inv.SentTo(ctx, n); err == nil && found {
|
||||
out[n] = s
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// whyFor is a passing gate's why as one module's verdict says it: the send's, and that module's own wait
|
||||
// for a person, never another's (issue 318 review).
|
||||
func whyFor(g *inventory.PlanGate, module string) string {
|
||||
@@ -802,6 +897,16 @@ func gateFailed(ctx context.Context, open *stores, p *inventory.Plan, module str
|
||||
"back", module, short(state.Previous), inventory.KeptBuilds))
|
||||
return
|
||||
}
|
||||
if g.Component == lease.ComponentController {
|
||||
// **The controller is never put back to a build older than the store's schema** (novox/hq issue
|
||||
// 352): the build before it carries fewer migrations than the failed one applied, starts behind
|
||||
// its own records, and judges the next gate with what it can read. The current build is kept and
|
||||
// the condition says so; a person decides.
|
||||
if why, ok := controllerSchemaAllows(ctx, inv, previous); !ok {
|
||||
notBack(why)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := inv.RestoreModule(ctx, previous); err != nil {
|
||||
notBack(err.Error())
|
||||
return
|
||||
@@ -835,6 +940,53 @@ func gateFailed(ctx context.Context, open *stores, p *inventory.Plan, module str
|
||||
sayRollback(ctx, open, module, g, "")
|
||||
}
|
||||
|
||||
// controllerSchemaAllows says the store's schema lets this build of the controller be put back: the
|
||||
// build recorded, when it served, a reach at or past the highest migration the store has applied. One
|
||||
// that never recorded a reach is not proved safe, and is refused as such (novox/hq issue 352). Why
|
||||
// says what is kept and why when it is not.
|
||||
func controllerSchemaAllows(ctx context.Context, inv *inventory.Inventory, previous inventory.Build) (string, bool) {
|
||||
applied, err := inv.SchemaApplied(ctx)
|
||||
if err != nil {
|
||||
return "what the store's schema reaches cannot be read, so whether the build before it can read it is not " +
|
||||
"known; the current build is kept: " + err.Error(), false
|
||||
}
|
||||
build := versionOfBuild(previous)
|
||||
if build == "" {
|
||||
return fmt.Sprintf("the build before it (%s) names no bundle to know it by, so whether it can read the store's "+
|
||||
"schema (migration %04d) is not known; the current build is kept, and a person decides", short(previous.Commit), applied), false
|
||||
}
|
||||
reach, known, err := inv.SchemaReachOf(ctx, build)
|
||||
if err != nil {
|
||||
return "what the build before it knows of the store's schema cannot be read; the current build is kept: " + err.Error(), false
|
||||
}
|
||||
if !known {
|
||||
return fmt.Sprintf("the build before it (%s, %s) never recorded how far it reads the store's schema — a "+
|
||||
"controller records that when it serves — so it is not proved to read migration %04d, which the store "+
|
||||
"has applied; a controller older than its store starts behind its own records and judges with what it "+
|
||||
"can read, so the current build is kept, and a person decides", short(previous.Commit), build, applied), false
|
||||
}
|
||||
if reach < applied {
|
||||
return fmt.Sprintf("the build before it (%s, %s) reads the store's schema up to migration %04d, and the store "+
|
||||
"is at %04d: a controller older than its store starts behind its own records and judges with what it "+
|
||||
"can read, so the current build is kept, and a person decides", short(previous.Commit), build, reach, applied), false
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
// versionOfBuild is the version a build's bundle is delivered as — its archive's digest, short, as the
|
||||
// catalogue names it (`${version}`) — read from the build's artifacts; empty when none is a bundle.
|
||||
func versionOfBuild(b inventory.Build) string {
|
||||
for _, a := range b.Made {
|
||||
if a.Kind != catalogue.ArtifactBundle && a.Kind != catalogue.ArtifactArchive {
|
||||
continue
|
||||
}
|
||||
if _, hex, found := strings.Cut(a.Reference, "sha256:"); found && len(hex) >= 12 {
|
||||
return hex[:12]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// rollbacks is what a failed send puts back, sent together (novox/hq issue 281): a gate that judged one
|
||||
// send judges what it moved as one, and what it found wanting goes back in one send per machine — not
|
||||
// in a send for each module, which is the churn that failed the gate in the first place.
|
||||
|
||||
@@ -113,9 +113,10 @@ func aGateMesh(t *testing.T) *gateMesh {
|
||||
}
|
||||
for node, h := range g.health {
|
||||
if h == healthNotYet {
|
||||
// Not reported on the send: neither the send made last, nor the gate's own (issue 352).
|
||||
f.rolledBack[node] = nil
|
||||
r := f.reports[node]
|
||||
r.Current = false
|
||||
r.Current, r.Declared, r.ReportedSequence = false, "", 0
|
||||
f.reports[node] = r
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,9 @@ var handActVerbs = []handActVerb{
|
||||
// to that judgement. Several values rotate for one leak, and a leak that recurs is a defect of the
|
||||
// module that prints them, an issue against it, not a healer that rotates. A rotation for any other
|
||||
// cause — a credential that stopped working — counts: a schedule or a healer could take it over.
|
||||
// A value given at the desk (novox/hq ADR 0259 §10): an outside party's key, such as a bot token, which
|
||||
// only a person can give. Their word, never a repair.
|
||||
{Verb: "secret accept", Decision: "a value an outside party issued is given by a person, at their desk"},
|
||||
{Verb: "secret rotate", Decision: "a value a person judged disclosed is replaced on their word",
|
||||
DecidedFor: []string{causeLeakedInLogs}},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// hideTyping turns a terminal's echo off while a secret is typed at it, and gives back what restores it. On
|
||||
// anything that is not a terminal (a pipe, a file) it does nothing.
|
||||
func hideTyping(f *os.File) func() {
|
||||
fd := int(f.Fd())
|
||||
before, err := unix.IoctlGetTermios(fd, unix.TCGETS)
|
||||
if err != nil {
|
||||
return func() {}
|
||||
}
|
||||
hidden := *before
|
||||
hidden.Lflag &^= unix.ECHO
|
||||
if err := unix.IoctlSetTermios(fd, unix.TCSETS, &hidden); err != nil {
|
||||
return func() {}
|
||||
}
|
||||
return func() {
|
||||
_ = unix.IoctlSetTermios(fd, unix.TCSETS, before)
|
||||
_, _ = os.Stderr.WriteString("\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/lease"
|
||||
)
|
||||
|
||||
// novox/hq issue 352: on 2026-10-09 a release's gate on the control node read the machine's report against a
|
||||
// newer send another plan had just made there — not against its own send — and failed three builds the
|
||||
// machine had reported healthy ("has not reported on what it was sent"), put them back on every machine,
|
||||
// to a controller older than the store's schema, and that controller then judged the newer plan's
|
||||
// controller passed from the put-back build's health.
|
||||
|
||||
// TestReplay352 replays the walk on the backlog fixture: the release sends anchor and anchor reports;
|
||||
// another send reaches anchor, unreported; the gate still passes. And a send that moves a judged module
|
||||
// to another build supersedes the judging: no verdict, nothing put back.
|
||||
func TestReplay352(t *testing.T) {
|
||||
t.Run("a newer send to the judged machine does not unreport the gate's", testANewerSendDoesNotUnreportTheGatesOwn)
|
||||
t.Run("a send that moves the module supersedes the judging", testASendThatMovesTheModuleSupersedesTheJudging)
|
||||
}
|
||||
|
||||
func testANewerSendDoesNotUnreportTheGatesOwn(t *testing.T) {
|
||||
b := aBacklog(t)
|
||||
ctx := t.Context()
|
||||
inv := b.open.inventory
|
||||
advancePlans(ctx, b.open) // anchor is sent, and the fixture reports it applied
|
||||
// 16:31:37 — another plan sends anchor a newer declaration, which it has not reported on.
|
||||
carried, _, _ := inv.SentBuilds(ctx, "anchor")
|
||||
if err := inv.RecordSent(ctx, nodeID(t, b.open, "anchor"), "d-anchor-newer", carried); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reports, _ := inv.LastReports(ctx)
|
||||
for _, r := range reports {
|
||||
if r.Node == "anchor" && r.Current {
|
||||
t.Fatal("the fixture's newer send reads as reported")
|
||||
}
|
||||
}
|
||||
gateEvery, gateBound = 0, 0 // past the bound at once: before the fix, "has not reported" fails it here
|
||||
for i := 0; i < 4; i++ {
|
||||
advancePlans(ctx, b.open)
|
||||
}
|
||||
p := b.release(t)
|
||||
if p.State == inventory.PlanFailed || strings.Contains(p.Note, "has not reported") {
|
||||
t.Fatalf("the release failed on the newer send: %s %s", p.State, p.Note)
|
||||
}
|
||||
if g := p.Release.Gate; g != nil && (g.Sent == nil || g.Sent["anchor"].Digest == "") {
|
||||
t.Fatalf("the gate does not keep what it sent: %+v", g)
|
||||
}
|
||||
if v, found, err := inv.GateOf(ctx, "build-app-c2"); err != nil || !found || v.Verdict != inventory.GatePassed {
|
||||
t.Fatalf("app's pass on anchor was not kept: %+v %v %v", v, found, err)
|
||||
}
|
||||
if current, _ := inv.CurrentBuilds(ctx); current["app"].Commit != "c2" {
|
||||
t.Fatalf("app was put back to %s", current["app"].Commit)
|
||||
}
|
||||
}
|
||||
|
||||
// A plan's own first send waits while a release judges the same module on that machine with another build.
|
||||
func TestAPlansFirstSendWaitsForAReleaseJudgingTheModuleThere(t *testing.T) {
|
||||
b := aBacklog(t)
|
||||
ctx := t.Context()
|
||||
advancePlans(ctx, b.open) // the release judges app c2 on anchor
|
||||
_, _, err := gatedSend(ctx, b.open, "anchor", []inventory.CarriedMove{{Module: "app", Node: "anchor", From: "c2", To: "c3", Build: "build-app-c3"}})
|
||||
if !errors.Is(err, errWalkedElsewhere) || !strings.Contains(err.Error(), "release-") {
|
||||
t.Fatalf("a newer build of a judged module was sent under the release's gate: %v", err)
|
||||
}
|
||||
if len(b.sent) != 1 {
|
||||
t.Fatalf("sent %v", b.sent)
|
||||
}
|
||||
}
|
||||
|
||||
// A merge plan's judging is superseded the same way: another send moved its module on the first machine.
|
||||
func TestAPlansJudgingIsSupersededByASendThatMovesItsModule(t *testing.T) {
|
||||
g := aGateMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := g.open.inventory
|
||||
advancePlans(ctx, g.open) // anchor is sent app c2 first
|
||||
if err := inv.RecordSent(ctx, nodeID(t, g.open, "anchor"), "d-anchor-c3", map[string]string{"app": "c3"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gateEvery = 0
|
||||
advancePlans(ctx, g.open)
|
||||
p := g.plan(t)
|
||||
if p.State != inventory.PlanSuperseded || !strings.Contains(p.Note, "superseded") || !strings.Contains(p.Note, "c3") {
|
||||
t.Fatalf("the plan is %s: %s", p.State, p.Note)
|
||||
}
|
||||
// Nothing put back: the registered build stands, the build is not marked, and the plan's gate made no
|
||||
// rollback (a release may walk what the other send left waiting on anchor; that is not a put-back).
|
||||
if r := p.Modules["app"].Gate.Rollback; r != "" {
|
||||
t.Fatalf("a superseded judging made a rollback: %q", r)
|
||||
}
|
||||
if current, _ := inv.CurrentBuilds(ctx); current["app"].Commit != "c2" {
|
||||
t.Fatalf("app was put back to %s", current["app"].Commit)
|
||||
}
|
||||
if failed, _ := inv.GateFailed(ctx, "build-2"); failed {
|
||||
t.Fatal("a superseded build was marked failed")
|
||||
}
|
||||
}
|
||||
|
||||
func testASendThatMovesTheModuleSupersedesTheJudging(t *testing.T) {
|
||||
b := aBacklog(t)
|
||||
ctx := t.Context()
|
||||
inv := b.open.inventory
|
||||
advancePlans(ctx, b.open)
|
||||
// Another send moves app on anchor to a build this gate does not judge.
|
||||
carried, _, _ := inv.SentBuilds(ctx, "anchor")
|
||||
carried["app"] = "c3"
|
||||
if err := inv.RecordSent(ctx, nodeID(t, b.open, "anchor"), "d-anchor-c3", carried); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gateEvery = 0
|
||||
advancePlans(ctx, b.open)
|
||||
p := b.release(t)
|
||||
if p.State != inventory.PlanSuperseded || !strings.Contains(p.Note, "superseded") || !strings.Contains(p.Note, "c3") {
|
||||
t.Fatalf("the release is %s: %s", p.State, p.Note)
|
||||
}
|
||||
if !reflect.DeepEqual(b.sent, [][]string{{"anchor"}}) {
|
||||
t.Fatalf("sent %v: a superseded judging puts nothing back", b.sent)
|
||||
}
|
||||
if _, found, _ := inv.GateOf(ctx, "build-app-c2"); found {
|
||||
t.Fatal("a superseded judging kept a verdict")
|
||||
}
|
||||
if current, _ := inv.CurrentBuilds(ctx); current["app"].Commit != "c2" {
|
||||
t.Fatalf("app was put back to %s", current["app"].Commit)
|
||||
}
|
||||
}
|
||||
|
||||
// A report is on the gate's own send: the declaration itself, or one sequenced after it; a gate kept
|
||||
// without its send reads the report against the send made last, as before. Pure.
|
||||
func TestAReportIsHeldAgainstTheGatesOwnSend(t *testing.T) {
|
||||
sent := inventory.SentDeclaration{Digest: "d-490", Sequence: 490}
|
||||
for _, c := range []struct {
|
||||
r inventory.Reported
|
||||
want bool
|
||||
}{
|
||||
{inventory.Reported{Declared: "d-490", Current: false}, true},
|
||||
{inventory.Reported{Declared: "d-491", ReportedSequence: 491, Current: true}, true},
|
||||
{inventory.Reported{Declared: "d-489", ReportedSequence: 489, Current: false}, false},
|
||||
{inventory.Reported{Declared: "other", ReportedSequence: 490}, true}, // the same sequence, said by another digest
|
||||
{inventory.Reported{Declared: "d-495", ReportedSequence: 495, Current: true}, true}, // the last send: this one or a later one
|
||||
{inventory.Reported{Declared: "", Current: false}, false},
|
||||
} {
|
||||
if got := sent.ReportsOn(c.r); got != c.want {
|
||||
t.Errorf("%+v on %+v: %v", c.r, sent, got)
|
||||
}
|
||||
}
|
||||
byDigest := inventory.SentDeclaration{Digest: "d-1"}
|
||||
if !byDigest.ReportsOn(inventory.Reported{Declared: "d-1"}) || byDigest.ReportsOn(inventory.Reported{ReportedSequence: 5}) ||
|
||||
!byDigest.ReportsOn(inventory.Reported{Current: true}) {
|
||||
t.Fatal("a send kept without a sequence is matched by its digest and by the last send alone")
|
||||
}
|
||||
f := gateFacts{sent: map[string]inventory.SentDeclaration{"anchor": sent}}
|
||||
if !f.reportedOn("anchor", inventory.Reported{Declared: "d-490"}) || f.reportedOn("anchor", inventory.Reported{Declared: "d-1"}) {
|
||||
t.Fatal("a gate that kept its send read the report against something other than it")
|
||||
}
|
||||
if !f.reportedOn("laptop", inventory.Reported{Current: true}) || f.reportedOn("laptop", inventory.Reported{Current: false}) {
|
||||
t.Fatal("a gate that did not keep its send does not read the report against the send made last")
|
||||
}
|
||||
// Through the merge plan's first-machine wait too.
|
||||
at := time.Now()
|
||||
state := inventory.PlanModule{First: []string{"anchor"}, FirstAt: &at,
|
||||
Gate: &inventory.PlanGate{Machines: []string{"anchor"}, Sent: map[string]inventory.SentDeclaration{"anchor": sent}}}
|
||||
reports := []inventory.Reported{{Node: "anchor", At: &at, Outcome: inventory.OutcomeApplied, Current: false, Declared: "d-490"}}
|
||||
if step := nextRollout(state, []string{"anchor", "laptop"}, false, reports, at.Add(time.Minute), time.Hour); step.waiting != "" || step.failed != "" {
|
||||
t.Fatalf("the first machine's report on the plan's own send read as none: %+v", step)
|
||||
}
|
||||
reports[0].Declared = "d-480"
|
||||
if step := nextRollout(state, []string{"anchor", "laptop"}, false, reports, at.Add(time.Minute), time.Hour); step.waiting == "" {
|
||||
t.Fatalf("a report on an older send read as the plan's: %+v", step)
|
||||
}
|
||||
}
|
||||
|
||||
// A gate judges only the build the machine was last sent: last sent another build of the module, the
|
||||
// judging is superseded, whatever the machine reports. Pure.
|
||||
func TestAGateJudgesOnlyTheBuildTheMachineWasLastSent(t *testing.T) {
|
||||
at := time.Now()
|
||||
f := gateFacts{now: at, reports: map[string]inventory.Reported{"anchor": {Node: "anchor", Outcome: inventory.OutcomeApplied,
|
||||
At: &at, Current: true}}, engines: map[string]string{}, served: map[string]served{}, rolledBack: map[string][]lease.Rollback{},
|
||||
commits: map[string]string{"mesh-controller": "e6b00e2e"}, sentBuilds: map[string]map[string]string{"anchor": {"mesh-controller": "ef26d4cb"}}}
|
||||
taken := at.Add(-30 * time.Second)
|
||||
f.holder = &lease.Holder{Taken: taken, Health: &lease.Health{Ready: true}}
|
||||
h, why := judgeHealth("mesh-controller", lease.ComponentController, catalogue.Manifest{}, "anchor", at.Add(-time.Minute), f)
|
||||
if h != healthSuperseded || !strings.Contains(why, "ef26d4cb") || !strings.Contains(why, "e6b00e2e") {
|
||||
t.Fatalf("a controller build the machine no longer runs: %v %q", h, why)
|
||||
}
|
||||
f.sentBuilds["anchor"]["mesh-controller"] = "e6b00e2e"
|
||||
if h, why := judgeHealth("mesh-controller", lease.ComponentController, catalogue.Manifest{}, "anchor", at.Add(-time.Minute), f); h == healthSuperseded {
|
||||
t.Fatalf("the build sent read as another: %q", why)
|
||||
}
|
||||
delete(f.sentBuilds, "anchor")
|
||||
if h, why := judgeHealth("mesh-controller", lease.ComponentController, catalogue.Manifest{}, "anchor", at.Add(-time.Minute), f); h == healthSuperseded {
|
||||
t.Fatalf("a machine whose send is not known read as superseded: %q", why)
|
||||
}
|
||||
g := &inventory.PlanGate{To: "c2", Carried: []inventory.CarriedMove{{Module: "late", Node: "anchor", To: "c5"}}}
|
||||
if got := judgedCommits(g, []judged{{"app", "anchor"}, {"late", "anchor"}}); got["app"] != "c2" || got["late"] != "c5" {
|
||||
t.Fatalf("judged commits %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A move of another build of a module to a machine where a release or a plan is judging that module
|
||||
// waits for that judging; the same build to that machine is already there. Pure.
|
||||
func TestAWalkWaitsForAJudgingOfTheSameModuleOnThatMachine(t *testing.T) {
|
||||
at := time.Now()
|
||||
release := inventory.Plan{ID: "release-1", State: inventory.PlanRolling, Release: &inventory.PlanRelease{
|
||||
Gate: &inventory.PlanGate{Machines: []string{"novox"}, Carried: []inventory.CarriedMove{
|
||||
{Module: "mesh-controller", Node: "novox", From: "ef26d4cb", To: "2913c54c"}}}}}
|
||||
merge := inventory.Plan{ID: "plan-1", State: inventory.PlanRolling, Modules: map[string]*inventory.PlanModule{
|
||||
"app": {First: []string{"anchor"}, FirstAt: &at, Commit: "c2", Gate: &inventory.PlanGate{Machines: []string{"anchor"}}}}}
|
||||
f := moveFacts{plans: []inventory.Plan{release, merge}}
|
||||
for _, c := range []struct {
|
||||
module, node, to, want string
|
||||
}{
|
||||
{"mesh-controller", "novox", "e6b00e2e", "release-1"}, // the day's case: a newer controller to the judged machine
|
||||
{"mesh-controller", "novox", "2913c54c", ""}, // the same build: already there
|
||||
{"mesh-controller", "ace", "2913c54c", "release-1"}, // another machine while the first is judged
|
||||
{"mesh-host", "novox", "x", ""}, // a module the release does not carry
|
||||
{"app", "anchor", "c2", ""},
|
||||
{"app", "anchor", "c3", "plan-1"},
|
||||
{"app", "laptop", "c2", "plan-1"},
|
||||
} {
|
||||
if got := f.walkedBy(c.module, c.node, c.to); got != c.want {
|
||||
t.Errorf("%s %s to %s: walked by %q, want %q", c.module, c.to, c.node, got, c.want)
|
||||
}
|
||||
}
|
||||
release.Release.Gate.Verdict = inventory.GatePassed
|
||||
merge.Modules["app"].Gate.Verdict = inventory.GatePassed
|
||||
if f.walkedBy("mesh-controller", "novox", "e6b00e2e") != "" || f.walkedBy("app", "laptop", "c3") != "" {
|
||||
t.Fatal("a passed judging still holds a move")
|
||||
}
|
||||
release.Release.Gate.Verdict = ""
|
||||
f.plans[0].State = inventory.PlanSuperseded
|
||||
if f.walkedBy("mesh-controller", "novox", "e6b00e2e") != "" {
|
||||
t.Fatal("a closed release still holds a move")
|
||||
}
|
||||
}
|
||||
|
||||
// The controller is never put back to a build that reaches less of the store's schema than the store
|
||||
// has, or to one that never said what it reaches: the current build is kept, and the condition says so.
|
||||
func TestTheControllerIsNotPutBackToABuildOlderThanTheStoresSchema(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
keeper, _ := withConditionsInMemory(t)
|
||||
told := &conditions.Told{}
|
||||
was := doctorFrom
|
||||
doctorFrom = &doctor{open: open, keeper: keeper, teller: told}
|
||||
t.Cleanup(func() { doctorFrom = was })
|
||||
wasSend := sendRollout
|
||||
var sent [][]string
|
||||
sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) {
|
||||
sent = append(sent, names)
|
||||
return names, nil
|
||||
}
|
||||
t.Cleanup(func() { sendRollout = wasSend })
|
||||
|
||||
build := func(id, commit, digest string, asked time.Time) inventory.Build {
|
||||
manifest, _ := json.Marshal(catalogue.Manifest{Module: "mesh-controller", Version: commit})
|
||||
b := inventory.Build{ID: id, Module: "mesh-controller", Commit: commit, Repository: "novox/mesh-controller", Path: ".",
|
||||
Manifest: manifest, Asked: asked, At: asked, Made: []inventory.Artifact{{Name: "controller", Kind: catalogue.ArtifactBundle,
|
||||
Reference: "mesh-artifact://mesh-controller/controller/blobs/sha256:" + digest}}}
|
||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "mesh-controller", Version: commit},
|
||||
inventory.Source{Repository: "novox/mesh-controller", Seat: "git", Path: ".", BuiltFrom: commit, Head: commit, Asked: asked}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
previous := build("build-old", "ef26d4cb", strings.Repeat("1", 64), time.Now().Add(-2*time.Hour))
|
||||
failed := build("build-new", "e6b00e2e", strings.Repeat("2", 64), time.Now().Add(-time.Minute))
|
||||
if versionOfBuild(previous) != strings.Repeat("1", 12) {
|
||||
t.Fatalf("the build's version is %q", versionOfBuild(previous))
|
||||
}
|
||||
applied, err := inv.SchemaApplied(ctx)
|
||||
if err != nil || applied < 87 {
|
||||
t.Fatalf("the store's schema reaches %d (%v)", applied, err)
|
||||
}
|
||||
// The build before never recorded what it reads: not proved, refused.
|
||||
if why, ok := controllerSchemaAllows(ctx, inv, previous); ok || !strings.Contains(why, "never recorded") {
|
||||
t.Fatalf("an unknown reach: %v %q", ok, why)
|
||||
}
|
||||
// It reads less than the store has: refused, naming both.
|
||||
if err := inv.RecordSchemaReach(ctx, versionOfBuild(previous), applied-1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if why, ok := controllerSchemaAllows(ctx, inv, previous); ok || !strings.Contains(why, "is at") {
|
||||
t.Fatalf("a reach behind the store: %v %q", ok, why)
|
||||
}
|
||||
// Through the gate: the failed build is marked, nothing is put back, nothing is sent, the condition is urgent.
|
||||
at := time.Now().Add(-5 * time.Minute)
|
||||
state := &inventory.PlanModule{Build: failed.ID, Commit: failed.Commit, Previous: previous.Commit, First: []string{"anchor"}, FirstAt: &at}
|
||||
p := inventory.Plan{ID: "plan-352", Repository: "novox/mesh-controller", Branch: "main", Commit: failed.Commit, Created: at,
|
||||
State: inventory.PlanRolling, Tiers: [][]string{{"mesh-controller"}}, Modules: map[string]*inventory.PlanModule{"mesh-controller": state}}
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gateFailed(ctx, open, &p, "mesh-controller", state, []string{"anchor"}, "not healthy within 10m0s of its apply")
|
||||
if state.Gate.Rollback != inventory.NotRolledBack || !strings.Contains(p.Note, "NOT put back") || !strings.Contains(p.Note, "the current build is kept") {
|
||||
t.Fatalf("rollback %q: %s", state.Gate.Rollback, p.Note)
|
||||
}
|
||||
if len(sent) != 0 {
|
||||
t.Fatalf("sent %v: nothing is put back", sent)
|
||||
}
|
||||
if current, _ := inv.CurrentBuilds(ctx); current["mesh-controller"].Commit != failed.Commit {
|
||||
t.Fatalf("the module was put back to %s", current["mesh-controller"].Commit)
|
||||
}
|
||||
if marked, _ := inv.GateFailed(ctx, failed.ID); !marked {
|
||||
t.Fatal("the failed build is not marked failed at its gate")
|
||||
}
|
||||
open2, _ := keeper.Open(ctx)
|
||||
var found bool
|
||||
for _, c := range open2 {
|
||||
if c.Kind == kindRollbackFailed && c.Severity == conditions.Urgent && strings.Contains(c.Summary, "current build is kept") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("no urgent rollback-failed condition saying the current build is kept: %+v", open2)
|
||||
}
|
||||
// Reaching the store: allowed.
|
||||
if err := inv.RecordSchemaReach(ctx, versionOfBuild(previous), applied); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if why, ok := controllerSchemaAllows(ctx, inv, previous); !ok {
|
||||
t.Fatalf("a build that reads the whole schema was refused: %q", why)
|
||||
}
|
||||
// A build with no bundle to know it by: refused.
|
||||
if why, ok := controllerSchemaAllows(ctx, inv, inventory.Build{Commit: "x"}); ok || !strings.Contains(why, "names no bundle") {
|
||||
t.Fatalf("a build without a bundle: %v %q", ok, why)
|
||||
}
|
||||
}
|
||||
|
||||
// The lease's holder names the build the declaration told it it is, and the version stamp only without one.
|
||||
func TestTheHolderNamesTheBuildTheDeclarationToldIt(t *testing.T) {
|
||||
t.Setenv(RunningBuildVar, " ad62528c47c7 ")
|
||||
if h := holderOf("x"); h.Build != "ad62528c47c7" {
|
||||
t.Fatalf("the holder's build is %q", h.Build)
|
||||
}
|
||||
t.Setenv(RunningBuildVar, "")
|
||||
if h := holderOf("x"); h.Build != version {
|
||||
t.Fatalf("without a declared version the holder's build is %q", h.Build)
|
||||
}
|
||||
if reach, err := schemaReach(); err != nil || reach < 87 {
|
||||
t.Fatalf("this build's reach is %d (%v)", reach, err)
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,12 @@ func runForMeshCLI(ctx context.Context, node string, asked link.CLIAsked, v cliV
|
||||
if v.refused != "" {
|
||||
return link.CLIRefusal(v.refused)
|
||||
}
|
||||
// Standard input is the terminal's alone: a secret given at the terminal reaches `secret accept`, and no ordinary
|
||||
// call is handed what the asker's standard input held (novox/hq ADR 0259 §10, ADR 0272).
|
||||
if len(asked.Stdin) > 0 && !v.terminal {
|
||||
return link.CLIAnswer{Exit: 1, Why: v.why, Refused: "standard input is given to a line that runs as the " +
|
||||
"controller's terminal alone, and this one does not. Nothing ran"}
|
||||
}
|
||||
if cliServers[asked.Line[0]] {
|
||||
return link.CLIAnswer{Exit: 1, Why: v.why, Refused: fmt.Sprintf("%s serves until stopped, and is not a "+
|
||||
"command line mesh-cli runs. Nothing ran", asked.Line[0])}
|
||||
@@ -155,8 +161,12 @@ func runForMeshCLI(ctx context.Context, node string, asked link.CLIAsked, v cliV
|
||||
}
|
||||
cmd := selfCommand(ctx, line)
|
||||
cmd.Env = commandEnvironment(fmt.Sprintf("%s through mesh-cli on %s", asked.Account, node), verb, v.terminal)
|
||||
// No standard input: a command that reads one gets nothing, and fails saying so (ADR 0272 §5).
|
||||
// No standard input unless mesh-cli carried one for a terminal line: a command that reads one gets nothing, and
|
||||
// fails saying so (ADR 0272 §5).
|
||||
cmd.Stdin = nil
|
||||
if len(asked.Stdin) > 0 {
|
||||
cmd.Stdin = bytes.NewReader(asked.Stdin)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
err := cmd.Run()
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// secretAcceptWants makes the test binary, run as a command line, read a secret as `secret accept` reads it and
|
||||
// say whether it is the value whose SHA-256 the variable names (TestMain).
|
||||
const secretAcceptWants = "MESH_TEST_SECRET_ACCEPT_WANTS"
|
||||
|
||||
// readAsSecretAccept is that process: `secret accept <node> <module> <name> [--from -]`, the value read by
|
||||
// valueFor, compared by digest, and only the verdict printed.
|
||||
func readAsSecretAccept(want string, argv []string) int {
|
||||
if len(argv) < 5 || argv[0] != "secret" || argv[1] != "accept" {
|
||||
fmt.Printf("not a secret accept line: %q\n", argv)
|
||||
return 2
|
||||
}
|
||||
from := ""
|
||||
if len(argv) == 7 && argv[5] == "--from" {
|
||||
from = argv[6]
|
||||
}
|
||||
value, err := valueFor(argv[2], argv[3], argv[4], from)
|
||||
if err != nil {
|
||||
fmt.Printf("secret accept read nothing: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
sum := sha256.Sum256([]byte(asSupplied(value)))
|
||||
if hex.EncodeToString(sum[:]) != want {
|
||||
fmt.Printf("secret accept read something else (%d bytes)\n", len(value))
|
||||
return 1
|
||||
}
|
||||
fmt.Println("secret accept read the value it was given")
|
||||
return 0
|
||||
}
|
||||
|
||||
// novox/hq ADR 0259 §10, ADR 0272: what mesh-cli's standard input held reaches `secret accept` on a line that runs
|
||||
// as the controller's terminal, and appears nowhere else — not in the answer, not in the journal, not in the calls
|
||||
// record; an ordinary line carrying it is refused and nothing runs.
|
||||
func TestStandardInputReachesSecretAcceptAtTheTerminalAndNowhereElse(t *testing.T) {
|
||||
token := "123456789:AAEhBOweik6ad9r_QxGivenAtTheTerminal"
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
t.Setenv(secretAcceptWants, hex.EncodeToString(sum[:]))
|
||||
var journal []string
|
||||
var mu sync.Mutex
|
||||
was := cliJournal
|
||||
cliJournal = func(line string) { mu.Lock(); journal = append(journal, line); mu.Unlock() }
|
||||
t.Cleanup(func() { cliJournal = was })
|
||||
ctx := context.Background()
|
||||
|
||||
for _, line := range [][]string{
|
||||
{"secret", "accept", "anchor", "telegram", "telegram-token", "--from", "-"},
|
||||
{"secret", "accept", "anchor", "telegram", "telegram-token"}, // the prompt's path, a line on standard input
|
||||
} {
|
||||
asked := cliAsked("operator", 1000, line...)
|
||||
asked.Stdin = []byte(token + "\n")
|
||||
a := runForMeshCLI(ctx, "control", asked, cliVerdict{terminal: true, why: "the terminal"})
|
||||
if a.Exit != 0 || !strings.Contains(string(a.Stdout), "read the value it was given") {
|
||||
t.Fatalf("%q: secret accept did not read what mesh-cli carried: %+v (%s)", line, a, a.Stdout)
|
||||
}
|
||||
if body, _ := json.Marshal(a); strings.Contains(string(body), "AAEh") || strings.Contains(string(body), base64.StdEncoding.EncodeToString([]byte(token))) {
|
||||
t.Fatalf("the answer carries the secret: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// Without standard input, the line reads nothing, as before.
|
||||
a := runForMeshCLI(ctx, "control", cliAsked("operator", 1000, "secret", "accept", "anchor", "telegram",
|
||||
"telegram-token", "--from", "-"), cliVerdict{terminal: true, why: "the terminal"})
|
||||
if a.Exit == 0 {
|
||||
t.Fatalf("a line with no standard input read a value: %+v", a)
|
||||
}
|
||||
|
||||
// An ordinary call is never handed it: refused, and nothing ran.
|
||||
asked := cliAsked("operator", 1000, "status")
|
||||
asked.Stdin = []byte(token)
|
||||
a = runForMeshCLI(ctx, "laptop", asked, cliVerdict{why: "not the terminal"})
|
||||
if a.Exit == 0 || !strings.Contains(a.Refused, "terminal alone") || len(a.Stdout) != 0 {
|
||||
t.Fatalf("an ordinary line was given standard input: %+v", a)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, l := range journal {
|
||||
if strings.Contains(l, "AAEh") {
|
||||
t.Fatalf("the journal says the secret: %s", l)
|
||||
}
|
||||
}
|
||||
if len(journal) == 0 {
|
||||
t.Fatal("the lines were not said in the journal at all")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -76,6 +76,21 @@ func serve(ctx context.Context) (err error) {
|
||||
}
|
||||
defer open.Close()
|
||||
inv := open.inventory
|
||||
// **What this build reads of the store's schema, on record** (novox/hq issue 352): the highest migration
|
||||
// it carries, by its version, so a gate that would put this build back later knows it reads the store
|
||||
// as it is then. A build that does not know its version records nothing, and is never put back.
|
||||
if build := runningBuild(); build != "" {
|
||||
if reach, err := schemaReach(); err != nil {
|
||||
fmt.Printf("what this build reads of the store's schema is not recorded: %v\n", err)
|
||||
} else if err := inv.RecordSchemaReach(ctx, build, reach); err != nil {
|
||||
fmt.Printf("what this build (%s) reads of the store's schema is not recorded: %v\n", build, err)
|
||||
} else {
|
||||
fmt.Printf("this build (%s) reads the store's schema up to migration %04d; recorded\n", build, reach)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("this process was not told which build it is (%s), so what it reads of the store's schema is not "+
|
||||
"recorded, and a gate will never put it back\n", RunningBuildVar)
|
||||
}
|
||||
|
||||
ident, err := openIdentity(ctx)
|
||||
if err != nil {
|
||||
@@ -1081,20 +1096,29 @@ func brokerBehind(ctx context.Context, open *stores, names []string) (string, bo
|
||||
return h.Node, false, nil
|
||||
}
|
||||
}
|
||||
// **What cannot be worked out is behind** (the review of hq issue 353). The list it would be sent cannot be
|
||||
// composed, so whether it carries the grants a send relies on is not known; it is taken as behind, so a push
|
||||
// sends that machine first and says why it cannot (`plan` says it too), and a rollout that needs the list
|
||||
// refuses rather than sending code whose grants the bus may not hold. It said "not behind" until then: a
|
||||
// send went ahead as if the bus held a list nobody could compose.
|
||||
list, composeErr := "", error(nil)
|
||||
plan, _, err := planFor(ctx, open, h.Node)
|
||||
if err != nil {
|
||||
// It cannot be worked out: sending it would refuse the whole send, and `plan` says why.
|
||||
return h.Node, false, nil
|
||||
}
|
||||
list, _, err := busUserList(ctx, inv, plan.Modules)
|
||||
if err != nil {
|
||||
return h.Node, false, nil
|
||||
composeErr = err
|
||||
} else if list, _, err = busUserList(ctx, inv, plan.Modules); err != nil {
|
||||
composeErr = err
|
||||
}
|
||||
sent, err := inv.SentBusUsers(ctx, h.Node)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return h.Node, userListBehind(list, sent), nil
|
||||
return h.Node, listBehind(list, composeErr, sent), nil
|
||||
}
|
||||
|
||||
// listBehind is whether the bus's machine is behind: the list composed now is not the one last sent, or it
|
||||
// could not be composed at all — not known is behind, never "not behind".
|
||||
func listBehind(list string, composeErr error, sentDigest string) bool {
|
||||
return composeErr != nil || userListBehind(list, sentDigest)
|
||||
}
|
||||
|
||||
// userListBehind is whether the user list composed now is not the one last sent, by its digest. An
|
||||
@@ -1598,3 +1622,18 @@ func reportUnheldPushed(w io.Writer, named bool, asked []string, unheld map[stri
|
||||
fmt.Fprintf(w, "%s: %d unmet seat dependenc(ies) — see `status`\n", node, len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
// schemaReach is the highest migration this build carries for the inventory's store (novox/hq issue 352).
|
||||
func schemaReach() (int, error) {
|
||||
migrations, err := inventory.Migrations()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
reach := 0
|
||||
for _, m := range migrations {
|
||||
if m.Number > reach {
|
||||
reach = m.Number
|
||||
}
|
||||
}
|
||||
return reach, nil
|
||||
}
|
||||
|
||||
@@ -180,12 +180,35 @@ func (f moveFacts) moves(node string, modules []string, sent map[string]string,
|
||||
return out
|
||||
}
|
||||
|
||||
// walkedBy is the open plan that has started walking a module's build — sent it to a first machine,
|
||||
// not yet passed — other than to this machine; empty when none does.
|
||||
func (f moveFacts) walkedBy(module, node string) string {
|
||||
// walkedBy is the open plan that has started walking a module's build — sent it to a first machine, not
|
||||
// yet passed — and whose walk this move would cross; empty when none does. A move of the same build to a
|
||||
// machine that plan already sent it is not a crossing: the build is there. A move of **another** build of
|
||||
// the module to that machine is (novox/hq issue 352): on 2026-10-09 a merge's plan sent the control node a
|
||||
// newer controller while a release's gate was judging the controller there, the release's gate read the
|
||||
// machine's report against the newer send, failed three builds and put them back under the new plan's
|
||||
// feet. A release keeps no module records: its open gate's carried moves are its walk.
|
||||
func (f moveFacts) walkedBy(module, node, to string) string {
|
||||
for _, p := range f.plans {
|
||||
if !p.Open() {
|
||||
continue
|
||||
}
|
||||
if p.Release != nil {
|
||||
g := p.Release.Gate
|
||||
if g == nil || g.Verdict != "" {
|
||||
continue
|
||||
}
|
||||
for _, c := range g.Carried {
|
||||
if c.Module == module && !(slices.Contains(g.Machines, node) && sameCommit(c.To, to)) {
|
||||
return p.ID
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
s, holds := p.Modules[module]
|
||||
if !p.Open() || !holds || s == nil || s.FirstAt == nil || s.SentAt != nil || slices.Contains(s.First, node) {
|
||||
if !holds || s == nil || s.FirstAt == nil || s.SentAt != nil {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(s.First, node) && sameCommit(s.Commit, to) {
|
||||
continue
|
||||
}
|
||||
if s.Gate != nil && s.Gate.Verdict == inventory.GatePassed {
|
||||
@@ -277,11 +300,19 @@ func gatedSend(ctx context.Context, open *stores, node string, owns []inventory.
|
||||
if own(mv.Module) {
|
||||
continue
|
||||
}
|
||||
if id := f.walkedBy(mv.Module, node); id != "" {
|
||||
if id := f.walkedBy(mv.Module, node, mv.To); id != "" {
|
||||
return nil, nil, fmt.Errorf("%w: %s's build %s waits on %s, which %s is walking", errWalkedElsewhere,
|
||||
mv.Module, short(mv.To), node, id)
|
||||
}
|
||||
}
|
||||
// **And the plan's own modules wait too** (novox/hq issue 352): a walk of the same module by another
|
||||
// plan, or a release, on this machine is not crossed with a newer build; this send waits for its gate.
|
||||
for _, o := range owns {
|
||||
if id := f.walkedBy(o.Module, node, o.To); id != "" {
|
||||
return nil, nil, fmt.Errorf("%w: %s's build %s waits on %s, which %s is walking", errWalkedElsewhere,
|
||||
o.Module, short(o.To), node, id)
|
||||
}
|
||||
}
|
||||
for _, o := range owns {
|
||||
i := slices.IndexFunc(moves, func(mv inventory.CarriedMove) bool { return mv.Module == o.Module })
|
||||
switch {
|
||||
@@ -526,7 +557,7 @@ func waitingMoves(ctx context.Context, open *stores, all bool) (map[string][]inv
|
||||
return nil, err
|
||||
}
|
||||
for _, mv := range moves {
|
||||
if f.walkedBy(mv.Module, n.Name) == "" {
|
||||
if f.walkedBy(mv.Module, n.Name, mv.To) == "" {
|
||||
out[n.Name] = append(out[n.Name], mv)
|
||||
}
|
||||
}
|
||||
@@ -658,7 +689,7 @@ func advanceRelease(ctx context.Context, open *stores, p *inventory.Plan) (bool,
|
||||
r.Next++
|
||||
continue
|
||||
}
|
||||
r.Gate = &inventory.PlanGate{Machines: sent, Since: &now, Carried: moves}
|
||||
r.Gate = &inventory.PlanGate{Machines: sent, Since: &now, Carried: moves, Sent: sentNow(ctx, open.inventory, sent)}
|
||||
p.Note = fmt.Sprintf("sent %s %d build(s) that waited for a gate; judging them there", node, len(moves))
|
||||
if said := recreationsSaid(moves); said != "" {
|
||||
p.Note += "; " + said
|
||||
@@ -693,6 +724,15 @@ func advanceRelease(ctx context.Context, open *stores, p *inventory.Plan) (bool,
|
||||
r.Next++
|
||||
r.Gate = nil
|
||||
return true, nil
|
||||
case inventory.GateSuperseded:
|
||||
// Another send moved a judged module on the judged machine (novox/hq issue 352): no verdict on what
|
||||
// was carried, nothing put back, and this release ends; the builds still waiting are released again
|
||||
// by the next pass, judged afresh.
|
||||
p.State = inventory.PlanSuperseded
|
||||
p.Note = fmt.Sprintf("superseded on %s: %s — nothing judged, nothing put back; what still waits is released again",
|
||||
strings.Join(g.Machines, ", "), g.Why)
|
||||
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
||||
return true, nil
|
||||
}
|
||||
p.Note = ""
|
||||
batched, back := batchingRollbacks(ctx)
|
||||
|
||||
@@ -793,6 +793,15 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
||||
case inventory.GateFailed:
|
||||
failFirstSend(ctx, open, p, m, state, state.Gate.Machines, state.Gate.Why, step.rest)
|
||||
return true, nil
|
||||
case inventory.GateSuperseded:
|
||||
// Another send moved this module on its first machine (novox/hq issue 352): the build is not
|
||||
// judged, not marked, not put back; the plan ends here, said, and a newer plan carries on.
|
||||
state.Why = "superseded: " + state.Gate.Why
|
||||
p.State = inventory.PlanSuperseded
|
||||
p.Note = fmt.Sprintf("%s's judging on %s was superseded: %s", m, strings.Join(state.Gate.Machines, ", "),
|
||||
state.Gate.Why)
|
||||
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
||||
return true, nil
|
||||
case inventory.GatePassed:
|
||||
if !state.Gate.Kept {
|
||||
gatePassed(ctx, open, p, m, state)
|
||||
@@ -964,6 +973,9 @@ func firstSend(ctx context.Context, open *stores, p *inventory.Plan, node string
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
lead := modules[0]
|
||||
// What each machine was just sent, kept on the gate (novox/hq issue 352): its report is held against
|
||||
// this send, whatever it is sent after.
|
||||
sentWhat := sentNow(ctx, inv, sent)
|
||||
for _, m := range modules {
|
||||
s := p.Modules[m]
|
||||
// What the first machine ran before: what a failed gate puts back (ADR 0236).
|
||||
@@ -972,7 +984,7 @@ func firstSend(ctx context.Context, open *stores, p *inventory.Plan, node string
|
||||
}
|
||||
s.First, s.FirstAt = sent, &now
|
||||
s.Gate = &inventory.PlanGate{Component: coreComponent(m), Machines: firstRunning(sent, runningOf[m]),
|
||||
From: s.Previous, To: s.Commit, Since: &now}
|
||||
From: s.Previous, To: s.Commit, Since: &now, Sent: sentWhat}
|
||||
s.GatedBy = ""
|
||||
if m == lead {
|
||||
s.Gate.Carried = carried
|
||||
@@ -1131,8 +1143,15 @@ func nextRollout(s inventory.PlanModule, running []string, together bool, report
|
||||
var waiting, failed []string
|
||||
for _, n := range s.First {
|
||||
r, said := byNode[n]
|
||||
// Only a report about what it was last sent says anything about this build.
|
||||
if !said || r.At == nil || !r.Current {
|
||||
// Only a report about what this plan sent it — or what it was sent after that — says anything about
|
||||
// this build (novox/hq issue 352); a plan from before sends were kept on the gate reads the report
|
||||
// against the send made last, as before.
|
||||
reported := r.Current
|
||||
if s.Gate != nil && s.Gate.Sent != nil {
|
||||
sent, kept := s.Gate.Sent[n]
|
||||
reported = kept && sent.ReportsOn(r)
|
||||
}
|
||||
if !said || r.At == nil || !reported {
|
||||
waiting = append(waiting, n)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -28,6 +28,11 @@ import (
|
||||
func TestMain(m *testing.M) {
|
||||
// The process a mesh-cli test runs as a command line: it says the verb and the caller it was given, and
|
||||
// ends (meshcli_test.go).
|
||||
// The process a mesh-cli test runs as `secret accept`: it reads its value exactly as `secret accept` does
|
||||
// (valueFor) and says whether it is the one the test gave, never the value (meshcli_stdin_test.go).
|
||||
if want := os.Getenv(secretAcceptWants); want != "" {
|
||||
os.Exit(readAsSecretAccept(want, os.Args[1:]))
|
||||
}
|
||||
if os.Getenv(echoEnvironment) != "" {
|
||||
fmt.Printf("verb=%q caller=%q terminal=%v\n", os.Getenv("MESH_VERB"), os.Getenv("MESH_CALLER"), startedAtTheTerminal())
|
||||
os.Exit(0)
|
||||
|
||||
@@ -762,6 +762,11 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
argv = append(argv, "--probe", p)
|
||||
}
|
||||
return append(argv, "--json"), nil
|
||||
case "give":
|
||||
if err := need("node", "module", "secret", "at"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{"secret", "accept", str("node"), str("module"), str("secret"), "--at-desk", str("at")}, nil
|
||||
case "rotate":
|
||||
if p := str("provision"); p != "" {
|
||||
argv := []string{"rotate", p}
|
||||
@@ -1495,6 +1500,20 @@ var terminalOnlyCommands = map[string]string{
|
||||
"licence": "the licences' secrets",
|
||||
}
|
||||
|
||||
// givenAtTheDesk is exactly the line the `give` verb composes, and nothing beside it: `secret accept <node>
|
||||
// <module> <secret> --at-desk <machine>`, with no other word — no value, no file, no provider.
|
||||
func givenAtTheDesk(argv []string) bool {
|
||||
if len(argv) != 7 || argv[0] != "secret" || argv[1] != "accept" || argv[5] != "--at-desk" {
|
||||
return false
|
||||
}
|
||||
for _, w := range argv[2:5] {
|
||||
if w == "" || strings.HasPrefix(w, "-") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return argv[6] != "" && !strings.HasPrefix(argv[6], "-")
|
||||
}
|
||||
|
||||
// terminalOnly refuses, through any verb, a command that is the operator's at the controller's terminal
|
||||
// alone (novox/hq ADR 0266). **Every `node` subcommand that is not a read**: `node account` and
|
||||
// `node agent-account` above all. Whoever may call a verb includes agents, and an agent that named itself
|
||||
@@ -1508,8 +1527,10 @@ func terminalOnly(argv []string) error {
|
||||
return terminalRefusal("%s is run at the controller's terminal only, never through a verb: it holds %s, and "+
|
||||
"whoever may call a verb includes agents (novox/hq ADR 0266). Nothing was done", argv[0], what)
|
||||
}
|
||||
// Of a secret's commands only rotation, which seals the new value to the machine that uses it.
|
||||
if argv[0] == "secret" && (len(argv) < 2 || argv[1] != "rotate") {
|
||||
// Of a secret's commands only rotation, which seals the new value to the machine that uses it, and the
|
||||
// `give` verb's own line: an own secret typed by the operator into the desk's hidden prompt, sealed to this
|
||||
// call and then to the module's machine, so no value travels in the verb or its answer (hq ADR 0259 §10).
|
||||
if argv[0] == "secret" && (len(argv) < 2 || argv[1] != "rotate") && !givenAtTheDesk(argv) {
|
||||
return terminalRefusal("secret %s is run at the controller's terminal only, never through a verb: accepting, "+
|
||||
"recovering or exporting a secret hands it to whoever asks, and that includes agents (novox/hq ADR "+
|
||||
"0266). Nothing was done", strings.Join(argv[1:], " "))
|
||||
|
||||
@@ -275,6 +275,13 @@ var accountedFlags = map[string]map[string]string{
|
||||
"json": "set by the verb: the answer is data",
|
||||
"all": "withheld: every measurement of a fortnight is more than a call should carry; `command` reaches it",
|
||||
},
|
||||
// The desk path of `secret accept` (novox/hq ADR 0259 §10): a value is never an argument of a call.
|
||||
"secret accept": {
|
||||
"at-desk": "=at",
|
||||
"from": "withheld: a file of the control node's is read at a shell, never named by a call",
|
||||
"provider": "withheld: a pair credential's value is given at a shell; give takes a module's own secret",
|
||||
"local": "withheld: it goes with --provider",
|
||||
},
|
||||
"hand-acts": {"json": "set by the verb: the answer is data"},
|
||||
"conditions": {"json": "set by the verb: the answer is data"},
|
||||
"retire": {"json": "set by the verb: the answer is data"},
|
||||
|
||||
@@ -55,6 +55,9 @@ func secretCommand(ctx context.Context, args []string) error {
|
||||
provider := set.String("provider", "",
|
||||
"the node providing <name>: the value becomes the PAIR credential between <module> on <node> "+
|
||||
"and that provider, sealed to both — the vault's operator-delivered secret (ADR 0092)")
|
||||
desk := set.String("at-desk", "",
|
||||
"ask the operator for the value in a prompt that does not show it, on this machine's desk; the "+
|
||||
"answer comes back sealed to this call alone (novox/hq ADR 0259 §10)")
|
||||
local := set.String("local", "",
|
||||
"with --provider: the name the credential goes by inside <module>, where its manifest keeps "+
|
||||
"several for <name> (ADR 0094)")
|
||||
@@ -65,6 +68,18 @@ func secretCommand(ctx context.Context, args []string) error {
|
||||
return errors.New(secretUsage)
|
||||
}
|
||||
node, module, name := rest[0], rest[1], rest[2]
|
||||
// A value comes from the terminal or the desk, never through a verb (the review of 2026-10-09, M4): a
|
||||
// verb's caller may be an agent, and a value it chose would become what a module acts with.
|
||||
if verb, through := throughAVerb(); through && *desk == "" {
|
||||
return fmt.Errorf("a secret's value is given at the controller's terminal or at the desk (`give`), never "+
|
||||
"through a verb (this line came through %q): nothing was read or sealed", verb)
|
||||
}
|
||||
if *desk != "" {
|
||||
if *from != "" || *provider != "" {
|
||||
return errors.New("--at-desk gives a module's own secret, and takes neither --from nor --provider")
|
||||
}
|
||||
return giveAtDesk(ctx, node, module, name, *desk)
|
||||
}
|
||||
|
||||
value, err := valueFor(node, module, name, *from)
|
||||
if err != nil {
|
||||
@@ -93,10 +108,26 @@ func secretCommand(ctx context.Context, args []string) error {
|
||||
fmt.Printf(" run `push %s` and `push %s` to send it\n", *provider, node)
|
||||
return nil
|
||||
}
|
||||
untilStart, err := open.inventory.AcceptGivenSecret(ctx, node, module, name, value)
|
||||
// A trusted party's secret is announced before it is kept (the confirmation review of 2026-10-09, N1-give):
|
||||
// on every channel, the one it replaces among them, which still runs on its old value until the next push.
|
||||
// Not announced, it is not kept: a channel whose token changed unheard of answers for somebody else.
|
||||
trusted, err := open.inventory.RunsAsItsOwnAccount(ctx, module)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
untilStart, unannounced, err := keepGiven(trusted,
|
||||
func() error { return announceSecretGiven(ctx, node, module, name, "at the controller's terminal") },
|
||||
func() (bool, error) { return open.inventory.AcceptGivenSecret(ctx, node, module, name, value) })
|
||||
if err != nil {
|
||||
if trusted && unannounced != nil {
|
||||
return fmt.Errorf("%s runs as an account of its own, and the change of its %s could not be announced on "+
|
||||
"your channels first, so nothing was kept: %w", module, name, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if unannounced != nil {
|
||||
fmt.Printf(" this change could NOT be announced on the operator's channels: %v\n", unannounced)
|
||||
}
|
||||
// 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)
|
||||
@@ -118,7 +149,7 @@ func secretCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
|
||||
const secretUsage = "secret rotate <node> <module> <name> [--why <text> [--cause <word>]]\n" +
|
||||
"secret accept <node> <module> <name> [--from <file>] [--provider <node> [--local <name>]]\n" +
|
||||
"secret accept <node> <module> <name> [--from <file> | --at-desk <machine>] [--provider <node> [--local <name>]]\n" +
|
||||
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
|
||||
"secret export [--out <file>]"
|
||||
|
||||
@@ -369,6 +400,8 @@ func valueFor(node, module, name, from string) (string, error) {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"reading %s's %q for %s from standard input; it is not echoed anywhere\n",
|
||||
module, name, node)
|
||||
// At a terminal, what is typed is not shown either: echo off while it is read.
|
||||
defer hideTyping(os.Stdin)()
|
||||
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
if err != nil && line == "" {
|
||||
return "", fmt.Errorf("nothing was given on standard input: %w", err)
|
||||
@@ -452,3 +485,23 @@ func whoAsked() string {
|
||||
}
|
||||
return "the mesh"
|
||||
}
|
||||
|
||||
// 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
|
||||
// fails**: a channel whose token changed unheard of answers for somebody else. Any other module's is kept first
|
||||
// and announced after, and a failed announcement is said (unannounced) without undoing it.
|
||||
func keepGiven(trusted bool, announce func() error, keep func() (bool, error)) (untilStart bool, unannounced, err error) {
|
||||
if trusted {
|
||||
if err := announce(); err != nil {
|
||||
return false, err, err
|
||||
}
|
||||
untilStart, err = keep()
|
||||
return untilStart, nil, err
|
||||
}
|
||||
untilStart, err = keep()
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
return untilStart, announce(), nil
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ const (
|
||||
|
||||
// callBounds are the verbs that may run longer than callDefault, and how long (S7).
|
||||
var callBounds = map[string]time.Duration{
|
||||
"push": 30 * time.Minute, "rotate": 30 * time.Minute, "assign": 15 * time.Minute,
|
||||
"push": 30 * time.Minute, "rotate": 30 * time.Minute, "give": 5 * time.Minute, "assign": 15 * time.Minute,
|
||||
"unassign": 15 * time.Minute, "command": 30 * time.Minute, "doctor": 3 * time.Minute,
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ require (
|
||||
github.com/novox/mesh-host v0.0.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/sys v0.48.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -24,7 +25,6 @@ require (
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
@@ -35,4 +35,4 @@ require (
|
||||
// committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and
|
||||
// fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without
|
||||
// `go mod vendor` fails loudly, at once, everywhere.
|
||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d
|
||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009141741-d8ff1540df56
|
||||
|
||||
@@ -1,23 +1,5 @@
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261006095519-3e80b7ae325e h1:g9h4QRaAMg5yaJLwqtb0FoOs23DVGUYpW6qvnQ3oY5A=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261006095519-3e80b7ae325e/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261007120832-bdd44154ccac h1:KvnKtJ2rWeIE/t4GweK+JL0OjKSNxsrVP3/nMdpii8o=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261007120832-bdd44154ccac/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac h1:yLtFS0pDCCqIE9Zx8hgXEFG9fUWzf8L9WQoKV+Amk1E=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261007162834-56e2ebec4bac/go.mod h1:VlilMCRZ5yyNXg7SNigNBLr0Gt32jrGw5KSNq5JAVYs=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261009081005-b28d7bbcbff4 h1:f4rBnKSemuN0Z9dTtRJMigIGfEs6ltFPOILJGHGab74=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261009081005-b28d7bbcbff4/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261009101157-2673e7a2c95e h1:H7eVqDILL6e9cMbWSLHTbCqu9ZxDOmyeQhUmWl9QBV0=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261009101157-2673e7a2c95e/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d h1:IrmJ+lz21n+eSqKrmXREtR/7raUCBJ+fZvs+BNhuXVI=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d/go.mod h1:72ZATZjxMLaJfWdvlSDJrygIoBzCmKIjCDMhEXxVzTo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008145004-62367ce15ad6 h1:JT7xM1bnLNInW7/oImV2OlXTrcQ4/GSM0Y8tAb+AhmY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008145004-62367ce15ad6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008162031-55090da7e08f h1:BNvyWq899GwP7F3sY4ACieB5a5fnFAq+sJ9lP6HQ5qI=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008162031-55090da7e08f/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8 h1:soqhLNpEXThdq6PdiPy6ExxjJ+yjhh1N1n9E3j1CtrM=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009095928-76902998cd39 h1:WHW6CgbuTxP7M+qRBOgzsiG9vT49xdkZ/rarc9/vKMA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009095928-76902998cd39/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261009141741-d8ff1540df56 h1:Vut7OdwSAL0rFaavaFmv3+FIGS3ISM4jA+xTiS88nvg=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261009141741-d8ff1540df56/go.mod h1:mBxSf6wULwn0bdpHkIHUnhTvNzqnULnjoRN28dUgSBU=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.10-0.20261009115850-16984aafc689 h1:Ti2P9nwders7YQ/hq3X/dPo+CXMj5pdUcfA5P/c12CU=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.10-0.20261009115850-16984aafc689/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
@@ -56,8 +38,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
|
||||
+72
-5
@@ -183,6 +183,49 @@ var VerbsTheSelfCheckAsks = []SeatVerb{{Seat: "node-intrusion-prevention", Verb:
|
||||
// the controller's grant that acts, and only through the step a person starts.
|
||||
var VerbsTheBusStepAsks = []SeatVerb{{Seat: "node-backup", Verb: "now"}}
|
||||
|
||||
// VerbsTheControllerAsksForASecret are the seat verbs `give` calls (novox/hq ADR 0259 §10): the operator's
|
||||
// desk opens a prompt that does not show what is typed, and answers it sealed to the controller's call.
|
||||
var VerbsTheControllerAsksForASecret = []SeatVerb{{Seat: "node-launcher", Verb: "secret"}}
|
||||
|
||||
// ControllerOnly are the subjects the controller alone may publish, however wide another's grant (the review
|
||||
// of 2026-10-09, M4): the desk's hidden prompt, on its seat's subjects and on any holder's own module
|
||||
// subjects. A grant of every tool — the runtime's, which carries every agent's calls, or a person's `*` — would
|
||||
// otherwise reach it, and the prompt says the controller asks: only the bus makes that true.
|
||||
func ControllerOnly() []string {
|
||||
var out []string
|
||||
for _, v := range VerbsTheControllerAsksForASecret {
|
||||
for _, base := range []string{"mesh.seat." + v.Seat + ".tool." + v.Verb, "mesh.mod.*.tool." + v.Seat + "." + v.Verb} {
|
||||
out = append(out, base, base+".*")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MayPublish says whether permissions let a principal publish one subject: an allow covers it and no deny does.
|
||||
func MayPublish(perms Permissions, subject string) bool {
|
||||
for _, d := range perms.PublishDeny {
|
||||
if SubjectsOverlap(d, subject) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, a := range perms.Publish {
|
||||
if SubjectsOverlap(a, subject) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MaySubscribe says whether a principal with these permissions may subscribe to (and so answer) a subject.
|
||||
func MaySubscribe(perms Permissions, subject string) bool {
|
||||
for _, a := range perms.Subscribe {
|
||||
if SubjectsOverlap(a, subject) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// VerbsTheControllerAsksTheDeliveryOwner are the mesh-delivery seat's verbs the controller calls (novox/hq
|
||||
// ADR 0239): its self-check reads `stalled`, and healer H2 takes the one transition the table allows
|
||||
// through `close`. A mesh seat's verb is flat: no machine in the subject.
|
||||
@@ -253,8 +296,11 @@ func (p Principal) inbox() string { return "_INBOX." + p.Username() + ".>" }
|
||||
|
||||
// Permissions is what a principal may publish and subscribe, and whether it may answer.
|
||||
type Permissions struct {
|
||||
Publish []string
|
||||
Subscribe []string
|
||||
Publish []string
|
||||
// PublishDeny are subjects refused although an allow covers them: the controller's alone (ControllerOnly),
|
||||
// denied to everybody whose grant is wide enough to reach them. The server's deny outranks its allow.
|
||||
PublishDeny []string
|
||||
Subscribe []string
|
||||
// AllowResponses lets a principal reply to a request it received, on the reply subject that
|
||||
// request carried, once.
|
||||
//
|
||||
@@ -392,6 +438,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
for _, v := range VerbsTheBusStepAsks {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
|
||||
}
|
||||
// And the operator's desk, for a secret given there (ADR 0259 §10).
|
||||
for _, v := range VerbsTheControllerAsksForASecret {
|
||||
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)
|
||||
@@ -796,9 +846,22 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
if err := CheckWriters(p, pub); err != nil {
|
||||
return Permissions{}, err
|
||||
}
|
||||
// What the controller alone may publish is denied to everybody else whose grant reaches it.
|
||||
var deny []string
|
||||
if p.Kind != KindController {
|
||||
for _, only := range ControllerOnly() {
|
||||
for _, a := range pub {
|
||||
if SubjectsOverlap(a, only) {
|
||||
deny = append(deny, only)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Permissions{
|
||||
Publish: pub,
|
||||
Subscribe: sub,
|
||||
Publish: pub,
|
||||
PublishDeny: deny,
|
||||
Subscribe: sub,
|
||||
// A module answers what it was asked — a tool call reaches it on its own namespace, so the
|
||||
// authority is bounded by having been asked — and so does the controller. A node and a
|
||||
// person are never asked anything, and are granted nothing here.
|
||||
@@ -1020,7 +1083,11 @@ func ComposeAccounts(principals []Principal) (string, error) {
|
||||
return "", fmt.Errorf("%s has no password hash: a user without one is a user anybody is", p.Username())
|
||||
}
|
||||
fmt.Fprintf(&b, " { user: %q, password: %q, permissions: {\n", p.Username(), p.PasswordHash)
|
||||
fmt.Fprintf(&b, " publish: { allow: [%s] }\n", quoted(perms.Publish))
|
||||
if len(perms.PublishDeny) > 0 {
|
||||
fmt.Fprintf(&b, " publish: { allow: [%s], deny: [%s] }\n", quoted(perms.Publish), quoted(perms.PublishDeny))
|
||||
} else {
|
||||
fmt.Fprintf(&b, " publish: { allow: [%s] }\n", quoted(perms.Publish))
|
||||
}
|
||||
fmt.Fprintf(&b, " subscribe: { allow: [%s] }\n", quoted(perms.Subscribe))
|
||||
if perms.AllowResponses {
|
||||
fmt.Fprintf(&b, " allow_responses: { max: 1, ttl: \"%dm\" }\n", int(ResponseTTL/time.Minute))
|
||||
|
||||
+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.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "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-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.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "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.*"] }
|
||||
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" }
|
||||
} }
|
||||
|
||||
@@ -173,11 +173,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
||||
// had — so re-composing a declaration moves nothing, where a commit would move the
|
||||
// path of an identical binary and recreate everything that reads it.
|
||||
for key, value := range filled {
|
||||
text, isText := value.(string)
|
||||
if !isText || !strings.Contains(text, versionRef) {
|
||||
continue
|
||||
}
|
||||
filled[key] = strings.ReplaceAll(text, versionRef, versionOf(artifact.Digest))
|
||||
filled[key] = withVersion(value, versionOf(artifact.Digest))
|
||||
}
|
||||
default:
|
||||
return Manifest{}, fmt.Errorf("%s: %q is a %q, and an artifact is %q, %q, %q or %q",
|
||||
@@ -189,6 +185,31 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// withVersion is a resource's value with `${version}` filled: in a string, and in each string of a map —
|
||||
// a process's env, where the controller is told which build it is (novox/hq issue 352). Anything else is
|
||||
// left as it is.
|
||||
func withVersion(value any, version string) any {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if strings.Contains(v, versionRef) {
|
||||
return strings.ReplaceAll(v, versionRef, version)
|
||||
}
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(v))
|
||||
for k, x := range v {
|
||||
out[k] = withVersion(x, version)
|
||||
}
|
||||
return out
|
||||
case map[string]string:
|
||||
out := make(map[string]string, len(v))
|
||||
for k, x := range v {
|
||||
out[k] = strings.ReplaceAll(x, versionRef, version)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// checkBuild is the manifest's own account of what it builds.
|
||||
func (b *Build) problems(module string) []string {
|
||||
if b == nil {
|
||||
|
||||
@@ -78,6 +78,22 @@ func graphicalSessionSeats() []Seat {
|
||||
"description": "the lines to choose between, in order"},
|
||||
"prompt": map[string]any{"type": "string", "description": "what the menu asks (optional)"},
|
||||
}}},
|
||||
// A value the operator types and nobody sees (novox/hq ADR 0259 §10): a hidden prompt whose answer
|
||||
// is sealed to the asker's key, so it is never plaintext on the bus or in any call's record.
|
||||
// **Optional while its holders catch up** (ADR 0246): rofi serves it once this is live.
|
||||
{Name: "secret", Optional: true, Description: "Ask the operator for a value in a prompt that " +
|
||||
"does not show what is typed, and answer it sealed to the key the asker gives — never in " +
|
||||
"the clear — or cancelled when the prompt was dismissed or not answered in time.",
|
||||
// By name, never by words (the review of 2026-10-09, M4): the holder writes the prompt from the
|
||||
// module, the secret and the machine, and says the controller asks — the bus lets nobody else
|
||||
// ask it (broker.ControllerOnly) — so no caller puts words of its own before the operator.
|
||||
Input: schema(map[string]string{
|
||||
"module": "the module whose own secret is asked for",
|
||||
"secret": "the own secret's name",
|
||||
"node": "the machine the module runs on",
|
||||
"seal_to": "the asker's public sealing key: the answer is sealed to it",
|
||||
"timeout_seconds": "give up after this long (optional)",
|
||||
}, []string{"module", "secret", "node", "seal_to"})},
|
||||
}},
|
||||
{Name: NotifierSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
||||
{Name: "send", Description: "Show the operator a notification.",
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestTheGraphicalSessionsSeatsAreTheMeshsOwnWithTheirVerbs(t *testing.T) {
|
||||
DisplayServerSeat: {"displays", "layout"},
|
||||
DisplaySessionSeat: {"reload", "workspaces", "windows"},
|
||||
TerminalEmulatorSeat: {"open"},
|
||||
LauncherSeat: {"menu"},
|
||||
LauncherSeat: {"menu", "secret"},
|
||||
NotifierSeat: {"send", "history"},
|
||||
LockScreenSeat: {"lock"},
|
||||
ClipboardSeat: {"history", "copy"},
|
||||
|
||||
@@ -230,6 +230,18 @@ var ControllerVerbs = []Verb{
|
||||
"why": "an own secret: why it is rotated — recorded in the hand-act log (optional)",
|
||||
"cause": "with why: the cause in a word, the word a second rotation for the same reason uses (optional)",
|
||||
}, nil)},
|
||||
{Name: "give", Description: "Take a module's own secret from the operator at their desk (novox/hq ADR 0259 " +
|
||||
"§10): a prompt that does not show what is typed opens on the machine named by at, its answer comes " +
|
||||
"back sealed to this call alone, and is sealed to the module's machine as `secret accept` seals it. " +
|
||||
"The value is never an argument and never in the answer: the answer says it was taken, or why not. " +
|
||||
"Recorded in the hand-act log as a value given at the desk. The prompt waits 25 seconds; dismissed " +
|
||||
"or unanswered, nothing changes. Then push the machine.",
|
||||
Input: schema(map[string]string{
|
||||
"node": "the machine the module runs on, which the secret is sealed to",
|
||||
"module": "the module's name",
|
||||
"secret": "the own secret's name in the module's definition",
|
||||
"at": "the machine the operator sits at, where the prompt opens",
|
||||
}, []string{"node", "module", "secret", "at"})},
|
||||
{Name: "issue", Description: "Give a module on a machine its account on the bus: minted, and sealed to the " +
|
||||
"machine as the module's own secret named broker, read at the next push of that machine. For a module " +
|
||||
"whose definition declares that secret; refused with the reason otherwise. Issued again, it replaces the account.",
|
||||
|
||||
@@ -95,3 +95,28 @@ func TestAResourceWithoutAVersionReferenceIsUntouched(t *testing.T) {
|
||||
t.Fatalf("a path naming no version became %q", path)
|
||||
}
|
||||
}
|
||||
|
||||
// A process's env can name the build's own version too (novox/hq issue 352): the controller is told which
|
||||
// build it is, and records what that build reads of the store's schema under it.
|
||||
func TestAProcessEnvCanNameTheBuildsOwnVersion(t *testing.T) {
|
||||
m := Manifest{
|
||||
Module: "mesh-controller",
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "controller", Kind: ArtifactBundle, Language: "go", System: "arch"}}},
|
||||
Resources: []map[string]any{{
|
||||
"id": "controller", "type": "process", "artifact": "controller", "run": []any{"./mesh-controller", "serve"},
|
||||
"env": map[string]any{"MESH_CONTROLLER_VERSION": "${version}", "OTHER": "kept"},
|
||||
}},
|
||||
}
|
||||
got, err := m.Resolve([]Built{{Name: "controller", Kind: ArtifactBundle,
|
||||
Reference: "artifact-store://mesh-controller/controller", Digest: aDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env, _ := got.Resources[0]["env"].(map[string]any)
|
||||
if env["MESH_CONTROLLER_VERSION"] != "ad62528c47c7" || env["OTHER"] != "kept" {
|
||||
t.Fatalf("the env resolved to %v", env)
|
||||
}
|
||||
if run, _ := got.Resources[0]["run"].([]any); len(run) != 2 {
|
||||
t.Fatalf("the run was changed: %v", run)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ import (
|
||||
const (
|
||||
GatePassed = "passed"
|
||||
GateFailed = "failed"
|
||||
// GateSuperseded is a judging ended by a later send to the judged machine that moved the module to
|
||||
// another build (novox/hq issue 352): no verdict on the build, and nothing put back.
|
||||
GateSuperseded = "superseded"
|
||||
|
||||
RollingBack = "rolling-back"
|
||||
RolledBack = "rolled-back"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// `give` takes only a value nobody but a person has (the review of 2026-10-09, M4): never the module's bus
|
||||
// account, which `issue` mints, nor a secret the mesh may make itself.
|
||||
func TestOnlyASecretThePersonHoldsIsGivenAtTheDesk(t *testing.T) {
|
||||
m := catalogue.Manifest{Module: "telegram", OwnSecrets: catalogue.OwnSecrets{
|
||||
"telegram-token": {Path: "/s/telegram-token"},
|
||||
"broker": {Path: "/s/broker"},
|
||||
"session": {Path: "/s/session", Taken: catalogue.TakenAtStart},
|
||||
}}
|
||||
if err := GivableAtDesk(m, "telegram-token"); err != nil {
|
||||
t.Errorf("the bot token was refused: %v", err)
|
||||
}
|
||||
for _, name := range []string{"broker", "session", "chat-id"} {
|
||||
if err := GivableAtDesk(m, name); err == nil {
|
||||
t.Errorf("%s was givable", name)
|
||||
} else if name != "chat-id" && !strings.Contains(err.Error(), "the mesh makes") {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
-- A controller records, when it serves, how far the store's schema reaches in the build it is (novox/hq
|
||||
-- issue 352): the highest migration it carries, by its build's version. A gate that fails the controller's
|
||||
-- build puts the build before it back, and on 2026-10-09 that build was older than the migrations the
|
||||
-- failed one had applied: it started, said it was behind its own row, and judged the next gate half-blind.
|
||||
-- A put-back now reads this and keeps the current build when the one before it reaches less than the store.
|
||||
create table controller_schema (
|
||||
build text primary key,
|
||||
reach integer not null,
|
||||
recorded timestamptz not null default now()
|
||||
);
|
||||
@@ -1054,13 +1054,57 @@ type Reported struct {
|
||||
// acted on the current words, not merely spoken after they were written. False also covers
|
||||
// a machine that has not said which, which is every host from before reports carried it.
|
||||
Current bool
|
||||
// Declared is the digest of the declaration the last report was about, and ReportedSequence that
|
||||
// declaration's sequence as the report claimed it (zero from an engine that claims none): what a
|
||||
// gate holds against the send it made, rather than against the send made last (novox/hq issue 352).
|
||||
Declared string
|
||||
ReportedSequence int64
|
||||
}
|
||||
|
||||
// SentDeclaration is what one send carried to a machine, as a gate keeps it: the declaration's digest and
|
||||
// its sequence (novox/hq issue 352). A report about this declaration, or about one sequenced after it, is a
|
||||
// report on what the gate sent — whatever the machine was sent since.
|
||||
type SentDeclaration struct {
|
||||
Digest string `json:"digest"`
|
||||
Sequence int64 `json:"sequence,omitempty"`
|
||||
}
|
||||
|
||||
// ReportsOn says a report is about this send: the declaration itself; one the same machine was sequenced
|
||||
// after it; or the declaration the machine was sent last (Current), which is this send or a later one —
|
||||
// sends to a machine are made one after another. A send kept without a sequence is matched by its digest
|
||||
// and by the last send alone.
|
||||
func (s SentDeclaration) ReportsOn(r Reported) bool {
|
||||
if r.Current || (s.Digest != "" && r.Declared == s.Digest) {
|
||||
return true
|
||||
}
|
||||
return s.Sequence > 0 && r.ReportedSequence >= s.Sequence
|
||||
}
|
||||
|
||||
// SentTo is the declaration a machine was last sent, by name: its digest and sequence, and false when it
|
||||
// was never sent one.
|
||||
func (i *Inventory) SentTo(ctx context.Context, name string) (SentDeclaration, bool, error) {
|
||||
var digest *string
|
||||
var seq *int64
|
||||
err := i.store.Pool().QueryRow(ctx, `select sent, sequence from node where name = $1`, name).Scan(&digest, &seq)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return SentDeclaration{}, false, fmt.Errorf("%w: %s", ErrNoSuchNode, name)
|
||||
}
|
||||
if err != nil || digest == nil || *digest == "" {
|
||||
return SentDeclaration{}, false, err
|
||||
}
|
||||
s := SentDeclaration{Digest: *digest}
|
||||
if seq != nil {
|
||||
s.Sequence = *seq
|
||||
}
|
||||
return s, true, nil
|
||||
}
|
||||
|
||||
// LastReports is every machine's last report beside when it was last sent a declaration.
|
||||
func (i *Inventory) LastReports(ctx context.Context) ([]Reported, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select n.name, coalesce(r.outcome, ''), r.at, n.sent_at,
|
||||
r.declared is not null and r.declared <> '' and r.declared = n.sent
|
||||
r.declared is not null and r.declared <> '' and r.declared = n.sent,
|
||||
coalesce(r.declared, ''), coalesce(r.reported_sequence, 0)
|
||||
from node n left join node_report r on r.node = n.id
|
||||
order by n.name`)
|
||||
if err != nil {
|
||||
@@ -1070,7 +1114,7 @@ func (i *Inventory) LastReports(ctx context.Context) ([]Reported, error) {
|
||||
var out []Reported
|
||||
for rows.Next() {
|
||||
var r Reported
|
||||
if err := rows.Scan(&r.Node, &r.Outcome, &r.At, &r.Sent, &r.Current); err != nil {
|
||||
if err := rows.Scan(&r.Node, &r.Outcome, &r.At, &r.Sent, &r.Current, &r.Declared, &r.ReportedSequence); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
|
||||
@@ -126,6 +126,10 @@ type PlanGate struct {
|
||||
To string `json:"to,omitempty"`
|
||||
// Since is when the judging began: the first machine reported the new build applied.
|
||||
Since *time.Time `json:"since,omitempty"`
|
||||
// Sent is, per machine, the declaration the gate's send carried there (novox/hq issue 352): what a
|
||||
// machine's report is held against. Absent on a gate kept before it was, which reads the report
|
||||
// against the send made last, as before.
|
||||
Sent map[string]SentDeclaration `json:"sent,omitempty"`
|
||||
// Passes counts the consecutive judgings that found it healthy, LastPass the newest; a judging that
|
||||
// does not resets them.
|
||||
Passes int `json:"passes,omitempty"`
|
||||
|
||||
@@ -115,3 +115,83 @@ func TestTheNewestMergeOfABranchIsTheOneMergedLast(t *testing.T) {
|
||||
t.Fatalf("one merge time, two plans: %s", p.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq issue 352: what a machine was last sent is read back by name with its sequence, a report keeps
|
||||
// the declaration it was about and that declaration's sequence, and a gate's sends are kept with the plan.
|
||||
func TestASendAndAReportAreKnownByTheirDeclaration(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := t.Context()
|
||||
record, err := inv.AddNode(ctx, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found, err := inv.SentTo(ctx, "anchor"); err != nil || found {
|
||||
t.Fatalf("a machine never sent anything: %v %v", found, err)
|
||||
}
|
||||
if _, _, err := inv.SentTo(ctx, "nobody"); err == nil {
|
||||
t.Fatal("a machine that does not exist was answered")
|
||||
}
|
||||
seq, err := inv.NextSequence(ctx, record.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSent(ctx, record.ID, "d-1", map[string]string{"app": "c1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent, found, err := inv.SentTo(ctx, "anchor")
|
||||
if err != nil || !found || sent.Digest != "d-1" || sent.Sequence != seq {
|
||||
t.Fatalf("sent %+v %v %v", sent, found, err)
|
||||
}
|
||||
if _, err := inv.RecordOrderedDoing(ctx, record.ID, Doing{Node: "anchor", Outcome: OutcomeApplied, Declared: "d-1", Applied: 1},
|
||||
ReportOrder{Sequence: seq, ReportSequence: 1}, func(ReportOrder) bool { return false }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reports, err := inv.LastReports(ctx)
|
||||
if err != nil || len(reports) != 1 || reports[0].Declared != "d-1" || reports[0].ReportedSequence != seq || !reports[0].Current {
|
||||
t.Fatalf("reports %+v %v", reports, err)
|
||||
}
|
||||
// Sent again, unreported: the report is no longer on the last send, and is still on the first.
|
||||
if err := inv.RecordSent(ctx, record.ID, "d-2", map[string]string{"app": "c1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reports, _ = inv.LastReports(ctx)
|
||||
if reports[0].Current || !sent.ReportsOn(reports[0]) {
|
||||
t.Fatalf("after a newer send: %+v", reports[0])
|
||||
}
|
||||
at := time.Now().UTC()
|
||||
p := Plan{ID: "plan-352", Repository: "novox/x", Commit: "c", Created: at, State: PlanRolling, Tiers: [][]string{{"app"}},
|
||||
Modules: map[string]*PlanModule{"app": {Gate: &PlanGate{Machines: []string{"anchor"}, Since: &at,
|
||||
Sent: map[string]SentDeclaration{"anchor": sent}}}}}
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kept, err := inv.PlanByID(ctx, "plan-352")
|
||||
if err != nil || kept.Modules["app"].Gate.Sent["anchor"] != sent {
|
||||
t.Fatalf("the gate's send was not kept with the plan: %+v %v", kept.Modules["app"].Gate, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A controller build's reach of the store's schema is kept by its version, and the store's own is read.
|
||||
func TestASchemaReachIsKeptByBuild(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := t.Context()
|
||||
applied, err := inv.SchemaApplied(ctx)
|
||||
if err != nil || applied < 87 {
|
||||
t.Fatalf("applied %d %v", applied, err)
|
||||
}
|
||||
if _, known, err := inv.SchemaReachOf(ctx, "ad62528c47c7"); err != nil || known {
|
||||
t.Fatalf("an unrecorded build: %v %v", known, err)
|
||||
}
|
||||
if err := inv.RecordSchemaReach(ctx, "", 87); err == nil {
|
||||
t.Fatal("a reach without a build was recorded")
|
||||
}
|
||||
if err := inv.RecordSchemaReach(ctx, "ad62528c47c7", 86); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSchemaReach(ctx, "ad62528c47c7", 87); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reach, known, err := inv.SchemaReachOf(ctx, "ad62528c47c7"); err != nil || !known || reach != 87 {
|
||||
t.Fatalf("reach %d %v %v", reach, known, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// What a controller build knows of the store's schema (novox/hq issue 352): the highest migration it
|
||||
// carries, recorded by its version when it serves, and the highest migration the store has applied. A
|
||||
// put-back of the controller to a build that reaches less than the store is refused (gate.go), because
|
||||
// such a controller starts behind its own records and judges with what it can read.
|
||||
|
||||
// RecordSchemaReach keeps the highest migration the build serving now carries.
|
||||
func (i *Inventory) RecordSchemaReach(ctx context.Context, build string, reach int) error {
|
||||
if build == "" {
|
||||
return errors.New("a schema reach is recorded by a build's version, and this controller has none")
|
||||
}
|
||||
_, err := i.store.Pool().Exec(ctx,
|
||||
`insert into controller_schema (build, reach) values ($1, $2)
|
||||
on conflict (build) do update set reach = excluded.reach, recorded = now()`, build, reach)
|
||||
return err
|
||||
}
|
||||
|
||||
// SchemaReachOf is the highest migration a build carries, as it recorded when it served; false for a
|
||||
// build that never did.
|
||||
func (i *Inventory) SchemaReachOf(ctx context.Context, build string) (int, bool, error) {
|
||||
var reach int
|
||||
err := i.store.Pool().QueryRow(ctx, `select reach from controller_schema where build = $1`, build).Scan(&reach)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, false, nil
|
||||
}
|
||||
return reach, err == nil, err
|
||||
}
|
||||
|
||||
// SchemaApplied is the highest migration the store has applied.
|
||||
func (i *Inventory) SchemaApplied(ctx context.Context) (int, error) {
|
||||
var n *int
|
||||
if err := i.store.Pool().QueryRow(ctx, `select max(number) from migration`).Scan(&n); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return *n, nil
|
||||
}
|
||||
@@ -554,6 +554,50 @@ func (i *Inventory) declared(ctx context.Context, module string) (catalogue.Mani
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// DeclaresOwnSecret refuses, in words, a module the mesh does not know or an own secret its definition does
|
||||
// not declare: asked before anybody is asked for a value, so nobody types one the mesh would refuse.
|
||||
func (i *Inventory) DeclaresOwnSecret(ctx context.Context, module, name string) error {
|
||||
m, err := i.declared(ctx, module)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return GivableAtDesk(m, name)
|
||||
}
|
||||
|
||||
// RunsAsItsOwnAccount says a module runs as an account of its own (novox/hq ADR 0259 §8): a trusted party — the
|
||||
// router, a channel that proves its sender or shows a link's code — whose own secret is what the operator's
|
||||
// answers are believed by. Its value is given at the controller's terminal alone.
|
||||
func (i *Inventory) RunsAsItsOwnAccount(ctx context.Context, module string) (bool, error) {
|
||||
m, err := i.declared(ctx, module)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return m.RunsAs != "", nil
|
||||
}
|
||||
|
||||
// BrokerSecret is the own secret that is a module's bus account, which `issue` mints.
|
||||
const BrokerSecret = "broker"
|
||||
|
||||
// GivableAtDesk refuses, in words, an own secret a module does not declare, and one the mesh makes itself
|
||||
// (the review of 2026-10-09, M4): the module's bus account, which `issue` mints, and any the mesh may make
|
||||
// in place of a value given (catalogue.OwnSecret.MeshMayMake). The desk takes only what a person holds and
|
||||
// the mesh cannot make — a bot's token — so nobody is asked to type the mesh's own credential into a prompt.
|
||||
func GivableAtDesk(m catalogue.Manifest, name string) error {
|
||||
own, ok := m.OwnSecrets[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s does not declare %q as an own secret; %s", m.Module, name, declaresOwn(m))
|
||||
}
|
||||
switch {
|
||||
case name == BrokerSecret:
|
||||
return fmt.Errorf("%q is %s's account on the bus, which the mesh makes (`issue`), never a value a person gives",
|
||||
name, m.Module)
|
||||
case own.MeshMayMake():
|
||||
return fmt.Errorf("%q of %s is a secret the mesh makes itself (it may replace a value given at the module's "+
|
||||
"start, ADR 0228); a person gives it only at the controller's terminal, with `secret accept`", name, m.Module)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func declaresOwn(m catalogue.Manifest) string {
|
||||
if len(m.OwnSecrets) == 0 {
|
||||
return "it declares no own secrets"
|
||||
|
||||
@@ -461,7 +461,7 @@ func kept(args json.RawMessage) json.RawMessage {
|
||||
for k, v := range given {
|
||||
s, isString := v.(string)
|
||||
switch {
|
||||
case k == "values" || k == "secret":
|
||||
case k == "values" || k == "secret" || k == "stdin":
|
||||
out[k] = "(given, not kept)"
|
||||
case k == "line":
|
||||
// A mesh-cli line (ADR 0272): its command word, never the rest, which may carry settings.
|
||||
|
||||
@@ -41,6 +41,30 @@ type CLIAsked struct {
|
||||
// (`session-<id>.scope` under the account's own slice), or empty: a service, a user unit. Only a login session is
|
||||
// the terminal (novox/hq ADR 0272).
|
||||
Session string `json:"session,omitempty"`
|
||||
// Stdin is what mesh-cli's standard input held, at most CLIMaxStdin: given to a line that runs as the terminal,
|
||||
// as its standard input, and refused with any other (novox/hq ADR 0259 §10, ADR 0272). It is kept nowhere: not in
|
||||
// the calls record (cliRecorded), not in the journal, not in the answer.
|
||||
Stdin []byte `json:"stdin,omitempty"`
|
||||
}
|
||||
|
||||
// CLIMaxStdin bounds the standard input a mesh-cli line carries (mesh-sdk go/cli MaxStdin).
|
||||
const CLIMaxStdin = 64 << 10
|
||||
|
||||
// cliRecorded is the request as the calls record keeps it: everything but the standard input, which the record
|
||||
// never holds in any form. The line itself is cut to its command word by the record's own rule (kept).
|
||||
func cliRecorded(raw json.RawMessage) json.RawMessage {
|
||||
var m map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
if _, given := m["stdin"]; given {
|
||||
// Said as given, under a key of its own: the record still reads as the request it was (followCLI decodes
|
||||
// it), and holds nothing of the input.
|
||||
delete(m, "stdin")
|
||||
m["stdin-given"] = json.RawMessage(`true`)
|
||||
}
|
||||
body, _ := json.Marshal(m)
|
||||
return body
|
||||
}
|
||||
|
||||
// CLIAnswer is what the controller answers, as the `result` of a call's answer: what the command printed, how it
|
||||
@@ -135,6 +159,10 @@ func (b OverNATS) answerCLI(msg *nats.Msg, calls *CallLog, slots chan struct{},
|
||||
case len(asked.Line) == 0:
|
||||
respond(cliEnvelope(CLIRefusal("the request names no command, so nothing ran")))
|
||||
return
|
||||
case len(asked.Stdin) > CLIMaxStdin:
|
||||
respond(cliEnvelope(CLIRefusal(fmt.Sprintf("standard input of more than %d bytes is not taken, so nothing ran",
|
||||
CLIMaxStdin))))
|
||||
return
|
||||
}
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
@@ -145,7 +173,7 @@ func (b OverNATS) answerCLI(msg *nats.Msg, calls *CallLog, slots chan struct{},
|
||||
return
|
||||
}
|
||||
limit := b.Conn.MaxPayload()
|
||||
calls.serveCallWithin(CLISeat, node, msg.Data, msg.Reply, func(ctx context.Context, _ json.RawMessage) (any, error) {
|
||||
calls.serveCallWithin(CLISeat, node, cliRecorded(msg.Data), msg.Reply, func(ctx context.Context, _ json.RawMessage) (any, error) {
|
||||
return fitCLI(handle(ctx, node, asked), limit-4096), nil
|
||||
}, msg.Respond, limit, logger)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ func TestTheMeshCLIRequestAndAnswerKeepTheirFieldNames(t *testing.T) {
|
||||
if got := keysIn(t, body); got != "cut exit refused stderr stdout terminal why" {
|
||||
t.Fatalf("the answer's fields are %q", got)
|
||||
}
|
||||
body, _ = json.Marshal(CLIAsked{Line: []string{"secret"}, Account: "a", UID: 1, Stdin: []byte("x")})
|
||||
if got := keysIn(t, body); got != "account line stdin uid" {
|
||||
t.Fatalf("a request with standard input has the fields %q", got)
|
||||
}
|
||||
body, _ = json.Marshal(CLIAsked{Follow: "call-1", Account: "a", UID: 1})
|
||||
if got := keysIn(t, body); got != "account follow uid" {
|
||||
t.Fatalf("a follow's fields are %q", got)
|
||||
@@ -267,3 +271,26 @@ func TestTheWithholdingTestsHoldSomething(t *testing.T) {
|
||||
t.Fatalf("a record carrying the answer is not found carrying it: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0259 §10: mesh-cli's standard input — a secret given at the terminal — is never in the calls record,
|
||||
// in any form, whichever way the request reaches it.
|
||||
func TestAMeshCLIRecordNeverKeepsItsStandardInput(t *testing.T) {
|
||||
secret := "123456789:AAEhBOweik6ad9r_QxGivenAtTheTerminal"
|
||||
raw, _ := json.Marshal(CLIAsked{Line: []string{"secret", "accept", "anchor", "telegram", "telegram-token", "--from", "-"},
|
||||
Account: "operator", UID: 1000, Stdin: []byte(secret)})
|
||||
for name, got := range map[string]json.RawMessage{"as answerCLI records it": kept(cliRecorded(raw)),
|
||||
"as the record alone would keep it": kept(raw)} {
|
||||
if carries(got, secret) || strings.Contains(string(got), base64.StdEncoding.EncodeToString([]byte(secret))[:20]) {
|
||||
t.Fatalf("%s, the record keeps %s", name, got)
|
||||
}
|
||||
if !strings.Contains(string(got), "secret") || !strings.Contains(string(got), "given, not kept") &&
|
||||
!strings.Contains(string(got), "stdin-given") {
|
||||
t.Fatalf("%s, the record does not say a line was asked with standard input: %s", name, got)
|
||||
}
|
||||
}
|
||||
// The record still reads as the request, so the asker can follow its call.
|
||||
var asked CLIAsked
|
||||
if err := json.Unmarshal(kept(cliRecorded(raw)), &asked); err != nil || asked.Account != "operator" || len(asked.Stdin) > 0 {
|
||||
t.Fatalf("the recorded request reads as %+v (%v)", asked, err)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -48,6 +48,7 @@
|
||||
"unpin",
|
||||
"push",
|
||||
"rotate",
|
||||
"give",
|
||||
"issue",
|
||||
"token",
|
||||
"settings",
|
||||
@@ -117,7 +118,8 @@
|
||||
"MESH_STORE_LICENCES_PORT": "${seat:mesh-store:5432}",
|
||||
"MESH_BROKER_MANAGEMENT_PORT": "${seat:mesh-broker:15672}",
|
||||
"MESH_BROKER_ADDRESS_PORT": "${seat:mesh-broker:5671}",
|
||||
"MESH_BUS_NATS_FILE": "${dir:mesh-state}/bus"
|
||||
"MESH_BUS_NATS_FILE": "${dir:mesh-state}/bus",
|
||||
"MESH_CONTROLLER_VERSION": "${version}"
|
||||
},
|
||||
"replaces": [
|
||||
"server"
|
||||
|
||||
Vendored
+2
-2
@@ -78,7 +78,7 @@ github.com/nats-io/nkeys
|
||||
# github.com/nats-io/nuid v1.0.1
|
||||
## explicit
|
||||
github.com/nats-io/nuid
|
||||
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d
|
||||
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261009141741-d8ff1540df56
|
||||
## explicit; go 1.26.0
|
||||
github.com/novox/mesh-host/internal/declaration
|
||||
github.com/novox/mesh-host/rootsearch
|
||||
@@ -135,4 +135,4 @@ golang.org/x/text/width
|
||||
# golang.org/x/time v0.15.0
|
||||
## explicit; go 1.25.0
|
||||
golang.org/x/time/rate
|
||||
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009103656-1c61b72f354d
|
||||
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261009141741-d8ff1540df56
|
||||
|
||||
Reference in New Issue
Block a user