ADR 0593 §7: the composer with its refusals leaves mesh-sdk, so mesh-sdk carries no composition rule. The catalogue functions only the node-engine's composition reaches (Resolve, Resolution's Compose, Declaration, DataItems, LeftOut, Rules and the rest, the settings, placeholder and template refusals, the rendering helpers) are copied with their tests into internal/compose/resolution; a method of a mesh-sdk shape becomes a function taking it first. The composition calls its own copies, and the captured catalogue its tests read lives here now, shared by mesh-compose's tests. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
411 lines
16 KiB
Go
411 lines
16 KiB
Go
package resolution
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
)
|
|
|
|
// BundleID names the archive resource that delivers one of a module's bundles; prefixed with the
|
|
// module like every resource of its own.
|
|
func BundleID(bundle string) string { return "bundle-" + bundle }
|
|
|
|
// BundlePath is where one module's bundle is unpacked on a machine.
|
|
func BundlePath(module, bundle string) string { return mcat.BundleRoot + "/" + module + "/" + bundle }
|
|
|
|
// runtimeHere says whether this node's set includes the runtime module, which is what decides
|
|
// whether anything about tools changes on the machine (to-be 38 WP2): until the runtime is assigned,
|
|
// a node is sent exactly what it was sent before, bundles included, because a bundle nothing loads
|
|
// is bytes nobody reads.
|
|
func resolutionRuntimeHere(r mcat.Resolution) bool {
|
|
for _, m := range r.Modules {
|
|
if m.Module == mcat.RuntimeModule {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// bundleArchives is one archive per tools bundle of a module — a bundle the runtime LOADS something
|
|
// from — as the host fetches and unpacks any artifact (novox/hq ADR 0175 §3: a module brings its
|
|
// tools as a bundle, delivered by the host like any artifact, never an image). A bundle it loads
|
|
// nothing from is run rather than loaded: a daemon, a step, the runtime itself — delivered by the
|
|
// process that runs it, and not again here.
|
|
//
|
|
// The source is the kept reference; the per-resource pass that follows routes it through the
|
|
// artifact store as this network reaches it now, as it does every image and archive the mesh built.
|
|
func bundleArchives(m mcat.Manifest) []map[string]any {
|
|
var out []map[string]any
|
|
for _, b := range m.Bundles {
|
|
if len(b.Loads) == 0 {
|
|
continue
|
|
}
|
|
out = append(out, map[string]any{
|
|
"id": BundleID(b.Name), "type": "archive",
|
|
"source": b.Source, "digest": b.Digest,
|
|
"path": BundlePath(m.Module, b.Name),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// RuntimeProcessID names the one process the mesh composes for a machine's runtime; prefixed with
|
|
// the runtime module like a resource of its own, because that module is what the host sees it as.
|
|
func RuntimeProcessID() string { return "runtime" }
|
|
|
|
// interpreterFor is how a bundle in a language is run: the program the host's unit starts, with the
|
|
// bundle's entrypoint after it. The one thing the composer takes from a language, and said here
|
|
// rather than in a manifest because the runtime's process is the mesh's to compose (to-be 38 WP3).
|
|
func interpreterFor(language string) (string, error) {
|
|
switch language {
|
|
case "typescript":
|
|
return "node", nil
|
|
}
|
|
return "", fmt.Errorf(
|
|
"%s is written in %q, and the mesh knows no interpreter to run a %q bundle with", mcat.RuntimeModule, language, language)
|
|
}
|
|
|
|
// runtimeProcess is the one process a machine runs the node's tool runtime as (novox/hq ADR 0175,
|
|
// to-be 38 WP2.3): the runtime module's own bundle, run by its language's interpreter, told which
|
|
// modules it serves and from which files, where its credential is, and who the machine's operator
|
|
// is — and restarted when any bundle it loads or the credential it holds changes.
|
|
//
|
|
// Composed from the placed manifests, so the credential's path is where this node puts it. The
|
|
// runtime runs as the operator's account when the machine has one, which is what lets a tool that
|
|
// needs root escalate as the operator would (ADR 0175 §4); on a machine with no account it runs as
|
|
// root, and the two operator words are not set.
|
|
func resolutionRuntimeProcess(r mcat.Resolution, with mcat.Rendering) (map[string]any, error) {
|
|
var runtime *mcat.Manifest
|
|
for i := range r.Modules {
|
|
if r.Modules[i].Module == mcat.RuntimeModule {
|
|
runtime = &r.Modules[i]
|
|
}
|
|
}
|
|
if runtime == nil || runtime.Stubbed {
|
|
// Held (its stand-in, Stubbed): its process is never applied, what runs of it is carried, and its stand-in is
|
|
// never refused by a rule about its bundle or its broker (issue 774: node-tools held for a build not done, then
|
|
// "its build produced 0 bundle(s)", refused every module of ace, g14 and shanks).
|
|
return nil, nil
|
|
}
|
|
if len(runtime.Bundles) != 1 {
|
|
return nil, fmt.Errorf(
|
|
"%s is assigned to %s and its build produced %d bundle(s); the runtime is one bundle "+
|
|
"the mesh runs, so the module declares exactly one (novox/hq to-be 38)", mcat.RuntimeModule, r.Node, len(runtime.Bundles))
|
|
}
|
|
bundle := runtime.Bundles[0]
|
|
// What runs it (novox/hq ADR 0193): a runtime compiled to a binary runs itself, from its own
|
|
// unpacked bundle; an interpreted one is its language's interpreter and its one entrypoint.
|
|
var run []any
|
|
if bundle.Binary != "" {
|
|
run = []any{"./" + bundle.Binary}
|
|
} else {
|
|
if len(bundle.Entrypoints) != 1 {
|
|
return nil, fmt.Errorf(
|
|
"%s's bundle %q names %d entrypoint(s); the runtime is run from one, so the module "+
|
|
"declares exactly one (novox/hq to-be 38)", mcat.RuntimeModule, bundle.Name, len(bundle.Entrypoints))
|
|
}
|
|
interpreter, err := interpreterFor(bundle.Language)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
run = []any{interpreter, bundle.Entrypoints[0]}
|
|
}
|
|
credential, declared := runtime.OwnSecrets["broker"]
|
|
if !declared {
|
|
return nil, fmt.Errorf(
|
|
"%s declares no own secret named broker, and the node's credential is delivered there: "+
|
|
"a module that speaks on the bus declares \"own-secrets\": {\"broker\": <path>}", mcat.RuntimeModule,
|
|
)
|
|
}
|
|
|
|
// What it serves, and from which files: every module on this machine that composes here, in
|
|
// name order, each bundle it loads from in the order the manifest gave. A module left out of
|
|
// the declaration — a filter on an adopted machine — is left out of this too, or the runtime
|
|
// would be told to load files that were never delivered.
|
|
var served []string
|
|
var restartOn []string
|
|
given := map[string]map[string]string{}
|
|
for _, m := range r.Modules {
|
|
if with.Adopted && m.Filtering != nil {
|
|
continue
|
|
}
|
|
if m.RunsAs != "" {
|
|
// Served by a runtime of its own, on its own account (ownRuntimes): never the machine's.
|
|
continue
|
|
}
|
|
words, err := bundleWords(m, with)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// The holder of node-files is told the same, and the paths modules own here (novox/hq ADR 0394 §4).
|
|
if words, err = resolutionFilesWords(r, m, words, with); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(words) > 0 {
|
|
given[m.Module] = words
|
|
}
|
|
for _, b := range m.Bundles {
|
|
if len(b.Loads) == 0 {
|
|
continue
|
|
}
|
|
for _, load := range b.Loads {
|
|
// What the runtime starts: the launcher the build wrote beside the entrypoint, where
|
|
// it wrote one (ADR 0193); the entrypoint itself for a build from before them.
|
|
if launcher, has := b.Launchers[load]; has {
|
|
load = launcher
|
|
}
|
|
served = append(served, m.Module+"="+BundlePath(m.Module, b.Name)+"/"+load)
|
|
}
|
|
restartOn = append(restartOn, m.Module+"."+BundleID(b.Name))
|
|
}
|
|
}
|
|
sort.Strings(served)
|
|
restartOn = append(restartOn, mcat.RuntimeModule+"."+mcat.NeedID("broker"))
|
|
sort.Strings(restartOn)
|
|
|
|
env := map[string]string{mcat.RuntimeToolModules: strings.Join(served, ","), mcat.RuntimeBrokerFile: credential.Path}
|
|
if len(given) > 0 {
|
|
// Marshalled from maps, whose keys encoding/json sorts: the same words, the same unit.
|
|
body, err := json.Marshal(given)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
env[mcat.RuntimeToolEnv] = string(body)
|
|
}
|
|
process := map[string]any{
|
|
"id": mcat.RuntimeModule + "." + RuntimeProcessID(), "type": "process", "name": mcat.RuntimeModule,
|
|
"source": bundle.Source, "digest": bundle.Digest,
|
|
"run": run,
|
|
"env": env,
|
|
"restart-on": toAny(restartOn),
|
|
}
|
|
if r.Account != "" {
|
|
env[mcat.RuntimeOperatorAccount] = r.Account
|
|
env[mcat.RuntimeOperatorHome] = accountHomeOf(r.Account, r.AccountHome)
|
|
process["user"] = r.Account
|
|
}
|
|
if agent, home := resolutionAgentAccount(r); agent != "" {
|
|
env[mcat.RuntimeAgentAccount] = agent
|
|
env[mcat.RuntimeAgentHome] = home
|
|
}
|
|
// Routed through the artifact store as this network reaches it now, like everything the mesh
|
|
// built; refused with the same words when there is no store to route through.
|
|
if err := artifactsInto(process, mcat.RuntimeModule, with); err != nil {
|
|
return nil, err
|
|
}
|
|
return process, nil
|
|
}
|
|
|
|
// NoRuntimeToRunError is a module of its own account with no runtime program on its node to run it from. Held says the
|
|
// runtime is there and held (its stand-in, Stubbed): the module cannot compose because the runtime does not, so a
|
|
// composition that holds the runtime holds it too, never the node (issue 774).
|
|
type NoRuntimeToRunError struct {
|
|
Held bool
|
|
said string
|
|
}
|
|
|
|
func (e *NoRuntimeToRunError) Error() string { return e.said }
|
|
|
|
// ownRuntimes are the processes the modules of their own account are served by (novox/hq ADR 0259 §8): each
|
|
// the machine's runtime program — the same build, run from the same source — serving that one module alone,
|
|
// as the module's own account, on the module's own bus credential. Never the machine's runtime, which runs
|
|
// as the operator's account and carries every module on the machine.
|
|
func resolutionOwnRuntimes(r mcat.Resolution, with mcat.Rendering) ([]map[string]any, error) {
|
|
var own []mcat.Manifest
|
|
for _, m := range r.Modules {
|
|
// A held module's stand-in (Stubbed) has no runtime of its own composed, and so none of this function's rules
|
|
// about one: none of its composition is applied, and what runs of it is carried as it runs. Refusing it here
|
|
// refused its whole node (issue 774, 2026-10-30 21:04: mesh-self-check, held for its broker not made, then
|
|
// "runs as its own account and declares no own secret broker", failed 74 modules on ace and 79 on novox).
|
|
if m.RunsAs != "" && !m.Stubbed && !(with.Adopted && m.Filtering != nil) {
|
|
own = append(own, m)
|
|
}
|
|
}
|
|
if len(own) == 0 {
|
|
return nil, nil
|
|
}
|
|
var runtime *mcat.Manifest
|
|
for i := range r.Modules {
|
|
if r.Modules[i].Module == mcat.RuntimeModule {
|
|
runtime = &r.Modules[i]
|
|
}
|
|
}
|
|
if runtime == nil || len(runtime.Bundles) != 1 || runtime.Bundles[0].Binary == "" {
|
|
return nil, &NoRuntimeToRunError{Held: runtime != nil && runtime.Stubbed, said: fmt.Sprintf("%s runs as its own "+
|
|
"account in a runtime of its own, and %s is not here to run it from: assign %s to %s first (novox/hq ADR 0259)",
|
|
own[0].Module, mcat.RuntimeModule, mcat.RuntimeModule, r.Node)}
|
|
}
|
|
program := runtime.Bundles[0]
|
|
var out []map[string]any
|
|
for _, m := range own {
|
|
// Never the node's operator account, nor the account agents run as there (the review of 2026-10-09):
|
|
// either would hand what it holds back to the very accounts it is kept from.
|
|
switch {
|
|
case r.Account != "" && m.RunsAs == r.Account:
|
|
return nil, fmt.Errorf("%s runs as %s, the operator's account on %s: a module of its own account never "+
|
|
"runs as it (novox/hq ADR 0259 §8)", m.Module, m.RunsAs, r.Node)
|
|
case r.AgentAccount != "" && m.RunsAs == r.AgentAccount:
|
|
return nil, fmt.Errorf("%s runs as %s, the account agents run as on %s: a module of its own account "+
|
|
"never runs as it (novox/hq ADR 0259 §8)", m.Module, m.RunsAs, r.Node)
|
|
}
|
|
credential, declared := m.OwnSecrets["broker"]
|
|
if !declared {
|
|
return nil, fmt.Errorf("%s runs as its own account and declares no own secret broker", m.Module)
|
|
}
|
|
var served, restartOn []string
|
|
for _, b := range m.Bundles {
|
|
for _, load := range b.Loads {
|
|
if launcher, has := b.Launchers[load]; has {
|
|
load = launcher
|
|
}
|
|
served = append(served, m.Module+"="+BundlePath(m.Module, b.Name)+"/"+load)
|
|
}
|
|
if len(b.Loads) > 0 {
|
|
restartOn = append(restartOn, m.Module+"."+BundleID(b.Name))
|
|
}
|
|
}
|
|
if len(served) == 0 {
|
|
return nil, fmt.Errorf("%s runs as its own account and its build produced no bundle to serve", m.Module)
|
|
}
|
|
sort.Strings(served)
|
|
restartOn = append(restartOn, m.Module+"."+mcat.NeedID("broker"))
|
|
sort.Strings(restartOn)
|
|
env := map[string]string{mcat.RuntimeToolModules: strings.Join(served, ","), mcat.RuntimeBrokerFile: credential.Path}
|
|
words, err := bundleWords(m, with)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// The holder of node-reads is told where this node keeps its secrets (novox/hq ADR 0310 §5).
|
|
if words, err = resolutionReadsWords(r, m, words, with); err != nil {
|
|
return nil, err
|
|
}
|
|
// A build seat's holder gives way on its node, and its steps are told their part of the node's share
|
|
// (novox/hq ADR 0553).
|
|
yielding, yieldWords := resolutionYieldsOf(r, m, with)
|
|
if len(yieldWords) > 0 {
|
|
if words == nil {
|
|
words = map[string]string{}
|
|
}
|
|
for k, v := range yieldWords {
|
|
words[k] = v
|
|
}
|
|
}
|
|
if len(words) > 0 {
|
|
body, err := json.Marshal(map[string]map[string]string{m.Module: words})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
env[mcat.RuntimeToolEnv] = string(body)
|
|
}
|
|
process := map[string]any{
|
|
"id": m.Module + "." + mcat.OwnRuntimeID(), "type": "process", "name": m.Module + "-runtime",
|
|
"source": program.Source, "digest": program.Digest,
|
|
"run": []any{"./" + program.Binary}, "env": env, "restart-on": toAny(restartOn),
|
|
"user": m.RunsAs,
|
|
}
|
|
// How it is ready, as the module declared it (novox/hq ADR 0366 §1), in the node-engine's words.
|
|
if _, has := mcat.OwnRuntimeHealth(m); has {
|
|
process[mcat.HealthField] = m.RuntimeHealth
|
|
healthInto(process, m, with)
|
|
}
|
|
if yielding != nil {
|
|
process["yields"] = yielding
|
|
}
|
|
if err := artifactsInto(process, mcat.RuntimeModule, with); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, process)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func toAny(in []string) []any {
|
|
out := make([]any, 0, len(in))
|
|
for _, s := range in {
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// bundleWords is what one module's tools bundles are given on this machine (novox/hq ADR 0192):
|
|
// each loaded bundle's env, its ${dir:…} resolved to where this machine places the module's
|
|
// directories and its ${port:…} to the port this machine gave it — the same resolution a
|
|
// container's environment gets. Two bundles of one module naming one word differently is refused:
|
|
// the runtime hands a module's words to all its bundles.
|
|
func bundleWords(m mcat.Manifest, with mcat.Rendering) (map[string]string, error) {
|
|
var out map[string]string
|
|
dirs := dirsFor(m, with)
|
|
for _, b := range m.Bundles {
|
|
if len(b.Loads) == 0 || len(b.Env) == 0 {
|
|
continue
|
|
}
|
|
for _, word := range sortedKeys(b.Env) {
|
|
value, err := dirFill(b.Env[word], dirs, m.Module)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if value, err = portsFilledInto(value, m.Module+"'s bundle "+b.Name+" ("+word+")", m.Module, m.Listens, with); err != nil {
|
|
return nil, err
|
|
}
|
|
if out == nil {
|
|
out = map[string]string{}
|
|
}
|
|
if was, had := out[word]; had && was != value {
|
|
return nil, fmt.Errorf("%s's bundles give %s two values (%q, %q); a module's words are "+
|
|
"handed to all its bundles, so they agree (novox/hq ADR 0192)", m.Module, word, was, value)
|
|
}
|
|
out[word] = value
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// givenTo makes what a bundle's words name readable by the account the runtime runs as (novox/hq
|
|
// ADR 0192): every file and directory of the module whose path a word names, or that holds one,
|
|
// is owned by the account — a tool reads its configuration and its secret as the account, and a
|
|
// root-owned 0600 file or a 0700 directory is one it cannot. Only where it says no owner already:
|
|
// a module that named one knew why. Nothing on a machine with no account, where the runtime is root.
|
|
//
|
|
// It answers the files a word names exactly: what a tool reads, whose change the runtime must be
|
|
// restarted for, as the container the tools came from was restarted when its configuration changed.
|
|
func givenTo(out []map[string]any, owner map[string]string, words map[string]map[string]string, account string) []string {
|
|
var named []string
|
|
if len(words) == 0 {
|
|
return nil
|
|
}
|
|
for _, resource := range out {
|
|
module := owner[fmt.Sprint(resource["id"])]
|
|
mine := words[module]
|
|
if len(mine) == 0 {
|
|
continue
|
|
}
|
|
kind := fmt.Sprint(resource["type"])
|
|
if kind != "file" && kind != "directory" {
|
|
continue
|
|
}
|
|
path, _ := resource["path"].(string)
|
|
if path == "" {
|
|
continue
|
|
}
|
|
for _, value := range mine {
|
|
if kind == "file" && value == path {
|
|
named = append(named, fmt.Sprint(resource["id"]))
|
|
}
|
|
}
|
|
if _, said := resource["owner"]; said || account == "" {
|
|
continue
|
|
}
|
|
for _, value := range mine {
|
|
if value == path || strings.HasPrefix(value, strings.TrimRight(path, "/")+"/") {
|
|
resource["owner"] = account
|
|
break
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(named)
|
|
return named
|
|
}
|