From 2b0689f6546fca798593bbe6bd7479c05693758e Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 18:29:45 +0100 Subject: [PATCH] =?UTF-8?q?A=20node's=20record=20rides=20in=20its=20own=20?= =?UTF-8?q?have=20key,=20.node:=20no=20bucket,=20writer=20or=20grant?= =?UTF-8?q?=20of=20its=20own,=20and=20its=20key's=20change=20is=20what=20r?= =?UTF-8?q?eaders=20hear=20(issue=201126,=20ADR=200555=20=C2=A73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/compose/frombus/frombus.go | 5 ++-- go/delivery/delivery.go | 11 +++++++-- go/delivery/noderecord.go | 43 +++++----------------------------- go/delivery/noderecord_test.go | 27 ++++++++++----------- 4 files changed, 31 insertions(+), 55 deletions(-) diff --git a/go/compose/frombus/frombus.go b/go/compose/frombus/frombus.go index 94aa253..033bbcb 100644 --- a/go/compose/frombus/frombus.go +++ b/go/compose/frombus/frombus.go @@ -12,7 +12,7 @@ // - what a person decided of each node (its endpoint, site, hub, address on the private network, public domain and // accounts): the same placement key, Placement.Decided; // - what each node observes of itself (its tunnel key, capabilities, keys, certificate, links and networks): the -// node-engine's state `records`; the tunnel found at a node's adoption and the names of the hub's peers are decided, +// node's own have key, `.node` (HaveNode.Record); the tunnel found at a node's adoption and the names of the hub's peers are decided, // in its placement, and whether the hub took the tunnel over and who enrolled under a peer's key are read from both; // - every module's manifest: the indexer's `modules.` and its manifests by digest, or the digest the // composing node's want names where it runs another build (Scope.Kept); @@ -44,7 +44,8 @@ import ( type Reader interface { // Placements is every node's placement, by node (delivery.PlacementBucket). Placements(ctx context.Context) (map[string]delivery.Placement, error) - // Records is every node's record, by node (delivery.NodeRecordsBucket). + // Records is every node's record, by node: HaveNode.Record of each node's own have key, `.node` of + // delivery.HaveBucket (ADR 0555 §3). Records(ctx context.Context) (map[string]delivery.NodeRecord, error) // Modules is every module the indexer holds, by module (delivery.ModulesBucket). Modules(ctx context.Context) (map[string]delivery.Module, error) diff --git a/go/delivery/delivery.go b/go/delivery/delivery.go index 5eecc31..1c7834b 100644 --- a/go/delivery/delivery.go +++ b/go/delivery/delivery.go @@ -213,12 +213,15 @@ func parseModuleKeyPart(part string) (string, error) { return module, nil } +// HaveNodeWord is the last token of a node's own facts' key in HaveBucket. +const HaveNodeWord = nodeWord + // HaveNodeKey is the key, in HaveBucket, of a node's own facts: `.node`. func HaveNodeKey(node string) (string, error) { if err := checkName("node", node); err != nil { return "", err } - return node + ".node", nil + return node + "." + HaveNodeWord, nil } // ParseHaveKey is the inverse of HaveKey and HaveNodeKey: isNode is true for `.node`, with module empty. @@ -381,6 +384,9 @@ type HaveNode struct { Strays json.RawMessage `json:"strays,omitempty"` Filters json.RawMessage `json:"filters,omitempty"` Profile json.RawMessage `json:"profile,omitempty"` + // Record is what the machine observes of itself, typed, for every node and composing part to read (issue 1126, + // ADR 0555 §3): the node's record lives in its own have key, so it needs no bucket, writer or grant of its own. + Record *NodeRecord `json:"record,omitempty"` } // Presence is whether a node or a part's instance is online; the entry's own time says since when. The watchman @@ -524,7 +530,8 @@ func SameHave(a, b Have) bool { // SameHaveNode says whether two node facts are the same value, comparing each carried JSON without its spacing. func SameHaveNode(a, b HaveNode) bool { return a.V == b.V && a.Composed == b.Composed && sameJSON(a.Windows, b.Windows) && sameJSON(a.Busy, b.Busy) && - sameJSON(a.Strays, b.Strays) && sameJSON(a.Filters, b.Filters) && sameJSON(a.Profile, b.Profile) + sameJSON(a.Strays, b.Strays) && sameJSON(a.Filters, b.Filters) && sameJSON(a.Profile, b.Profile) && + (a.Record == nil) == (b.Record == nil) && (a.Record == nil || SameNodeRecord(*a.Record, *b.Record)) } // SamePresence says whether two presences are the same value. diff --git a/go/delivery/noderecord.go b/go/delivery/noderecord.go index 3b662b0..d7fef79 100644 --- a/go/delivery/noderecord.go +++ b/go/delivery/noderecord.go @@ -3,47 +3,16 @@ package delivery import ( "encoding/json" "fmt" - "strings" "git.novox.be/novox/mesh-sdk/go/catalogue" ) -// A node's record is what the node knows of itself, written by its own agent into the node-engine's state `records`, -// one key `record.` per node, and read by every node and by the parts that compose (novox/hq ADR 0547 §2: "of -// other nodes it reads only what is public: their records"; issue 1126's prepare, L1 as narrowed by ADR 0549). Where a -// module runs, its host ports, pins, bindings and seat holdings are not in it: they are the catalogue's placement -// (placement.go). Nothing in it is secret: its keys are public halves, its accounts are login names. -// -// The agent writes it at start and whenever a fact it holds changes, only when the value differs, and says -// `node-record-changed`. Seeded from what the node runs and reports, never from the controller's store. -const ( - // NodeRecordsState is the node-engine's state of node records, and NodeRecordsBucket its bucket. - NodeRecordsState = "records" - NodeRecordsBucket = "node-engine_" + NodeRecordsState - // NodeRecordsHistory is the revisions the records bucket keeps per key. - NodeRecordsHistory = 5 - // NodeRecordChangedEvent is what a node's agent says when it wrote its record. - NodeRecordChangedEvent = "node-record-changed" - - recordWord = "record" -) - -// NodeRecordKey is the key, in NodeRecordsBucket, of one node's record: `record.`. -func NodeRecordKey(node string) (string, error) { - if err := checkName("node", node); err != nil { - return "", err - } - return recordWord + "." + node, nil -} - -// ParseNodeRecordKey is the inverse of NodeRecordKey; it refuses any other key, naming it. -func ParseNodeRecordKey(key string) (node string, err error) { - node, ok := strings.CutPrefix(key, recordWord+".") - if !ok || checkName("node", node) != nil { - return "", fmt.Errorf("delivery: %q is not a node record key, record.", key) - } - return node, nil -} +// A node's record is what the machine observes of itself (issue 1126, ADR 0555 §3), written by its own agent in its +// own have key, `.node` of the node-engine's have bucket (HaveNode.Record), whenever an observed fact changes and +// only when the value differs. The key's change is the event its readers hear: every node and the parts that compose +// watch it. Where a module runs, and every fact a person decides of the node (its endpoint, site, hub, address on the +// private network, public domain, accounts, the tunnel found at adoption), are the catalogue's placement +// (placement.go). Nothing in it is secret: its keys are public halves. // RecordTunnel is a tunnel found on a node when it was adopted, as compose.Tunnel holds it: public keys and addresses // only. After the node took it over the tunnel is no longer there to observe, so it is kept as what was decided at diff --git a/go/delivery/noderecord_test.go b/go/delivery/noderecord_test.go index e69e24f..e49b327 100644 --- a/go/delivery/noderecord_test.go +++ b/go/delivery/noderecord_test.go @@ -13,22 +13,9 @@ import ( // check (ADR 0201 §13) refuses such a field in a state every node reads. var secretNamed = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|private[-_]?key|credential|bearer|cookie|salt|signing)`) -// Issue 1126 (ADR 0555): a node's record keys, reads back, refuses a node that is not a name and an empty capability, and +// Issue 1126 (ADR 0555): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and // neither it nor the placement file names a field like a secret. func TestNodeRecord(t *testing.T) { - key, err := NodeRecordKey("ace") - if err != nil || key != "record.ace" || NodeRecordsBucket != "node-engine_records" { - t.Fatalf("NodeRecordKey = %q, %v; bucket %q", key, err, NodeRecordsBucket) - } - if n, err := ParseNodeRecordKey(key); err != nil || n != "ace" { - t.Fatalf("ParseNodeRecordKey = %q, %v", n, err) - } - for _, bad := range []string{"placement.ace", "record.", "record.a.b"} { - if _, err := ParseNodeRecordKey(bad); err == nil { - t.Errorf("ParseNodeRecordKey(%q) took it", bad) - } - } - hub := NodeRecord{Node: "novox", TunnelKey: "k", Capabilities: []string{"docker"}, SealingKey: "age1x", ServingKey: "pub", Certificate: "cert", OutwardLinks: []string{"eth0"}, Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}}, NetworksSaid: true, Contract: 3} @@ -40,6 +27,18 @@ func TestNodeRecord(t *testing.T) { if err != nil || !SameNodeRecord(back, hub) || back.V != Version { t.Fatalf("round trip: %+v, %v", back, err) } + // It rides in the node's own have key: a have that differs only in its record is another value. + with, without := HaveNode{V: Version, Record: &hub}, HaveNode{V: Version} + if raw, err := EncodeHaveNode(with); err != nil { + t.Fatal(err) + } else if got, err := DecodeHaveNode(raw); err != nil || got.Record == nil || !SameHaveNode(got, with) || SameHaveNode(got, without) { + t.Fatalf("the have's record: %+v, %v", got, err) + } + changed := hub + changed.Contract = 4 + if SameHaveNode(with, HaveNode{V: Version, Record: &changed}) { + t.Error("a changed record reads as the same have") + } for name, r := range map[string]NodeRecord{ "empty capability": {Node: "ace", Capabilities: []string{""}},