Files
mesh-controller/internal/builder/builder.go
T
jschoubben 7d033ad9f6 Publish to the registry, and a command that builds a repository
One store, and it is the registry the bootstrap already pulls from. An
OCI registry is a content-addressed blob store that also understands
images: PUT a blob and it is retrievable at /v2/<name>/blobs/sha256:… for
ever, by digest. An archive is a content-addressed blob.

A second store beside it was considered and is the right answer for
objects that are mutable, need per-reader access, or are not build output
— somebody's uploads, a backup, a thing with a lifecycle. None of that
describes a digest-pinned archive, and running a second service to hold
one kind of immutable blob is two things to run, two to back up, and two
ways for an artifact to be missing. Overturnable by reading: the manifest
carries a URL and a digest, and neither says what served it.

`build <repository>` clones, reads module.json, builds what it declares,
publishes, and records the manifest with the commit it came from. It is a
command rather than something the control plane does on its own, because
building runs things on a machine and what the control plane may send a
machine is bounded by the declaration language. This is the shape the
builder module takes when it is given work over the broker.

Proven end to end on a real repository and a real registry: a shell
module with a package, a user and a dotfile archive built, published,
fetched back at the digest it declared, rebuilt to the same digest, and
its manifest accepted by the host's own parser — including `user` and
`archive`, which did not exist this morning.

A tag is never accepted as a pin, and a blob already stored is not sent
again — it is named by its content, so re-uploading asks the registry to
store what it already has under the name it already has.
2026-08-30 03:36:04 +02:00

264 lines
9.3 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.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)