Build a change once: the check builds what its merge moves, the walk takes it by key
mesh/merge-gate out of date: judged against trunk bff5b7f0e4f8, the trunk is now 372faebf2824; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk bff5b7f0e4f8, the trunk is now 372faebf2824; judged again on a recheck or when it is merged
mesh/delivery-group group feat/350-one-build-per-change checking: composed check out of date: novox/mesh-controller@cd49b50c4075 was judged against trunk bff5b…
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate out of date: judged against trunk bff5b7f0e4f8, the trunk is now 372faebf2824; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk bff5b7f0e4f8, the trunk is now 372faebf2824; judged again on a recheck or when it is merged
mesh/delivery-group group feat/350-one-build-per-change checking: composed check out of date: novox/mesh-controller@cd49b50c4075 was judged against trunk bff5b…
mesh/delivery superseded: a newer head of the same pull request
A merge built every moved module a second time although its check had judged the same tree (issue 649). The check now builds into the scratch namespace under a reuse key (source fingerprint plus the builder's own digest), and the walk of the trunk commit copies a build of the same key by digest instead of building, saying per module why when it cannot (novox/hq ADR 0350).
This commit is contained in:
@@ -0,0 +1,541 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// One build per change (novox/hq ADR 0350): the merge check builds what the merge would move into the scratch
|
||||
// namespace under its reuse key, and the walk of the trunk commit takes that build by digest instead of building.
|
||||
|
||||
// aScratch is the scratch namespace in memory: check builds by module and key, and what the walk took.
|
||||
type aScratch struct {
|
||||
builds map[string]CheckBuild
|
||||
took []string
|
||||
failTake bool
|
||||
}
|
||||
|
||||
func newScratch() *aScratch { return &aScratch{builds: map[string]CheckBuild{}} }
|
||||
|
||||
func (s *aScratch) Keep(_ context.Context, _ PullRef, _ string, _ time.Time, builds []CheckBuild) error {
|
||||
for _, b := range builds {
|
||||
s.builds[b.Module+" "+b.Key] = b
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *aScratch) Find(_ context.Context, module, key string) (CheckBuild, bool, error) {
|
||||
b, ok := s.builds[module+" "+key]
|
||||
return b, ok, nil
|
||||
}
|
||||
|
||||
func (s *aScratch) OfPulls(_ context.Context, pulls []PullRef) ([]CheckBuild, error) {
|
||||
var out []CheckBuild
|
||||
for _, b := range s.builds {
|
||||
if slices.Contains(pulls, b.Pull) {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *aScratch) Take(_ context.Context, module string, made catalogue.Built) (catalogue.Built, error) {
|
||||
if s.failTake {
|
||||
return catalogue.Built{}, errors.New("the store refused the mount")
|
||||
}
|
||||
s.took = append(s.took, made.Reference)
|
||||
made.Reference = strings.Replace(made.Reference, ScratchPrefix, "", 1)
|
||||
return made, nil
|
||||
}
|
||||
|
||||
func (s *aScratch) Forget(context.Context, PullRef) (int, error) { return 0, nil }
|
||||
func (s *aScratch) Expire(context.Context, time.Duration, time.Time) (int, error) { return 0, nil }
|
||||
func (s *aScratch) Discard(context.Context, []CheckBuild) error { return nil }
|
||||
|
||||
const reusedModule = `{"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"}]}`
|
||||
|
||||
// aModuleAt is a repository holding the module under mod/, whose directory git names as tree, on its trunk.
|
||||
func aModuleAt(t *testing.T, tree string) (*recorded, Runner, string) {
|
||||
t.Helper()
|
||||
r := &recorded{tree: tree, contents: map[string]string{"mod/" + ManifestName: reusedModule,
|
||||
"mod/Dockerfile": "FROM scratch", "mod/files/theme.conf": "dark"}}
|
||||
run := func(ctx context.Context, dir, name string, args ...string) (string, error) {
|
||||
if name == "git" && len(args) > 0 && args[0] == "symbolic-ref" {
|
||||
r.ran = append(r.ran, name+" "+strings.Join(args, " "))
|
||||
return "origin/main\n", nil
|
||||
}
|
||||
return r.run(ctx, dir, name, args...)
|
||||
}
|
||||
return r, run, t.TempDir()
|
||||
}
|
||||
|
||||
func dockerBuilt(r *recorded) bool {
|
||||
for _, line := range r.ran {
|
||||
if strings.HasPrefix(line, "docker build") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var aPull = PullRef{Owner: "novox", Repo: "meshboard", Number: 7}
|
||||
|
||||
// aCheckBuilt is the check of aPull building the module from a tree, into the scratch namespace.
|
||||
func aCheckBuilt(t *testing.T, scratch *aScratch, tree string) CheckBuilt {
|
||||
t.Helper()
|
||||
r, run, workspace := aModuleAt(t, tree)
|
||||
spec := CheckSpec{ID: "build-1", Owner: aPull.Owner, Repo: aPull.Repo, Number: aPull.Number,
|
||||
Builds: []CheckBuildSpec{{Module: "meshboard", Repository: "https://forge.invalid/meshboard.git", Path: "mod",
|
||||
Ref: "main"}}}
|
||||
built := buildForTheMerge(t.Context(), run, spec, CheckBuilding{Publish: r, Scratch: scratch}, workspace, "", "",
|
||||
CheckVerdict{Head: "abcdefabcdef", Trunk: "123456123456"}, GitCredential{}, nil, logging(nil))
|
||||
if len(built) != 1 || built[0].NotBuilt != "" || built[0].Key == "" {
|
||||
t.Fatalf("the check did not build the module for its merge: %+v", built)
|
||||
}
|
||||
if !dockerBuilt(r) {
|
||||
t.Fatal("the check built no image")
|
||||
}
|
||||
for repository := range r.images {
|
||||
if !strings.HasPrefix(repository, ScratchPrefix) {
|
||||
t.Fatalf("the check published %s outside the scratch namespace", repository)
|
||||
}
|
||||
}
|
||||
for repository := range r.archives {
|
||||
if !strings.HasPrefix(repository, ScratchPrefix) {
|
||||
t.Fatalf("the check published %s outside the scratch namespace", repository)
|
||||
}
|
||||
}
|
||||
return built[0]
|
||||
}
|
||||
|
||||
func withVersion(t *testing.T, v string) {
|
||||
t.Helper()
|
||||
was := Version
|
||||
Version = v
|
||||
t.Cleanup(func() { Version = was })
|
||||
}
|
||||
|
||||
// A merge whose reuse key equals its check's builds nothing again: the walk copies the check's build by digest
|
||||
// and registers that digest.
|
||||
func TestAWalkWhoseKeyIsItsChecksBuildsNothingAndRegistersTheChecksDigest(t *testing.T) {
|
||||
withVersion(t, "builder-1")
|
||||
scratch := newScratch()
|
||||
checked := aCheckBuilt(t, scratch, "tree-one")
|
||||
|
||||
r, run, workspace := aModuleAt(t, "tree-one")
|
||||
got, err := BuildReusing(t.Context(), run, r, Reuse{Scratch: scratch, Pulls: []PullRef{aPull}},
|
||||
"https://forge.invalid/meshboard.git", "mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dockerBuilt(r) || len(r.images) > 0 || len(r.archives) > 0 {
|
||||
t.Fatalf("the walk built again: ran %v, published %v %v", r.ran, r.images, r.archives)
|
||||
}
|
||||
if got.Reused == nil || got.Why != "" {
|
||||
t.Fatalf("the walk did not say it reused the check's build: reused %+v, why %q", got.Reused, got.Why)
|
||||
}
|
||||
if got.ReuseKey != checked.Key {
|
||||
t.Fatalf("the walk's key %s is not the check's %s", got.ReuseKey, checked.Key)
|
||||
}
|
||||
if len(scratch.took) != 2 {
|
||||
t.Fatalf("the walk copied %v, not the check's two artifacts", scratch.took)
|
||||
}
|
||||
for _, made := range checked.Made {
|
||||
registered := strings.Replace(made.Reference, ScratchPrefix, "", 1)
|
||||
found := false
|
||||
for _, b := range got.Built {
|
||||
found = found || (b.Reference == registered && b.Digest == made.Digest)
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("the walk registers %+v, not the check's %s copied", got.Built, made.Reference)
|
||||
}
|
||||
}
|
||||
if image, _ := got.Manifest.Resources[0]["image"].(string); image != "registry.invalid/meshboard/server@sha256:"+strings.Repeat("a", 64) {
|
||||
t.Fatalf("the manifest pins %q, not the check's digest in the module's namespace", image)
|
||||
}
|
||||
words := got.Reused.Words()
|
||||
if !strings.HasPrefix(words, "reused from the check of novox/meshboard#7 at head abcdefabcdef (merged into trunk 123456123456)") ||
|
||||
!strings.Contains(words, "sha256:") {
|
||||
t.Fatalf("the walk says %q", words)
|
||||
}
|
||||
}
|
||||
|
||||
// A differing key builds, and says which part differs.
|
||||
func TestAWalkWhoseKeyDiffersBuildsAndSaysWhy(t *testing.T) {
|
||||
withVersion(t, "builder-1")
|
||||
scratch := newScratch()
|
||||
aCheckBuilt(t, scratch, "tree-one")
|
||||
|
||||
r, run, workspace := aModuleAt(t, "tree-two")
|
||||
got, err := BuildReusing(t.Context(), run, r, Reuse{Scratch: scratch, Pulls: []PullRef{aPull}},
|
||||
"https://forge.invalid/meshboard.git", "mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !dockerBuilt(r) || got.Reused != nil {
|
||||
t.Fatalf("a walk whose source differs did not build: reused %+v", got.Reused)
|
||||
}
|
||||
if !strings.Contains(got.Why, "the key differs") || !strings.Contains(got.Why, partTree) {
|
||||
t.Fatalf("the walk says %q, not which part of the key differs", got.Why)
|
||||
}
|
||||
|
||||
// And a builder of another version builds too, saying so.
|
||||
withVersion(t, "builder-2")
|
||||
r, run, workspace = aModuleAt(t, "tree-one")
|
||||
got, err = BuildReusing(t.Context(), run, r, Reuse{Scratch: scratch, Pulls: []PullRef{aPull}},
|
||||
"https://forge.invalid/meshboard.git", "mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !dockerBuilt(r) || !strings.Contains(got.Why, partBuilder) {
|
||||
t.Fatalf("a newer builder took the older one's build, or did not say why not: %q", got.Why)
|
||||
}
|
||||
|
||||
// No check of the walk's pull requests built it: said.
|
||||
r, run, workspace = aModuleAt(t, "tree-one")
|
||||
got, _ = BuildReusing(t.Context(), run, r, Reuse{Scratch: newScratch(), Pulls: []PullRef{aPull}},
|
||||
"https://forge.invalid/meshboard.git", "mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||
if !dockerBuilt(r) || !strings.Contains(got.Why, "no check of novox/meshboard#7 built it") {
|
||||
t.Fatalf("the walk says %q", got.Why)
|
||||
}
|
||||
}
|
||||
|
||||
// A copy that fails is said and falls back to a build; it never fails the walk.
|
||||
func TestAFailedCopyOfAChecksBuildFallsBackToBuilding(t *testing.T) {
|
||||
withVersion(t, "builder-1")
|
||||
scratch := newScratch()
|
||||
aCheckBuilt(t, scratch, "tree-one")
|
||||
scratch.failTake = true
|
||||
|
||||
r, run, workspace := aModuleAt(t, "tree-one")
|
||||
got, err := BuildReusing(t.Context(), run, r, Reuse{Scratch: scratch, Pulls: []PullRef{aPull}},
|
||||
"https://forge.invalid/meshboard.git", "mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("a failed copy failed the walk: %v", err)
|
||||
}
|
||||
if !dockerBuilt(r) || got.Reused != nil || len(r.images) == 0 {
|
||||
t.Fatal("a failed copy did not fall back to a build")
|
||||
}
|
||||
if !strings.Contains(got.Why, "copying meshboard's") || !strings.Contains(got.Why, "refused the mount") {
|
||||
t.Fatalf("the walk says %q", got.Why)
|
||||
}
|
||||
for repository := range r.images {
|
||||
if strings.HasPrefix(repository, ScratchPrefix) {
|
||||
t.Fatalf("the walk's own build went into the scratch namespace: %s", repository)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only a walk takes a check's build: a plain build — a check's, a dry run's, `build --ref`'s — builds, and a commit
|
||||
// off the trunk never takes one.
|
||||
func TestOnlyAWalkOfATrunkCommitTakesAChecksBuild(t *testing.T) {
|
||||
withVersion(t, "builder-1")
|
||||
scratch := newScratch()
|
||||
aCheckBuilt(t, scratch, "tree-one")
|
||||
|
||||
r, run, workspace := aModuleAt(t, "tree-one")
|
||||
if _, err := Build(t.Context(), run, r, "https://forge.invalid/meshboard.git", "mod", "main", workspace, nil,
|
||||
Npmrc{}, GitCredential{}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !dockerBuilt(r) || len(scratch.took) > 0 {
|
||||
t.Fatal("a build not asked to reuse took a check's build")
|
||||
}
|
||||
|
||||
r, _, workspace = aModuleAt(t, "tree-one") // no trunk said: off it
|
||||
got, err := BuildReusing(t.Context(), r.run, r, Reuse{Scratch: scratch}, "https://forge.invalid/meshboard.git",
|
||||
"mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !dockerBuilt(r) || got.Reused != nil || !strings.Contains(got.Why, "not on its trunk") {
|
||||
t.Fatalf("a commit off the trunk took a check's build: %q", got.Why)
|
||||
}
|
||||
}
|
||||
|
||||
// A check that did not pass builds nothing for its merge, and one asked to build nothing builds nothing.
|
||||
func TestACheckThatFailedBuildsNothingForItsMerge(t *testing.T) {
|
||||
if skipsTheBuilds(CheckVerdict{Gate: &Layer{Verdict: "pass"}, Repo: &Layer{Verdict: "fail"}}) == "" {
|
||||
t.Fatal("a check whose repository layer failed builds for its merge")
|
||||
}
|
||||
if skipsTheBuilds(CheckVerdict{Gate: &Layer{Verdict: "pass"}, Repo: &Layer{Verdict: "warning"}}) != "" {
|
||||
t.Fatal("a check that passed with a warning builds nothing for its merge")
|
||||
}
|
||||
r, run, workspace := aModuleAt(t, "tree-one")
|
||||
if built := buildForTheMerge(t.Context(), run, CheckSpec{ID: "build-1"}, CheckBuilding{Publish: r, Scratch: newScratch()},
|
||||
workspace, "", "", CheckVerdict{}, GitCredential{}, nil, logging(nil)); built != nil || len(r.ran) > 0 {
|
||||
t.Fatalf("a check moving no module built %v, ran %v", built, r.ran)
|
||||
}
|
||||
}
|
||||
|
||||
// A module whose source does not pin its build has no key and is not built by the check; what stands after it
|
||||
// is not built either.
|
||||
func TestACheckBuildsNoModuleWithoutAKeyNorWhatStandsAfterIt(t *testing.T) {
|
||||
withVersion(t, "builder-1")
|
||||
r, run, workspace := aModuleAt(t, "tree-one")
|
||||
r.contents["mod/"+ManifestName] = `{"module":"meshboard","version":"1","build":{"artifacts":[
|
||||
{"name":"mirror","kind":"upstream","from":"docker.io/library/alpine:3"}]}}`
|
||||
spec := CheckSpec{ID: "build-1", Owner: "novox", Repo: "meshboard", Number: 7, Builds: []CheckBuildSpec{
|
||||
{Module: "meshboard", Repository: "https://forge.invalid/meshboard.git", Path: "mod", Tier: 0},
|
||||
{Module: "after", Repository: "https://forge.invalid/after.git", Tier: 1}}}
|
||||
built := buildForTheMerge(t.Context(), run, spec, CheckBuilding{Publish: r, Scratch: newScratch()}, workspace, "", "",
|
||||
CheckVerdict{}, GitCredential{}, nil, logging(nil))
|
||||
if len(built) != 2 || !strings.Contains(built[0].NotBuilt, "named by tag") ||
|
||||
!strings.Contains(built[1].NotBuilt, "stands after meshboard") {
|
||||
t.Fatalf("built %+v", built)
|
||||
}
|
||||
if len(r.images) > 0 {
|
||||
t.Fatalf("a module without a key was published: %v", r.images)
|
||||
}
|
||||
}
|
||||
|
||||
// What the key names: every input a fresh build could differ by.
|
||||
func TestTheReuseKeyNamesEveryInputOfTheBuild(t *testing.T) {
|
||||
src := newSourceInputs("m")
|
||||
src.tree = "t1"
|
||||
src.bases = []string{"store:5000/b/x@sha256:" + strings.Repeat("b", 64)}
|
||||
src.contexts["server"] = "ctx1"
|
||||
src.toolchains["tools"] = "go sha256:c 1234"
|
||||
manifest := catalogue.Manifest{Module: "m"}
|
||||
base := keyOf(src, manifest, "v1")
|
||||
if base.Key == "" {
|
||||
t.Fatalf("no key: %s", base.Why)
|
||||
}
|
||||
for name, change := range map[string]func(*sourceInputs) string{
|
||||
partTree: func(s *sourceInputs) string { s.tree = "t2"; return "v1" },
|
||||
partBases: func(s *sourceInputs) string {
|
||||
s.bases = []string{"other@sha256:" + strings.Repeat("c", 64)}
|
||||
return "v1"
|
||||
},
|
||||
partContext: func(s *sourceInputs) string { s.contexts["server"] = "ctx2"; return "v1" },
|
||||
partTool: func(s *sourceInputs) string { s.toolchains["tools"] = "go sha256:d 1234"; return "v1" },
|
||||
partBuilder: func(*sourceInputs) string { return "v2" },
|
||||
} {
|
||||
s := src.copy()
|
||||
version := change(s)
|
||||
if k := keyOf(s, manifest, version); k.Key == base.Key {
|
||||
t.Errorf("a change of %s left the key the same", name)
|
||||
}
|
||||
}
|
||||
// A base copied to another address is the same base: the key names it by digest.
|
||||
moved := src.copy()
|
||||
moved.bases = []string{"elsewhere:5000/scratch/b/x@sha256:" + strings.Repeat("b", 64)}
|
||||
if keyOf(moved, manifest, "v1").Key != base.Key {
|
||||
t.Error("the same base at another address changed the key")
|
||||
}
|
||||
if k := keyOf(src, manifest, ""); k.Key != "" {
|
||||
t.Error("a builder whose version cannot be said keyed its build")
|
||||
}
|
||||
unpinned := src.copy()
|
||||
unpinned.notPinned("it resolves packages from the mesh's registry at build time")
|
||||
if k := keyOf(unpinned, manifest, "v1"); k.Key != "" || !strings.Contains(k.Why, "registry") {
|
||||
t.Errorf("a build its source does not pin was keyed: %+v", k)
|
||||
}
|
||||
}
|
||||
|
||||
// aTaggingRegistry is the in-memory registry with tags and their listing, as the store's scratch namespace needs.
|
||||
type aTaggingRegistry struct {
|
||||
*aRegistryOfRepositories
|
||||
tags map[string]map[string]string // repository → tag → manifest digest
|
||||
}
|
||||
|
||||
func (m *aTaggingRegistry) serve() http.Handler {
|
||||
under := m.handler()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if repository, ok := strings.CutSuffix(strings.TrimPrefix(r.URL.Path, "/v2/"), "/tags/list"); ok {
|
||||
m.mu.Lock()
|
||||
var tags []string
|
||||
for tag := range m.tags[repository] {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if tags == nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"name": repository, "tags": tags})
|
||||
return
|
||||
}
|
||||
repository, kind, rest := splitPath(r.URL.Path)
|
||||
if kind == "blobs" && r.Method == http.MethodHead {
|
||||
m.mu.Lock()
|
||||
body, linked := m.blobs[rest], m.links[repository][rest]
|
||||
m.mu.Unlock()
|
||||
if !linked {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if kind != "manifests" {
|
||||
under.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(rest, "sha256:") {
|
||||
m.mu.Lock()
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
body, _ := readAll(r)
|
||||
if m.tags[repository] == nil {
|
||||
m.tags[repository] = map[string]string{}
|
||||
}
|
||||
m.tags[repository][rest] = digestOf(body)
|
||||
m.manifests[repository+"@"+digestOf(body)] = body
|
||||
m.mu.Unlock()
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
return
|
||||
default:
|
||||
digest, ok := m.tags[repository][rest]
|
||||
body, held := m.manifests[repository+"@"+digest]
|
||||
m.mu.Unlock()
|
||||
if !ok || !held {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Docker-Content-Digest", digest)
|
||||
w.Header().Set("Content-Type", mediaTypeOf(body))
|
||||
if r.Method == http.MethodGet {
|
||||
_, _ = w.Write(body)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
}
|
||||
if r.Method == http.MethodDelete {
|
||||
m.mu.Lock()
|
||||
for _, tags := range m.tags {
|
||||
for tag, digest := range tags {
|
||||
if digest == rest {
|
||||
delete(tags, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
under.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// A check's builds go after seven days, and when its pull request closes or is merged; the walk's copy in the
|
||||
// module's own namespace stays.
|
||||
func TestCheckBuildsExpireAfterSevenDaysAndGoWhenTheirPullRequestCloses(t *testing.T) {
|
||||
m := &aTaggingRegistry{aRegistryOfRepositories: newRegistryOfRepositories(), tags: map[string]map[string]string{}}
|
||||
server := httptest.NewServer(m.serve())
|
||||
defer server.Close()
|
||||
address := strings.TrimPrefix(server.URL, "http://")
|
||||
registry := Registry{Address: address}
|
||||
ctx := t.Context()
|
||||
now := time.Date(2026, 10, 13, 20, 0, 0, 0, time.UTC)
|
||||
|
||||
image := func(repository, body string) string {
|
||||
digest := digestOf([]byte(body))
|
||||
m.manifests[repository+"@"+digest] = []byte(body)
|
||||
return address + "/" + repository + "@" + digest
|
||||
}
|
||||
check := func(pull PullRef, id string, at time.Time, key string) string {
|
||||
reference := image("scratch/meshboard/server", `{"mediaType":"`+mediaManifestOCI+`","layers":[],"n":"`+id+`"}`)
|
||||
build := CheckBuild{Key: key, Module: "meshboard", Made: []catalogue.Built{{Name: "server", Kind: "image",
|
||||
Reference: reference}}, Pull: pull, Check: id, At: at}
|
||||
if err := registry.Keep(ctx, pull, id, at, []CheckBuild{build}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return reference
|
||||
}
|
||||
held := func(reference string) bool {
|
||||
_, digest, _ := strings.Cut(reference, "@")
|
||||
_, ok := m.manifests["scratch/meshboard/server@"+digest]
|
||||
return ok
|
||||
}
|
||||
other := PullRef{Owner: "novox", Repo: "meshboard", Number: 8}
|
||||
old := check(aPull, "build-1", now.Add(-8*24*time.Hour), "r1:"+strings.Repeat("1", 64))
|
||||
fresh := check(aPull, "build-2", now.Add(-time.Hour), "r1:"+strings.Repeat("2", 64))
|
||||
others := check(other, "build-3", now.Add(-time.Hour), "r1:"+strings.Repeat("3", 64))
|
||||
walked := image("meshboard/server", `{"mediaType":"`+mediaManifestOCI+`","layers":[],"n":"walk"}`)
|
||||
|
||||
if _, found, err := registry.Find(ctx, "meshboard", "r1:"+strings.Repeat("2", 64)); err != nil || !found {
|
||||
t.Fatalf("a check build kept is not found by its key: %v %v", found, err)
|
||||
}
|
||||
n, err := registry.Expire(ctx, CheckBuildsKept, now)
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("expired %d checks (%v), want the one eight days old", n, err)
|
||||
}
|
||||
if held(old) || !held(fresh) || !held(others) {
|
||||
t.Fatalf("after the week's sweep: old %v, fresh %v, another's %v", held(old), held(fresh), held(others))
|
||||
}
|
||||
if _, found, _ := registry.Find(ctx, "meshboard", "r1:"+strings.Repeat("1", 64)); found {
|
||||
t.Fatal("the expired check build's record is still found")
|
||||
}
|
||||
|
||||
n, err = registry.Forget(ctx, aPull)
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("forgot %d checks of %s (%v)", n, aPull, err)
|
||||
}
|
||||
if held(fresh) || !held(others) {
|
||||
t.Fatalf("after %s closed: its build held %v, another pull request's %v", aPull, held(fresh), held(others))
|
||||
}
|
||||
if _, ok := m.manifests["meshboard/server@"+strings.SplitN(walked, "@", 2)[1]]; !ok {
|
||||
t.Fatal("letting go of check builds took the walk's copy in the module's namespace")
|
||||
}
|
||||
if left, _ := registry.OfPulls(ctx, []PullRef{aPull}); len(left) != 0 {
|
||||
t.Fatalf("%s still has check builds: %+v", aPull, left)
|
||||
}
|
||||
}
|
||||
|
||||
// The walk copies a check build by digest: the digest registered is the digest the check built, and the copy is
|
||||
// in the module's own namespace.
|
||||
func TestAChecksBuildIsCopiedByDigestIntoTheModulesNamespace(t *testing.T) {
|
||||
m := &aTaggingRegistry{aRegistryOfRepositories: newRegistryOfRepositories(), tags: map[string]map[string]string{}}
|
||||
server := httptest.NewServer(m.serve())
|
||||
defer server.Close()
|
||||
address := strings.TrimPrefix(server.URL, "http://")
|
||||
registry := Registry{Address: address}
|
||||
|
||||
layer := []byte("layer")
|
||||
m.blobs[digestOf(layer)] = layer
|
||||
m.link("scratch/meshboard/server", digestOf(layer))
|
||||
manifest := []byte(`{"mediaType":"` + mediaManifestOCI + `","layers":[{"digest":"` + digestOf(layer) + `","size":5}]}`)
|
||||
m.manifests["scratch/meshboard/server@"+digestOf(manifest)] = manifest
|
||||
archive := []byte("archive")
|
||||
m.blobs[digestOf(archive)] = archive
|
||||
m.link("scratch/meshboard/look", digestOf(archive))
|
||||
|
||||
got, err := registry.Take(t.Context(), "meshboard", catalogue.Built{Name: "server", Kind: "image",
|
||||
Reference: address + "/scratch/meshboard/server@" + digestOf(manifest)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Reference != address+"/meshboard/server@"+digestOf(manifest) {
|
||||
t.Fatalf("copied as %s", got.Reference)
|
||||
}
|
||||
if m.uploads != 0 || m.mounts != 1 {
|
||||
t.Fatalf("the image's layer was moved (%d uploads, %d mounts), not mounted", m.uploads, m.mounts)
|
||||
}
|
||||
got, err = registry.Take(t.Context(), "meshboard", catalogue.Built{Name: "look", Kind: "archive",
|
||||
Digest: digestOf(archive), Reference: "http://" + address + "/v2/scratch/meshboard/look/blobs/" + digestOf(archive)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Digest != digestOf(archive) || got.Reference != "http://"+address+"/v2/meshboard/look/blobs/"+digestOf(archive) ||
|
||||
!m.links["meshboard/look"][digestOf(archive)] {
|
||||
t.Fatalf("the archive was copied as %+v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user