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 {