A builder: a repository becomes artifacts the mesh can pin
It 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, 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. The alternative, the control plane holding a docker socket, would make it the one component that can do anything anywhere, which is the property the whole design is arranged to avoid. A module repository has one file at its root, module.json, saying what it is and what it builds. A convention somebody can look for beats a setting somebody has to find. Properties that are decisions rather than details: - a fresh clone every time. A build reusing a working tree can succeed because of something a previous build left behind, and that is a build nobody can reproduce. - archives are packed deterministically — sorted, and carrying no timestamps, uid, gid or original names. Two builds of one commit must produce one digest, or nothing downstream can tell "this changed" from "this was built again", and every rebuild looks like a change to every machine holding it. - nothing is published until everything is built. Half a module in the store under a digest the mesh never records is reachable, unreferenced, and indistinguishable from something in use. The reproducibility test was passing for the wrong reason: both builds landed in the same second, so a packer carrying timestamps would still have agreed. It now stamps the two trees a year apart, and a timestamp in the header breaks it. One line is honest about not being independently tested: the sort before packing is belt and braces over filepath.Walk's documented lexical order, and no injection can distinguish it.
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
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) {
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,228 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
)
|
||||
|
||||
// A repository becoming artifacts the mesh can pin.
|
||||
//
|
||||
// git and docker are injected rather than run, because what is under test is the ORDER and the
|
||||
// refusals — that nothing is published until everything is built, that a build reads only its own
|
||||
// tree, that two builds of one commit produce one digest. Running docker here would test docker.
|
||||
|
||||
type recorded struct {
|
||||
ran []string
|
||||
images map[string]string
|
||||
archives map[string]string
|
||||
failPush bool
|
||||
// contents is what a clone of this repository lands, so the fake clone can restore the tree
|
||||
// Build deliberately removes first.
|
||||
contents map[string]string
|
||||
// stamped is the modification time the clone gives every file. Set differently between two
|
||||
// builds of one commit, because otherwise both land in the same second and a packer that
|
||||
// carried timestamps would still produce one digest — which is a test that passes for a
|
||||
// reason that has nothing to do with what it claims.
|
||||
stamped time.Time
|
||||
}
|
||||
|
||||
func (r *recorded) run(_ context.Context, dir, name string, args ...string) (string, error) {
|
||||
line := name + " " + strings.Join(args, " ")
|
||||
r.ran = append(r.ran, line)
|
||||
switch {
|
||||
case name == "git" && len(args) > 0 && args[0] == "clone":
|
||||
tree := args[len(args)-1]
|
||||
if err := os.MkdirAll(tree, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for path, body := range r.contents {
|
||||
full := filepath.Join(tree, path)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !r.stamped.IsZero() {
|
||||
if err := os.Chtimes(full, r.stamped, r.stamped); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
case name == "git" && len(args) > 0 && args[0] == "rev-parse":
|
||||
return "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff\n", nil
|
||||
}
|
||||
_ = dir
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (r *recorded) PublishImage(_ context.Context, localTag, repository string) (string, error) {
|
||||
if r.failPush {
|
||||
return "", os.ErrPermission
|
||||
}
|
||||
if r.images == nil {
|
||||
r.images = map[string]string{}
|
||||
}
|
||||
r.images[repository] = localTag
|
||||
return "registry.invalid/" + repository + "@sha256:" + strings.Repeat("a", 64), nil
|
||||
}
|
||||
|
||||
func (r *recorded) PublishArchive(_ context.Context, repository string, body []byte, digest string) (string, error) {
|
||||
if r.failPush {
|
||||
return "", os.ErrPermission
|
||||
}
|
||||
if r.archives == nil {
|
||||
r.archives = map[string]string{}
|
||||
}
|
||||
r.archives[repository] = digest
|
||||
_ = body
|
||||
return "https://store.invalid/" + repository, nil
|
||||
}
|
||||
|
||||
// aRepository is a workspace whose clone lands a manifest and some files.
|
||||
func aRepository(t *testing.T, manifest string, files map[string]string) (*recorded, string) {
|
||||
t.Helper()
|
||||
contents := map[string]string{ManifestName: manifest}
|
||||
for name, body := range files {
|
||||
contents[name] = body
|
||||
}
|
||||
return &recorded{contents: contents}, t.TempDir()
|
||||
}
|
||||
|
||||
const withBoth = `{"module":"meshboard","version":"1",
|
||||
"build":{"artifacts":[
|
||||
{"name":"server","kind":"image","from":"Dockerfile"},
|
||||
{"name":"look","kind":"archive","from":"files"}]},
|
||||
"resources":[
|
||||
{"id":"svc","type":"container","name":"meshboard","artifact":"server"},
|
||||
{"id":"theme","type":"archive","path":"/opt/meshboard","artifact":"look"}]}`
|
||||
|
||||
func TestABuildProducesAManifestThePinsAreIn(t *testing.T) {
|
||||
r, workspace := aRepository(t, withBoth, map[string]string{
|
||||
"Dockerfile": "FROM scratch", "files/theme.conf": "dark",
|
||||
})
|
||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", workspace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Commit != "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff" {
|
||||
t.Fatalf("the commit was not recorded: %q", got.Commit)
|
||||
}
|
||||
if got.Manifest.Resources[0]["image"] == nil {
|
||||
t.Fatalf("the image was not pinned: %v", got.Manifest.Resources[0])
|
||||
}
|
||||
digest, _ := got.Manifest.Resources[1]["digest"].(string)
|
||||
if !strings.HasPrefix(digest, "sha256:") {
|
||||
t.Fatalf("the archive was not pinned: %v", got.Manifest.Resources[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoBuildsOfOneCommitProduceOneDigest(t *testing.T) {
|
||||
// Or nothing downstream can tell "this changed" from "this was built again", and every
|
||||
// rebuild looks like a change to every machine holding it.
|
||||
var digests []string
|
||||
for i := 0; i < 2; i++ {
|
||||
r, workspace := aRepository(t, withBoth, map[string]string{
|
||||
"Dockerfile": "FROM scratch", "files/a.conf": "one", "files/b.conf": "two",
|
||||
})
|
||||
// A year apart, so a packer carrying timestamps cannot accidentally agree.
|
||||
r.stamped = time.Date(2020+i, time.March, 3, 4, 5, 6, 0, time.UTC)
|
||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", workspace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, b := range got.Built {
|
||||
if b.Kind == catalogue.ArtifactArchive {
|
||||
digests = append(digests, b.Digest)
|
||||
}
|
||||
}
|
||||
}
|
||||
if digests[0] != digests[1] {
|
||||
t.Fatalf("two builds of one commit produced %s and %s", digests[0], digests[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingIsPublishedUntilEverythingIsBuilt(t *testing.T) {
|
||||
// Half a module in the store under a digest the mesh never records is reachable,
|
||||
// unreferenced, and indistinguishable from something in use.
|
||||
r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch"})
|
||||
// `files` is missing, so packing the archive fails — after the image would have been pushed.
|
||||
_, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", workspace)
|
||||
if err == nil {
|
||||
t.Fatal("a build with a missing input succeeded")
|
||||
}
|
||||
if len(r.archives) != 0 {
|
||||
t.Fatalf("an archive was published by a failed build: %v", r.archives)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARepositoryWithNoManifestSaysSo(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
r := &recorded{contents: map[string]string{"README.md": "nothing to see"}}
|
||||
_, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", workspace)
|
||||
if err == nil {
|
||||
t.Fatal("a repository with nothing saying what it is was built")
|
||||
}
|
||||
if !strings.Contains(err.Error(), ManifestName) {
|
||||
t.Fatalf("the failure does not name what is missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleThatBuildsNothingStillProducesAManifest(t *testing.T) {
|
||||
// Most of what a person installs is configuration.
|
||||
r, workspace := aRepository(t, `{"module":"shell","version":"1","resources":[
|
||||
{"id":"rc","type":"file","path":"/etc/zsh/zshrc","content":"setopt"}]}`, nil)
|
||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/shell.git", "", workspace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Built) != 0 {
|
||||
t.Fatalf("something was built: %v", got.Built)
|
||||
}
|
||||
if got.Manifest.Module != "shell" || len(got.Manifest.Resources) != 1 {
|
||||
t.Fatalf("got %+v", got.Manifest)
|
||||
}
|
||||
for _, line := range r.ran {
|
||||
if strings.HasPrefix(line, "docker") {
|
||||
t.Fatalf("docker was run for a module that builds nothing: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheTreeIsFreshEveryTime(t *testing.T) {
|
||||
// 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.
|
||||
r, workspace := aRepository(t, withBoth, map[string]string{
|
||||
"Dockerfile": "FROM scratch", "files/a": "b",
|
||||
})
|
||||
leftover := filepath.Join(workspace, "source", "files", "from-last-time")
|
||||
if err := os.MkdirAll(filepath.Dir(leftover), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(leftover, []byte("stale"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", workspace); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(leftover); err == nil {
|
||||
t.Fatal("a previous build's file survived into this one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestABuildThatCannotPushFails(t *testing.T) {
|
||||
r, workspace := aRepository(t, withBoth, map[string]string{
|
||||
"Dockerfile": "FROM scratch", "files/a": "b",
|
||||
})
|
||||
r.failPush = true
|
||||
if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", workspace); err == nil {
|
||||
t.Fatal("a build that could publish nothing reported success")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user