The stub's mark (Manifest.Stubbed) is no field of a manifest's JSON, so an input a node-engine sent the check's sandbox lost it: node-tools, held by its manifest's verdict, read as a tool runner with no bundle and refused every module of ace, g14 and shanks for "its build produced 0 bundle(s)". compose.Input now writes its held modules (catalogue and each node's kept) beside its fields and marks them again on reading; an input from an engine before the mark is read by its contract, every module Refused names held as its stub. The sandbox composer also says, in the module's read, why the input's node-engine holds a module the case carries, so the check names the cause, not a rule about the stub. One subtest round-trips an input holding a refused tool runner, in both shapes; it refused for 0 bundles before. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
343 lines
12 KiB
Go
343 lines
12 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"
|
|
"strings"
|
|
|
|
"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
|
|
}
|
|
// A module the input's node-engine holds as refused (compose.Input.Refused: its verdict, or bytes that do not decode) is
|
|
// held here as there, and its read says why, whatever the case's own verdicts: the check then names the cause the node
|
|
// holds it for, never a pass the node would not compose, nor a rule about its stub (issue 774, group check 278aa5f0ccbe).
|
|
for name, why := range base.Refused {
|
|
if read, carried := cv.Manifests[name]; carried && read.Accepted {
|
|
cv.Manifests[name] = ManifestRead{Refused: "its node-engine holds it: " + why}
|
|
}
|
|
}
|
|
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}
|
|
}
|
|
// Composed as the node-engine composes it, when its input says how (compose.Input.ComposedHow, issue 774): the same
|
|
// path, at the same builds, a module folded in its own composition kept as it runs.
|
|
if !declarations {
|
|
if _, how, err := frombus.ComposeSent(in, node); how {
|
|
if err != nil {
|
|
return Machine{Reason: err.Error()}
|
|
}
|
|
return Machine{Composes: true}
|
|
}
|
|
}
|
|
d, problems, err := compose.Compose(in, node)
|
|
if err != nil {
|
|
// An input from a node-engine before ComposedHow says nothing of what runs of the modules folded in the node's
|
|
// own composition: the sandbox cannot compose them as the node does, so it does not judge the node, and names
|
|
// it (ADR 0593 §9), never a failure of the change and never a silent pass.
|
|
if folded := foldedUnsaid(in, node); len(folded) > 0 && in.ComposedHow == nil {
|
|
return Machine{NotJudged: NotJudged + strings.Join(folded, ", ") +
|
|
" (its own composition carries them as they run, and the sandbox cannot): " + err.Error()}
|
|
}
|
|
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
|
|
}
|
|
|
|
// NotJudged opens the words of a machine the sandbox does not judge (Machine.NotJudged, the contract's field, ADR 0593 §9):
|
|
// its node-engine sent an input that does not say how it composes (compose.Input.ComposedHow), and the node runs modules
|
|
// folded in its own composition. The machine is then neither composed nor refused; a reader decides by the field.
|
|
const NotJudged = "not judged: its node-engine sends no running state for "
|
|
|
|
// foldedUnsaid is each module node runs folded in its own composition (Node.InOwn) whose manifest names a build: what
|
|
// the node composes as it runs, and an input without ComposedHow cannot.
|
|
func foldedUnsaid(in compose.Input, node string) []string {
|
|
var out []string
|
|
for _, n := range in.Nodes {
|
|
if n.Name != node {
|
|
continue
|
|
}
|
|
for m, ok := range n.InOwn {
|
|
man, known := n.Kept[m]
|
|
if !known {
|
|
man, known = in.Catalogue[m]
|
|
}
|
|
if ok && known && compose.NamesBuild(man) {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// 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
|
|
}
|