The tool containers were restarted when their configuration file changed; the runtime now is too, for every file a module's words name exactly — configuration and own secret alike.
353 lines
14 KiB
Go
353 lines
14 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// The node's tool runtime, as the catalogue knows it (novox/hq ADR 0175, to-be 38).
|
|
//
|
|
// **One module is the runtime.** Where it is assigned, one process per machine serves every assigned
|
|
// module's tools and every held seat's verbs, on the host side, from the bundles each module's build
|
|
// produced — and no module needs a container to reach the bus with its tools. The name is a constant
|
|
// rather than a manifest field because a rule turns on it: the composer places the runtime's process
|
|
// where this module is, and registration refuses the old pattern once this module exists.
|
|
|
|
// RuntimeModule is the module that is the node's tool runtime. Mirrored in the broker package,
|
|
// which composes a principal of its own for it; the agreement test there holds the two to one string.
|
|
const RuntimeModule = "node-tools"
|
|
|
|
// BundleRoot is where a machine keeps the tools bundles the mesh delivers to it: under the mesh's
|
|
// own directory, beside the daemons the host unpacks there, and never where a package manager also
|
|
// writes. One directory per module, one per bundle beneath it, at a path that does not move with
|
|
// the version — so the runtime's process names each entrypoint once and is restarted, not
|
|
// recomposed, when a bundle changes.
|
|
const BundleRoot = "/var/lib/mesh/bundles"
|
|
|
|
// 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 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 (r Resolution) runtimeHere() bool {
|
|
for _, m := range r.Modules {
|
|
if m.Module == 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 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" }
|
|
|
|
// RuntimeToolModules is the variable the runtime reads the modules it serves from: one
|
|
// `<module>=<entrypoint>` per file it loads, comma-separated — several entries may name one module.
|
|
// RuntimeBrokerFile is where it reads the node's credential; RuntimeOperatorAccount and
|
|
// RuntimeOperatorHome are the machine's operator account and home, handed to every tool's
|
|
// environment (to-be 38 WP1), and absent on a machine with no account.
|
|
const (
|
|
RuntimeToolModules = "MESH_TOOL_MODULES"
|
|
RuntimeBrokerFile = "MESH_BROKER_FILE"
|
|
RuntimeOperatorAccount = "MESH_OPERATOR_ACCOUNT"
|
|
RuntimeOperatorHome = "MESH_OPERATOR_HOME"
|
|
// RuntimeToolEnv is every served module's composed environment, as JSON (novox/hq ADR 0192):
|
|
// {"<module>": {"<word>": "<value>"}}. The runtime takes it at start, removes it from its own
|
|
// environment and hands each module's words to that module's bundles alone. In the unit, so a
|
|
// change to any module's words changes the process and restarts it.
|
|
RuntimeToolEnv = "MESH_TOOL_ENV"
|
|
)
|
|
|
|
// 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",
|
|
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 (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
|
|
var runtime *Manifest
|
|
for i := range r.Modules {
|
|
if r.Modules[i].Module == RuntimeModule {
|
|
runtime = &r.Modules[i]
|
|
}
|
|
}
|
|
if runtime == nil {
|
|
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)",
|
|
RuntimeModule, r.Node, len(runtime.Bundles))
|
|
}
|
|
bundle := runtime.Bundles[0]
|
|
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)", RuntimeModule, bundle.Name, len(bundle.Entrypoints))
|
|
}
|
|
interpreter, err := interpreterFor(bundle.Language)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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>}",
|
|
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
|
|
}
|
|
words, err := bundleWords(m, with)
|
|
if 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 {
|
|
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, RuntimeModule+"."+NeedID("broker"))
|
|
sort.Strings(restartOn)
|
|
|
|
env := map[string]string{
|
|
RuntimeToolModules: strings.Join(served, ","),
|
|
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[RuntimeToolEnv] = string(body)
|
|
}
|
|
process := map[string]any{
|
|
"id": RuntimeModule + "." + RuntimeProcessID(), "type": "process", "name": RuntimeModule,
|
|
"source": bundle.Source, "digest": bundle.Digest,
|
|
"run": []any{interpreter, bundle.Entrypoints[0]},
|
|
"env": env,
|
|
"restart-on": toAny(restartOn),
|
|
}
|
|
if r.Account != "" {
|
|
env[RuntimeOperatorAccount] = r.Account
|
|
env[RuntimeOperatorHome] = accountHomeOf(r.Account, r.AccountHome)
|
|
process["user"] = r.Account
|
|
}
|
|
// 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, RuntimeModule, with); err != nil {
|
|
return nil, err
|
|
}
|
|
return process, nil
|
|
}
|
|
|
|
func toAny(in []string) []any {
|
|
out := make([]any, 0, len(in))
|
|
for _, s := range in {
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// RuntimeImageModule and RuntimeImageArtifact name the image every per-module tool container was
|
|
// built on: the tool runtime's own runtime image. With the runtime a module of its own, that image
|
|
// stays the way a module's SERVICE may be built and stops being the way tools reach a node (ADR 0175).
|
|
const (
|
|
RuntimeImageModule = "mesh-tools"
|
|
RuntimeImageArtifact = "runtime"
|
|
)
|
|
|
|
// ToolContainerOnTheRuntime says why a manifest is the pattern ADR 0175 retires — a module whose tools
|
|
// are served from a container built on the tool runtime's image — or nothing when it is not. Judged
|
|
// from the manifest's own `build.on` when it is a repository manifest, and from what its build stood
|
|
// on when it is a built one, because a resolved manifest carries no build. The gate itself is
|
|
// registration's (to-be 38 WP2.4): once the runtime module is in the catalogue, this is refused.
|
|
//
|
|
// Three things must hold, and each alone is fine: declaring tools (a bundle does that); a container
|
|
// (a module's service may well be one); building on the runtime's image (a service written against
|
|
// the SDK may). All three is a container whose purpose is tools, which the runtime now serves.
|
|
func ToolContainerOnTheRuntime(m Manifest, against []string) string {
|
|
if len(m.Tools) == 0 {
|
|
return ""
|
|
}
|
|
container := false
|
|
for _, r := range m.Resources {
|
|
if fmt.Sprint(r["type"]) == "container" {
|
|
container = true
|
|
}
|
|
}
|
|
if !container {
|
|
return ""
|
|
}
|
|
onTheRuntime := false
|
|
if m.Build != nil {
|
|
for _, on := range m.Build.On {
|
|
if on.Module == RuntimeImageModule && on.Artifact == RuntimeImageArtifact {
|
|
onTheRuntime = true
|
|
}
|
|
}
|
|
}
|
|
for _, ref := range against {
|
|
path, kept := InArtifactStore(Recorded(ref))
|
|
if kept && strings.HasPrefix(path, RuntimeImageModule+"/"+RuntimeImageArtifact+"@") {
|
|
onTheRuntime = true
|
|
}
|
|
}
|
|
if !onTheRuntime {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf(
|
|
"%s declares tools and a container built on %s's %s image — a container whose purpose is "+
|
|
"serving tools. The node's tool runtime (%s) serves every module's tools from its bundle "+
|
|
"now (novox/hq ADR 0175, to-be 38); declare the tools as a bundle and drop the container",
|
|
m.Module, RuntimeImageModule, RuntimeImageArtifact, RuntimeModule)
|
|
}
|
|
|
|
// 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 Manifest, with 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
|
|
}
|