Files
mesh-host/internal/composer/composer.go
T
jschoubben df13a83a91
mesh/delivery merged: source.main moved to novox/mesh-host@798d73eb11e9
mesh/check checking df13a83a9117 on 798d73eb11e9
The sandbox composes a node from the input its engine took in, through one shape
The second sandbox fault of the same kind on mesh-host #200 (postgres's secret
reader handed, the module not): the engine stripped the whole catalogue from
the input it answers, so the sandbox composed the node's own modules from the
indexer's source manifests while the bus reader composes them taken in from
their builds (artifacts, templates archive texts, what is handed). Both sides
now go through composer.SentInput: the node's own taken-in manifests and kept
builds stay in the input, and the sandbox composes them over the cases'. An
input past what one bus answer carries is refused in words. A conformance row
composes one node through the bus reader and through the sandbox and requires
the same declaration.

Issue 1126.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-03 01:55:08 +01:00

290 lines
10 KiB
Go

// Package composer is the node-engine's composition as one function over a plain request (novox/hq ADR 0482 clause 4,
// ADR 0593 §7): what the command mesh-compose runs, so a group that changes the node-engine's composition is composed by
// the composer built at that group's head of the node-engine's repository, in a build module's sandbox.
//
// **It reads its request and nothing else.** No bus, store, network, file or environment: the manifests come inside the
// request (raw, as their repository holds them), the rest of the mesh is the request's input, and the answer is a value.
// The request's and the verdict's shapes are mesh-sdk's composer package; their logic, which composes, is here.
package composer
import (
"encoding/json"
"fmt"
"slices"
"sort"
"git.novox.be/novox/mesh-sdk/go/catalogue"
sdk "git.novox.be/novox/mesh-sdk/go/composer"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/sdkversion"
"github.com/novox/mesh-host/internal/compose"
"github.com/novox/mesh-host/internal/compose/frombus"
)
// The shapes of mesh-sdk's composer package, by the names this package's code was written with.
type (
Request = sdk.Request
Case = sdk.Case
Manifest = sdk.Manifest
Verdict = sdk.Verdict
CaseVerdict = sdk.CaseVerdict
ManifestRead = sdk.ManifestRead
Machine = sdk.Machine
Path = sdk.Path
)
const (
Version = sdk.Version
DepthResolve = sdk.DepthResolve
DepthCompose = sdk.DepthCompose
)
// ErrUnfilled is a manifest named by a digest that nobody replaced by its bytes (sdk.ErrUnfilled).
var ErrUnfilled = sdk.ErrUnfilled
// inputOf is the request's input as this composer's composition input: the request carries it as JSON (mesh-sdk holds no
// composition), the same bytes either way.
func inputOf(r Request) (compose.Input, error) {
var in compose.Input
raw, err := json.Marshal(r.Input)
if err != nil {
return in, fmt.Errorf("composer: the request's input cannot be read: %w", err)
}
if len(raw) == 0 || string(raw) == "null" {
return in, nil
}
if err := json.Unmarshal(raw, &in); err != nil {
return in, fmt.Errorf("composer: the request's input is not a composition input: %w", err)
}
return in, nil
}
// Run composes every case of r and answers the verdict, its SDK this binary's mesh-sdk. An error is a request it
// cannot run (an unknown depth, a case named twice, a manifest left as a digest); a machine that does not compose is a
// verdict.
func Run(r Request) (Verdict, error) {
depth := r.Depth
if depth == "" {
depth = DepthCompose
}
if depth != DepthResolve && depth != DepthCompose {
return Verdict{}, fmt.Errorf("composer: the depth %q is not resolve or compose", r.Depth)
}
v := Verdict{V: Version, SDK: sdkversion.Version()}
seen := map[string]bool{}
for _, c := range r.Cases {
if seen[c.Name] {
return Verdict{}, fmt.Errorf("composer: the case %q is named twice", c.Name)
}
seen[c.Name] = true
cv, err := runCase(r, c, depth)
if err != nil {
return Verdict{}, err
}
v.Cases = append(v.Cases, cv)
}
return v, nil
}
// parse decodes every manifest leniently and takes its verdict (novox/hq ADR 0578 §3, §5): the composer judges none. A
// manifest refused by its verdict is held, composed as its stub without its items (as the bus reader holds one); bytes
// that do not decode are left out. Both are named in reads.
func parse(m map[string]Manifest, reads map[string]ManifestRead, verdicts map[string]delivery.ManifestVerdict) (map[string]catalogue.Manifest, error) {
out := map[string]catalogue.Manifest{}
for name, man := range m {
if len(man.Raw) == 0 {
return nil, fmt.Errorf("%w: %s (digest %s)", ErrUnfilled, name, man.Digest)
}
decoded, err := catalogue.Decode(man.Raw)
read := ManifestRead{Accepted: err == nil, SetAside: decoded.SetAside()}
if err != nil {
read.Refused = fmt.Sprintf("the manifest of %s does not decode: %v", name, err)
} else if v, ok := verdicts[name]; ok {
if refused, _ := delivery.TakeManifestVerdict(&v); refused {
if v.Module == "" {
v.Module = name
}
read = ManifestRead{Refused: v.Why()}
decoded.Items = nil
decoded = compose.Stub(decoded)
}
}
if reads != nil {
reads[name] = read
}
if err == nil {
out[name] = decoded
}
}
return out, nil
}
func runCase(r Request, c Case, depth string) (CaseVerdict, error) {
cv := CaseVerdict{Name: c.Name, Manifests: map[string]ManifestRead{}}
shelf, err := parse(c.Manifests, cv.Manifests, c.Verdicts)
if err != nil {
return CaseVerdict{}, err
}
base, err := inputOf(r)
if err != nil {
return CaseVerdict{}, err
}
in, err := frombus.Placed(base, c.Placements)
if err != nil {
return CaseVerdict{}, fmt.Errorf("composer: the case %q: %w", c.Name, err)
}
// The module the mesh provides itself (the private network) has no repository the indexer reads, so no case carries
// it: its manifest comes with mesh-sdk and always wins, as the bus reader has it (frombus readBase). Without it a node
// it is assigned to composed nothing (issue 1126, check of mesh-host #200: "nothing provides mesh-wireguard").
for _, name := range delivery.ProvidedModules() {
m, _, err := delivery.ProvidedManifest(name)
if err != nil {
return CaseVerdict{}, fmt.Errorf("composer: %w", err)
}
shelf[name] = m
}
// A module the input carries as its node-engine took it in from its build (SentInput) is composed so: the bus
// reader composes it from its build, never from the indexer's manifest a case gives.
for name, m := range base.Catalogue {
shelf[name] = m
}
in.Catalogue = shelf
in.Nodes = slices.Clone(in.Nodes)
in.Holdings = slices.Clone(in.Holdings)
for i := range in.Nodes {
if len(c.Kept[in.Nodes[i].Name]) == 0 {
continue // what the input keeps of this node, as its engine took it in, stands
}
in.Nodes[i].Kept = nil
if kept := c.Kept[in.Nodes[i].Name]; len(kept) > 0 {
k, err := parse(kept, nil, nil)
if err != nil {
return CaseVerdict{}, err
}
in.Nodes[i].Kept = k
}
}
if r.HoldingsFromClaims {
in.Holdings = append(in.Holdings, HoldingsOf(shelf, in.Nodes)...)
}
if len(r.Machines) == 0 {
return cv, nil
}
cv.Machines = map[string]Machine{}
for _, node := range r.Machines {
cv.Machines[node] = machine(in, node, depth, r.Declarations)
}
return cv, nil
}
// HoldingsOf is every seat an assigned module claims, held on the node it is assigned to, in node order: the holdings
// of an input that has none of record (the assigner's, mesh-delivery-assigner internal/assign input).
func HoldingsOf(shelf map[string]catalogue.Manifest, nodes []compose.Node) []catalogue.Held {
var out []catalogue.Held
sorted := slices.Clone(nodes)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
for _, n := range sorted {
for _, mod := range n.Assigned {
for _, cl := range shelf[mod].Claims {
scope := cl.Scope
if seat, ok := catalogue.SeatNamed(cl.Name); ok && scope == "" {
scope = seat.Scope
}
out = append(out, catalogue.Held{Claim: cl.Name, Scope: scope, Node: n.Name, Module: mod})
}
}
}
return out
}
func machine(in compose.Input, node, depth string, declarations bool) Machine {
if depth == DepthResolve {
if _, err := compose.Resolve(in, node); err != nil {
return Machine{Reason: err.Error()}
}
return Machine{Composes: true}
}
d, problems, err := compose.Compose(in, node)
if err != nil {
return Machine{Reason: err.Error()}
}
m := Machine{Composes: len(problems) == 0, Problems: problems, Refusals: d.Refusals()}
if len(problems) > 0 {
m.Reason = "the node-engine's validator refuses the declaration"
}
if declarations {
m.Declared = pathsOf(d)
}
return m
}
// pathsOf is every resource of a declaration that names a path, with its owner, group and mode as declared, sorted by
// path.
func pathsOf(d compose.Declared) []Path {
var out []Path
str := func(r map[string]any, key string) string {
if v, ok := r[key]; ok && v != nil {
return fmt.Sprint(v)
}
return ""
}
for _, r := range d.Resources {
p := str(r, "path")
if p == "" {
continue
}
out = append(out, Path{Path: p, Kind: str(r, "type"), Owner: str(r, "owner"), Group: str(r, "group"),
Mode: str(r, "mode"), Module: d.ModuleOf[str(r, "id")]})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Path != out[j].Path {
return out[i].Path < out[j].Path
}
return out[i].Kind < out[j].Kind
})
return out
}
// Package is the import path of the command, which a build module builds at the node-engine's repository's head of a
// group that changes the node-engine's composition (novox/hq ADR 0593 §7): mesh-sdk's composer.Package names the same.
const Package = "github.com/novox/mesh-host/cmd/mesh-compose"
// Decode reads a request (sdk.Decode); it refuses one that is not JSON or of another version.
func Decode(data []byte) (Request, error) { return sdk.Decode(data) }
// SentInput is the composition input a node-engine answers for the sandbox (delivery.ComposeCandidateAsk.Input), from the
// input it composed node from: the one shape both read (issue 1126, checks of mesh-host #200). Every other node's modules
// are left to the cases (the indexer's manifests, as the bus reader reads them too); node's own placed modules stay as the
// bus reader took them in from their builds (their artifacts, the texts of their templates archive, what is handed to the
// provisioner), with what node keeps at another build, which no case can give: the sandbox has no build or archive to
// read. Without them the sandbox composed another node than the engine (restic's templates and artifacts unread, postgres's
// secret reader handed and the module not).
func SentInput(in compose.Input, node string) compose.Input {
own := map[string]bool{}
for _, n := range in.Nodes {
if n.Name == node {
for _, m := range n.Assigned {
own[m] = true
}
}
}
taken := in.Catalogue
in.Catalogue = nil
for name, m := range taken {
if own[name] {
if in.Catalogue == nil {
in.Catalogue = map[string]catalogue.Manifest{}
}
in.Catalogue[name] = m
}
}
in.Nodes = slices.Clone(in.Nodes)
for i := range in.Nodes {
if in.Nodes[i].Name != node {
in.Nodes[i].Kept = nil
}
}
return in
}