From 6be65acb576baede817bb0c8dfefcb2382406365 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 06:47:27 +0000 Subject: [PATCH] Compose a node's declaration over an Input and validate it with the node-engine's own validator: Compose and ComposeIn (issue 1298). Ported from mesh-control 6c2a8d62 plan.go declarationWith, carriedOn, wireBuilds, sendable.go Body and adoptionFor, facts.go composedAndValidated, in the Foreseeing mode: it allocates, makes, keeps and writes nothing. Named Compose, not Node, because the Input already has a type Node. A pull request's check refuses what Declared.Refusals() returns beside the validator's problems. --- go/compose/node.go | 330 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 go/compose/node.go diff --git a/go/compose/node.go b/go/compose/node.go new file mode 100644 index 0000000..d9f82f6 --- /dev/null +++ b/go/compose/node.go @@ -0,0 +1,330 @@ +package compose + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/validate" +) + +// Declared is a declaration as a machine is sent it (the controller's sendable, sendable.go): its resources and, for an adopted +// node, its mode and which modules were taken on it (novox/hq ADR 0100), with what the composition says beside it and never puts on +// the wire. +// +// **Body is the only place the envelope is marshalled**, so the digest the mesh compares and the body validated are one. +type Declared struct { + Resources []map[string]any + // Sequence, Epoch and Generation are what the Input gave the node: zero is not sent at all. + Sequence int64 + Epoch uint64 + Generation int64 + // ReadsModules says the machine's node-engine reads a module per resource and a build per module (novox/hq issue 1145): only + // then are they sent. ModuleOf is the composer's owner of each resource id and BuildIDs the build each module carries. + ReadsModules bool + ModuleOf map[string]string + BuildIDs map[string]string + // Adoption is nil for a converged node, and then the body is byte for byte what it was before adoption existed. + Adoption *Adoption + // Filled is the path of every file this declaration hands to the provisioner, and Provisioner the account its client runs as + // (novox/hq ADR 0349): on the wire as `filled_files` and `provisioner_account`, both absent when nothing is handed. + Filled []string + Provisioner string + // LeftOut is every module of the machine's set left out because a stored setting cannot compose with its definition (novox/hq + // ADR 0163, rule 6), sorted; LeftOutWhy is why each was. + LeftOut []string + LeftOutWhy map[string]string + + // Not on the wire: + Received map[string]map[string][]catalogue.Contribution + Mesh []string + BusUsers string + // Modules are the modules this declaration names, sorted. + Modules []string + // LocalNetwork is every port admitted from the local network and what it resolved to (novox/hq ADR 0340). + LocalNetwork []catalogue.LocalNetworkPort + Withheld []catalogue.Overflow + Unbound []catalogue.Unbound + Unplaced []string + HandedOnly []string + // RequiredBy is, for each handed module only pulled in by another, the module that requires it (issue 765). + RequiredBy map[string]string + // Foreseen is every own secret composed with a stand-in, as `module/name`; StoodIn is everything the composition stood in for + // (an own secret, a grant's sealed copy, the certificate, the bus's user list); ForeseenPorts the ports the mesh may assign that + // the Input holds none for. A declaration that has any is never sent: what a send would make, named, never invented. + Foreseen []string + StoodIn []string + ForeseenPorts []string + // CoreBoxesWanted are the core credentials whose box the send must ask the vault for and keep. + CoreBoxesWanted []CoreBoxWanted + // Passes are the seats the mesh would pass on, for the caller to record, and Said the lines found while composing. + Passes Passes + Said []string +} + +// Adoption is what an adopted node is told about its mode. Taken is every module taken on it that it runs; Untaken is, for every +// module it runs that is not taken, the ids of every one of that module's resources: what the host keeps as found until the module +// is taken (ADR 0103). +type Adoption struct { + Taken []string `json:"taken"` + Untaken map[string][]string `json:"untaken,omitempty"` +} + +// Body is the declaration's bytes, as sent and as digested. +func (d Declared) Body() ([]byte, error) { + envelope := map[string]any{"declaration": 1, "resources": d.Resources} + if d.ReadsModules { + // The module of each resource the composer owns, and the build of each module (novox/hq issue 1145): to a machine that + // said it reads them, and no other. + named := make([]map[string]any, len(d.Resources)) + for n, r := range d.Resources { + module, owned := d.ModuleOf[fmt.Sprint(r["id"])] + if !owned { + named[n] = r + continue + } + with := make(map[string]any, len(r)+1) + for k, v := range r { + with[k] = v + } + with["module"] = module + named[n] = with + } + envelope["resources"] = named + if len(d.BuildIDs) > 0 { + envelope["builds"] = d.BuildIDs + } + } + if d.Adoption != nil { + envelope["adoption"] = d.Adoption + } + if d.Sequence > 0 { + envelope["sequence"] = d.Sequence + } + if d.Epoch > 0 { + envelope["epoch"] = d.Epoch + } + if len(d.LeftOut) > 0 { + envelope["left_out"] = d.LeftOut + } + if d.Generation > 0 { + envelope["generation"] = d.Generation + } + if len(d.Filled) > 0 { + envelope["filled_files"] = d.Filled + envelope["provisioner_account"] = d.Provisioner + } + // An empty declaration is deliberate: the node owns nothing the mesh put there (novox/hq issue 127), and the host refuses an + // empty body unless it is told the emptiness is meant. + if len(d.Resources) == 0 { + envelope["owns_nothing"] = true + } + return json.Marshal(envelope) +} + +// Refusals are the findings a check refuses a change for, beside the validator's problems: a consumer left out of the grants +// because its identity overflows the provision's bound, and a credential bound to another consumer (novox/hq ADR 0446 clause 1). +// Each names the consumer and the provision. +func (d Declared) Refusals() []string { + var out []string + for _, o := range d.Withheld { + out = append(out, o.String()) + } + for _, u := range d.Unbound { + out = append(out, u.String()) + } + sort.Strings(out) + return out +} + +// Compose composes one node's declaration (the issue calls it Node; the Input already has a type of that name) over an Input -- as a push would, without the sending (the controller's +// composedAndValidated in the Foreseeing mode) -- and runs the node-engine's own validator over its body. An error is that the +// node did not compose (a set that does not compose satisfies IsUnresolvable); problems are what the validator refuses, in the +// node-engine's words. It allocates, makes, keeps and writes nothing, and names what a send would do as foreseen. +func Compose(in Input, node string) (Declared, []string, error) { + return ComposeIn(in, node, Foreseeing) +} + +// ComposeIn is Compose in a chosen mode. +func ComposeIn(in Input, node string, mode Mode) (Declared, []string, error) { + c := newComposer(in) + d, err := c.declaration(node, mode) + if err != nil { + return Declared{}, nil, err + } + body, err := d.Body() + if err != nil { + return Declared{}, nil, err + } + return d, validate.Declaration(body), nil +} + +// declaration is the controller's declarationWith over the plain Input. +func (c *composer) declaration(nodeName string, mode Mode) (Declared, error) { + resolved, err := c.planFor(nodeName) + if err != nil { + return Declared{}, err + } + gens, err := c.generators() + if err != nil { + return Declared{}, err + } + rendered, err := c.render(nodeName, mode) + if err != nil { + return Declared{}, err + } + with := rendered.Rendering + with.Generators = gens + plan := resolved.Plan + composed, err := plan.Compose(with) + if err != nil { + return Declared{}, err + } + // Which ports the filter admits from the local network, and from which networks (novox/hq ADR 0340 clause 8): the same rules the + // composition rendered, never sent. + rules, err := plan.Rules(with) + if err != nil { + return Declared{}, err + } + node, err := c.in.node(nodeName) + if err != nil { + return Declared{}, err + } + out := Declared{Resources: composed.Resources, Adoption: adoptionFor(plan, node, composed), + Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers, + LeftOut: sortedKeysOf(composed.LeftOut), LeftOutWhy: composed.LeftOut, + Withheld: rendered.Withheld, Unbound: rendered.Unbound, + Foreseen: composed.Foreseen, StoodIn: rendered.Foreseen, ForeseenPorts: rendered.ForeseenPorts, + Unplaced: composed.Unplaced, HandedOnly: composed.HandedOnly, Modules: namedModules(plan, composed.LeftOut), + Filled: composed.Filled, LocalNetwork: catalogue.LocalNetworkPorts(rules, with.LocalNetworks), + CoreBoxesWanted: rendered.CoreBoxesWanted, Said: rendered.Said, + Sequence: node.Sequence, Epoch: node.Epoch, Generation: node.Generation} + if len(composed.Filled) > 0 { + out.Provisioner = with.Provisioner + } + // A handed module only pulled in by another keeps what requires it, for its value of its own in the assignments mirror (issue 765). + for module := range composed.Plans { + if by := plan.RequiredBy(module); by != "" { + if out.RequiredBy == nil { + out.RequiredBy = map[string]string{} + } + out.RequiredBy[module] = by + } + } + // And the module of each resource and the build of each module, to a machine whose node-engine said it reads them (novox/hq issue + // 1145) and to no other. + if node.ReadsModules { + carried := c.carriedOn(node, plan, composed.LeftOut) + out.ReadsModules, out.ModuleOf, out.BuildIDs = true, composed.Owner, c.wireBuilds(carried, node.Runs) + } + _, passes, left, err := c.theRestOfTheMesh("") + if err != nil { + return Declared{}, err + } + out.Passes = passes + for _, l := range left { + out.Said = append(out.Said, l.String()) + } + return out, nil +} + +// carriedOn is the build of each module a declaration for the node carries (plan.go carriedOn, in a question that sends nothing): the +// module's current build for each module in it, and for a module left out of it the build it was last sent, since the machine keeps +// that one -- or nothing, when that is not known. +func (c *composer) carriedOn(node Node, plan catalogue.Resolution, leftOut map[string]string) map[string]string { + before := node.SentBuilds + if !node.SentKnown { + before = nil + } + out := map[string]string{} + for _, m := range plan.Modules { + if _, left := leftOut[m.Module]; left { + if was, kept := before[m.Module]; kept { + out[m.Module] = was + } + continue + } + out[m.Module] = c.in.CurrentBuilds[m.Module] + } + return out +} + +// wireBuilds is the build of each carried module that goes on the wire (novox/hq issue 1145): the build its assignment names when +// that assignment is of the commit carried, else the build made from that commit; a module with neither is not named. +func (c *composer) wireBuilds(carried map[string]string, runs map[string]Run) map[string]string { + out := map[string]string{} + for module, commit := range carried { + if commit == "" { + continue + } + if ref, ok := runs[module]; ok && ref.Commit == commit && ref.Build != "" { + out[module] = ref.Build + continue + } + if build := c.in.BuildIDs[module][commit]; build != "" { + out[module] = build + } + } + return out +} + +// adoptionFor is the envelope computed from what was taken and who owns each resource: nil for a converged node. Every module the +// node runs that is not taken is untaken -- including one pulled in by another rather than assigned. +func adoptionFor(plan catalogue.Resolution, node Node, composed catalogue.Composed) *Adoption { + if !node.Adopted { + return nil + } + isTaken := map[string]bool{} + for _, m := range node.Taken { + isTaken[m] = true + } + out := &Adoption{Taken: []string{}} + runs := map[string]bool{} + for _, m := range plan.Modules { + runs[m.Module] = true + if isTaken[m.Module] { + out.Taken = append(out.Taken, m.Module) + } + } + sort.Strings(out.Taken) + for _, r := range composed.Resources { + id, _ := r["id"].(string) + module, owned := composed.Owner[id] + // Every kind, not only files and containers (novox/hq ADR 0103). What the mesh declares of its own is never held. + if !owned || !runs[module] || isTaken[module] || strings.HasPrefix(id, catalogue.AdoptionPrefix) { + continue + } + if out.Untaken == nil { + out.Untaken = map[string][]string{} + } + out.Untaken[module] = append(out.Untaken[module], id) + } + return out +} + +// sortedKeysOf is a map's keys, sorted: so what a declaration says it left out does not move for a reordering nobody made. +func sortedKeysOf(m map[string]string) []string { + if len(m) == 0 { + return nil + } + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// namedModules are the modules a declaration names: its machine's set, less what was left out of it. +func namedModules(plan catalogue.Resolution, leftOut map[string]string) []string { + out := make([]string, 0, len(plan.Modules)) + for _, m := range plan.Modules { + if _, left := leftOut[m.Module]; !left { + out = append(out, m.Module) + } + } + sort.Strings(out) + return out +}