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
}