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) } }