The runtime knows no language: the build makes each served entrypoint executable. For a TypeScript bundle that is <entry>.serve.mjs, which imports the entrypoint and serves what it registered over MCP on stdio through the bundle's own SDK. The build records its launchers on the bundle, and the composer names the launcher where a build wrote one and the entrypoint where it did not, so bundles built before this keep serving until they are rebuilt.
420 lines
18 KiB
Go
420 lines
18 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"fmt"
|
|
"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
|
|
}
|
|
|
|
// 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)))
|
|
}
|
|
|
|
out := m
|
|
out.Build = nil
|
|
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 {
|
|
for _, a := range m.Build.Artifacts {
|
|
if a.Kind != ArtifactBundle {
|
|
continue
|
|
}
|
|
made := by[a.Name]
|
|
// 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).
|
|
loads := append([]string(nil), a.Loads...)
|
|
if a.Loads == nil && len(m.Tools) > 0 {
|
|
loads = append([]string(nil), a.Entrypoints...)
|
|
}
|
|
// **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),
|
|
})
|
|
}
|
|
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 {
|
|
text, isText := value.(string)
|
|
if !isText || !strings.Contains(text, versionRef) {
|
|
continue
|
|
}
|
|
filled[key] = strings.ReplaceAll(text, versionRef, 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
|
|
}
|
|
|
|
// checkBuild is the manifest's own account of what it builds.
|
|
func (b *Build) problems(module string) []string {
|
|
if b == nil {
|
|
return nil
|
|
}
|
|
var problems []string
|
|
seen := map[string]bool{}
|
|
for _, a := range b.Artifacts {
|
|
if a.Name == "" {
|
|
problems = append(problems, module+" builds an artifact with no name")
|
|
continue
|
|
}
|
|
if seen[a.Name] {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s builds two artifacts called %q, and a resource naming it could mean either",
|
|
module, a.Name))
|
|
}
|
|
seen[a.Name] = true
|
|
switch a.Kind {
|
|
case ArtifactImage, ArtifactArchive, ArtifactUpstream, ArtifactBundle, ArtifactPackage:
|
|
default:
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: %q is a %q, and an artifact is %q, %q, %q or %q",
|
|
module, a.Name, a.Kind, ArtifactImage, ArtifactArchive, ArtifactUpstream,
|
|
ArtifactBundle+", "+ArtifactPackage))
|
|
}
|
|
// **A bundle is built from the module itself, so it says a language instead.** Everything
|
|
// else names what it is built from: a Dockerfile, a directory, somebody else's reference.
|
|
// A bundle's source is the module's own directory by definition, and what it needs to say
|
|
// is which compiler — because the mesh chooses that, and cannot choose for a module that
|
|
// has not said.
|
|
if len(a.Env) > 0 && a.Kind != ArtifactBundle {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: %q is a %q and says what it is given (env). Only a bundle the node's runtime "+
|
|
"serves is given words (novox/hq ADR 0192); a container says its own environment",
|
|
module, a.Name, a.Kind))
|
|
}
|
|
if a.Kind == ArtifactBundle || a.Kind == ArtifactPackage {
|
|
// **Except for a language that compiles to a binary, where it names which one**
|
|
// (novox/hq 04-ISSUES/142). A bundle in an interpreted language is the module's own
|
|
// directory compiled whole, and naming a source would be describing its own build. A
|
|
// repository written in a compiled language holds several commands — the host and its
|
|
// bootstrap live in one, and the mesh needs the host — and "the module's own directory"
|
|
// is then not a package at all. So the compiled case may say which package, and says
|
|
// the module root by saying nothing.
|
|
if a.From != "" && !compilesToABinary(a.Language) {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: %q is a bundle and names what it is built from (%q). A bundle is built "+
|
|
"from the module's own directory; what it says is the language",
|
|
module, a.Name, a.From))
|
|
}
|
|
if strings.TrimSpace(a.Language) == "" {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: %q is a bundle and says no language, so nothing can choose a compiler "+
|
|
"for it", module, a.Name))
|
|
}
|
|
problems = append(problems, bundleEnvProblems(module, a)...)
|
|
// What the runtime loads is among what was compiled (ADR 0175): a name here that is
|
|
// not an entrypoint is a file the bundle does not contain, and the runtime would
|
|
// fail to import it on every machine rather than here.
|
|
for _, load := range a.Loads {
|
|
found := false
|
|
for _, e := range a.Entrypoints {
|
|
found = found || e == load
|
|
}
|
|
if !found {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: %q says the runtime loads %q, which is not among its entrypoints — "+
|
|
"what is loaded is compiled, so it is named there too", module, a.Name, load))
|
|
}
|
|
}
|
|
// **A system, for a language that compiles to a binary** (novox/hq ADR 0142). A binary
|
|
// is pinned to one operating system at link time so a host refuses to touch a machine
|
|
// it was not built for (novox/hq ADR 0005); an artifact that says nothing would be
|
|
// compiled for whatever the build machine happened to be, which reads as portable and
|
|
// is not.
|
|
if compiled := compilesToABinary(a.Language); compiled && strings.TrimSpace(a.System) == "" {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: %q is compiled to a binary and says no system, so it would be built for "+
|
|
"whatever the build machine happens to be. Declare one artifact per "+
|
|
"system: %s", module, a.Name, spokenSystems()))
|
|
} else if !compiled && strings.TrimSpace(a.System) != "" {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: %q names the system %q and is written in %q, which compiles to code that "+
|
|
"runs anywhere — a system that decides nothing reads as though it did",
|
|
module, a.Name, a.System, a.Language))
|
|
} else if compiled && !knownSystem(a.System) {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: %q is built for %q, and a system is %s",
|
|
module, a.Name, a.System, spokenSystems()))
|
|
}
|
|
} else {
|
|
if a.From == "" {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: %q says nothing about what it is built from", module, a.Name))
|
|
}
|
|
if a.Language != "" {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: %q is a %q and names a language. Only a bundle is compiled by the mesh; "+
|
|
"everything else brings its own recipe", module, a.Name, a.Kind))
|
|
}
|
|
}
|
|
// An upstream image is named, not read from the repository, so the path rule does not
|
|
// apply to it — and applying it anyway would refuse every reference with a registry host
|
|
// in it.
|
|
if a.Kind != ArtifactUpstream &&
|
|
(strings.HasPrefix(a.From, "/") || strings.Contains(a.From, "..")) {
|
|
// A build reads its own repository and nothing else. A path leaving it would make
|
|
// what gets built depend on whatever happens to be on the machine building it.
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: %q is built from %q, which is outside its own repository",
|
|
module, a.Name, a.From))
|
|
}
|
|
if a.Kind == ArtifactUpstream && !strings.Contains(a.From, ":") {
|
|
// Without a tag or digest, what gets mirrored is whatever `latest` means today, and
|
|
// a module pinned to that is not pinned.
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: %q mirrors %q, which names no tag or digest", module, a.Name, a.From))
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
|
|
// Systems the mesh builds binaries for, which is the set a host may be pinned to (novox/hq ADR 0005).
|
|
//
|
|
// **A closed list, and the host's own, not the compiler's.** These are not the values a Go toolchain
|
|
// would call an operating system — the difference between two of them is a C library, not a kernel.
|
|
// They are what a machine reports itself to be and what a host is linked to refuse, so the list that
|
|
// matters is the one the host understands.
|
|
var systems = []string{"alpine", "android", "arch"}
|
|
|
|
// knownSystem is whether the mesh builds for it.
|
|
func knownSystem(system string) bool {
|
|
want := strings.ToLower(strings.TrimSpace(system))
|
|
for _, s := range systems {
|
|
if s == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// spokenSystems is the list as a refusal says it, so a reader is one edit from right.
|
|
func spokenSystems() string {
|
|
return strings.Join(systems, ", ")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// bundleEnvWords are the words the runtime sets for itself; a bundle that named one would be
|
|
// telling the runtime what it is, which is the mesh's to say (novox/hq ADR 0192).
|
|
var bundleEnvWords = map[string]bool{
|
|
RuntimeToolModules: true, RuntimeBrokerFile: true, RuntimeOperatorAccount: true,
|
|
RuntimeOperatorHome: true, RuntimeToolEnv: true,
|
|
}
|
|
|
|
// bundleEnvProblems says what is wrong with what a bundle says it is given (novox/hq ADR 0192):
|
|
// a value is a path or a constant written with the references a container's environment may use
|
|
// for a place or a port, and never a secret's content or another module's binding — a secret
|
|
// reaches a tool as a file whose path is named.
|
|
func bundleEnvProblems(module string, a Artifact) []string {
|
|
if len(a.Env) == 0 {
|
|
return nil
|
|
}
|
|
var problems []string
|
|
for _, word := range sortedKeys(a.Env) {
|
|
value := a.Env[word]
|
|
if bundleEnvWords[word] {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: %q gives itself %s, which the node's runtime sets for itself; a bundle is "+
|
|
"given its own words beside the runtime's, never in place of them (novox/hq ADR 0192)",
|
|
module, a.Name, word))
|
|
}
|
|
rest := ofPort.ReplaceAllString(dirRef.ReplaceAllString(value, ""), "")
|
|
if strings.Contains(rest, "${") {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: %q gives %s the value %q. A bundle's word is a path or a constant, written with "+
|
|
"${dir:…} and ${port:…} only; a secret reaches a tool as a file the mesh places, "+
|
|
"named by its path, never as its content (novox/hq ADR 0192)",
|
|
module, a.Name, word, value))
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
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
|
|
}
|