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.

This commit is contained in:
2026-10-20 06:47:27 +00:00
parent c1ba7bc1ae
commit 6be65acb57
+330
View File
@@ -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
}