An unrecorded index or a copy in progress can name a platform the records do not see, so only a person's collect, after its dry run, takes an index's platforms, and only once every kept index of each repository it touches was read. A copy missing a platform is copied again, and a copy a build holds again is no longer recorded as collected (review of #144).
177 lines
7.0 KiB
Go
177 lines
7.0 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)
|
|
spare, err := store.SpareKeptIndexes(context.Background(), []string{kept}, []string{old})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store.Spare = spare
|
|
|
|
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
|
|
spare, err := store.SpareKeptIndexes(context.Background(), nil, []string{catalogue.ArtifactStoreScheme + "web/app@" + digestN(5)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store.Spare = spare
|
|
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)
|
|
}
|
|
}
|
|
|
|
// The spare list is read whole before the sweep: a kept index that cannot be read is an error before
|
|
// anything is deleted, and a repository not read before keeps its index.
|
|
func TestASpareListIsReadWholeBeforeAnythingGoes(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
store := Store{Address: strings.TrimPrefix(server.URL, "http://")}
|
|
kept := catalogue.ArtifactStoreScheme + "web/base@" + digestN(2)
|
|
old := catalogue.ArtifactStoreScheme + "web/base@" + digestN(1)
|
|
if _, err := store.SpareKeptIndexes(context.Background(), []string{kept}, []string{old}); err == nil {
|
|
t.Fatal("a kept index the store would not answer for was taken as naming nothing")
|
|
}
|
|
|
|
s := &indexStore{indexes: map[string][]string{digestN(1): {digestN(11)}}, images: map[string]bool{digestN(11): true}}
|
|
good := s.serve(t)
|
|
spare, err := good.SpareKeptIndexes(context.Background(), nil, []string{catalogue.ArtifactStoreScheme + "other/repo@" + digestN(3)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
good.Spare = spare
|
|
if err := good.LetGo(context.Background(), old); err == nil || len(s.deleted) != 0 {
|
|
t.Fatalf("an index in a repository not read before the sweep was let go of: %v, deleted %v", err, s.deleted)
|
|
}
|
|
}
|