Files
mesh-controller/internal/builder/toolchain.go
T
jochen ca7e81e964 A TypeScript bundle carries what it runs with: the toolchain's runtime directory is copied into it (hq to-be 38 WP3, ADR 0188)
A bundle that compiled was not yet a bundle that ran. The compiler resolved `import "nats"` from
the toolchain image's own node_modules and the pack took only what the compiler wrote, so what a
machine unpacked could not find a single dependency — and Node would have read the bare `.js` as
CommonJS besides. No TypeScript bundle had run live to show it; the runtime's own is the first that
must. A toolchain now names a Dependencies directory in its image, copied whole into the output's
root after the compile by a second run in the same image: for TypeScript /app/runtime, which the
runtime's image puts a `"type": "module"` package.json and its pruned node_modules at. An older
image without it fails the build by name rather than packing a bundle that starts nowhere. The
SDK's and the runtime's dependencies, nothing module-specific yet: a skeleton, by ADR 0188 §5.
2026-10-02 21:44:44 +02:00

229 lines
11 KiB
Go

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 directory is appended by the builder, per artifact, because one module may
// declare several bundles — a daemon in one language, tools in another, a package in a third —
// and a toolchain with one fixed output would have them overwrite each other and then be
// packed together.
Compile []string
// OutputFlag is how this compiler is told where to put its output.
OutputFlag string
// Unit is what this compiler is pointed at: UnitSources, the entrypoint files the module named,
// or UnitPackage, the one directory the artifact is built `from`.
//
// **The difference is the language and not the module.** A TypeScript bundle is a set of files
// compiled into a set of files, so what to compile is the module's entrypoints with their source
// extension. A Go bundle is a package compiled into one binary, and there is no per-file
// compilation to name — pointing `go build` at a file list builds a program out of exactly those
// files and ignores the rest of the package, which fails as a missing symbol rather than as a
// wrong instruction.
Unit string
// SourceExt is the extension an entrypoint has in the repository, for UnitSources. An entrypoint
// is named as it will be FOUND, inside the unpacked bundle, so the source is the same path with
// the output directory taken off the front and this on the end.
SourceExt string
// LinkerFlags are passed to the linker as one flag, together with the system stamp below.
//
// **Separate from Compile because a repeated flag is not a merged one.** They were in the compile
// line, and appending the stamp as a second `-ldflags` meant the Go command took the last and
// dropped the first — so the binary gained its system and lost `-s -w`, growing by half and
// carrying its debug info. The mistake was believing a comment rather than reading the file it
// produced (novox/hq 04-ISSUES/161).
LinkerFlags []string
// Dependencies is a directory inside the toolchain image whose contents a bundle in this
// language runs with, copied whole into the compiled output's root after the compile.
//
// **A bundle that compiles is not yet a bundle that runs.** The compiler resolves `import
// "nats"` from the toolchain image's own node_modules and the pack takes only what the compiler
// wrote, so what a machine unpacked could not find a single dependency — and no TypeScript bundle
// had ever run live to show it (novox/hq to-be 38 WP3). For TypeScript the directory holds a
// `package.json` saying `"type": "module"` — Node reads a bare `.js` as CommonJS otherwise, so a
// bundle with its dependencies and without that line still fails to start — and the pruned,
// production-only node_modules the runtime itself ships with: the SDK's and the runtime's
// dependencies, and nothing module-specific yet (novox/hq ADR 0188 §5: a skeleton; a module's
// own npm dependencies are a later step). Empty for a language whose bundle carries its own —
// a Go binary is static, a Python bundle is installed with its dependencies.
//
// A toolchain image without the directory fails the build by name rather than packing a bundle
// that starts nowhere: the image predates this and must be rebuilt first.
Dependencies string
// SystemStamp is the variable this language's linker fills with the artifact's declared system,
// for a language whose binaries are pinned to one at link time (novox/hq ADR 0005).
//
// **The one thing a toolchain takes from the artifact, and 0142 says why**: the target is a
// property of the artifact rather than of the recipe, because a compiled binary is per system
// and a toolchain that accepted it from the module would be accepting a build instruction. This
// is the narrow exception, named here rather than inferred.
//
// Empty for a language that compiles to nothing pinned. A host built without it refuses every
// declaration before applying anything — safely, totally, and with nothing reporting it
// (novox/hq 04-ISSUES/161).
SystemStamp string
}
// What a toolchain is pointed at.
const (
// UnitSources is a list of files, derived from the module's entrypoints.
UnitSources = "sources"
// UnitPackage is the single directory the artifact is built `from`, compiled whole.
UnitPackage = "package"
)
// Out is where one artifact's compiled output lands, inside the module's own directory.
//
// **Per artifact, never per toolchain.** A module is one piece of software and may still be
// written in several languages — a daemon in one, a tool in another, a package in a third (ADR
// 0040). Each bundle is compiled and packed alone, so what a machine unpacks is that artifact and
// nothing else.
//
// Under a directory named for the build rather than beside the source, so a pack never sweeps up
// the module's own working files, and two builds of one commit see the same tree.
func Out(artifact string) string { return ".mesh-build/" + artifact }
// 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.
// **Rooted at the module, so an entrypoint lands where it is named.** Without a root the
// compiler takes the common directory of the files it is given: a module compiling only
// `tools/index.ts` had its output at `index.js`, and the entrypoint it declared —
// `tools/index.js`, "named as it will be found" — named a file the bundle did not
// contain. The runtime that loads bundles by their declared entrypoints (novox/hq ADR
// 0175) is what made this visible.
Compile: []string{
"node", "/app/node_modules/typescript/bin/tsc",
"--module", "NodeNext", "--moduleResolution", "NodeNext",
"--target", "ES2022", "--rootDir", ".",
},
OutputFlag: "--outDir",
Unit: UnitSources,
SourceExt: ".ts",
Dependencies: "/app/runtime",
},
{
Language: "go",
Base: "mesh-tools-go",
Artifact: "build",
// **The mesh's own components, and not modules.** The warning above this list — that every
// language is another implementation of the contracts modules share, so adding one commits
// to keeping N implementations in step — does not attach here. Go is how the host, the
// control plane and the builder are written, and none of them is a module in that sense:
// the host is what APPLIES modules. So there is no SDK obligation, and the reason this
// entry did not exist was that nothing needed to compile the mesh itself
// (novox/hq ADR 0142, and 04-ISSUES/142 where that is why nothing delivers the host).
//
// Static, because what a machine ends up holding is a file rather than a container, and a
// binary that needs a libc it did not bring is a delivery that works until a machine
// differs. Trimmed of its own paths for the same reason a version comes from where it sits
// rather than from the linker: two builds of one commit produce the same bytes.
Compile: []string{
"env", "CGO_ENABLED=0", "GOFLAGS=-trimpath",
"go", "build",
},
// Stripped of symbols and debug info: what a machine holds is a file it runs, not one it
// debugs, and the difference measured 12.2MB against 8.5MB.
LinkerFlags: []string{"-s", "-w"},
OutputFlag: "-o",
// Pointed at the package the artifact is built `from`, compiled whole. Go writes the binary
// into the output directory, named after the package — so the bundle a machine unpacks is a
// directory holding one executable, which is what the delivery mechanism expects
// (novox/hq ADR 0141).
Unit: UnitPackage,
// The mesh's own Go components read the system they were built for from this variable, and
// refuse to touch a machine without one.
SystemStamp: "main.builtFor",
},
{
Language: "python",
Base: "mesh-tools-python",
Artifact: "build",
// Nothing to compile: what a bundle needs is the module's own code and its dependencies
// resolved, so the "compile" is an install into the output directory. Named here rather
// than left implicit because a reader comparing two toolchains should be able to see what
// each actually does.
Compile: []string{"python", "-m", "pip", "install", "--no-compile", "--target"},
OutputFlag: "",
Unit: UnitSources,
SourceExt: ".py",
},
}
// 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
}