Files
mesh-sdk/go/catalogue/build.go
T
jschoubben 68aaf235d4 Take the judge out of mesh-sdk: it is mesh-delivery's now (ADR 0578 §4, §7)
Issue 1585, S19: the strict parser, every manifest problem function and the judge's tables (192 declarations,
about 5,200 lines) and their 244 tests leave; composition keeps its own refusals and the helpers it uses. The
catalogue's tests decode the manifests they build. ParseManifest, ParseManifestWith, ParseManifestForInputs,
CatalogueProblems and the rest are gone; mesh-delivery-indexer/judge holds them.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 02:36:24 +01:00

380 lines
16 KiB
Go

package catalogue
import (
"fmt"
"path"
"sort"
"strings"
)
// Turning a manifest that names artifacts into one that names digests.
//
// **Two documents, deliberately.** The manifest in a repository says *this resource uses the
// archive called `config`*; the manifest the mesh holds says *this resource is sha256:…*. A digest
// is not knowable until something is built, so a repository carrying one would be a repository
// whose file is wrong the moment anybody edits anything — and the mesh would be pinning a value
// nobody could have checked.
//
// So the built manifest is **derived**, and the record of which commit it was derived from is what
// makes "is this current?" answerable without building (novox/hq ADR 0009).
// Built is one artifact after it exists: where it is and what it hashes to.
type Built struct {
// Name is what the manifest called it.
Name string
// Kind is "image" or "archive".
Kind string
// Reference is what a machine uses to get it — an image reference for an image, a URL for an
// archive. Both already carry the digest for an image; an archive carries it separately.
Reference string
// Digest is "sha256:<hex>", for an archive. An image reference already ends in one.
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
// Texts are, for the archive a module packs its templates into (TemplatesArtifact), each file's text as the
// build read it back from the packed archive, by its path inside it (novox/hq ADR 0349 §2). Never carried on
// the wire or kept: Resolve reads them into the files' content, which is what the mesh holds.
Texts map[string]string `json:"-"`
}
// Resolve fills a manifest's resources in from what was built.
//
// Every resource naming an artifact is rewritten to name the thing itself, and the `artifact` key
// is removed — because it is a build-time word and the host has never heard of it. A resource
// naming an artifact nothing produced is refused: it would otherwise reach a machine with an
// empty image or an unpinned archive, which is the shape of failure that looks like success.
func (m Manifest) Resolve(built []Built) (Manifest, error) {
if m.Build == nil && len(built) == 0 {
return m, nil
}
by := map[string]Built{}
for _, b := range built {
by[b.Name] = b
}
// Declared and not produced is a build that did not do what the manifest asked, and saying so
// here beats a machine reporting it later.
var missing []string
if m.Build != nil {
for _, a := range m.Build.Artifacts {
if _, ok := by[a.Name]; !ok {
missing = append(missing, a.Name)
}
}
}
if len(missing) > 0 {
sort.Strings(missing)
return Manifest{}, fmt.Errorf(
"%s says it builds %s and the build did not produce %s — the build did not do what "+
"the manifest asked, which is a different fault from a resource asking for the "+
"wrong thing",
m.Module, strings.Join(missing, " and "), oneOrOther(len(missing)))
}
// **Each template's text, as the build read it back from its templates archive** (novox/hq ADR 0349 §2): into
// the file's content, so what the mesh holds and composes is what the same text inline would be.
var handed *Handed
if m.Templated() {
name := TemplatesArtifact(m)
texts := by[name].Texts
if name == "" || texts == nil {
return Manifest{}, fmt.Errorf("%s names templates, and the build carried no text read back from the "+
"archive it packs %s/ into (%q): the builder that built it does not read templates",
m.Module, m.TemplatesDir(), name)
}
dir := m.TemplatesDir()
filled, err := fillTemplates(m.Module, dir, m.Resources, func(template string) (string, bool) {
t, ok := texts[strings.TrimPrefix(template, dir+"/")]
return t, ok
}, fmt.Sprintf("the archive %q", name))
if err != nil {
return Manifest{}, err
}
items, err := fillItemTemplates(m, m.Items, func(template string) (string, bool) {
t, ok := texts[strings.TrimPrefix(template, dir+"/")]
return t, ok
}, fmt.Sprintf("the archive %q", name))
if err != nil {
return Manifest{}, err
}
// And, for a moved module, where its templates are and which one each file and item is filled from
// (handed.go): read while its resources and items still name their templates.
handed = handedBy(m, by[name])
m.Resources = filled
m.Items = items
}
out := m
out.Handed = handed
out.Build = nil
// Read with the build, like Build: the manifest the mesh holds carries each file's text (issue 731).
out.Templates = ""
out.Resources = nil
// What the build compiled, kept on the resolved manifest (novox/hq ADR 0175): a tools bundle is
// named by no resource of the module's own — the node's runtime loads it — so this is the only
// place the mesh would otherwise not have it. In artifact order, so two resolutions of one
// build compare equal.
out.Bundles = nil
if m.Build != nil {
run := runByAResource(m)
for _, a := range m.Build.Artifacts {
if a.Kind != ArtifactBundle {
continue
}
made := by[a.Name]
loads := BundleLoads(m, a, run)
// **Kept, never routed** (ADR 0155): the builder publishes to the store at the address
// it reached it by, and a manifest carrying that address names an installation —
// registration refused node-tools for exactly this on 2026-10-02. The build record
// already keeps the store-relative form; the resolved manifest keeps the same, and
// composition routes it through the store a machine reaches (Routed).
out.Bundles = append(out.Bundles, Bundle{
Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest,
Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...),
Loads: loads, Env: copyWords(a.Env), Launchers: copyWords(made.Launchers),
Binary: BinaryOf(a), Bus: a.Bus,
})
}
sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name })
}
for _, r := range m.Resources {
named, _ := r["artifact"].(string)
if named == "" {
out.Resources = append(out.Resources, r)
continue
}
artifact, ok := by[named]
if !ok {
return Manifest{}, fmt.Errorf(
"%s: %v uses the artifact %q, and this module builds no such thing",
m.Module, r["id"], named)
}
filled := map[string]any{}
for k, v := range r {
filled[k] = v
}
delete(filled, "artifact")
switch artifact.Kind {
case ArtifactPackage:
// A package is not a resource on any machine; it is consumed by other builds. A
// resource that names one is a manifest error, named here rather than shipped.
return Manifest{}, fmt.Errorf(
"%s: %v uses %q, which is a package — a build input, not a resource a machine runs",
m.Module, r["id"], named)
case ArtifactImage, ArtifactUpstream:
filled["image"] = artifact.Reference
// An image is not unpacked anywhere, so it has no directory to be named for its
// version and `${version}` has nothing to mean. Refused rather than left as literal
// text in a path, which is how it would reach a machine and be created as a directory
// called `${version}`.
for key, value := range filled {
if text, isText := value.(string); isText && strings.Contains(text, versionRef) {
return Manifest{}, fmt.Errorf(
"%s: %v says %s in %q, and %q is an image — an image is not unpacked, so "+
"it has no versioned place. %s is for an archive or a bundle",
m.Module, r["id"], versionRef, key, named, versionRef)
}
}
case ArtifactArchive, ArtifactBundle:
// The same on the wire: both are bytes fetched by digest and unpacked. They differ in
// how they were made — one packed as it stood, the other compiled first — and a
// machine has no reason to care which.
// Kept, not routed, for the reason the bundles above are (ADR 0155).
filled["source"] = Recorded(artifact.Reference)
filled["digest"] = artifact.Digest
// **And `${version}`, so a resource can name a place that is this build's alone**
// (novox/hq ADR 0141, 04-ISSUES/142). A component is unpacked into a directory named
// for its version so it can read its own version from its path — and until this,
// nothing could compose that path: an archive named a fixed one in the manifest and
// nothing interpolated the build into it, so nothing could ask for
// `…/versions/<version>/` and every machine took a hand-placed fallback.
//
// The version is the artifact's own digest, short. Not the commit: two builds of one
// commit are meant to be the same bytes (the toolchains are `-trimpath` for this), and
// a content-addressed version means an unchanged build resolves to the path it already
// had — so re-composing a declaration moves nothing, where a commit would move the
// path of an identical binary and recreate everything that reads it.
for key, value := range filled {
filled[key] = withVersion(value, versionOf(artifact.Digest))
}
default:
return Manifest{}, fmt.Errorf("%s: %q is a %q, and an artifact is %q, %q, %q or %q",
m.Module, named, artifact.Kind, ArtifactImage, ArtifactArchive, ArtifactUpstream,
ArtifactBundle)
}
out.Resources = append(out.Resources, filled)
}
return out, nil
}
// withVersion is a resource's value with `${version}` filled: in a string, and in each string of a map —
// a process's env, where the controller is told which build it is (novox/hq issue 352). Anything else is
// left as it is.
func withVersion(value any, version string) any {
switch v := value.(type) {
case string:
if strings.Contains(v, versionRef) {
return strings.ReplaceAll(v, versionRef, version)
}
case map[string]any:
out := make(map[string]any, len(v))
for k, x := range v {
out[k] = withVersion(x, version)
}
return out
case map[string]string:
out := make(map[string]string, len(v))
for k, x := range v {
out[k] = strings.ReplaceAll(x, versionRef, version)
}
return out
}
return value
}
// NamedPaths are the paths a module's fingerprint takes its tree from (novox/hq ADR 0499 clause 2): the union of the
// paths its artifacts name, cleaned and sorted, and every true only when the module builds something and every artifact
// names paths; otherwise none, and the module keeps its directory's tree.
//
// **Read by the core alone** (ADR 0495 clause 1, the lead's decision L8 of R4e): the indexer and its merge preview
// (buildstep.ModuleTrees) call it. A build module's fetch step is handed the paths by the builder, from the indexer's
// kept inputs (delivery.KeptInputs2.Paths), and reads no manifest for them.
func (m Manifest) NamedPaths() (paths []string, every bool) {
if m.Build == nil || len(m.Build.Artifacts) == 0 {
return nil, false
}
seen := map[string]bool{}
for _, a := range m.Build.Artifacts {
if len(a.Paths) == 0 {
return nil, false
}
for _, p := range a.Paths {
seen[path.Clean(p)] = true
}
}
for p := range seen {
paths = append(paths, p)
}
sort.Strings(paths)
return paths, true
}
// oneOrOther keeps the message readable for one artifact and for several, because a message that
// says "neither" about one thing reads as a bug in the message.
func oneOrOther(n int) string {
if n == 1 {
return "it"
}
return "them"
}
// compilesToABinary is whether this language's bundle is a binary for one operating system rather
// than code that runs wherever its interpreter does.
//
// **Asked of the language, not of the artifact.** A module says what it is written in; what that
// implies is the mesh's to know, exactly as the compiler is (novox/hq ADR 0142). Asking the artifact
// would let two artifacts in one language disagree about whether they are portable.
func compilesToABinary(language string) bool {
switch strings.ToLower(strings.TrimSpace(language)) {
case "go":
return true
default:
return false
}
}
// versionRef is how a resource names the version of the artifact it uses: ${version}.
//
// No artifact name in it, because the resource already says which artifact it is for — a second
// name would be a second thing to keep in step with the first.
const versionRef = "${version}"
// versionOf is an artifact's version as a path names it: its digest, short.
//
// **Content-addressed on purpose.** The alternative is the commit a build came from, and two builds
// of one commit are meant to produce the same bytes — every toolchain here is `-trimpath` for that
// reason. A commit-named path would move for an identical binary, and everything reading that path
// would be recreated for a change that is not one. A digest-named path moves exactly when the bytes
// do.
//
// Twelve hex characters: enough that two of this mesh's builds will not collide, short enough to
// read in a path and in a journal line. The `sha256:` prefix goes, because a directory name carrying
// a colon is a directory name people quote wrong.
func versionOf(digest string) string {
hex := strings.TrimPrefix(strings.TrimSpace(digest), "sha256:")
if len(hex) > 12 {
return hex[:12]
}
return hex
}
func copyWords(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
// BinaryOf is what a bundle compiled to a binary is called once built: what the artifact names, else
// the package it is built from, else the artifact's own name (novox/hq 04-ISSUES/142). Empty for a
// language that does not compile to one. The builder writes the binary under this name, and the
// composer runs it by it, so both ask here.
func BinaryOf(a Artifact) string {
if !compilesToABinary(a.Language) {
return ""
}
if name := strings.TrimSpace(a.Binary); name != "" {
return name
}
if from := strings.Trim(a.From, "./"); from != "" {
return path.Base(from)
}
return a.Name
}
// runByAResource is the artifacts one of a module's own resources names — a process that runs it,
// a step, an archive that unpacks it — by name.
func runByAResource(m Manifest) map[string]bool {
named := map[string]bool{}
for _, r := range m.Resources {
if a, ok := r["artifact"].(string); ok && a != "" {
named[a] = true
}
}
return named
}
// BundleLoads is what the runtime loads of the bundle artifact a of m (run: the bundles one of m's own resources runs,
// runByAResource): what the artifact said, else every entrypoint of a module that declares tools, else nothing; a
// bundle compiled to a binary is served by its binary. The rule a build's resolution keeps on Bundle.Loads, and what a
// module composed as what runs of it reads again when the runtime already dropped it (issue 1126).
func BundleLoads(m Manifest, a Artifact, run map[string]bool) []string {
// What the runtime loads: what the artifact said, else every entrypoint of a module
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
//
// **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213).
// A process the host runs is the module's service, not its tools: the controller declares
// the verbs it answers as `tools` and serves them itself, and its bundle would otherwise
// have been launched a second time by the node's runtime, as an MCP child it is not.
loads := append([]string(nil), a.Loads...)
if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] {
loads = append([]string(nil), a.Entrypoints...)
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
// as Go — the runtime execs it — exactly as a TypeScript one is through its launcher.
if bin := BinaryOf(a); bin != "" {
loads = []string{bin}
}
}
return loads
}
// RunByAResource is the bundles of m one of its own resources runs (its service, not its tools).
func RunByAResource(m Manifest) map[string]bool { return runByAResource(m) }