A module says what it is written in, and needs no Dockerfile

The bundle recipe: the one that both builds and packs. An archive packs a
directory as it stands, so shipping compiled output meant compiling somewhere
first — which meant a Dockerfile repeating the same incantation in every module.
Two base arguments with no defaults, a working directory chosen so the SDK
resolves upward, the compiler invoked by absolute path because the usual symlink
is resolved away when the base image is assembled, a second stage, an environment
variable naming the entrypoints. Most of the catalogue is unconverted and that is
why; two conversions done in one session were each wrong twice with a working
example open in the next window.

A bundle says a language and a list of entrypoints. The mesh knows what the
language implies. Anything a module could override there it would be writing a
Dockerfile to override, so a toolchain is deliberately not configurable.

Declared rather than inferred, both of them: guessing the language from which
files are present makes a build depend on a directory listing, and guessing the
entrypoints makes it change meaning when somebody adds a helper.

A toolchain the mesh does not hold is refused before anything is compiled, naming
what to build first — the same treatment a missing base already gets, because it
is the same question and somebody can answer it. A language the mesh does not
build is refused saying what would have worked, since the author is usually one
word away.

The list of languages is closed and adding to it is a decision. Every language is
another implementation of the contracts every module shares, and those change
rarely and cascade when they do (ADR 0039) — a mesh whose SDKs disagree about the
envelope fails by ignoring messages rather than by failing to compile.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
2026-09-15 01:59:07 +02:00
parent 3ae7b88c6d
commit 4b7bd1b3e2
6 changed files with 424 additions and 11 deletions
+87 -2
View File
@@ -129,7 +129,7 @@ func Build(ctx context.Context, run Runner, publish Publisher,
// logs can be compared.
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
for _, a := range artifacts {
made, err := one(ctx, run, publish, manifest.Module, within, commit, a, args)
made, err := one(ctx, run, publish, manifest.Module, within, commit, a, args, held)
if err != nil {
return Result{}, err
}
@@ -215,7 +215,8 @@ func against(within string, manifest catalogue.Manifest) []string {
const ManifestName = "module.json"
func one(ctx context.Context, run Runner, publish Publisher,
module, tree, commit string, a catalogue.Artifact, args []string) (catalogue.Built, error) {
module, tree, commit string, a catalogue.Artifact, args []string,
held map[string]string) (catalogue.Built, error) {
switch a.Kind {
case catalogue.ArtifactUpstream:
@@ -252,6 +253,46 @@ func one(ctx context.Context, run Runner, publish Publisher,
}
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil
case catalogue.ArtifactBundle:
// **The one recipe that both builds and packs.** Everything else either produces an image
// or packs what is already there; this compiles the module's own code first, in a
// toolchain the mesh chose from what the module said it was written in, and packs the
// result.
//
// The compiler runs in a container rather than on the build machine, for the reason every
// other build does: what a build needs installed is the toolchain's business, and a build
// machine that accumulated one toolchain per language would be a machine nobody could
// reproduce.
chain, err := ToolchainFor(a.Language)
if err != nil {
return catalogue.Built{}, fmt.Errorf("%s: %s: %w", module, a.Name, err)
}
base, ok := held[chain.Base+"/"+chain.Artifact]
if !ok {
// Named, not pinned: the mesh answers with the copy it holds. Refused before anything
// is built, saying which module has to exist first, rather than failing inside a
// compile with a message about an image (novox/hq 04-ISSUES/044).
return catalogue.Built{}, fmt.Errorf(
"%s: %s is written in %s, which is compiled by %s's %q artifact, and this mesh "+
"holds no copy of it. Build %s first",
module, a.Name, chain.Language, chain.Base, chain.Artifact, chain.Base)
}
compiled, err := compile(ctx, run, tree, chain, base, a)
if err != nil {
return catalogue.Built{}, fmt.Errorf("%s: compiling %s failed: %w", module, a.Name, err)
}
body, err := pack(compiled)
if err != nil {
return catalogue.Built{}, fmt.Errorf("%s: packing %s failed: %w", module, a.Name, err)
}
sum := sha256.Sum256(body)
digest := "sha256:" + hex.EncodeToString(sum[:])
where, err := publish.PublishArchive(ctx, module+"/"+a.Name, body, digest)
if err != nil {
return catalogue.Built{}, err
}
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest}, nil
case catalogue.ArtifactArchive:
body, err := pack(filepath.Join(tree, a.From))
if err != nil {
@@ -406,3 +447,47 @@ func standingOn(manifest catalogue.Manifest, held map[string]string) ([]string,
}
return args, nil
}
// compile runs a module's own code through its toolchain, and says where the result is.
//
// **In the module's own directory, under the path the toolchain expects.** A module is compiled
// where its dependencies resolve upward into the base's own library directory, so what it is
// compiled against is exactly what it will run against — the reason every hand-written Dockerfile
// had to choose a working directory carefully, and the reason none of them has to now.
func compile(ctx context.Context, run Runner, tree string, chain Toolchain,
base string, a catalogue.Artifact) (string, error) {
// Where inside the toolchain the module's source is mounted, and where its output lands. Fixed
// rather than configurable: a module that could move this would be describing its own build.
const within = "/app/modules/module"
invocation := []string{
"run", "--rm",
"--volume", tree + ":" + within,
"--workdir", within,
base,
}
invocation = append(invocation, chain.Compile...)
// What to compile. Named by the module rather than discovered, so adding a file does not
// silently change what a build produces.
if len(a.Entrypoints) > 0 {
invocation = append(invocation, sourcesFor(a.Entrypoints)...)
}
if _, err := run(ctx, tree, "docker", invocation...); err != nil {
return "", err
}
return filepath.Join(tree, chain.Output), nil
}
// sourcesFor turns compiled entrypoints back into what to compile.
//
// A module names what a tool host should LOAD — compiled paths under the bundle's root — because
// that is the thing anything else needs to know. What to compile is the same list with the
// language's own extension, which is the toolchain's business rather than the module's.
func sourcesFor(entrypoints []string) []string {
out := make([]string, 0, len(entrypoints))
for _, e := range entrypoints {
out = append(out, strings.TrimSuffix(e, filepath.Ext(e))+".ts")
}
return out
}
+109
View File
@@ -0,0 +1,109 @@
package builder
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
const aBundle = `{"module":"greeter","version":"1",
"build":{"artifacts":[
{"name":"code","kind":"bundle","language":"typescript","entrypoints":["dist/index.js"]}]},
"resources":[
{"id":"files","type":"archive","path":"/opt/greeter","artifact":"code"}]}`
// compiling is a runner that behaves like a toolchain: when asked to compile, it leaves output
// where the toolchain says output lands. Without this the pack step has nothing to pack, and the
// test would be asserting on a failure rather than on a build.
type compiling struct{ *recorded }
func (c compiling) run(ctx context.Context, dir, name string, args ...string) (string, error) {
out, err := c.recorded.run(ctx, dir, name, args...)
if name == "docker" && len(args) > 0 && args[0] == "run" {
made := filepath.Join(dir, "dist")
if err := os.MkdirAll(made, 0o755); err != nil {
return "", err
}
if err := os.WriteFile(filepath.Join(made, "index.js"), []byte("console.log(1)"), 0o644); err != nil {
return "", err
}
}
return out, err
}
// **A module says what it is written in, and needs no Dockerfile.** This is the whole point of the
// bundle recipe: the same module previously needed a hand-written recipe repeating an incantation
// that is easy to get wrong in ways that fail somewhere else.
func TestABundleIsCompiledAndPackedWithNoDockerfile(t *testing.T) {
r, workspace := aRepository(t, aBundle, map[string]string{"index.ts": "console.log(1)"})
held := map[string]string{"mesh-tools/build": "registry.invalid/mesh-tools/build@sha256:" + strings.Repeat("b", 64)}
got, err := Build(context.Background(), compiling{r}.run, r,
"https://forge.invalid/greeter.git", "", "", workspace, held)
if err != nil {
t.Fatalf("a module with a language and no Dockerfile did not build: %v", err)
}
// Compiled in the toolchain the mesh chose, not in one the module named.
var compiled string
for _, line := range r.ran {
if strings.HasPrefix(line, "docker run") {
compiled = line
}
}
if compiled == "" {
t.Fatalf("nothing was compiled:\n%s", strings.Join(r.ran, "\n"))
}
if !strings.Contains(compiled, "mesh-tools/build@sha256:") {
t.Fatalf("the compile did not run in the mesh's own toolchain: %s", compiled)
}
if strings.Contains(strings.Join(r.ran, "\n"), "docker build") {
t.Fatalf("a bundle invoked a Dockerfile build, which is the thing it exists to avoid:\n%s",
strings.Join(r.ran, "\n"))
}
// And pinned by a digest of what came out, like any other artifact.
digest, _ := got.Manifest.Resources[0]["digest"].(string)
if !strings.HasPrefix(digest, "sha256:") {
t.Fatalf("the bundle was not pinned: %v", got.Manifest.Resources[0])
}
}
// **Refused before anything is built, naming what to build first.** A base the mesh has not built
// is not a compile that fails on its first line — it is a question somebody can answer, and saying
// it early is the difference between a fixable message and one about a missing image.
func TestABundleWhoseToolchainIsNotHeldIsRefusedFirst(t *testing.T) {
r, workspace := aRepository(t, aBundle, map[string]string{"index.ts": "console.log(1)"})
_, err := Build(context.Background(), compiling{r}.run, r,
"https://forge.invalid/greeter.git", "", "", workspace, nil)
if err == nil {
t.Fatal("a bundle was built with no toolchain to compile it in")
}
if !strings.Contains(err.Error(), "mesh-tools") {
t.Fatalf("the refusal does not name what has to be built first: %v", err)
}
for _, line := range r.ran {
if strings.HasPrefix(line, "docker run") {
t.Fatalf("a compile was attempted before the refusal: %s", line)
}
}
}
// A language the mesh does not build is refused the same way, and names what it can build.
func TestABundleInAnUnknownLanguageIsRefused(t *testing.T) {
manifest := strings.Replace(aBundle, `"language":"typescript"`, `"language":"cobol"`, 1)
r, workspace := aRepository(t, manifest, map[string]string{"index.ts": "x"})
_, err := Build(context.Background(), compiling{r}.run, r,
"https://forge.invalid/greeter.git", "", "", workspace,
map[string]string{"mesh-tools/build": "registry.invalid/x@sha256:" + strings.Repeat("c", 64)})
if err == nil {
t.Fatal("a language nothing can compile was accepted")
}
if !strings.Contains(err.Error(), "typescript") {
t.Fatalf("the refusal does not say what would have worked: %v", err)
}
}
+99
View File
@@ -0,0 +1,99 @@
package builder
import (
"fmt"
"sort"
"strings"
)
// What a language implies, so a module does not have to say it.
//
// **A module says what it is written in; this says what that means.** The alternative is what the
// mesh had: every module carrying a Dockerfile that repeated the same incantation, and most of the
// catalogue never converted because the incantation is easy to get wrong in ways that fail
// somewhere else (novox/hq 03-DESIGN/01-to-be/18-building-a-module.md).
//
// A toolchain is deliberately not configurable by the module. Anything a module could override
// here it would be writing a Dockerfile to override, and then this bought nothing.
// Toolchain is how one language is compiled into a bundle.
type Toolchain struct {
// Language is what a module declares to select this.
Language string
// Base is the module whose artifact provides the compiler, named rather than pinned: the mesh
// answers with the copy it holds, so a recipe never names one particular build of it
// (novox/hq 04-ISSUES/044).
Base string
// Artifact is which of that module's artifacts is the compiling one.
Artifact string
// Compile is what runs inside it, relative to the module's own directory. The output goes to
// Output, which is what gets packed.
Compile []string
// Output is the directory the compiled result lands in, relative to the module's directory.
Output string
}
// toolchains is every language the mesh can build.
//
// **A closed list, and adding to it is a decision rather than a configuration.** Every language is
// permanent: it needs an SDK carrying the broker client, sealed-credential reading, the event
// envelope and tool serving, and the contracts every module shares change rarely and cascade when
// they do (novox/hq ADR 0039). A mesh whose languages disagree about the envelope fails by ignoring
// messages rather than by failing to compile, so a new entry here is a commitment to keeping N
// implementations of one contract in step.
var toolchains = []Toolchain{
{
Language: "typescript",
Base: "mesh-tools",
Artifact: "build",
// Invoked by its real path rather than through node_modules/.bin, whose entries are
// symlinks to a launcher that requires its library relatively — and the base image's own
// assembly resolves them away, leaving a launcher whose relative require points nowhere.
// Every module's hand-written Dockerfile had to know this. Now none of them does.
Compile: []string{
"node", "/app/node_modules/typescript/bin/tsc",
"--module", "NodeNext", "--moduleResolution", "NodeNext",
"--target", "ES2022", "--outDir", "dist",
},
Output: "dist",
},
}
// ToolchainFor is what builds this language, or says what it can build.
func ToolchainFor(language string) (Toolchain, error) {
want := strings.ToLower(strings.TrimSpace(language))
if want == "" {
return Toolchain{}, fmt.Errorf(
"a bundle must say what language it is written in: the mesh chooses the compiler, and "+
"it cannot choose one for a module that has not said. It can build %s", spoken())
}
for _, t := range toolchains {
if t.Language == want {
return t, nil
}
}
return Toolchain{}, fmt.Errorf(
"%q is not a language this mesh builds. It can build %s — and adding one is a decision "+
"rather than a setting, because every language is another implementation of the "+
"contracts every module shares", language, spoken())
}
// spoken lists the languages, so a refusal says what would have worked.
func spoken() string {
names := make([]string, 0, len(toolchains))
for _, t := range toolchains {
names = append(names, t.Language)
}
sort.Strings(names)
return strings.Join(names, ", ")
}
// Languages is every language the mesh can build, for anything that wants to say so.
func Languages() []string {
names := make([]string, 0, len(toolchains))
for _, t := range toolchains {
names = append(names, t.Language)
}
sort.Strings(names)
return names
}
+56
View File
@@ -0,0 +1,56 @@
package builder
import (
"strings"
"testing"
)
// A language the mesh builds resolves to the toolchain that builds it.
func TestADeclaredLanguageSelectsItsToolchain(t *testing.T) {
chain, err := ToolchainFor("typescript")
if err != nil {
t.Fatalf("typescript is not buildable: %v", err)
}
if chain.Base == "" || chain.Artifact == "" {
t.Fatalf("a toolchain names no base to compile in: %+v", chain)
}
if len(chain.Compile) == 0 || chain.Output == "" {
t.Fatalf("a toolchain says nothing about how to compile or where output lands: %+v", chain)
}
}
// Case and stray whitespace are a module author's slip, not a different language.
func TestALanguageIsMatchedLoosely(t *testing.T) {
for _, said := range []string{"TypeScript", " typescript ", "TYPESCRIPT"} {
if _, err := ToolchainFor(said); err != nil {
t.Fatalf("%q was refused: %v", said, err)
}
}
}
// **A refusal says what would have worked.** A module author who names a language the mesh does
// not build is one word away from a language it does, and a bare "unsupported" makes them go
// looking for a list that exists in one place in the source.
func TestAnUnknownLanguageSaysWhatIsBuildable(t *testing.T) {
_, err := ToolchainFor("cobol")
if err == nil {
t.Fatal("a language nothing can build was accepted")
}
for _, want := range Languages() {
if !strings.Contains(err.Error(), want) {
t.Fatalf("the refusal does not mention %q, which would have worked: %v", want, err)
}
}
}
// And saying nothing is its own message: the mesh chooses the compiler, so a bundle that names no
// language has not asked for anything in particular — which is a mistake rather than a default.
func TestABundleMustSayWhatItIsWrittenIn(t *testing.T) {
_, err := ToolchainFor("")
if err == nil {
t.Fatal("a bundle with no language was accepted, so the mesh guessed a compiler")
}
if !strings.Contains(err.Error(), "must say") {
t.Fatalf("the refusal does not say a language is required: %v", err)
}
}