A fingerprint written into a recipe names one particular copy of the base — the copy on whichever machine the person typing it was using. On any other mesh that copy has never existed, so the build stops on its first line with a message about an image nobody can look up. Three modules in the catalogue were in exactly that state, and the line each of them replaced was equally dead. A module now names the module and artifact instead, and the mesh answers with what it holds. The builder is still a thing that clones, builds and answers: the answer travels with the question, because only the mesh knows what it has. A base the mesh has not built is refused before anything is built, naming which module has to exist first.
406 lines
15 KiB
Go
406 lines
15 KiB
Go
package builder
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/novox/mesh-control/internal/catalogue"
|
|
)
|
|
|
|
// Turning a repository into artifacts the mesh can pin.
|
|
//
|
|
// **This runs on a node, not in the control plane.** Building needs a container runtime and a
|
|
// working tree, and the control plane deliberately cannot run commands on a machine — what it may
|
|
// send is bounded by the declaration language (novox/hq ADR 0005), and "run this build" is not in
|
|
// it. So the builder is something a node runs *as a module*, given work over the broker like
|
|
// anything else, and this package is what it does when it gets some.
|
|
//
|
|
// The alternative — the control plane holding a docker socket — would make it the one component
|
|
// that can do anything on a machine, which is the property the whole design is arranged to avoid.
|
|
|
|
// Runner runs a command in a directory and returns what it said. Injected so the tests do not
|
|
// need docker and git, and so the failure of either is reported rather than assumed.
|
|
type Runner func(ctx context.Context, dir string, name string, args ...string) (string, error)
|
|
|
|
// Publisher puts an artifact somewhere a machine can fetch it, and says how to refer to it.
|
|
type Publisher interface {
|
|
// PublishImage pushes a locally built image and returns a reference pinned by digest.
|
|
PublishImage(ctx context.Context, localTag, repository string) (string, error)
|
|
// PublishArchive stores bytes and returns where to fetch them from.
|
|
PublishArchive(ctx context.Context, repository string, body []byte, digest string) (string, error)
|
|
}
|
|
|
|
// Result is everything one build produced.
|
|
type Result struct {
|
|
// Against is every pinned image this build was built on top of, read out of its own inputs.
|
|
//
|
|
// **Derived, not declared** (novox/hq ADR 0009): a declared list of dependencies drifts from
|
|
// what the code actually uses, and an artifact is out of date when anything it was built
|
|
// against moved. These are artifact references rather than module-versions, because that is
|
|
// what a build input names; resolving them to modules is the catalogue's work, since it is
|
|
// what knows which module-version published which artifact.
|
|
Against []string
|
|
|
|
// Manifest is the module as the mesh should hold it: artifacts resolved to digests.
|
|
Manifest catalogue.Manifest
|
|
// Commit is what was built, so "is this current?" is answerable without building again.
|
|
Commit string
|
|
// Built is each artifact, for reporting.
|
|
Built []catalogue.Built
|
|
}
|
|
|
|
// Build clones a repository at a ref, reads its manifest, produces what it declares, publishes
|
|
// each, and returns the manifest the mesh should hold.
|
|
//
|
|
// **Nothing is published until everything is built.** A module whose image succeeded and whose
|
|
// archive failed would otherwise leave half of itself in the store under a digest the mesh never
|
|
// records — reachable, unreferenced, and indistinguishable from something in use.
|
|
func Build(ctx context.Context, run Runner, publish Publisher,
|
|
repository, path, ref, workspace string, held map[string]string) (Result, error) {
|
|
|
|
// Made rather than required. A builder that fails because the directory it was told to work
|
|
// in does not exist is a builder that needs a setup step nobody documented.
|
|
if err := os.MkdirAll(workspace, 0o755); err != nil {
|
|
return Result{}, err
|
|
}
|
|
tree := filepath.Join(workspace, "source")
|
|
if err := os.RemoveAll(tree); err != nil {
|
|
return Result{}, err
|
|
}
|
|
// A fresh clone every time rather than a fetch into a tree that is already there. A build
|
|
// that reuses a working tree can succeed because of something a previous build left behind,
|
|
// and that is a build nobody can reproduce.
|
|
if _, err := run(ctx, workspace, "git", "clone", "--quiet", repository, tree); err != nil {
|
|
return Result{}, fmt.Errorf("cannot clone %s: %w", repository, err)
|
|
}
|
|
if ref != "" {
|
|
if _, err := run(ctx, tree, "git", "checkout", "--quiet", ref); err != nil {
|
|
return Result{}, fmt.Errorf("%s has no %s: %w", repository, ref, err)
|
|
}
|
|
}
|
|
commit, err := run(ctx, tree, "git", "rev-parse", "HEAD")
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
commit = strings.TrimSpace(commit)
|
|
|
|
// A module is a repository and a path within it (novox/hq ADR 0069). The ordinary case is an
|
|
// empty path, meaning the repository's root; a repository holding several modules names each
|
|
// by its own directory, which is what the catalogue is and what the system this replaces has
|
|
// always done.
|
|
within, err := inside(tree, path)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
|
|
raw, err := os.ReadFile(filepath.Join(within, ManifestName))
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"%s has no %s at %s, so there is nothing saying what it is: %w",
|
|
repository, ManifestName, describe(path), err)
|
|
}
|
|
manifest, err := catalogue.ParseManifest(raw)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
|
|
var built []catalogue.Built
|
|
if manifest.Build != nil {
|
|
// What this module said it stands on, answered with what this mesh actually holds. Done
|
|
// before anything is built, so a missing base is refused in front of the person who can
|
|
// fix it rather than inside a build that stops on its own first line.
|
|
args, err := standingOn(manifest, held)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
artifacts := append([]catalogue.Artifact{}, manifest.Build.Artifacts...)
|
|
// Ordered, so two builds of one commit do the same work in the same sequence and their
|
|
// logs can be compared.
|
|
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
|
|
for _, a := range artifacts {
|
|
made, err := one(ctx, run, publish, manifest.Module, within, commit, a, args)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
built = append(built, made)
|
|
}
|
|
}
|
|
|
|
resolved, err := manifest.Resolve(built)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
return Result{Manifest: resolved, Commit: commit, Built: built,
|
|
Against: against(within, manifest)}, nil
|
|
}
|
|
|
|
// inside resolves a module's path within a clone, and refuses one that leaves it.
|
|
//
|
|
// **A build reads only its own tree.** A path of `../../etc` would otherwise make a build read —
|
|
// and an archive artifact publish — whatever the build machine happens to hold, which is the one
|
|
// thing a machine that builds other people's repositories must not do.
|
|
func inside(tree, path string) (string, error) {
|
|
if path == "" {
|
|
return tree, nil
|
|
}
|
|
if filepath.IsAbs(path) {
|
|
return "", fmt.Errorf(
|
|
"a module's path is inside its repository, and %q is an absolute path", path)
|
|
}
|
|
within := filepath.Join(tree, path)
|
|
rel, err := filepath.Rel(tree, within)
|
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return "", fmt.Errorf(
|
|
"%q leaves the repository, and a build reads only its own tree", path)
|
|
}
|
|
return within, nil
|
|
}
|
|
|
|
// describe says where a manifest was looked for, in words a person can act on.
|
|
func describe(path string) string {
|
|
if path == "" {
|
|
return "its root"
|
|
}
|
|
return path
|
|
}
|
|
|
|
// pinnedImage matches an image reference pinned by digest, which is the only kind a build input is
|
|
// allowed to name — a tag is something somebody else can move under you.
|
|
var pinnedImage = regexp.MustCompile(`[A-Za-z0-9][A-Za-z0-9._/:-]*@sha256:[0-9a-f]{64}`)
|
|
|
|
// against reads what this module's image artifacts are built on top of, out of the files that
|
|
// build them. Nothing is guessed: a reference that is not written down is not reported.
|
|
func against(within string, manifest catalogue.Manifest) []string {
|
|
if manifest.Build == nil {
|
|
return nil
|
|
}
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, a := range manifest.Build.Artifacts {
|
|
if a.Kind != catalogue.ArtifactImage || a.From == "" {
|
|
continue
|
|
}
|
|
body, err := os.ReadFile(filepath.Join(within, a.From))
|
|
if err != nil {
|
|
// Not fatal: the build itself already failed if this file was needed and missing, and
|
|
// reporting no edges is honest where inventing them would not be.
|
|
continue
|
|
}
|
|
for _, found := range pinnedImage.FindAllString(string(body), -1) {
|
|
if !seen[found] {
|
|
seen[found] = true
|
|
out = append(out, found)
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// ManifestName is the one file a module repository must have.
|
|
//
|
|
// At the root, and named the same in every repository. A convention somebody can look for beats a
|
|
// setting somebody has to find.
|
|
const ManifestName = "module.json"
|
|
|
|
func one(ctx context.Context, run Runner, publish Publisher,
|
|
module, tree, commit string, a catalogue.Artifact, args []string) (catalogue.Built, error) {
|
|
|
|
switch a.Kind {
|
|
case catalogue.ArtifactUpstream:
|
|
// Mirrored, not built. Pulled by the reference the module names and pushed under a name
|
|
// of the mesh's own, so what a machine fetches is pinned by a digest this registry
|
|
// assigned rather than by a tag somebody else can move.
|
|
if _, err := run(ctx, tree, "docker", "pull", a.From); err != nil {
|
|
return catalogue.Built{}, fmt.Errorf("%s: cannot fetch %s: %w", module, a.From, err)
|
|
}
|
|
reference, err := publish.PublishImage(ctx, a.From, module+"/"+a.Name)
|
|
if err != nil {
|
|
return catalogue.Built{}, err
|
|
}
|
|
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil
|
|
|
|
case catalogue.ArtifactImage:
|
|
// Tagged by commit rather than by version, because a version is what a person calls a
|
|
// release and a commit is what was actually built. The mesh pins the digest anyway; this
|
|
// is only so a person looking at the build node can tell what is there.
|
|
local := fmt.Sprintf("%s-%s:%s", module, a.Name, short(commit))
|
|
// The bases this module named, resolved to what this mesh holds. A recipe reads them as
|
|
// build arguments, so a module says which module it stands on and never which copy.
|
|
invocation := append([]string{"build", "-f", a.From, "-t", local}, args...)
|
|
invocation = append(invocation, ".")
|
|
if _, err := run(ctx, tree, "docker", invocation...); err != nil {
|
|
return catalogue.Built{}, fmt.Errorf("%s: building %s failed: %w", module, a.Name, err)
|
|
}
|
|
reference, err := publish.PublishImage(ctx, local, module+"/"+a.Name)
|
|
if err != nil {
|
|
return catalogue.Built{}, err
|
|
}
|
|
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil
|
|
|
|
case catalogue.ArtifactArchive:
|
|
body, err := pack(filepath.Join(tree, a.From))
|
|
if err != nil {
|
|
return catalogue.Built{}, fmt.Errorf("%s: packing %s failed: %w", module, a.Name, err)
|
|
}
|
|
sum := sha256.Sum256(body)
|
|
digest := "sha256:" + hex.EncodeToString(sum[:])
|
|
where, err := publish.PublishArchive(ctx, module+"/"+a.Name, body, digest)
|
|
if err != nil {
|
|
return catalogue.Built{}, err
|
|
}
|
|
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest}, nil
|
|
}
|
|
return catalogue.Built{}, fmt.Errorf("%s: %q is a %q, which is not something this builds",
|
|
module, a.Name, a.Kind)
|
|
}
|
|
|
|
// pack tars and gzips a directory.
|
|
//
|
|
// **Deterministically**: entries sorted, and no timestamps, uid, gid or original names carried
|
|
// through. Two builds of one commit must produce one digest, or nothing downstream can tell "this
|
|
// changed" from "this was built again" — and every rebuild would look like a change to every
|
|
// machine holding it.
|
|
func pack(root string) ([]byte, error) {
|
|
info, err := os.Stat(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !info.IsDir() {
|
|
return nil, fmt.Errorf("%s is not a directory", root)
|
|
}
|
|
|
|
var paths []string
|
|
err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.IsDir() || !info.Mode().IsRegular() {
|
|
// Only files. A symlink or a device in an archive is refused by the host that unpacks
|
|
// it, so putting one in would build something that cannot be applied.
|
|
if !info.IsDir() && !info.Mode().IsRegular() {
|
|
return fmt.Errorf("%s is neither a file nor a directory, and an archive carries "+
|
|
"only those", path)
|
|
}
|
|
return nil
|
|
}
|
|
paths = append(paths, path)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// filepath.Walk is documented to walk in lexical order, so this is belt and braces rather
|
|
// than load-bearing — and no test distinguishes it, which is worth saying rather than
|
|
// implying otherwise. It stays because the cost is nothing and the failure it guards against
|
|
// is silent: an archive whose digest changes because the traversal did.
|
|
sort.Strings(paths)
|
|
|
|
var out strings.Builder
|
|
zipped := gzip.NewWriter(&stringWriter{&out})
|
|
writer := tar.NewWriter(zipped)
|
|
for _, path := range paths {
|
|
body, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
relative, err := filepath.Rel(root, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
mode := int64(info.Mode().Perm())
|
|
if err := writer.WriteHeader(&tar.Header{
|
|
Name: filepath.ToSlash(relative), Mode: mode, Size: int64(len(body)),
|
|
Typeflag: tar.TypeReg,
|
|
// Everything else left at its zero value on purpose — see the note above.
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := writer.Write(body); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := zipped.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
return []byte(out.String()), nil
|
|
}
|
|
|
|
type stringWriter struct{ to *strings.Builder }
|
|
|
|
func (w *stringWriter) Write(p []byte) (int, error) { return w.to.Write(p) }
|
|
|
|
func short(commit string) string {
|
|
if len(commit) > 8 {
|
|
return commit[:8]
|
|
}
|
|
return commit
|
|
}
|
|
|
|
// Command is a Runner that actually runs things.
|
|
func Command(ctx context.Context, dir, name string, args ...string) (string, error) {
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
cmd.Dir = dir
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return string(out), fmt.Errorf("%s %s: %w\n%s",
|
|
name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
|
}
|
|
return string(out), nil
|
|
}
|
|
|
|
var _ io.Writer = (*stringWriter)(nil)
|
|
|
|
// standingOn turns the bases a module named into build arguments for what this mesh holds.
|
|
//
|
|
// **Refused rather than defaulted** (novox/hq issue 044). A module naming a base the mesh has not
|
|
// built cannot be built here yet, and the useful sentence names which module is missing — not the
|
|
// one a container runtime produces when a recipe's first line refers to an image nobody has.
|
|
//
|
|
// The order is fixed so two builds of one commit invoke the same command.
|
|
func standingOn(manifest catalogue.Manifest, held map[string]string) ([]string, error) {
|
|
if manifest.Build == nil || len(manifest.Build.On) == 0 {
|
|
return nil, nil
|
|
}
|
|
on := append([]catalogue.BuildsOn{}, manifest.Build.On...)
|
|
sort.Slice(on, func(i, j int) bool { return on[i].Arg < on[j].Arg })
|
|
|
|
var args []string
|
|
for _, base := range on {
|
|
if base.Arg == "" || base.Module == "" || base.Artifact == "" {
|
|
return nil, fmt.Errorf(
|
|
"%s says its build stands on something, and does not say all of what: a base "+
|
|
"needs the module, the artifact, and the build argument the recipe reads it "+
|
|
"from", manifest.Module)
|
|
}
|
|
key := base.Module + "/" + base.Artifact
|
|
reference, has := held[key]
|
|
if !has {
|
|
return nil, fmt.Errorf(
|
|
"%s builds on %s, and this mesh has not built it. Build %s first — every module "+
|
|
"in this toolchain stands on it, so it is the thing to have before anything "+
|
|
"else", manifest.Module, key, base.Module)
|
|
}
|
|
args = append(args, "--build-arg", base.Arg+"="+reference)
|
|
}
|
|
return args, nil
|
|
}
|