187 lines
7.1 KiB
Go
187 lines
7.1 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
|
|
// tree is what git names as the tree of the module's directory; empty answers as for a commit.
|
|
tree string
|
|
}
|
|
|
|
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)
|
|
// A clone may carry `-c` configuration in front of the verb — the credential store — so the
|
|
// verb is found rather than assumed first.
|
|
isClone := false
|
|
for _, a := range args {
|
|
if a == "clone" {
|
|
isClone = true
|
|
break
|
|
}
|
|
}
|
|
switch {
|
|
case name == "git" && isClone:
|
|
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) > 1 && args[0] == "rev-parse" && strings.HasPrefix(args[1], "HEAD:") && r.tree != "":
|
|
return r.tree + "\n", nil
|
|
case name == "git" && len(args) > 0 && args[0] == "rev-parse":
|
|
return "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff\n", nil
|
|
case name == "git" && len(args) > 1 && args[0] == "rev-list" && args[1] == "--count":
|
|
return "42\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 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.
|
|
|
|
// 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.
|
|
|
|
// **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.
|
|
|
|
// The forge credential is offered through git's own credential store — a file, never argv — and
|
|
// git decides when it applies. What is checked: the clone names the store, the secret never
|
|
// appears in a command line, and the file holds exactly the URL at 0600.
|
|
|
|
// storeNamedIn is the credential store a git command line offers.
|
|
func storeNamedIn(t *testing.T, line string) string {
|
|
t.Helper()
|
|
_, after, ok := strings.Cut(line, "credential.helper=store --file=")
|
|
if !ok {
|
|
t.Fatalf("the clone does not name the credential store: %s", line)
|
|
}
|
|
return strings.Fields(after)[0]
|
|
}
|
|
|
|
// Without a credential, a clone is exactly the invocation it always was, and no credential file
|
|
// appears — the builder a mesh of public repositories runs is unchanged.
|
|
|
|
// An artifact's own context is cloned with the same offer: a private module whose context is a
|
|
// second private repository on the same forge builds, and the secret still never reaches argv.
|