What a person decides of a node lives in its placement file and what the machine observes in its record; two nodes on one address or two hubs are refused (issue 1126, K2)

This commit is contained in:
2026-10-26 18:17:59 +01:00
parent 5db53e738f
commit a431746c0e
6 changed files with 84 additions and 58 deletions
+15 -11
View File
@@ -9,8 +9,10 @@
// **What it reads, and from whom.**
// - which modules run on each node, with their pins, holdings, host ports, bindings and settings placement: the
// indexer's state `placement`, one key per node (ADR 0549 §2);
// - what each node knows of itself (its place on the private network, capabilities, accounts, keys, networks,
// tunnel, and on the hub the adopted tunnel and its carried peers): the node-engine's state `records`;
// - 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, networks, the tunnel it found, and on the
// hub the adopted tunnel and its carried peers): the node-engine's state `records`;
// - every module's manifest: the indexer's `modules.<module>` and its manifests by digest, or the digest the
// composing node's want names where it runs another build (Scope.Kept);
// - each module's config values on a node: mesh-config's resolved state, the composing node's slice alone unless the
@@ -170,12 +172,12 @@ func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, er
if in.Tunnels == nil {
in.Tunnels = map[string]compose.FoundTunnel{}
}
in.Tunnels[node] = compose.FoundTunnel{Tunnel: tunnelOf(*rec.Tunnel), NodeAdopted: rec.Adopted}
if rec.Place.Hub {
in.Tunnels[node] = compose.FoundTunnel{Tunnel: tunnelOf(*rec.Tunnel), NodeAdopted: p.Decided.Adopted}
if p.Decided.Hub {
in.Adopted = &compose.AdoptedTunnel{Tunnel: tunnelOf(*rec.Tunnel), Hub: node, Adopted: rec.HubAdopted}
}
}
if rec.Place.Hub {
if p.Decided.Hub {
for _, c := range rec.Carried {
in.CarriedPeers = append(in.CarriedPeers, compose.CarriedPeer{PublicKey: c.PublicKey, Address: c.Address,
EnrolledAs: c.EnrolledAs, Named: c.Named})
@@ -204,15 +206,17 @@ func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]s
return compose.Compositions(in, s.Node, fps, compose.Reading)
}
// nodeOf is the composition's node from its record and its placement.
// nodeOf is the composition's node from its record (what the machine observes) and its placement (what a person
// decided of it, and where its modules run).
func nodeOf(rec delivery.NodeRecord, p delivery.Placement) compose.Node {
d := p.Decided
n := compose.Node{
Name: rec.Node,
Place: compose.Place{Key: rec.Place.Key, Endpoint: rec.Place.Endpoint, Site: rec.Place.Site, Hub: rec.Place.Hub, Address: rec.Place.Address},
Assigned: p.Assigned(), PublicDomain: rec.PublicDomain,
Account: rec.Account, AccountHome: rec.AccountHome, AgentAccount: rec.AgentAccount, AgentAccountHome: rec.AgentHome,
Adopted: rec.Adopted, Pins: p.ChosenPins(), Bound: p.Bound(), Ports: p.HostPorts(), SealingKey: rec.SealingKey,
Taken: rec.Taken, OutwardLinks: rec.OutwardLinks, Networks: rec.Networks, NetworksSaid: rec.NetworksSaid,
Place: compose.Place{Key: rec.TunnelKey, Endpoint: d.Endpoint, Site: d.Site, Hub: d.Hub, Address: d.Address},
Assigned: p.Assigned(), PublicDomain: d.PublicDomain,
Account: d.Account, AccountHome: d.AccountHome, AgentAccount: d.AgentAccount, AgentAccountHome: d.AgentHome,
Adopted: d.Adopted, Taken: d.Taken, Pins: p.ChosenPins(), Bound: p.Bound(), Ports: p.HostPorts(), SealingKey: rec.SealingKey,
OutwardLinks: rec.OutwardLinks, Networks: rec.Networks, NetworksSaid: rec.NetworksSaid,
ServingKey: rec.ServingKey, Certificate: rec.Certificate, Contract: rec.Contract, Stated: rec.Contract > 0,
}
if len(rec.Capabilities) > 0 {
+8 -8
View File
@@ -71,17 +71,17 @@ func TestInput(t *testing.T) {
mk := func() *bus {
return &bus{
placements: map[string]delivery.Placement{
"anchor": {Node: "anchor", Modules: []delivery.PlacedModule{{Module: overlay.Name}},
Holds: []delivery.Holding{{Claim: "mesh-terminal", Scope: "mesh", Module: "xterm"}}},
"laptop": {Node: "laptop", Modules: []delivery.PlacedModule{{Module: overlay.Name},
{Module: "xterm", HasLayer: true, Ports: map[string]int{"80": 8080}}}},
"anchor": {Node: "anchor", Decided: delivery.NodeDecided{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1"},
Modules: []delivery.PlacedModule{{Module: overlay.Name}},
Holds: []delivery.Holding{{Claim: "mesh-terminal", Scope: "mesh", Module: "xterm"}}},
"laptop": {Node: "laptop", Decided: delivery.NodeDecided{Endpoint: "laptop.example:51820", Site: "here", Address: "10.77.0.2"},
Modules: []delivery.PlacedModule{{Module: overlay.Name},
{Module: "xterm", HasLayer: true, Ports: map[string]int{"80": 8080}}}},
},
records: map[string]delivery.NodeRecord{
"anchor": {Node: "anchor", Place: delivery.NodePlace{Key: keys["anchor"], Endpoint: "anchor.example:51820",
Site: "here", Hub: true, Address: "10.77.0.1"}, Capabilities: caps, Tunnel: tunnel, Contract: 2,
"anchor": {Node: "anchor", TunnelKey: keys["anchor"], Capabilities: caps, Tunnel: tunnel, Contract: 2,
Carried: []delivery.RecordCarriedPeer{{PublicKey: "peer", Address: "10.77.0.9", EnrolledAs: "phone"}}},
"laptop": {Node: "laptop", Place: delivery.NodePlace{Key: keys["laptop"], Endpoint: "laptop.example:51820",
Site: "here", Address: "10.77.0.2"}, Capabilities: caps},
"laptop": {Node: "laptop", TunnelKey: keys["laptop"], Capabilities: caps},
},
modules: map[string]delivery.Module{"xterm": {Manifest: digest}, "unread": {}},
manifests: map[string][]byte{digest: xterm},
+10 -26
View File
@@ -45,16 +45,6 @@ func ParseNodeRecordKey(key string) (node string, err error) {
return node, nil
}
// NodePlace is where the node is on the private network: its tunnel key (public), endpoint, site, whether it is the
// hub, and its address (compose.Place).
type NodePlace struct {
Key string `json:"key,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Site string `json:"site,omitempty"`
Hub bool `json:"hub,omitempty"`
Address string `json:"address,omitempty"`
}
// RecordTunnel is a tunnel found on the node, as compose.Tunnel holds it: public keys and addresses only.
type RecordTunnel struct {
Interface string `json:"interface"`
@@ -82,21 +72,17 @@ type RecordCarriedPeer struct {
Named string `json:"named,omitempty"`
}
// NodeRecord is one node's record. Tunnel is the tunnel found on it whose key is its private-network key; on the hub
// alone, Adopted says whether the hub took its found tunnel over and Carried is every peer of it the hub carries, so
// the tunnels and the carried peers are each one node's own and need no other home.
// NodeRecord is one node's record: what the machine observes of itself (issue 1126's K2: what a person decides lives
// in the catalogue's placement file, Placement.Decided; what the machine observes lives here). TunnelKey is the public
// key of its private-network tunnel; Tunnel the tunnel found on it whose key that is; on the hub alone, HubAdopted says
// whether the hub took its found tunnel over and Carried is every peer of it the hub carries, so the tunnels and the
// carried peers are each one node's own and need no other home. Certificate is the one the mesh issued for its
// internal name, public, as the node holds it.
type NodeRecord struct {
V int `json:"v"`
Node string `json:"node"`
Place NodePlace `json:"place"`
TunnelKey string `json:"tunnel-key,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
PublicDomain string `json:"public-domain,omitempty"`
Account string `json:"account,omitempty"`
AccountHome string `json:"account-home,omitempty"`
AgentAccount string `json:"agent-account,omitempty"`
AgentHome string `json:"agent-account-home,omitempty"`
Adopted bool `json:"adopted,omitempty"`
Taken []string `json:"taken,omitempty"`
SealingKey string `json:"sealing-key,omitempty"`
ServingKey string `json:"serving-key,omitempty"`
Certificate string `json:"certificate,omitempty"`
@@ -113,9 +99,6 @@ func (r NodeRecord) check() error {
if err := checkName("node", r.Node); err != nil {
return err
}
if !r.Place.Hub && (r.HubAdopted || len(r.Carried) > 0) {
return fmt.Errorf("delivery: the record of %s carries peers or an adopted tunnel, which only the hub's does", r.Node)
}
if r.HubAdopted && r.Tunnel == nil {
return fmt.Errorf("delivery: the record of %s says the hub adopted its tunnel, and names none", r.Node)
}
@@ -136,8 +119,9 @@ func EncodeNodeRecord(r NodeRecord) ([]byte, error) {
return json.Marshal(r)
}
// DecodeNodeRecord reads a node record; it refuses an unknown version (ErrUnknownVersion), a node that is not a name,
// carried peers or an adopted tunnel on a node that is not the hub, and an empty capability.
// DecodeNodeRecord reads a node record; it refuses an unknown version (ErrUnknownVersion), a node that is not a name, an
// adopted tunnel with no tunnel, and an empty capability. Whether the node is the hub is the catalogue's (Placement.Decided):
// a reader takes carried peers and an adopted tunnel only from the hub's record.
func DecodeNodeRecord(data []byte) (NodeRecord, error) {
var r NodeRecord
if err := decode(data, &r); err != nil {
+8 -10
View File
@@ -13,8 +13,8 @@ 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 (L1 as narrowed by ADR 0549): a node's record keys, reads back, refuses what only the hub may carry, and
// names no field like a secret.
// Issue 1126 (K2): a node's record keys, reads back, refuses an adopted tunnel it does not name, 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" {
@@ -29,11 +29,9 @@ func TestNodeRecord(t *testing.T) {
}
}
hub := NodeRecord{Node: "novox", Place: NodePlace{Key: "k", Endpoint: "192.0.2.1:51820", Hub: true, Address: "10.0.0.1"},
Capabilities: []string{"docker"}, PublicDomain: "example.org", Account: "op", AccountHome: "/home/op",
AgentAccount: "agent", AgentHome: "/var/lib/agent", SealingKey: "age1x", ServingKey: "pub", Certificate: "cert",
OutwardLinks: []string{"eth0"}, Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}},
NetworksSaid: true, Contract: 3, Taken: []string{"x"}, Adopted: true,
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,
Tunnel: &RecordTunnel{Interface: "wg0", PublicKey: "k", Peers: []RecordTunnelPeer{{PublicKey: "p"}}},
HubAdopted: true, Carried: []RecordCarriedPeer{{PublicKey: "p", EnrolledAs: "phone"}}}
raw, err := EncodeNodeRecord(hub)
@@ -46,9 +44,7 @@ func TestNodeRecord(t *testing.T) {
}
for name, r := range map[string]NodeRecord{
"carried off the hub": {Node: "ace", Carried: []RecordCarriedPeer{{PublicKey: "p"}}},
"adopted off the hub": {Node: "ace", HubAdopted: true},
"adopted with no tunnel": {Node: "novox", Place: NodePlace{Hub: true}, HubAdopted: true},
"adopted with no tunnel": {Node: "novox", HubAdopted: true},
"empty capability": {Node: "ace", Capabilities: []string{""}},
"node that is not a name": {Node: "a.b"},
} {
@@ -76,4 +72,6 @@ func TestNodeRecord(t *testing.T) {
}
}
walk(reflect.TypeOf(NodeRecord{}), "NodeRecord")
// And the placement file, which holds what a person decides of a node (K2): accounts by name, never a credential.
walk(reflect.TypeOf(Placement{}), "Placement")
}
+38 -1
View File
@@ -109,11 +109,33 @@ type Placement struct {
V int `json:"v"`
Node string `json:"node"`
Revision string `json:"revision,omitempty"`
Decided NodeDecided `json:"decided"`
Modules []PlacedModule `json:"modules"`
Pins map[string]Provider `json:"pins,omitempty"`
Holds []Holding `json:"holds,omitempty"`
}
// NodeDecided is what a person decides of the node, kept in its placement file (issue 1126's K2: what a person decides
// lives in the catalogue, what the machine observes lives in its record, NodeRecord): where it is dialled (endpoint) and
// its site, whether it is the private network's hub, its address on the private network, the domain its routed names
// are composed under, its operator and agent logins by name and their homes, whether it is adopted rather than
// converged, and the modules taken on an adopted node. The address is a decision written once, at enrolment, by a
// catalogue pull request: nothing allocates it at run time, and CheckPlacements refuses two nodes with one address.
// No secret and no key material: accounts are names, never credentials.
type NodeDecided struct {
Endpoint string `json:"endpoint,omitempty"`
Site string `json:"site,omitempty"`
Hub bool `json:"hub,omitempty"`
Address string `json:"address,omitempty"`
PublicDomain string `json:"public-domain,omitempty"`
Account string `json:"account,omitempty"`
AccountHome string `json:"account-home,omitempty"`
AgentAccount string `json:"agent-account,omitempty"`
AgentHome string `json:"agent-account-home,omitempty"`
Adopted bool `json:"adopted,omitempty"`
Taken []string `json:"taken,omitempty"`
}
func (p Placement) check() error {
if err := checkName("node", p.Node); err != nil {
return err
@@ -326,7 +348,8 @@ func (p Placement) Held() []catalogue.Held {
// CheckPlacements is the catalogue's own check of every placement file (ADR 0549 §1), over the placements by node, the
// modules the catalogue holds and the nodes enrolled. It returns every refusal, in node order, never only the first: a
// placement naming a node not enrolled or a module the catalogue does not hold; two modules of one node on the same host
// port; a pin, binding or holding naming a provider that is not placed where it says; a seat of scope mesh held twice.
// port; a pin, binding or holding naming a provider that is not placed where it says; a seat of scope mesh held twice;
// two nodes with one address on the private network; more than one hub.
// A host port a manifest declares without a number and a requirement left unbound are the composition's to find (its
// ForeseenPorts and Unbound), which the catalogue's composition check reads.
func CheckPlacements(placements map[string]Placement, held func(module string) bool, enrolled func(node string) bool) []string {
@@ -349,11 +372,22 @@ func CheckPlacements(placements map[string]Placement, held func(module string) b
return false
}
meshSeats := map[string]string{}
addresses := map[string]string{}
var hubs []string
for _, node := range nodes {
p := placements[node]
if p.Node != node {
refusals = append(refusals, fmt.Sprintf("the placement file of %s names the node %q", node, p.Node))
}
if a := strings.TrimSpace(p.Decided.Address); a != "" {
if other, twice := addresses[a]; twice {
refusals = append(refusals, fmt.Sprintf("%s and %s both have the address %s on the private network", other, node, a))
}
addresses[a] = node
}
if p.Decided.Hub {
hubs = append(hubs, node)
}
if !enrolled(node) {
refusals = append(refusals, fmt.Sprintf("%s is not an enrolled node", node))
}
@@ -391,6 +425,9 @@ func CheckPlacements(placements map[string]Placement, held func(module string) b
meshSeats[seat] = node
}
}
if len(hubs) > 1 {
refusals = append(refusals, fmt.Sprintf("the private network has one hub, and %s are each placed as it", strings.Join(hubs, ", ")))
}
return refusals
}
+5 -2
View File
@@ -40,6 +40,7 @@ func TestPlacement(t *testing.T) {
})
g14 := Placement{Node: "g14", Revision: "abc",
Decided: NodeDecided{Endpoint: "g14.example:51820", Site: "home", Address: "10.77.0.2", Account: "op", Taken: []string{"zsh"}},
Modules: []PlacedModule{
{Module: "zsh"},
{Module: "app", Ports: map[string]int{"8080": 18080}, Bindings: map[string]Provider{"postgres": {Node: "novox", Module: "postgres"}}},
@@ -47,7 +48,7 @@ func TestPlacement(t *testing.T) {
Pins: map[string]Provider{"shell": {Node: "g14", Module: "zsh"}},
Holds: []Holding{{Claim: "node-shell", Scope: "node", Module: "zsh"}},
}
novox := Placement{Node: "novox", Modules: []PlacedModule{{Module: "postgres", Ports: map[string]int{"5432": 5432}}},
novox := Placement{Node: "novox", Decided: NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []PlacedModule{{Module: "postgres", Ports: map[string]int{"5432": 5432}}},
Holds: []Holding{{Claim: "mesh-store", Scope: "mesh", Module: "postgres"}}}
t.Run("value", func(t *testing.T) {
@@ -153,10 +154,12 @@ func TestPlacement(t *testing.T) {
clash.Pins = map[string]Provider{"shell": {Node: "g14", Module: "bash"}}
twice := novox
twice.Node = "stranger"
twice.Decided.Address = "10.77.0.2"
r := CheckPlacements(map[string]Placement{"g14": clash, "novox": novox, "stranger": twice}, held, enrolled)
for _, want := range []string{"holds no module ghost", "ghost and app are both published on host port 18080",
"bound to ace/postgres", "pin of shell names g14/bash", "stranger is not an enrolled node",
"mesh-store, held once for the mesh, is held on novox and stranger"} {
"mesh-store, held once for the mesh, is held on novox and stranger",
"g14 and stranger both have the address 10.77.0.2", "novox, stranger are each placed as it"} {
found := false
for _, line := range r {
found = found || strings.Contains(line, want)