diff --git a/go/compose/frombus/frombus.go b/go/compose/frombus/frombus.go index 4601762..2deff24 100644 --- a/go/compose/frombus/frombus.go +++ b/go/compose/frombus/frombus.go @@ -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} diff --git a/go/compose/frombus/frombus_test.go b/go/compose/frombus/frombus_test.go index 1d7bbcc..05cb693 100644 --- a/go/compose/frombus/frombus_test.go +++ b/go/compose/frombus/frombus_test.go @@ -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 { diff --git a/go/composer/composer.go b/go/composer/composer.go index a456a1a..216c8fd 100644 --- a/go/composer/composer.go +++ b/go/composer/composer.go @@ -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) diff --git a/go/composer/composer_test.go b/go/composer/composer_test.go index 5e26900..6968c2c 100644 --- a/go/composer/composer_test.go +++ b/go/composer/composer_test.go @@ -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) diff --git a/go/delivery/composed_compare.go b/go/delivery/composed_compare.go new file mode 100644 index 0000000..f442d60 --- /dev/null +++ b/go/delivery/composed_compare.go @@ -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 `.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 +} diff --git a/go/delivery/placement_test.go b/go/delivery/placement_test.go index f11371d..77d453a 100644 --- a/go/delivery/placement_test.go +++ b/go/delivery/placement_test.go @@ -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},