The store's garbage-collect marks only from manifests, and archives were published as bare blobs, so the first real collection would delete every archive the mesh keeps. PublishArchive now puts a deterministic OCI holder manifest (empty config, one layer) beside each archive; the sweep holds every kept archive before it lets anything go, which backfills existing bare blobs, and lets go of an archive holder-first. A forgotten module no longer keeps its five recent builds (ADR 0189). `collection [--json]` reports kept archives held/unheld and what may be let go, so the dry run can be lifted on evidence.
135 lines
5.2 KiB
Go
135 lines
5.2 KiB
Go
package artifacts
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
)
|
|
|
|
// Asking the store to let go of what the mesh no longer keeps (novox/hq ADR 0189, issue 108).
|
|
//
|
|
// A fake store records what it was asked to delete, so what is asserted is the mesh's decision
|
|
// and the shape of the request — not the registry's behaviour, which is the registry's to test.
|
|
|
|
func fakeStore(t *testing.T, answer int) (Store, *[]string) {
|
|
t.Helper()
|
|
var asked []string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodHead && strings.Contains(r.URL.Path, "/blobs/") {
|
|
// An archive's size, asked so its holder can be named (novox/hq issue 253). A store
|
|
// that does not have the thing does not have its blob either.
|
|
if answer == http.StatusNotFound {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Length", "7")
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if r.Method != http.MethodDelete {
|
|
t.Errorf("the store was asked %s %s; collecting is a delete", r.Method, r.URL.Path)
|
|
}
|
|
asked = append(asked, r.URL.Path)
|
|
w.WriteHeader(answer)
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
return Store{Address: strings.TrimPrefix(server.URL, "http://")}, &asked
|
|
}
|
|
|
|
func TestAnImageAndAnArchiveAreAskedForAtTheirOwnEndpoints(t *testing.T) {
|
|
// The two shapes the mesh records: a manifest by digest, and a blob by digest. They are
|
|
// different endpoints, and asking at the wrong one answers 404 — which this would then
|
|
// record as collected, leaving the bytes on disk for ever while the record says otherwise.
|
|
store, asked := fakeStore(t, http.StatusAccepted)
|
|
ctx := context.Background()
|
|
|
|
image := catalogue.ArtifactStoreScheme + "web/app@sha256:abc123"
|
|
archive := catalogue.ArtifactStoreScheme + "web/config/blobs/sha256:def456"
|
|
if err := store.LetGo(ctx, image); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.LetGo(ctx, archive); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The archive's holder goes first, then the archive (novox/hq issue 253).
|
|
_, holder := Holder("sha256:def456", 7)
|
|
want := []string{
|
|
"/v2/web/app/manifests/sha256:abc123",
|
|
"/v2/web/config/manifests/" + holder,
|
|
"/v2/web/config/blobs/sha256:def456",
|
|
}
|
|
if strings.Join(*asked, " ") != strings.Join(want, " ") {
|
|
t.Fatalf("the store was asked %v; want %v", *asked, want)
|
|
}
|
|
}
|
|
|
|
func TestAStoreThatDoesNotHaveItAnswersGone(t *testing.T) {
|
|
// The outcome wanted, already true. Told apart from success only so the sweep can say which
|
|
// happened; both are recorded, because retrying for ever is the thing to avoid.
|
|
store, _ := fakeStore(t, http.StatusNotFound)
|
|
err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/app@sha256:abc123")
|
|
if !errors.Is(err, Gone) {
|
|
t.Fatalf("a store that does not hold it answered %v, want Gone", err)
|
|
}
|
|
}
|
|
|
|
func TestAStoreWithDeletionOffSaysSoAndNamesTheRemedy(t *testing.T) {
|
|
// The registry answers 405 when it was started without deletion enabled. The remedy is a
|
|
// setting on the store's module, and saying "405" would send somebody to the wrong place.
|
|
store, _ := fakeStore(t, http.StatusMethodNotAllowed)
|
|
err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/app@sha256:abc123")
|
|
if err == nil {
|
|
t.Fatal("a store that refuses deletion was read as success")
|
|
}
|
|
if !strings.Contains(err.Error(), "REGISTRY_STORAGE_DELETE_ENABLED") {
|
|
t.Fatalf("the refusal does not name the remedy: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAReferenceThatIsNotTheMeshsOwnIsNeverAsked(t *testing.T) {
|
|
// The whole safety of the sweep is that it names only what the mesh recorded putting there.
|
|
// A reference of another shape — a vendor's image, a package version — is refused rather
|
|
// than composed into a delete somewhere that is not the mesh's store.
|
|
store, asked := fakeStore(t, http.StatusAccepted)
|
|
for _, reference := range []string{
|
|
"docker.io/library/registry@sha256:abc123",
|
|
"registry@sha256:abc123",
|
|
"1.4.2",
|
|
} {
|
|
if err := store.LetGo(context.Background(), reference); err == nil {
|
|
t.Errorf("%s was asked about; it is not a reference into the mesh's store", reference)
|
|
}
|
|
}
|
|
if len(*asked) != 0 {
|
|
t.Fatalf("the store was asked about %v", *asked)
|
|
}
|
|
}
|
|
|
|
// A reference this sweep will not address says so as ErrNotOurs, which is a fact about the
|
|
// record and not about the store (novox/hq issue 226).
|
|
//
|
|
// The sweep skips one and abandons itself for the other, so they cannot be the same error. The
|
|
// first live run met a reference recorded with the store's old address, read the refusal as "the
|
|
// store refuses everything", and collected none of the 1681 it had found.
|
|
func TestAReferenceThisSweepWillNotAddressIsToldApartFromAStoreRefusing(t *testing.T) {
|
|
store, asked := fakeStore(t, http.StatusAccepted)
|
|
for _, reference := range []string{
|
|
"docker.io/library/registry@sha256:abc123",
|
|
"127.0.0.1:5100/mesh-tools/build@sha256:abc123",
|
|
"1.4.2",
|
|
} {
|
|
err := store.LetGo(context.Background(), reference)
|
|
if !errors.Is(err, ErrNotOurs) {
|
|
t.Errorf("%s answered %v; a sweep must be able to skip it and go on", reference, err)
|
|
}
|
|
}
|
|
if len(*asked) != 0 {
|
|
t.Fatalf("the store was asked about %v", *asked)
|
|
}
|
|
}
|