diff --git a/internal/builder/builder.go b/internal/builder/builder.go new file mode 100644 index 0000000..3ca6495 --- /dev/null +++ b/internal/builder/builder.go @@ -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) diff --git a/internal/builder/builder_test.go b/internal/builder/builder_test.go new file mode 100644 index 0000000..1380afa --- /dev/null +++ b/internal/builder/builder_test.go @@ -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") + } +}