The builder cloned a repository and read the manifest at its root, which means one repository per module. Nothing we have is shaped that way, so the builder could be asked to build nothing that exists (novox/hq ADR 0069). The path travels the whole way — named when asking, carried in the request, used to read the manifest and as the context everything is produced from, echoed back in the result, and recorded as part of where a module came from. Without that last part the mesh could notice a module was behind its source and then be unable to rebuild it, which is the worst of both. A path climbing out of the clone is refused: a machine whose job is building other people's repositories must not read whatever else is on its disk. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
334 lines
12 KiB
Go
334 lines
12 KiB
Go
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")
|
|
}
|
|
}
|
|
|
|
func TestAnUpstreamImageIsMirroredRatherThanBuilt(t *testing.T) {
|
|
// A module usually runs software it did not write. Naming the upstream reference directly
|
|
// would need every machine to reach a public registry, and would pin to a tag somebody else
|
|
// can move.
|
|
const mirrors = `{"module":"postgres","version":"1",
|
|
"build":{"artifacts":[{"name":"store","kind":"upstream","from":"postgres:17-alpine"}]},
|
|
"resources":[{"id":"db","type":"container","name":"mesh-postgres","artifact":"store"}]}`
|
|
|
|
r, workspace := aRepository(t, mirrors, nil)
|
|
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/postgres.git", "", "", workspace)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Pulled, not built.
|
|
var pulled, built bool
|
|
for _, line := range r.ran {
|
|
if strings.HasPrefix(line, "docker pull postgres:17-alpine") {
|
|
pulled = true
|
|
}
|
|
if strings.HasPrefix(line, "docker build") {
|
|
built = true
|
|
}
|
|
}
|
|
if !pulled {
|
|
t.Fatalf("the upstream image was not fetched: %v", r.ran)
|
|
}
|
|
if built {
|
|
t.Fatalf("something was built for an image that is mirrored: %v", r.ran)
|
|
}
|
|
// And the resource names what this registry serves, pinned by the digest it assigned.
|
|
image, _ := got.Manifest.Resources[0]["image"].(string)
|
|
if !strings.Contains(image, "@sha256:") {
|
|
t.Fatalf("the mirrored image is not pinned by digest: %q", image)
|
|
}
|
|
if strings.Contains(image, "17-alpine") {
|
|
t.Fatalf("the resource still names the upstream tag: %q", image)
|
|
}
|
|
}
|
|
|
|
func TestAnUpstreamImageWithNoTagIsRefused(t *testing.T) {
|
|
// What gets mirrored would be whatever `latest` means today, and a module pinned to that is
|
|
// not pinned.
|
|
_, err := catalogue.ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
|
{"name":"x","kind":"upstream","from":"postgres"}]}}`))
|
|
if err == nil {
|
|
t.Fatal("an untagged upstream reference was accepted")
|
|
}
|
|
if !strings.Contains(err.Error(), "no tag or digest") {
|
|
t.Fatalf("unhelpful refusal: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAnUpstreamReferenceIsNotAPathInTheRepository(t *testing.T) {
|
|
// The rule that a build reads only its own repository must not refuse every reference with a
|
|
// registry host in it.
|
|
if _, err := catalogue.ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
|
{"name":"x","kind":"upstream","from":"registry.example/library/postgres:17"}]}}`)); err != nil {
|
|
t.Fatalf("a perfectly ordinary upstream reference was refused: %v", err)
|
|
}
|
|
}
|
|
|
|
// **A module is a repository and a path within it** (novox/hq ADR 0069). The catalogue holds its
|
|
// modules one to a directory and the system this replaces has always built one that way, so a
|
|
// builder that could only read a repository's root could build none of what exists.
|
|
func TestAModuleIsBuiltFromItsPathWithinTheRepository(t *testing.T) {
|
|
r := &recorded{contents: map[string]string{
|
|
"README.md": "this repository holds several modules",
|
|
"modules/shell/" + ManifestName: withBoth,
|
|
"modules/shell/Dockerfile": "FROM scratch",
|
|
"modules/shell/files/theme.conf": "dark",
|
|
// A second module beside it, so what is built is chosen by the path rather than by
|
|
// happening to be the only manifest in the clone.
|
|
"modules/other/" + ManifestName: `{"module":"other","version":"1"}`,
|
|
}}
|
|
got, err := Build(context.Background(), r.run, r,
|
|
"https://forge.invalid/catalogue.git", "modules/shell", "", t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Manifest.Module != "meshboard" {
|
|
t.Fatalf("built %q, which is not the module at the path asked for", got.Manifest.Module)
|
|
}
|
|
if got.Manifest.Resources[0]["image"] == nil {
|
|
t.Fatalf("the image was not pinned: %v", got.Manifest.Resources[0])
|
|
}
|
|
}
|
|
|
|
// A build reads only its own tree. A path climbing out of the clone would otherwise let 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 TestAPathThatLeavesTheRepositoryIsRefused(t *testing.T) {
|
|
for _, escaping := range []string{"../../etc", "/etc"} {
|
|
r := &recorded{contents: map[string]string{ManifestName: withBoth}}
|
|
_, err := Build(context.Background(), r.run, r,
|
|
"https://forge.invalid/x.git", escaping, "", t.TempDir())
|
|
if err == nil {
|
|
t.Fatalf("%q was accepted as a module's path", escaping)
|
|
}
|
|
if !strings.Contains(err.Error(), "leaves the repository") &&
|
|
!strings.Contains(err.Error(), "absolute path") {
|
|
t.Fatalf("the refusal of %q does not say why: %v", escaping, err)
|
|
}
|
|
}
|
|
}
|