mesh/delivery-group group feat/sdk1 rejected: composed together: the checker's verdict at the members' heads (428d048dd50a): unit: git.novox.be/novox/mesh-sdk/…
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: unit: git.novox.be/novox/mesh-sdk/go/process.TestStatePutWritesAKeyWhole: a Put through a short blackout: 0, mesh-mail's state send…
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
366 lines
15 KiB
Go
366 lines
15 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 (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
|
|
}
|