A module usually runs software somebody else built: a database module ships configuration and a provisioner and does not build a database. It could name the upstream reference directly, and then every machine needs a route to a public registry and the reference is a tag somebody else can move — which is what pinning exists to prevent. So an artifact may be `upstream`: pulled by the reference the module names, pushed into the mesh's own registry, and pinned by the digest that registry assigns. This is what the bootstrap already does by hand; it is now something a module can say. Refused: an upstream reference with no tag or digest, because what gets mirrored would be whatever `latest` means today and a module pinned to that is not pinned. And the rule that a build reads only its own repository does not apply to it — applying it anyway refused every reference with a registry host in it, which the test caught. Written by trying to write a real postgres module and finding it could not be said. It can now: two directories, two containers pinned by digest, a superuser password sealed to the machine, and the grants manifest — six resources from one assignment, all accepted by the host's own parser. That exercise also found my manifest wrong rather than the host: a container declared `restart-on`, which is a service field, and the host refused it by name. It is right to. A container whose own definition changes is recreated, and a file it mounts is read by the process inside, which is that image's business.
277 lines
9.9 KiB
Go
277 lines
9.9 KiB
Go
package builder
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"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 {
|
|
// 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, ref, workspace 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)
|
|
|
|
raw, err := os.ReadFile(filepath.Join(tree, ManifestName))
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"%s has no %s at its root, so there is nothing saying what it is: %w",
|
|
repository, ManifestName, err)
|
|
}
|
|
manifest, err := catalogue.ParseManifest(raw)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
|
|
var built []catalogue.Built
|
|
if manifest.Build != nil {
|
|
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, tree, commit, a)
|
|
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}, nil
|
|
}
|
|
|
|
// 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) (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))
|
|
if _, err := run(ctx, tree, "docker", "build", "-f", a.From, "-t", local, "."); 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)
|