Let frombus and the composer compose with a given placement and compare a node's compositions with what it runs, so a placement candidate can be answered and checked (issue 1126)

This commit is contained in:
2026-10-27 02:12:11 +01:00
parent 7e99c354ac
commit 82f10c3bc9
6 changed files with 353 additions and 13 deletions
+103 -9
View File
@@ -80,10 +80,15 @@ type Mesh struct {
// Scope says who composes. Node is the node composing; only its config values are read unless Every is set, as the
// composition check sets it to compose every node. Kept is, by module, the manifest digest the composing node's wants
// name where it differs from the indexer's trunk: that node composes the module as it runs it.
//
// Placements, when set, stands in for the indexer's placement of the nodes it names: a node's candidate placement
// (delivery.PlacementCandidate), so a node composes in shadow with a catalogue pull request's placement and compares
// the result with what it runs (issue 1126); a nil value removes the node's placement. The rest is read as always.
type Scope struct {
Node string
Every bool
Kept map[string]string
Node string
Every bool
Kept map[string]string
Placements map[string]*delivery.Placement
}
// Input is the composition input for s, read through r.
@@ -92,6 +97,23 @@ func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, er
if err != nil {
return compose.Input{}, fmt.Errorf("frombus: placement: %w", err)
}
if len(s.Placements) > 0 {
given := make(map[string]delivery.Placement, len(placements)+len(s.Placements))
for node, p := range placements {
given[node] = p
}
for node, p := range s.Placements {
if p == nil {
delete(given, node)
continue
}
if p.Node != node {
return compose.Input{}, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node)
}
given[node] = *p
}
placements = given
}
records, err := r.Records(ctx)
if err != nil {
return compose.Input{}, fmt.Errorf("frombus: node records: %w", err)
@@ -283,16 +305,14 @@ func declaresOwnSecrets(in compose.Input, node string) bool {
// 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.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,
Name: rec.Node,
Place: compose.Place{Key: rec.TunnelKey},
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,
}
placeNode(&n, p)
if len(rec.Capabilities) > 0 {
n.Capabilities = make(map[string]bool, len(rec.Capabilities))
for _, c := range rec.Capabilities {
@@ -302,6 +322,80 @@ func nodeOf(rec delivery.NodeRecord, p delivery.Placement) compose.Node {
return n
}
// placeNode sets what a person decided of n and where its modules run, from its placement; what the machine observes
// (its keys, links, networks, certificate) and its settings layers are left as they are.
func placeNode(n *compose.Node, p delivery.Placement) {
d := p.Decided
n.Place = compose.Place{Key: n.Place.Key, Endpoint: d.Endpoint, Site: d.Site, Hub: d.Hub, Address: d.Address}
n.Assigned, n.PublicDomain = p.Assigned(), d.PublicDomain
n.Account, n.AccountHome, n.AgentAccount, n.AgentAccountHome = d.Account, d.AccountHome, d.AgentAccount, d.AgentHome
n.Adopted, n.Taken = d.Adopted, d.Taken
n.Pins, n.Bound, n.Ports = p.ChosenPins(), p.Bound(), p.HostPorts()
}
// Placed is in with the placements given standing in for those of the nodes they name (issue 1126): each such node's
// decided parts, modules, pins, bindings, host ports, settings placement and holdings are the given placement's, its
// config values those in already carries for it (the layer of the node's own name), and what the machine observes is
// kept. A nil placement leaves the node in the mesh with nothing placed on it. A node in does not hold is refused: it has
// no record to compose it from. The found tunnel and the hub's peers are read from in as they are, not from the given
// placement. It is how a check composes a change's own placement over an input already filled (composer.Case's
// Placements).
func Placed(in compose.Input, placements map[string]*delivery.Placement) (compose.Input, error) {
if len(placements) == 0 {
return in, nil
}
out := in
out.Nodes = append([]compose.Node(nil), in.Nodes...)
index := map[string]int{}
for i, n := range out.Nodes {
index[n.Name] = i
}
for _, node := range sortedKeys(placements) {
p := placements[node]
i, ok := index[node]
if !ok {
return compose.Input{}, fmt.Errorf("frombus: a placement is given for %s, which the input holds no node for", node)
}
if p == nil {
p = &delivery.Placement{Node: node}
}
if p.Node != node {
return compose.Input{}, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node)
}
n := out.Nodes[i]
placeNode(&n, *p)
settings := map[string][]catalogue.Layer{}
for _, m := range p.Modules {
var values map[string]any
for _, l := range n.Settings[m.Module] {
if l.From == node {
values = l.Values
}
}
if layers := m.Layers(node, values); layers != nil {
settings[m.Module] = layers
}
}
n.Settings = nil
if len(settings) > 0 {
n.Settings = settings
}
out.Nodes[i] = n
}
out.Holdings = nil
for _, h := range in.Holdings {
if _, given := placements[h.Node]; !given {
out.Holdings = append(out.Holdings, h)
}
}
for _, node := range sortedKeys(placements) {
if p := placements[node]; p != nil {
out.Holdings = append(out.Holdings, p.Held()...)
}
}
return out, nil
}
func tunnelOf(t delivery.RecordTunnel) compose.Tunnel {
out := compose.Tunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU,
Address: t.Address, Range: t.Range, PublicKey: t.PublicKey}
+40
View File
@@ -179,6 +179,46 @@ func TestInput(t *testing.T) {
t.Fatalf("compositions differ or are empty: %d from the bus, %d from the store", len(fromBus), len(fromStore))
}
// Issue 1126: a candidate placement equal to the node's composes what it runs, and CompareComposed says so; one
// thing changed in the group's candidate of another node (the hub's endpoint) names exactly the module it moves
// here. Placed puts the same placement over the store's input unchanged.
running := make([]delivery.Composed, 0, len(fromBus))
for _, c := range fromBus {
running = append(running, c)
}
laptop := mk().placements["laptop"]
asIs, _, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop}}, fps)
if err != nil {
t.Fatal(err)
}
if same, differs := delivery.CompareComposed(asIs, running); len(differs) != 0 || len(same) != len(running) {
t.Errorf("the candidate equal to the placement: same %v, differs %+v", same, differs)
}
moved := mk().placements["anchor"]
moved.Decided.Endpoint = "anchor.example:51821"
changed, _, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop, "anchor": &moved}}, fps)
if err != nil {
t.Fatal(err)
}
if _, differs := delivery.CompareComposed(changed, running); len(differs) != 1 || differs[0].Module != overlay.Name || differs[0].Resource != overlay.Name+".overlay-config" {
t.Errorf("the hub's endpoint changed: differs %+v, want %s alone, naming its config", differs, overlay.Name)
}
if _, differs := delivery.CompareComposed(changed, running[:0]); len(differs) != len(changed) {
t.Errorf("nothing runs: differs %+v", differs)
}
if got, err := Placed(want, map[string]*delivery.Placement{"laptop": &laptop}); err != nil || !reflect.DeepEqual(got, want) {
t.Errorf("Placed with the node's own placement changes the input: %v", err)
}
if got, err := Placed(want, map[string]*delivery.Placement{"laptop": nil}); err != nil || len(got.Nodes[1].Assigned) != 0 || got.Nodes[1].Place.Key != keys["laptop"] {
t.Errorf("Placed with no placement: %+v, %v", got.Nodes[1], err)
}
if _, err := Placed(want, map[string]*delivery.Placement{"stranger": {Node: "stranger"}}); err == nil {
t.Error("Placed a node the input holds no node for")
}
if _, err := Input(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": {Node: "anchor"}}}); err == nil {
t.Error("a given placement naming another node was taken")
}
// ADR 0555 §6: Export is Input's inverse for what a person decides: the store's input exported, node by node, is the
// placement the bus holds, and read back it is the store's input again.
for node, placed := range mk().placements {
+14 -4
View File
@@ -17,6 +17,8 @@ import (
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/compose"
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/sdkversion"
)
@@ -64,10 +66,15 @@ type Request struct {
}
// Case is one catalogue composed: every module's manifest by name, and per node the manifests kept at the build it runs.
//
// Placements are, by node, the placement the case composes that node with in place of what Input says of it: a change's
// own placement files, so a check composes a catalogue pull request's placement (issue 1126, frombus.Placed); a null
// placement leaves nothing placed on the node. A case without them composes Input's.
type Case struct {
Name string `json:"name"`
Manifests map[string]Manifest `json:"manifests"`
Kept map[string]map[string]Manifest `json:"kept,omitempty"`
Name string `json:"name"`
Manifests map[string]Manifest `json:"manifests"`
Kept map[string]map[string]Manifest `json:"kept,omitempty"`
Placements map[string]*delivery.Placement `json:"placements,omitempty"`
}
// Manifest is a module's manifest as its repository holds it (Raw, the bytes), or the digest the delivery indexer keeps
@@ -251,7 +258,10 @@ func runCase(r Request, c Case, depth string) (CaseVerdict, error) {
if err != nil {
return CaseVerdict{}, err
}
in := r.Input
in, err := frombus.Placed(r.Input, c.Placements)
if err != nil {
return CaseVerdict{}, fmt.Errorf("composer: the case %q: %w", c.Name, err)
}
in.Catalogue = shelf
in.Nodes = slices.Clone(in.Nodes)
in.Holdings = slices.Clone(in.Holdings)
+14
View File
@@ -10,6 +10,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/compose"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
const demo = `{"module": "demo", "version": "1", "claims": [{"name": "node-files"}]}`
@@ -100,6 +101,19 @@ func TestARequestItCannotRunIsRefused(t *testing.T) {
if _, err := Run(Request{V: Version, Depth: "deep"}); err == nil {
t.Error("an unknown depth runs")
}
// A case's own placement (issue 1126) composes over a node the input holds, and is refused for one it does not.
placed := Request{V: Version, Input: compose.Input{Nodes: []compose.Node{{Name: "a"}}}, Machines: []string{"a"}, Depth: DepthResolve,
Cases: []Case{{Name: "trunk", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}},
{Name: "change", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}},
Placements: map[string]*delivery.Placement{"a": {Node: "a", Modules: []delivery.PlacedModule{{Module: "ghost"}}}}}}}
if v, err := Run(placed); err != nil || !v.Cases[0].Machines["a"].Composes || v.Cases[1].Machines["a"].Composes ||
!strings.Contains(v.Cases[1].Machines["a"].Reason, "ghost") {
t.Errorf("a case composes its own placement: %+v, %v", v, err)
}
placed.Cases[1].Placements = map[string]*delivery.Placement{"z": {Node: "z"}}
if _, err := Run(placed); err == nil || !strings.Contains(err.Error(), "holds no node for") {
t.Errorf("a case placing a node the input lacks: %v", err)
}
v, err := Run(Request{V: Version, Cases: []Case{{Name: "only-read", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}}})
if err != nil || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted || v.Cases[0].Machines != nil || v.SDK == "" {
t.Fatalf("a request with no machines: %+v, %v", v, err)
+152
View File
@@ -0,0 +1,152 @@
package delivery
import (
"bytes"
"encoding/json"
"strconv"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// CompareComposed compares the compositions a node composed (by key, as compose.Compositions answers them) with the ones
// it runs, as its agent would (issue 1126: a node's answer to a placement candidate). A module is the same when its
// composition's digest is the one it runs (ComposedDigestOf, the signature left out), so the agent would apply nothing
// of it; it differs when the digests differ, when it is composed and not run, or run and not composed. The node's own
// composition is named HaveNodeWord. The bus's user list (catalogue.BusUsersID) is left out on both sides: the bus node
// follows it alone and never applies it from a composition it made (ADR 0547 §2, §8). A difference names the first
// resource, by its id, whose content differs or that one side alone holds; when every resource is equal it says what
// else differs (their order, the files filled, the data, the node's own parts, a file carried by digest).
func CompareComposed(composed map[string]Composed, running []Composed) (same []string, differs []ModuleDiffers) {
name := func(c Composed) string {
if c.Module == "" {
return HaveNodeWord
}
return c.Module
}
ours := map[string]Composed{}
for _, c := range composed {
ours[name(c)] = c
}
theirs := map[string]Composed{}
for _, c := range running {
theirs[name(c)] = c
}
modules := map[string]bool{}
for m := range ours {
modules[m] = true
}
for m := range theirs {
modules[m] = true
}
for _, m := range sortedKeys(modules) {
a, composedHere := ours[m]
b, runs := theirs[m]
switch {
case !runs:
differs = append(differs, ModuleDiffers{Module: m, Why: "composed, and the node does not run it"})
case !composedHere:
differs = append(differs, ModuleDiffers{Module: m, Why: "the node runs it, and it is not composed"})
default:
if d, ok := compareOne(m, a, b); ok {
same = append(same, m)
} else {
differs = append(differs, d)
}
}
}
return same, differs
}
// compareOne is one module's comparison: true when the digests agree, else what differs first.
func compareOne(module string, composed, running Composed) (ModuleDiffers, bool) {
composed, running = withoutBusUsers(composed), withoutBusUsers(running)
dc, errC := ComposedDigestOf(composed)
if errC != nil {
return ModuleDiffers{Module: module, Why: "the composition has no digest: " + errC.Error()}, false
}
dr, errR := ComposedDigestOf(running)
if errR == nil && dc == dr {
return ModuleDiffers{}, true
}
// Words for the difference: resource by resource, each side with its large files by digest, so a file carried whole
// and the same file carried by digest read as one.
a, _, errA := composed.ByDigest()
b, _, errB := running.ByDigest()
if errA != nil || errB != nil {
return ModuleDiffers{Module: module, Why: "the compositions differ and cannot be read resource by resource"}, false
}
ra, rb := resourcesByID(a.Resources), resourcesByID(b.Resources)
ids := map[string]bool{}
for id := range ra {
ids[id] = true
}
for id := range rb {
ids[id] = true
}
for _, id := range sortedKeys(ids) {
x, inA := ra[id]
y, inB := rb[id]
switch {
case !inB:
return ModuleDiffers{Module: module, Resource: id, Why: "composed, and not in what runs"}, false
case !inA:
return ModuleDiffers{Module: module, Resource: id, Why: "in what runs, and not composed"}, false
case !bytes.Equal(x, y):
return ModuleDiffers{Module: module, Resource: id, Why: "the resource differs"}, false
}
}
switch {
case !jsonEqual(a.Filled, b.Filled):
return ModuleDiffers{Module: module, Why: "the files the provisioner fills differ"}, false
case a.Data != b.Data:
return ModuleDiffers{Module: module, Why: "whether it declares data differs"}, false
case !jsonEqual(a.Own, b.Own):
return ModuleDiffers{Module: module, Why: "the node's own parts differ"}, false
case a.FP != b.FP:
return ModuleDiffers{Module: module, Why: "the fingerprint differs"}, false
}
if dA, err := ComposedDigestOf(a); err == nil {
if dB, err := ComposedDigestOf(b); err == nil && dA == dB {
return ModuleDiffers{Module: module, Why: "the same content, a large file carried whole on one side and by digest on the other"}, false
}
}
return ModuleDiffers{Module: module, Why: "the same resources in another order"}, false
}
// withoutBusUsers is c without the bus's user list resource (its id `bus-users`, or `<module>.bus-users`).
func withoutBusUsers(c Composed) Composed {
out := c
out.Resources = nil
for _, raw := range c.Resources {
var head struct {
ID string `json:"id"`
}
if json.Unmarshal(raw, &head) == nil && (head.ID == catalogue.BusUsersID() || strings.HasSuffix(head.ID, "."+catalogue.BusUsersID())) {
continue
}
out.Resources = append(out.Resources, raw)
}
return out
}
// resourcesByID is each resource canonical, by its id (a resource without one by its place).
func resourcesByID(resources []json.RawMessage) map[string][]byte {
out := make(map[string][]byte, len(resources))
for i, raw := range resources {
var head struct {
ID string `json:"id"`
}
_ = json.Unmarshal(raw, &head)
id := head.ID
if id == "" {
id = "#" + strconv.Itoa(i)
}
canon, err := canonicalJSON(raw)
if err != nil {
canon = raw
}
out[id] = canon
}
return out
}
+30
View File
@@ -1,6 +1,7 @@
package delivery
import (
"encoding/json"
"errors"
"reflect"
"strings"
@@ -239,6 +240,35 @@ func TestPlacement(t *testing.T) {
if SameHaveNode(back, HaveNode{V: Version, Composed: n.Composed, Sealed: n.Sealed}) {
t.Error("a node's have without its answers counts as the same")
}
// CompareComposed: the bus's user list is left out on both sides; anything else that differs is named.
file := func(id, content string) json.RawMessage {
return json.RawMessage(`{"id":"` + id + `","type":"file","module":"nats","path":"/etc/` + id + `","mode":"0600","content":"` + content + `"}`)
}
fp := "src2:" + strings.Repeat("a", 64)
comp := func(rs ...json.RawMessage) Composed {
return Composed{Node: "novox", Module: "nats", FP: fp, Resources: rs}
}
for name, tc := range map[string]struct {
composed, running Composed
resource, why string
}{
"user list alone": {comp(file("conf", "a"), file("nats.bus-users", "stand-in")), comp(file("conf", "a"), file("nats.bus-users", "real")), "", ""},
"a resource": {comp(file("conf", "a")), comp(file("conf", "b")), "conf", "the resource differs"},
"one side": {comp(file("conf", "a"), file("more", "x")), comp(file("conf", "a")), "more", "composed, and not in what runs"},
"order": {comp(file("conf", "a"), file("more", "x")), comp(file("more", "x"), file("conf", "a")), "", "the same resources in another order"},
} {
same, differs := CompareComposed(map[string]Composed{"novox.nats." + fp: tc.composed}, []Composed{tc.running})
if tc.why == "" {
if len(differs) != 0 || len(same) != 1 {
t.Errorf("%s: same %v, differs %+v", name, same, differs)
}
} else if len(differs) != 1 || differs[0].Resource != tc.resource || differs[0].Why != tc.why {
t.Errorf("%s: differs %+v, want %s: %s", name, differs, tc.resource, tc.why)
}
}
if _, differs := CompareComposed(nil, []Composed{{Node: "novox", Resources: []json.RawMessage{}}}); len(differs) != 1 || differs[0].Module != "node" {
t.Errorf("the node's own composition run and not composed: %+v", differs)
}
for name, a := range map[string]CandidateAnswer{
"no head": {Verdict: CandidateSame},
"same with a diff": {Head: "h", Verdict: CandidateSame, Differs: answers["differs"].Differs},