route-proxy's own Dockerfile documents the shape it has always needed and
never had: 'the proxy source is not vendored here... the build context is
the mesh-controller repository root, and this Dockerfile compiles
./examples/route-proxy from it.' Nothing in the mesh could do that — the
build command clones one repository and builds every artifact from
within it, so route-proxy has never once been built through the pipeline,
consistent with it never having been assigned anywhere. Found attempting
exactly that build tonight: 'stat go.mod: file does not exist', because
the context was mesh-catalog, which does not have one.
An image artifact may now carry a context: {repository, ref}, cloned
fresh alongside the module's own tree. The recipe (Dockerfile) is still
read from the module's own directory, at the module's own commit — only
docker build's own context argument moves. Packaging and source stay
exactly as separate as route-proxy's own comment already said they were,
now for real.
414 lines
16 KiB
Go
414 lines
16 KiB
Go
package builder
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/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
|
|
// dirs is the directory each entry in ran was run from, same index — so a test can ask not
|
|
// only what ran but where.
|
|
dirs []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
|
|
// secondary is what a clone of a repository OTHER than the one under test lands, keyed by
|
|
// that repository's URL — an artifact's own build context, cloned apart from the module.
|
|
secondary map[string]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)
|
|
r.dirs = append(r.dirs, dir)
|
|
switch {
|
|
case name == "git" && len(args) > 0 && args[0] == "clone":
|
|
repository := args[len(args)-2]
|
|
tree := args[len(args)-1]
|
|
if err := os.MkdirAll(tree, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
lands := r.contents
|
|
if by, is := r.secondary[repository]; is {
|
|
lands = by
|
|
}
|
|
for path, body := range lands {
|
|
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
|
|
}
|
|
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, nil, Npmrc{}, nil)
|
|
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, nil, Npmrc{}, nil)
|
|
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, nil, Npmrc{}, nil)
|
|
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, nil, Npmrc{}, nil)
|
|
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, nil, Npmrc{}, nil)
|
|
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, nil, Npmrc{}, nil); 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, nil, Npmrc{}, nil); 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, nil, Npmrc{}, nil)
|
|
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(), nil, Npmrc{}, nil)
|
|
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(), nil, Npmrc{}, nil)
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// **Packaging and source are allowed to live apart** — a module that ships only the recipe for
|
|
// source that lives in a second repository (the reference route-proxy, packaged in the catalogue
|
|
// but built from mesh-controller's own repository) names where that source actually is, rather
|
|
// than vendoring a second copy the two could drift from.
|
|
func TestAnArtifactWithItsOwnContextIsBuiltFromThere(t *testing.T) {
|
|
const withContext = `{"module":"route-proxy","version":"1",
|
|
"build":{"artifacts":[
|
|
{"name":"server","kind":"image","from":"Dockerfile",
|
|
"context":{"repository":"https://forge.invalid/source.git","ref":"main"}}]}}`
|
|
r := &recorded{
|
|
contents: map[string]string{
|
|
ManifestName: withContext,
|
|
// The recipe lives with the packaging, not the source — read from here regardless of
|
|
// where the build context comes from. FROM scratch declares no base, so what is under
|
|
// test — where the context comes from — is not entangled with ADR 0097's own checks.
|
|
"Dockerfile": "FROM scratch\nCOPY go.mod ./\n",
|
|
},
|
|
secondary: map[string]map[string]string{
|
|
// go.mod exists only in the second repository. A build context taken from the wrong
|
|
// place would never find it, which a real docker build would refuse on — the fake
|
|
// does not read files, so what is checked below is that the build was even pointed
|
|
// at the right place, not that COPY would have succeeded.
|
|
"https://forge.invalid/source.git": {"go.mod": "module route-proxy\n"},
|
|
},
|
|
}
|
|
_, err := Build(context.Background(), r.run, r,
|
|
"https://forge.invalid/catalogue.git", "", "", t.TempDir(), nil, Npmrc{}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var clonedSource bool
|
|
for _, line := range r.ran {
|
|
if strings.HasPrefix(line, "git clone") && strings.Contains(line, "https://forge.invalid/source.git") {
|
|
clonedSource = true
|
|
}
|
|
}
|
|
if !clonedSource {
|
|
t.Fatalf("the artifact's own context was never cloned: %v", r.ran)
|
|
}
|
|
|
|
buildIndex := -1
|
|
for i, line := range r.ran {
|
|
if strings.HasPrefix(line, "docker build ") {
|
|
buildIndex = i
|
|
}
|
|
}
|
|
if buildIndex == -1 {
|
|
t.Fatal("no docker build was run")
|
|
}
|
|
build := r.ran[buildIndex]
|
|
buildDir := r.dirs[buildIndex]
|
|
|
|
if !strings.Contains(buildDir, "context-server") {
|
|
t.Errorf("docker build ran from %q, not the artifact's own cloned context", buildDir)
|
|
}
|
|
recipe := strings.SplitN(strings.SplitN(build, "-f ", 2)[1], " ", 2)[0]
|
|
if !filepath.IsAbs(recipe) {
|
|
t.Errorf("the recipe %q is not an absolute path, so it is read relative to whatever "+
|
|
"directory the build context moved to rather than where it actually is", recipe)
|
|
}
|
|
if !strings.HasSuffix(recipe, string(filepath.Separator)+"Dockerfile") {
|
|
t.Errorf("the recipe is not the module's own Dockerfile: %q", recipe)
|
|
}
|
|
if !strings.HasSuffix(build, " .") {
|
|
t.Errorf("the build was not given a context: %s", build)
|
|
}
|
|
}
|