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
190 lines
6.9 KiB
Go
190 lines
6.9 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
|
|
}
|
|
|
|
// 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
|
|
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 ArtifactImage, ArtifactUpstream:
|
|
filled["image"] = artifact.Reference
|
|
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.
|
|
filled["source"] = artifact.Reference
|
|
filled["digest"] = 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:
|
|
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))
|
|
}
|
|
// **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 a.Kind == ArtifactBundle {
|
|
if a.From != "" {
|
|
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))
|
|
}
|
|
} 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"
|
|
}
|