Beside every TypeScript entrypoint the builder writes an executable launcher; the runtime is told it (hq ADR 0193) #239
@@ -587,6 +587,12 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Built{}, fmt.Errorf("%s: compiling %s failed: %w", module, a.Name, err)
|
return catalogue.Built{}, fmt.Errorf("%s: compiling %s failed: %w", module, a.Name, err)
|
||||||
}
|
}
|
||||||
|
// **Every entrypoint the runtime may serve is executable** (novox/hq ADR 0193). The runtime
|
||||||
|
// knows no language; for one that runs through an interpreter the build writes the launcher.
|
||||||
|
launchers, err := writeLaunchers(compiled, chain, a)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Built{}, fmt.Errorf("%s: writing %s's launchers failed: %w", module, a.Name, err)
|
||||||
|
}
|
||||||
say("bundle", "compiled, packing")
|
say("bundle", "compiled, packing")
|
||||||
body, err := pack(compiled)
|
body, err := pack(compiled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -598,7 +604,7 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Built{}, err
|
return catalogue.Built{}, err
|
||||||
}
|
}
|
||||||
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest}, nil
|
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest, Launchers: launchers}, nil
|
||||||
|
|
||||||
case catalogue.ArtifactPackage:
|
case catalogue.ArtifactPackage:
|
||||||
// Built and published on a public base, to the mesh's package registry, by version
|
// Built and published on a public base, to the mesh's package registry, by version
|
||||||
@@ -1173,3 +1179,45 @@ func binaryName(a catalogue.Artifact) string {
|
|||||||
}
|
}
|
||||||
return a.Name
|
return a.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// launcherSuffix is what a TypeScript entrypoint's launcher is called beside it: index.js is
|
||||||
|
// started as index.serve.mjs (novox/hq ADR 0193). An ES module by its own extension, whatever the
|
||||||
|
// bundle's package.json says.
|
||||||
|
const launcherSuffix = ".serve.mjs"
|
||||||
|
|
||||||
|
// writeLaunchers writes, beside every entrypoint of a TypeScript bundle, an executable that
|
||||||
|
// imports the entrypoint and serves what it registered over MCP on stdio — through the bundle's
|
||||||
|
// own copy of the SDK, so registering and serving meet in one registry (novox/hq ADR 0193). Its
|
||||||
|
// answer is each entrypoint's launcher, by entrypoint, relative to the bundle's root; nothing for
|
||||||
|
// a language whose build is already executable.
|
||||||
|
func writeLaunchers(root string, chain Toolchain, a catalogue.Artifact) (map[string]string, error) {
|
||||||
|
if chain.Language != "typescript" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, entry := range a.Entrypoints {
|
||||||
|
if !strings.HasSuffix(entry, ".js") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
launcher := strings.TrimSuffix(entry, ".js") + launcherSuffix
|
||||||
|
body := "#!/usr/bin/env node\n" +
|
||||||
|
"// Written by the mesh's builder (novox/hq ADR 0193): serve what " + entry + " registers,\n" +
|
||||||
|
"// over MCP on stdio, as the module the node's runtime names in MESH_SERVED_MODULE.\n" +
|
||||||
|
"import { serveRegisteredOverStdio } from \"@novox/mesh-sdk/stdio\";\n" +
|
||||||
|
"await import(\"./" + filepath.Base(entry) + "\");\n" +
|
||||||
|
"await serveRegisteredOverStdio();\n"
|
||||||
|
path := filepath.Join(root, filepath.FromSlash(launcher))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// WriteFile honours the umask; the mode a machine unpacks is the one packed, so it is set.
|
||||||
|
if err := os.Chmod(path, 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[entry] = launcher
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq ADR 0193: every entrypoint the runtime may serve is executable, and the runtime knows no
|
||||||
|
// language — so a TypeScript bundle carries a launcher beside each entrypoint.
|
||||||
|
func TestATypeScriptBundleCarriesAnExecutableLauncherBesideEachEntrypoint(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
chain, err := ToolchainFor("typescript")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := writeLaunchers(root, chain, catalogue.Artifact{Name: "tools", Kind: catalogue.ArtifactBundle,
|
||||||
|
Language: "typescript", Entrypoints: []string{"tools/index.js", "index.js"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got["tools/index.js"] != "tools/index.serve.mjs" || got["index.js"] != "index.serve.mjs" {
|
||||||
|
t.Fatalf("launchers: %v", got)
|
||||||
|
}
|
||||||
|
path := filepath.Join(root, "tools", "index.serve.mjs")
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0o755 {
|
||||||
|
t.Errorf("the launcher is %v, not executable 0755", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
body, _ := os.ReadFile(path)
|
||||||
|
for _, want := range []string{"#!/usr/bin/env node\n", `from "@novox/mesh-sdk/stdio"`, `await import("./index.js")`, "serveRegisteredOverStdio()"} {
|
||||||
|
if !strings.Contains(string(body), want) {
|
||||||
|
t.Errorf("the launcher lacks %q:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A compiled language's build is executable already: no launcher.
|
||||||
|
goChain, _ := ToolchainFor("go")
|
||||||
|
none, err := writeLaunchers(t.TempDir(), goChain, catalogue.Artifact{Name: "tools", Kind: catalogue.ArtifactBundle, Language: "go"})
|
||||||
|
if err != nil || len(none) != 0 {
|
||||||
|
t.Errorf("a Go bundle was given launchers: %v %v", none, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,9 @@ type Built struct {
|
|||||||
Reference string
|
Reference string
|
||||||
// Digest is "sha256:<hex>", for an archive. An image reference already ends in one.
|
// Digest is "sha256:<hex>", for an archive. An image reference already ends in one.
|
||||||
Digest string
|
Digest string
|
||||||
|
// Launchers are, for a bundle in an interpreted language, the executable the build wrote beside
|
||||||
|
// each entrypoint, by entrypoint (novox/hq ADR 0193): what the node's runtime starts to serve it.
|
||||||
|
Launchers map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve fills a manifest's resources in from what was built.
|
// Resolve fills a manifest's resources in from what was built.
|
||||||
@@ -92,7 +95,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
|||||||
out.Bundles = append(out.Bundles, Bundle{
|
out.Bundles = append(out.Bundles, Bundle{
|
||||||
Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest,
|
Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest,
|
||||||
Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...),
|
Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...),
|
||||||
Loads: loads, Env: copyWords(a.Env),
|
Loads: loads, Env: copyWords(a.Env), Launchers: copyWords(made.Launchers),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name })
|
sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name })
|
||||||
|
|||||||
@@ -604,6 +604,9 @@ type Bundle struct {
|
|||||||
Loads []string `json:"loads,omitempty"`
|
Loads []string `json:"loads,omitempty"`
|
||||||
// Env is what the artifact said it is given (ADR 0192), as written; composed per machine.
|
// Env is what the artifact said it is given (ADR 0192), as written; composed per machine.
|
||||||
Env map[string]string `json:"env,omitempty"`
|
Env map[string]string `json:"env,omitempty"`
|
||||||
|
// Launchers are the executables the build wrote beside its entrypoints, by entrypoint (novox/hq
|
||||||
|
// ADR 0193). A bundle built before them has none, and is served as it was built.
|
||||||
|
Launchers map[string]string `json:"launchers,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build says how to produce this module's artifacts from its source.
|
// Build says how to produce this module's artifacts from its source.
|
||||||
|
|||||||
@@ -169,6 +169,11 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, load := range b.Loads {
|
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)
|
served = append(served, m.Module+"="+BundlePath(m.Module, b.Name)+"/"+load)
|
||||||
}
|
}
|
||||||
restartOn = append(restartOn, m.Module+"."+BundleID(b.Name))
|
restartOn = append(restartOn, m.Module+"."+BundleID(b.Name))
|
||||||
|
|||||||
@@ -336,3 +336,29 @@ func TestABundlesWordsAreRefusedWhenTheyAreNotPathsOrConstants(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0193: the runtime is told the launcher a build wrote, and the entrypoint itself for a
|
||||||
|
// build from before launchers — so the move needs no flag day.
|
||||||
|
func TestTheRuntimeStartsTheLauncherWhereTheBuildWroteOne(t *testing.T) {
|
||||||
|
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||||
|
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
|
||||||
|
launched := Manifest{Module: "dash", Version: "1", Tools: []string{"status"},
|
||||||
|
Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "typescript",
|
||||||
|
Entrypoints: []string{"tools/index.js"}}}}}
|
||||||
|
launched, err := launched.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle,
|
||||||
|
Reference: ArtifactStoreScheme + "dash/tools/blobs/" + bundleDigest, Digest: bundleDigest,
|
||||||
|
Launchers: map[string]string{"tools/index.js": "tools/index.serve.mjs"}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
older := aToolsModule(t, "nftables", "tools/index.js")
|
||||||
|
out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{launched, older, theRuntime(t)}}.Declaration(with)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
env := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())["env"].(map[string]string)
|
||||||
|
want := "dash=" + BundleRoot + "/dash/tools/tools/index.serve.mjs,nftables=" + BundleRoot + "/nftables/tools/tools/index.js"
|
||||||
|
if env[RuntimeToolModules] != want {
|
||||||
|
t.Errorf("the runtime is told %q, want %q", env[RuntimeToolModules], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user