Renew the engine placed by hand to the proved build, and take a hand-over from the controller's terminal (hq issue 356)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request

The file on PATH was the engine a person copied at installation, and every delivered engine ran from the versions directory: the operator typed hand-over and a build from before it answered "unexpected argument". A proved engine now copies itself over that file when the bytes differ (never a link). And the hand-over of a directory used as found (issue 339) gets its channel: the controller's terminal asks this node's engine on mesh.node.<node>.ask.hand-over; the engine judges every value — the ask's size and shape, who asked, the path as it states it, a directory and no link, through no link — and records it or refuses and records nothing. Every text the engine addresses to the operator names the nox line (ADR 0272); the engine's own subcommands are install-time and rescue, and its usage says so.
This commit is contained in:
jochen
2026-10-09 18:34:10 +02:00
parent b0d03be7a4
commit e74ee82a76
14 changed files with 776 additions and 64 deletions
+79
View File
@@ -0,0 +1,79 @@
package link
import (
"encoding/json"
"fmt"
"strings"
)
// A hand-over asked over the bus (novox/hq issue 356, issue 339).
//
// A directory used as found is handed to the mesh by its operator, at the controller's terminal:
// `nox node hand-over <node> <path>` on the control-node (novox/hq ADR 0272). The controller asks this
// node's engine on AskHandOverSubject — a request on core NATS, its own machine's subject, which only the
// controller may publish — and the engine answers once, on the reply the request carried. The engine
// judges every value it is sent and records the hand-over, or refuses and records nothing; the next
// apply gives the directory its declared owner and mode. The controller holds the same two shapes in its
// own link code, with a test on each side holding the field names.
// AskHandOverSubject is where the controller asks this node's engine to take a directory used as found as
// the mesh's.
func AskHandOverSubject(node string) string { return "mesh.node." + node + ".ask.hand-over" }
// HandOverAsk is one hand-over asked: 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 — never both, never neither.
type HandOverAnswer struct {
Said string `json:"said,omitempty"`
Refused string `json:"refused,omitempty"`
}
// HandOverFunc judges and records one hand-over: the words to answer with, or the refusal.
type HandOverFunc func(path, by string) (string, error)
// MaxHandOverAsk bounds a request: a path and a name are hundreds of bytes.
const MaxHandOverAsk = 8 << 10
// MaxHandOverBy bounds who asked.
const MaxHandOverBy = 200
// AnswerHandOver is the engine's judgement of one ask's bytes: every value is checked before anything is
// recorded, and a refusal records nothing. The handler is given only what passed.
func AnswerHandOver(handle HandOverFunc, body []byte) HandOverAnswer {
if handle == nil {
return HandOverAnswer{Refused: "this engine takes no hand-over"}
}
if len(body) > MaxHandOverAsk {
return HandOverAnswer{Refused: fmt.Sprintf("the ask is %d bytes, more than a path and a name; nothing was handed over",
len(body))}
}
var ask HandOverAsk
if err := json.Unmarshal(body, &ask); err != nil {
return HandOverAnswer{Refused: "the ask is not a hand-over: " + err.Error() + "; nothing was handed over"}
}
by := strings.TrimSpace(ask.By)
switch {
case by == "":
return HandOverAnswer{Refused: "the ask names nobody as asking; nothing was handed over"}
case len(by) > MaxHandOverBy || strings.ContainsAny(by, "\r\n\x00"):
return HandOverAnswer{Refused: "who asked is not one short line; nothing was handed over"}
case ask.Path == "":
return HandOverAnswer{Refused: "the ask names no directory; nothing was handed over"}
}
said, err := handle(ask.Path, by)
if err != nil {
return HandOverAnswer{Refused: err.Error()}
}
return HandOverAnswer{Said: said}
}
// HandOvers is a link that hears the controller asking this node to hand a directory over. Optional, as
// Asked is.
type HandOvers interface {
HearHandOvers(HandOverFunc)
}
+89
View File
@@ -0,0 +1,89 @@
package link
import (
"encoding/json"
"errors"
"sort"
"strings"
"testing"
)
// The hand-over's ask and answer hold these field names; the controller's side holds the same list
// (mesh-controller internal/link, TestTheHandOverAskAndAnswerKeepTheirFieldNames).
func TestTheHandOverAskAndAnswerKeepTheirFieldNames(t *testing.T) {
body, _ := json.Marshal(HandOverAsk{Path: "/srv/notes", By: "jo through mesh-cli on anchor"})
if got := keysOf(t, body); got != "by path" {
t.Fatalf("the ask's fields are %q", got)
}
body, _ = json.Marshal(HandOverAnswer{Said: "s", Refused: "r"})
if got := keysOf(t, body); got != "refused said" {
t.Fatalf("the answer's fields are %q", got)
}
if AskHandOverSubject("laptop") != "mesh.node.laptop.ask.hand-over" {
t.Fatalf("the subject is %q", AskHandOverSubject("laptop"))
}
}
func keysOf(t *testing.T, body []byte) string {
t.Helper()
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
t.Fatal(err)
}
var keys []string
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return strings.Join(keys, " ")
}
// Every value of an ask is judged before the handler sees it, and a refusal hands nothing to the handler:
// an ask too large, not a hand-over, naming nobody, naming somebody over more than a line, or naming no
// directory. What passes reaches the handler with its path as given and who asked trimmed.
func TestAHandOverAskIsJudgedBeforeAnythingIsRecorded(t *testing.T) {
var given []string
handle := func(path, by string) (string, error) {
given = append(given, path+" by "+by)
if path == "/refused" {
return "", errors.New("not a directory this machine uses as found")
}
return path + " is handed to the mesh", nil
}
refusals := map[string][]byte{
"too large": []byte(`{"path":"/` + strings.Repeat("a", MaxHandOverAsk) + `","by":"jo"}`),
"not a hand-over": []byte(`not json`),
"nobody": []byte(`{"path":"/srv/notes","by":" "}`),
"two lines": []byte(`{"path":"/srv/notes","by":"jo\nroot"}`),
"a name too long": []byte(`{"path":"/srv/notes","by":"` + strings.Repeat("x", MaxHandOverBy+1) + `"}`),
"no directory": []byte(`{"path":"","by":"jo"}`),
"a NUL in the name": []byte(`{"path":"/srv/notes","by":"jo\u0000"}`),
}
for name, body := range refusals {
a := AnswerHandOver(handle, body)
if a.Refused == "" || a.Said != "" {
t.Fatalf("%s was answered %+v; want a refusal", name, a)
}
if !strings.Contains(a.Refused, "nothing was handed over") {
t.Fatalf("%s's refusal does not say nothing was handed over: %q", name, a.Refused)
}
}
if len(given) != 0 {
t.Fatalf("the handler was given %v although every ask was refused", given)
}
if a := AnswerHandOver(nil, []byte(`{"path":"/srv/notes","by":"jo"}`)); a.Refused == "" {
t.Fatal("an engine with no handler took a hand-over")
}
a := AnswerHandOver(handle, []byte(`{"path":"/refused","by":"jo"}`))
if a.Refused != "not a directory this machine uses as found" || a.Said != "" {
t.Fatalf("the handler's refusal was answered as %+v", a)
}
a = AnswerHandOver(handle, []byte(`{"path":"/srv/notes","by":" jo through mesh-cli on anchor "}`))
if a.Said != "/srv/notes is handed to the mesh" || a.Refused != "" {
t.Fatalf("answered %+v", a)
}
if strings.Join(given, "; ") != "/refused by jo; /srv/notes by jo through mesh-cli on anchor" {
t.Fatalf("the handler was given %v", given)
}
}
+30
View File
@@ -2,6 +2,7 @@ package link
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -60,6 +61,7 @@ type natsLink struct {
sub *nats.Subscription
asking *nats.Subscription
asked chan struct{}
handing *nats.Subscription
node string
arrived chan Declaration
lost chan error
@@ -192,6 +194,31 @@ func (l *natsLink) hearAsks() {
}
}
// HearHandOvers listens for the controller asking this node to hand a directory used as found to the mesh
// (novox/hq issue 356): a request on this machine's own subject, answered once on the reply it carried.
// The engine judges the ask (AnswerHandOver) and runs nothing it is sent. A bus whose user list is older
// than the grant refuses the subscription asynchronously, said by the client; the operator's line then
// says that nothing on this node answers.
func (l *natsLink) HearHandOvers(handle HandOverFunc) {
if handle == nil {
return
}
handing, err := l.conn.Subscribe(AskHandOverSubject(l.node), func(msg *nats.Msg) {
if msg.Reply == "" {
// Nobody to answer: nothing is handed over on a word nobody waits for.
return
}
body, err := json.Marshal(AnswerHandOver(handle, msg.Data))
if err != nil {
return
}
_ = msg.Respond(body)
})
if err == nil {
l.handing = handing
}
}
func (l *natsLink) Declarations() <-chan Declaration { return l.arrived }
func (l *natsLink) AskedToReport() <-chan struct{} { return l.asked }
func (l *natsLink) Lost() <-chan error { return l.lost }
@@ -203,6 +230,9 @@ func (l *natsLink) Close() {
if l.asking != nil {
_ = l.asking.Unsubscribe()
}
if l.handing != nil {
_ = l.handing.Unsubscribe()
}
if l.conn != nil {
l.conn.Close()
}
+1 -1
View File
@@ -268,7 +268,7 @@ const KindAccount = "account"
// KindDirectory is a directory a module declares that the engine uses as found (novox/hq issue 339): there before
// the mesh, with another owner or mode than declared, and left so. Said unhealthy on every look, its reason
// starting ReasonUsedAsFound, until a person hands it to the mesh at the machine (`mesh-host hand-over`).
// starting ReasonUsedAsFound, until the operator hands it to the mesh (`nox node hand-over <node> <path>` on the control-node, novox/hq issue 356).
const KindDirectory = "directory"
// ReasonUsedAsFound starts the reason of a directory used as found.
+5 -2
View File
@@ -51,8 +51,11 @@ type Queue struct {
// Numbers keeps the report sequence and the refusals counted, across restarts and self-updates.
// Nil counts in memory, which is only right where nothing outlives the process — a test.
Numbers Numbers
Say Announce
Timeout time.Duration
// HandOver judges and records a directory used as found handed to the mesh by the operator, asked by
// the controller over the link (novox/hq issue 356). Nil answers every such ask with a refusal.
HandOver HandOverFunc
Say Announce
Timeout time.Duration
once sync.Once
wake chan struct{}
+5
View File
@@ -239,6 +239,11 @@ func serve(ctx context.Context, link Link, m Membership, queue *Queue, say Annou
if a, ok := link.(Asked); ok {
asks = a.AskedToReport()
}
// And the controller asking this node to hand a directory used as found to the mesh (novox/hq issue
// 356), answered on the link the question came over.
if h, ok := link.(HandOvers); ok {
h.HearHandOvers(queue.HandOver)
}
for {
// Asked to stop — by standing aside, say — nothing further is taken in.