One source, two images, deliberately different: a toolchain carries a compiler and the image the same module runs in should not.
409 lines
15 KiB
Go
409 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...)
|
|
if a.Target != "" {
|
|
invocation = append(invocation, "--target", a.Target)
|
|
}
|
|
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
|
|
}
|