Resolve keeps its own address strip (kept, ADR 0155), which needs no seat name. The one seat name left is ManifestNeeds' store holder (ADR 0632 §2), beside its rule in manifest_reads.go. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
382 lines
16 KiB
Go
382 lines
16 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"fmt"
|
|
"path"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// 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 (the registry clients' Routed).
|
|
out.Bundles = append(out.Bundles, Bundle{
|
|
Name: a.Name, Source: kept(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"] = kept(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
|
|
}
|
|
|
|
// kept is a bundle's or an archive's reference without the artifact store's address, if the builder wrote one (ADR 0155:
|
|
// a resolved manifest names no installation). Only the address is read here: an `http://<host>/v2/<path>/blobs/<digest>`
|
|
// becomes `artifact-store://<path>/blobs/<digest>`; a kept reference, or one in a registry seat's form (whose first part,
|
|
// a seat's name, has no dot or port), is what it was. How the registry seats' forms are read is the registry clients'.
|
|
func kept(reference string) string {
|
|
if strings.HasPrefix(reference, ArtifactStoreScheme) {
|
|
return reference
|
|
}
|
|
if rest, isURL := strings.CutPrefix(reference, "http://"); isURL {
|
|
if _, path, ok := strings.Cut(rest, "/v2/"); ok && strings.Contains(path, "/blobs/") {
|
|
return ArtifactStoreScheme + path
|
|
}
|
|
}
|
|
return reference
|
|
}
|