A copied base was named only in what a build stood on, and nowhere when the build failed, so the store's sweep could never let one go (hq issue 321). One repository per upstream image stops each module asking the public registry for the same image again, and letting an index go now takes its own platform manifests, which otherwise kept every byte. A person can record the copies no record names through the new mirrors verb (hq ADR 0257). The forge test fix is the same commit as on feat/plain-notifications: main fails without it.
143 lines
5.4 KiB
Go
143 lines
5.4 KiB
Go
package artifacts
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
)
|
|
|
|
// An index is let go of with the platform manifests it names, except those a kept index of the same
|
|
// repository names too (novox/hq ADR 0257). Each platform manifest is a manifest of the repository in
|
|
// its own right, and the store's collector keeps every manifest a repository holds — so an index let go
|
|
// of alone frees nothing of the images it names.
|
|
|
|
func digestN(n int) string { return fmt.Sprintf("sha256:%064x", n) }
|
|
|
|
// indexStore holds indexes and images in one repository, answers GETs with their documents, and records
|
|
// every delete.
|
|
type indexStore struct {
|
|
mu sync.Mutex
|
|
indexes map[string][]string // digest → the platform manifests it names
|
|
images map[string]bool
|
|
deleted []string
|
|
}
|
|
|
|
func (s *indexStore) serve(t *testing.T) Store {
|
|
t.Helper()
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
digest := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
if children, ok := s.indexes[digest]; ok {
|
|
var named []string
|
|
for _, c := range children {
|
|
named = append(named, `{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"`+c+`","size":1}`)
|
|
}
|
|
w.Header().Set("Content-Type", "application/vnd.oci.image.index.v1+json")
|
|
_, _ = w.Write([]byte(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[` +
|
|
strings.Join(named, ",") + `]}`))
|
|
return
|
|
}
|
|
if s.images[digest] {
|
|
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
|
|
_, _ = w.Write([]byte(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","layers":[]}`))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
case http.MethodHead:
|
|
if _, ok := s.indexes[digest]; ok || s.images[digest] {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
case http.MethodDelete:
|
|
if _, ok := s.indexes[digest]; !ok && !s.images[digest] {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
s.deleted = append(s.deleted, digest)
|
|
delete(s.indexes, digest)
|
|
delete(s.images, digest)
|
|
w.WriteHeader(http.StatusAccepted)
|
|
default:
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
}
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
return Store{Address: strings.TrimPrefix(server.URL, "http://")}
|
|
}
|
|
|
|
func TestAnIndexGoesWithItsPlatformsAndAKeptIndexKeepsItsOwn(t *testing.T) {
|
|
// Two indexes of one image's repository: the old one names platforms 11 and 12, the kept one 12 and 13.
|
|
s := &indexStore{
|
|
indexes: map[string][]string{digestN(1): {digestN(11), digestN(12)}, digestN(2): {digestN(12), digestN(13)}},
|
|
images: map[string]bool{digestN(11): true, digestN(12): true, digestN(13): true},
|
|
}
|
|
store := s.serve(t)
|
|
repository := "upstream/docker.io/library/golang"
|
|
old := catalogue.ArtifactStoreScheme + repository + "@" + digestN(1)
|
|
kept := catalogue.ArtifactStoreScheme + repository + "@" + digestN(2)
|
|
store.Spare = store.SpareKeptIndexes([]string{kept})
|
|
|
|
if err := store.LetGo(context.Background(), old); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Its own platform first, the index last; the platform the kept index names is left in place.
|
|
if !slices.Equal(s.deleted, []string{digestN(11), digestN(1)}) {
|
|
t.Fatalf("deleted %v; want the old index's own platform, then the index", s.deleted)
|
|
}
|
|
if !s.images[digestN(12)] || !s.images[digestN(13)] {
|
|
t.Fatal("a platform a kept index names was let go of")
|
|
}
|
|
}
|
|
|
|
func TestWithoutSparingAnIndexGoesAloneAndAnImageNamesNoPlatforms(t *testing.T) {
|
|
s := &indexStore{indexes: map[string][]string{digestN(1): {digestN(11)}}, images: map[string]bool{digestN(11): true, digestN(5): true}}
|
|
store := s.serve(t)
|
|
// No Spare: as before ADR 0257, the index alone.
|
|
if err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/base@"+digestN(1)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !slices.Equal(s.deleted, []string{digestN(1)}) {
|
|
t.Fatalf("without a Spare, deleted %v", s.deleted)
|
|
}
|
|
// An image is one delete, with a Spare or without.
|
|
s.deleted = nil
|
|
store.Spare = store.SpareKeptIndexes(nil)
|
|
if err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/app@"+digestN(5)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !slices.Equal(s.deleted, []string{digestN(5)}) {
|
|
t.Fatalf("an image was let go of as %v", s.deleted)
|
|
}
|
|
// An index the store no longer holds is Gone, and nothing is deleted on its account.
|
|
s.deleted = nil
|
|
err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/base@"+digestN(9))
|
|
if !errors.Is(err, Gone) || len(s.deleted) != 0 {
|
|
t.Fatalf("an index the store does not hold: %v, deleted %v", err, s.deleted)
|
|
}
|
|
}
|
|
|
|
func TestASpareThatCannotBeReadKeepsTheIndex(t *testing.T) {
|
|
s := &indexStore{indexes: map[string][]string{digestN(1): {digestN(11)}}, images: map[string]bool{digestN(11): true}}
|
|
store := s.serve(t)
|
|
store.Spare = func(context.Context, string) (map[string]bool, error) { return nil, fmt.Errorf("the store went away") }
|
|
err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/base@"+digestN(1))
|
|
if err == nil || !strings.Contains(err.Error(), "was kept") {
|
|
t.Fatalf("a spare that could not be read: %v", err)
|
|
}
|
|
if len(s.deleted) != 0 {
|
|
t.Fatalf("deleted %v without knowing what a kept index names", s.deleted)
|
|
}
|
|
}
|