First of the steps in novox/hq ADR 0142, and it ships alone: a new manifest word reaches the builder and the controller one release before any manifest uses it. A toolchain deliberately accepts nothing from the module — anything a module could override there it would be writing a Dockerfile to override — and yet a compiled binary is per operating system, pinned at link time so a host refuses to touch a machine it was not built for (ADR 0005). The way out is that the target belongs to the artifact: one artifact per system, one build each, recipe still the mesh's. A bundle in a language that compiles to a binary must name a system, or it would be built for whatever the build machine happened to be — which reads as portable and is not. A bundle in a language that runs anywhere may not name one, because a system that decides nothing reads as though it did. The list is the host's own names, not a compiler's: the difference between two of them is a C library rather than a kernel. Nothing declares a system yet, and no toolchain compiles to a binary yet, so this changes no build.
255 lines
10 KiB
Go
255 lines
10 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 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
|
|
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, 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 a.Kind == ArtifactBundle || a.Kind == ArtifactPackage {
|
|
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))
|
|
}
|
|
// **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
|
|
}
|
|
}
|