node hand-over: the terminal hands a directory used as found to the mesh, asked of the node's engine (hq issue 356)
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request

A module's condition told the operator to run the node-engine's hand-over at the machine, as root (issue 339), and the mesh had no channel for it. Now node hand-over <node> <path> is the controller's terminal's — a node subcommand that is not a read, so every verb and mesh-cli outside the terminal refuse it — and asks that node's engine on mesh.node.<node>.ask.hand-over, a request only the controller may publish and only that node's engine may hear and answer (its grant gains the subject and the right to answer what it was asked). The line's node and path are judged before anything is asked; the engine's answer is printed, a refusal as a refusal. Every text addressed to the operator names the nox line (ADR 0272); the condition's words stay plain.
This commit is contained in:
jochen
2026-10-09 18:38:09 +02:00
parent 1c7c385839
commit 9006c82393
12 changed files with 397 additions and 21 deletions
+13 -4
View File
@@ -289,6 +289,11 @@ func (p Principal) Username() string {
// (novox/hq to-be 45 §6): its answer is an ordinary report, on its own report subject.
func AskReportSubject(node string) string { return "mesh.node." + node + ".ask.report" }
// AskHandOverSubject is where the controller's terminal asks one machine's node-engine to hand a directory it
// uses as found to the mesh (novox/hq issue 356, issue 339): a request on core NATS, answered once on the reply
// it carries. Only the controller publishes under `mesh.node.`, so the engine hears nobody else.
func AskHandOverSubject(node string) string { return "mesh.node." + node + ".ask.hand-over" }
// inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25
// §4): with one account, inbox privacy is the permission list or it is nothing, so each user's
// inbox is derived from its own identity and its permissions name that prefix and no other.
@@ -562,7 +567,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
// And the mesh asking it to say again what it last applied (novox/hq to-be 45 §6, the
// `report` verb healer H1 asks): its own machine's, on core NATS and off any stream. It
// answers through its report, the one thing it already says — no reply to anybody's inbox.
AskReportSubject(p.Node)}
AskReportSubject(p.Node),
// And the controller's terminal asking it to hand a directory used as found to the mesh (novox/hq
// issue 356), which it answers on the request's reply: the one request a node is asked.
AskHandOverSubject(p.Node)}
// The node-engine witnesses the core builds it places (novox/hq to-be 45 §8, ADR 0236; the
// contract is lease/witness.go): it asks its own machine's node tools PING, and, where the
// machine runs the controller, reads the lease's one key — read, never written.
@@ -863,9 +871,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
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.
AllowResponses: p.Kind == KindModule || p.Kind == KindController || p.Kind == KindNodeTools,
// authority is bounded by having been asked — and so does the controller. A node is asked one
// thing, a hand-over on its own subject (novox/hq issue 356), and answers that. A person is never
// asked anything, and is granted nothing here.
AllowResponses: p.Kind == KindModule || p.Kind == KindController || p.Kind == KindNodeTools || p.Kind == KindNode,
}, nil
}
+8 -3
View File
@@ -103,7 +103,8 @@ func TestAnInboxIsScopedToItsOwner(t *testing.T) {
// A responder answers on the caller's inbox, which it has no permission for. allow_responses is
// what makes a scoped inbox workable at all — the authority is bounded by having been asked. A
// module is asked on its own namespace and may answer; a node and a person are never asked.
// module is asked on its own namespace and may answer; a node is asked one thing, a hand-over on its own
// subject (novox/hq issue 356), and may answer that; a person is never asked.
func TestOnlyWhatCanBeAskedMayAnswer(t *testing.T) {
module, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "audit",
Consumes: []string{"shop.order.placed"}, PasswordHash: "x"})
@@ -111,8 +112,12 @@ func TestOnlyWhatCanBeAskedMayAnswer(t *testing.T) {
t.Fatal("a module cannot answer a tool call on its own namespace")
}
node, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", PasswordHash: "x"})
if node.AllowResponses {
t.Fatal("a node was granted the right to answer, and nothing asks a node anything")
if !node.AllowResponses || !slices.Contains(node.Subscribe, AskHandOverSubject("one")) {
t.Fatalf("a node cannot answer the hand-over it is asked: %v %v", node.AllowResponses, node.Subscribe)
}
person, _ := PermissionsFor(Principal{Kind: KindPerson, Node: "one", Module: "jo", PasswordHash: "x"})
if person.AllowResponses {
t.Fatal("a person was granted the right to answer, and nothing asks a person anything")
}
}
+2 -1
View File
@@ -34,7 +34,8 @@ accounts {
} }
{ user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: {
publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.INFO.NODES.one", "$SRV.PING.node-tools.one", "mesh.control.one.>"] }
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.ask.report", "mesh.node.one.declare"] }
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.report", "mesh.node.one.declare"] }
allow_responses: { max: 1, ttl: "1m" }
} }
{ user: "one.nats", password: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb", permissions: {
publish: { allow: ["$JS.API.STREAM.INFO.*", "$JS.API.STREAM.NAMES", "$JS.API.STREAM.SNAPSHOT.*", "$JS.SNAPSHOT.ACK.>"] }
+92
View File
@@ -0,0 +1,92 @@
package link
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/broker"
)
// A hand-over asked of a machine's node-engine (novox/hq issue 356, issue 339).
//
// The operator hands a directory the node-engine uses as found to the mesh at the controller's terminal:
// `nox node hand-over <node> <path>` on the control-node (ADR 0272). The controller asks that machine's
// engine on its own subject, a request on core NATS the engine answers once; the engine judges every
// value and records the hand-over, or refuses and records nothing. The engine holds the same two shapes
// in its own link code (mesh-host internal/link HandOverAsk, HandOverAnswer); a test on each side holds
// the field names.
// HandOverAsk is what the controller asks: the directory's absolute path as the engine states it, and who
// asked, in the controller's words.
type HandOverAsk struct {
Path string `json:"path"`
By string `json:"by"`
}
// HandOverAnswer is the engine's answer: what it recorded, or why it refused.
type HandOverAnswer struct {
Said string `json:"said,omitempty"`
Refused string `json:"refused,omitempty"`
}
// HandOverWithin is how long the controller waits for the engine's answer: a file write, on a machine that is
// up; a machine that is down is said as not answering.
const HandOverWithin = 30 * time.Second
// AskHandOver asks one machine's node-engine to hand a directory used as found to the mesh, and reads its
// answer. An error is the ask not reaching an engine, or an answer that is not one; a refusal is the engine's
// and comes back in the answer.
func AskHandOver(ctx context.Context, conn *nats.Conn, node, path, by string, timeout time.Duration) (HandOverAnswer, error) {
if conn == nil {
return HandOverAnswer{}, errors.New("this controller is not on the bus")
}
body, err := json.Marshal(HandOverAsk{Path: path, By: by})
if err != nil {
return HandOverAnswer{}, err
}
asking, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
subject := broker.AskHandOverSubject(node)
refused, stop := refusalsOf(conn, subject)
defer stop()
type replied struct {
msg *nats.Msg
err error
}
done := make(chan replied, 1)
go func() {
msg, err := conn.RequestWithContext(asking, subject, body)
done <- replied{msg, err}
}()
var reply *nats.Msg
select {
case r := <-done:
reply, err = r.msg, r.err
case why := <-refused:
cancel()
return HandOverAnswer{}, fmt.Errorf("the bus refused the controller asking %s for a hand-over: %v", node, why)
}
switch {
case errors.Is(err, nats.ErrNoResponders):
return HandOverAnswer{}, fmt.Errorf("nothing on %s answers a hand-over: its node-engine is not running, is not "+
"on the bus, or is older than this ask (novox/hq issue 356); nothing was handed over", node)
case errors.Is(err, context.DeadlineExceeded), errors.Is(err, nats.ErrTimeout):
return HandOverAnswer{}, fmt.Errorf("%s did not answer the hand-over within %s; whether it was recorded is not "+
"known — the module's condition says whether the directory is still used as found", node, timeout)
case err != nil:
return HandOverAnswer{}, err
}
var answer HandOverAnswer
if err := json.Unmarshal(reply.Data, &answer); err != nil {
return HandOverAnswer{}, fmt.Errorf("%s answered the hand-over with something unreadable: %w", node, err)
}
if answer.Said == "" && answer.Refused == "" {
return HandOverAnswer{}, fmt.Errorf("%s answered the hand-over with neither a record nor a refusal", node)
}
return answer, nil
}
+103
View File
@@ -0,0 +1,103 @@
package link
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/testbus"
)
// The hand-over's ask and answer hold these field names; the engine's side holds the same list (mesh-host
// internal/link, TestTheHandOverAskAndAnswerKeepTheirFieldNames).
func TestTheHandOverAskAndAnswerKeepTheirFieldNames(t *testing.T) {
body, _ := json.Marshal(HandOverAsk{Path: "/srv/notes", By: "jo through mesh-cli on anchor"})
if got := keysIn(t, body); got != "by path" {
t.Fatalf("the ask's fields are %q", got)
}
body, _ = json.Marshal(HandOverAnswer{Said: "s", Refused: "r"})
if got := keysIn(t, body); got != "refused said" {
t.Fatalf("the answer's fields are %q", got)
}
if broker.AskHandOverSubject("laptop") != "mesh.node.laptop.ask.hand-over" {
t.Fatalf("the subject is %q", broker.AskHandOverSubject("laptop"))
}
}
// The ask reaches the machine's engine on its own subject and its answer comes back whole: what it recorded, or
// its refusal as the engine worded it. A machine with no engine listening is said as not answering, and an
// answer that is neither is refused rather than read as a record.
func TestAHandOverIsAskedOfTheMachineAndItsAnswerComesBack(t *testing.T) {
url := testbus.URL(t)
conn, err := nats.Connect(url)
if err != nil {
t.Fatal(err)
}
t.Cleanup(conn.Close)
var heard HandOverAsk
engine, err := conn.Subscribe(broker.AskHandOverSubject("laptop"), func(msg *nats.Msg) {
_ = json.Unmarshal(msg.Data, &heard)
switch heard.Path {
case "/srv/notes":
body, _ := json.Marshal(HandOverAnswer{Said: "/srv/notes (notes.data) is handed to the mesh by " + heard.By})
_ = msg.Respond(body)
case "/srv/empty":
_ = msg.Respond([]byte(`{}`))
default:
body, _ := json.Marshal(HandOverAnswer{Refused: heard.Path + " is not a directory this machine uses as found; nothing was handed over"})
_ = msg.Respond(body)
}
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = engine.Unsubscribe() })
ctx := context.Background()
a, err := AskHandOver(ctx, conn, "laptop", "/srv/notes", "jo through mesh-cli on anchor", 5*time.Second)
if err != nil || a.Refused != "" || !strings.Contains(a.Said, "handed to the mesh by jo through mesh-cli on anchor") {
t.Fatalf("answered %+v, %v", a, err)
}
if heard.Path != "/srv/notes" || heard.By != "jo through mesh-cli on anchor" {
t.Fatalf("the engine heard %+v", heard)
}
a, err = AskHandOver(ctx, conn, "laptop", "/srv/other", "jo", 5*time.Second)
if err != nil || a.Said != "" || !strings.Contains(a.Refused, "nothing was handed over") {
t.Fatalf("a refusal came back as %+v, %v", a, err)
}
if _, err := AskHandOver(ctx, conn, "laptop", "/srv/empty", "jo", 5*time.Second); err == nil ||
!strings.Contains(err.Error(), "neither") {
t.Fatalf("an empty answer was taken: %v", err)
}
if _, err := AskHandOver(ctx, conn, "anchor", "/srv/notes", "jo", 5*time.Second); err == nil ||
!strings.Contains(err.Error(), "node-engine") {
t.Fatalf("a machine with no engine listening: %v", err)
}
if _, err := AskHandOver(ctx, nil, "anchor", "/srv/notes", "jo", time.Second); err == nil {
t.Fatal("asked with no bus")
}
}
// A machine's grant hears its own hand-over ask and nobody else's, and may answer it: the one request a node is
// asked (novox/hq issue 356).
func TestAMachineHearsItsOwnHandOverAskAndMayAnswerIt(t *testing.T) {
perms, err := broker.PermissionsFor(broker.Principal{Kind: broker.KindNode, Node: "laptop", PasswordHash: "x"})
if err != nil {
t.Fatal(err)
}
var hears, other bool
for _, s := range perms.Subscribe {
hears = hears || s == "mesh.node.laptop.ask.hand-over"
other = other || s == "mesh.node.anchor.ask.hand-over"
}
if !hears || other || !perms.AllowResponses {
t.Fatalf("a machine's grant: hears its own %v, another's %v, answers %v (%v)", hears, other, perms.AllowResponses,
perms.Subscribe)
}
}
+3 -2
View File
@@ -471,8 +471,9 @@ const KindUnit = "unit"
const KindAccount = "account"
// KindDirectory is a directory of a module that the node-engine uses as found (novox/hq issue 339): there before
// the mesh, with another owner or mode than declared, and left so until a person hands it over at the machine
// (`mesh-host hand-over`). Stated unhealthy with a reason that starts ReasonUsedAsFound.
// the mesh, with another owner or mode than declared, and left so until the operator hands it over at the
// controller's terminal (`nox node hand-over <node> <path>`, issue 356). Stated unhealthy with a reason that starts
// ReasonUsedAsFound.
const KindDirectory = "directory"
// ReasonUsedAsFound starts the reason of a directory used as found.