diff --git a/modules/artifact-store-tools/internal/registry/blob.go b/modules/artifact-store-tools/internal/registry/blob.go deleted file mode 100644 index bedbfa38c..000000000 --- a/modules/artifact-store-tools/internal/registry/blob.go +++ /dev/null @@ -1,57 +0,0 @@ -package registry - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - "net/http" -) - -// blobBound is the most GetBlob reads: a templates archive is a few kilobytes; anything near this is no archive a -// take-in reads text from. -const blobBound = 64 << 20 - -// GetBlob is the archive a recorded reference names (`artifact-store:///blobs/sha256:`), read whole -// and checked against the digest it names: bytes that hash to another digest are refused, never read (R4e: the -// controller reads a templates archive by the reference of a builder's done key). Gone when the store does not hold -// it; ErrNotOurs for a reference outside the store or one that is not an archive. -func (s Store) GetBlob(ctx context.Context, reference string) ([]byte, error) { - repository, digest, archive, err := s.archive(reference) - if err != nil { - return nil, err - } - if !archive { - return nil, fmt.Errorf("%w: %s is not an archive", ErrNotOurs, reference) - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, s.url(repository, "blobs", digest), nil) - if err != nil { - return nil, err - } - response, err := s.client().Do(request) - if err != nil { - return nil, fmt.Errorf("cannot reach the artifact store at %s: %w", s.Address, err) - } - defer response.Body.Close() - switch response.StatusCode { - case http.StatusOK: - case http.StatusNotFound: - return nil, Gone - default: - return nil, fmt.Errorf("the artifact store answered %s for %s/blobs/%s", response.Status, repository, digest) - } - body, err := io.ReadAll(io.LimitReader(response.Body, blobBound+1)) - if err != nil { - return nil, fmt.Errorf("reading %s/blobs/%s: %w", repository, digest, err) - } - if len(body) > blobBound { - return nil, fmt.Errorf("%s/blobs/%s is larger than %d bytes, which no archive read here is", repository, digest, blobBound) - } - sum := sha256.Sum256(body) - if got := "sha256:" + hex.EncodeToString(sum[:]); got != digest { - return nil, fmt.Errorf("the artifact store answered %s/blobs/%s with bytes of the digest %s: refused", repository, - digest, got) - } - return body, nil -} diff --git a/modules/artifact-store-tools/internal/registry/blob_test.go b/modules/artifact-store-tools/internal/registry/blob_test.go deleted file mode 100644 index 13487cc7b..000000000 --- a/modules/artifact-store-tools/internal/registry/blob_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package registry - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -// A take-in reads a templates archive by its reference, and only bytes of the digest the reference names. -func TestGetBlobReadsOnlyTheBytesOfItsDigest(t *testing.T) { - good := []byte("the archive") - sum := sha256.Sum256(good) - digest := "sha256:" + hex.EncodeToString(sum[:]) - liar := "sha256:" + strings.Repeat("0", 64) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case strings.HasSuffix(r.URL.Path, "/x/files/blobs/"+digest), strings.HasSuffix(r.URL.Path, "/x/files/blobs/"+liar): - _, _ = w.Write(good) - default: - w.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() - s := Store{Address: strings.TrimPrefix(server.URL, "http://")} - ctx := context.Background() - body, err := s.GetBlob(ctx, "artifact-store://x/files/blobs/"+digest) - if err != nil || string(body) != string(good) { - t.Fatalf("the archive: %q %v", body, err) - } - if _, err := s.GetBlob(ctx, "artifact-store://x/files/blobs/"+liar); err == nil || !strings.Contains(err.Error(), "refused") { - t.Fatalf("bytes of another digest are read: %v", err) - } - if _, err := s.GetBlob(ctx, "artifact-store://x/other/blobs/"+digest); !errors.Is(err, Gone) { - t.Fatalf("a blob the store does not hold: %v", err) - } - if _, err := s.GetBlob(ctx, "registry.example/x@"+digest); !errors.Is(err, ErrNotOurs) { - t.Fatalf("a reference outside the store: %v", err) - } -} diff --git a/modules/artifact-store-tools/internal/registry/hold.go b/modules/artifact-store-tools/internal/registry/hold.go deleted file mode 100644 index 391e9c894..000000000 --- a/modules/artifact-store-tools/internal/registry/hold.go +++ /dev/null @@ -1,345 +0,0 @@ -package registry - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net/http" - "strconv" - "strings" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// Every archive the store keeps is held by a manifest (novox/hq issue 253, ADR 0189). -// -// **The store's collector marks only from manifests.** The mesh's store is a stock registry, and -// its nightly `registry garbage-collect` walks every manifest in every repository, marks the blobs -// those manifests name, and deletes every blob it did not mark. An image is a manifest, so what -// the mesh keeps of an image survives. An archive was not: the builder put it in the store as a -// bare blob — upload, then `PUT ?digest=` — and nothing in the store names it. To the collector a -// bare blob is unreferenced, so the first real collection would have deleted every archive the -// mesh holds, kept or not, and every machine pinning a bundle would have found it gone. The -// collector runs `--dry-run` until this is true. -// -// **So each archive gets a holder**: the smallest OCI image manifest that names it — the empty -// config, one layer, nothing else — put in the archive's own repository, by digest, untagged. The -// collector marks it and so keeps the archive; the sweep lets go of an archive by deleting its -// holder first, which is what lets the bytes go at the next collection. -// -// **Nothing a machine reads changes.** The recorded reference stays -// `artifact-store:////blobs/sha256:…`, and machines fetch the blob exactly as -// before. The holder is the store's bookkeeping, not a second way to reach anything. -// -// **Deterministic, so it never needs recording.** The holder is composed from the archive's digest -// and size alone, in a fixed field order with no timestamps or annotations, so the sweep can -// compute which manifest holds any archive from the reference it already has plus one HEAD for the -// size. No schema change, no second record that could disagree with the store. - -const ( - // mediaManifest is the type a holder is put and asked for as. - mediaManifest = "application/vnd.oci.image.manifest.v1+json" - // mediaEmpty is the OCI empty descriptor's type: a config that says nothing, for a manifest - // whose only purpose is to name its layer. - mediaEmpty = "application/vnd.oci.empty.v1+json" - // emptyDigest is the digest of `{}`, the empty config's content, fixed by the OCI spec. - emptyDigest = "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" - // mediaArchive is the layer type an archive is held as. Every archive the builder publishes is - // `pack`'s gzipped tar, so this is the true type and not a placeholder — and it is a constant, - // not read from anywhere, because the holder must be recomputable from the reference alone. - mediaArchive = "application/vnd.oci.image.layer.v1.tar+gzip" -) - -// emptyConfig is the content emptyDigest names. -var emptyConfig = []byte("{}") - -// manifestAccept is what a manifest is asked for as. A registry answers a manifest HEAD only in a -// type the caller named, and answers 404 to a bare one for a manifest it holds perfectly well -// (measured 2026-09-28; internal/builder/registry.go says how that was found). -var manifestAccept = []string{ - mediaManifest, - "application/vnd.docker.distribution.manifest.v2+json", -} - -type descriptor struct { - MediaType string `json:"mediaType"` - Digest string `json:"digest"` - Size int64 `json:"size"` -} - -type holderManifest struct { - SchemaVersion int `json:"schemaVersion"` - MediaType string `json:"mediaType"` - Config descriptor `json:"config"` - Layers []descriptor `json:"layers"` -} - -// Holder is the manifest that holds an archive in the store, and its digest. -// -// A pure function of the archive's digest and size: the same two in give the same bytes out, -// always, because `encoding/json` writes a struct's fields in their declared order and there is -// nothing here that varies by when or where it was composed. -func Holder(digest string, size int64) (body []byte, holder string) { - body, err := json.Marshal(holderManifest{ - SchemaVersion: 2, - MediaType: mediaManifest, - Config: descriptor{MediaType: mediaEmpty, Digest: emptyDigest, Size: int64(len(emptyConfig))}, - Layers: []descriptor{{MediaType: mediaArchive, Digest: digest, Size: size}}, - }) - if err != nil { - // Marshalling a struct of strings and integers cannot fail. - panic(err) - } - sum := sha256.Sum256(body) - return body, "sha256:" + hex.EncodeToString(sum[:]) -} - -// Hold makes sure the store holds this archive by a manifest, and says whether it had to write one. -// -// Takes a reference as the mesh records it. An image is its own manifest and needs no holder, so -// it answers false and nothing is asked. Idempotent: a holder already there is left alone, which -// is what lets the sweep run it over every kept archive on every build and so backfill the bare -// blobs published before holders existed (novox/hq issue 253). -// -// Gone when the store does not hold the archive at all: there is nothing to hold, and that is a -// fact the caller reports rather than one this invents a remedy for. -func (s Store) Hold(ctx context.Context, reference string) (bool, error) { - repository, digest, archive, err := s.archive(reference) - if err != nil || !archive { - return false, err - } - size, err := s.blobSize(ctx, repository, digest) - if err != nil { - return false, err - } - return s.HoldBlob(ctx, repository, digest, size) -} - -// Held is whether the store holds this archive by its manifest. Asks and changes nothing — the -// question an operator needs answered with "none unheld" before the collector is let loose. -// -// An image answers true: it is its own manifest. An archive the store does not have answers Gone. -func (s Store) Held(ctx context.Context, reference string) (bool, error) { - repository, digest, archive, err := s.archive(reference) - if err != nil { - return false, err - } - if !archive { - return true, nil - } - size, err := s.blobSize(ctx, repository, digest) - if err != nil { - return false, err - } - _, holder := Holder(digest, size) - return s.has(ctx, s.url(repository, "manifests", holder), manifestAccept...) -} - -// HoldBlob puts the holder for a blob of this digest and size into its repository, unless it is -// there already. Answers whether it wrote one. -// -// The builder calls this with the size it has just uploaded; the sweep, through Hold, with the size -// the store reports. Both arrive at the same holder, which is the point of composing it. -func (s Store) HoldBlob(ctx context.Context, repository, digest string, size int64) (bool, error) { - if s.Address == "" { - return false, fmt.Errorf("this mesh has no artifact store on its network to hold %s/%s in", repository, digest) - } - body, holder := Holder(digest, size) - there, err := s.has(ctx, s.url(repository, "manifests", holder), manifestAccept...) - if err != nil { - return false, err - } - if there { - return false, nil - } - - // The config must be in the repository before a manifest naming it is accepted: a registry - // refuses a manifest whose blobs it cannot find there, which is the property that makes a - // holder mean something. - if err := s.putBlob(ctx, repository, emptyDigest, emptyConfig); err != nil { - return false, err - } - - // **By digest, never by tag.** A tag would be one more name to move and one more thing the - // collector's `--delete-untagged` would read as meaningful; the mesh names nothing by tag that - // it pins by digest, and an untagged manifest is kept by plain collection. - request, err := http.NewRequestWithContext(ctx, http.MethodPut, - s.url(repository, "manifests", holder), bytes.NewReader(body)) - if err != nil { - return false, err - } - request.Header.Set("Content-Type", mediaManifest) - response, err := s.client().Do(request) - if err != nil { - return false, err - } - defer response.Body.Close() - if response.StatusCode != http.StatusCreated { - said, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) - return false, fmt.Errorf("the artifact store refused to hold %s/%s: %s %s", - repository, digest, response.Status, strings.TrimSpace(string(said))) - } - return true, nil -} - -// letGoOfHolder deletes the manifest holding an archive, before the archive's own link goes. -// -// **Holder first.** Deleting the blob link alone leaves a manifest still naming the blob, and the -// collector would keep its bytes for ever on the strength of it — the sweep would record the -// archive collected while the disk said otherwise. Deleting the holder first and failing before -// the link goes leaves an unheld archive that the next sweep still offers, which is safe. -// -// A store that no longer has the blob answers Gone: without its size the holder cannot be named, -// and without the blob there is nothing left for a holder to keep. A store that never had a holder -// for it — an archive published before holders, never backfilled — answers 404 to the delete, and -// that is the outcome wanted. -func (s Store) letGoOfHolder(ctx context.Context, repository, digest string) error { - size, err := s.blobSize(ctx, repository, digest) - if err != nil { - return err - } - _, holder := Holder(digest, size) - err = s.remove(ctx, s.url(repository, "manifests", holder), repository+"/manifests/"+holder) - if err == Gone { - return nil - } - return err -} - -// archive reads a recorded reference into its repository and digest, and whether it is an archive -// at all. Refuses as ErrNotOurs anything the mesh did not put in its own store. -func (s Store) archive(reference string) (repository, digest string, archive bool, err error) { - path, kept := catalogue.InArtifactStore(reference) - if !kept { - return "", "", false, fmt.Errorf("%w: %s", ErrNotOurs, reference) - } - if s.Address == "" { - return "", "", false, fmt.Errorf("this mesh has no artifact store on its network to ask about %s", reference) - } - repository, kind, digest, err := split(path) - if err != nil { - return "", "", false, err - } - return repository, digest, kind == "blobs", nil -} - -// blobSize is how large the store says a blob is; Gone when it does not have it. -func (s Store) blobSize(ctx context.Context, repository, digest string) (int64, error) { - request, err := http.NewRequestWithContext(ctx, http.MethodHead, s.url(repository, "blobs", digest), nil) - if err != nil { - return 0, err - } - response, err := s.client().Do(request) - if err != nil { - return 0, fmt.Errorf("cannot reach the artifact store at %s: %w", s.Address, err) - } - defer response.Body.Close() - switch response.StatusCode { - case http.StatusOK: - case http.StatusNotFound: - return 0, Gone - default: - return 0, fmt.Errorf("the artifact store answered %s for %s/blobs/%s", response.Status, repository, digest) - } - // Read from the header rather than ContentLength: a HEAD's ContentLength is what the response - // says it would have sent, which Go reports faithfully, but a proxy in between is free to drop - // it, and the header is what the registry itself wrote. - if length := response.Header.Get("Content-Length"); length != "" { - if n, err := strconv.ParseInt(length, 10, 64); err == nil && n >= 0 { - return n, nil - } - } - if response.ContentLength >= 0 { - return response.ContentLength, nil - } - return 0, fmt.Errorf("the artifact store holds %s/blobs/%s and will not say how large it is", repository, digest) -} - -// putBlob uploads a small blob unless the repository already has it: ask where, then put it there -// naming the digest — the registry's own two steps, the same the builder takes for an archive. -func (s Store) putBlob(ctx context.Context, repository, digest string, body []byte) error { - if there, err := s.has(ctx, s.url(repository, "blobs", digest)); err != nil { - return err - } else if there { - return nil - } - start, err := http.NewRequestWithContext(ctx, http.MethodPost, - "http://"+s.Address+"/v2/"+repository+"/blobs/uploads/", nil) - if err != nil { - return err - } - begun, err := s.client().Do(start) - if err != nil { - return fmt.Errorf("cannot start an upload to %s: %w", repository, err) - } - begun.Body.Close() - if begun.StatusCode != http.StatusAccepted { - return fmt.Errorf("the artifact store answered %s when asked where to put a blob in %s", begun.Status, repository) - } - where := begun.Header.Get("Location") - if where == "" { - return fmt.Errorf("the artifact store accepted an upload to %s and said nowhere to put it", repository) - } - if strings.HasPrefix(where, "/") { - where = "http://" + s.Address + where - } - separator := "?" - if strings.Contains(where, "?") { - separator = "&" - } - put, err := http.NewRequestWithContext(ctx, http.MethodPut, where+separator+"digest="+digest, bytes.NewReader(body)) - if err != nil { - return err - } - put.Header.Set("Content-Type", "application/octet-stream") - done, err := s.client().Do(put) - if err != nil { - return err - } - defer done.Body.Close() - if done.StatusCode != http.StatusCreated { - said, _ := io.ReadAll(io.LimitReader(done.Body, 4096)) - return fmt.Errorf("the artifact store refused a blob in %s: %s %s", repository, done.Status, strings.TrimSpace(string(said))) - } - return nil -} - -// has is whether the store answers 200 for a HEAD at that URL. -func (s Store) has(ctx context.Context, url string, accept ...string) (bool, error) { - request, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) - if err != nil { - return false, err - } - for _, media := range accept { - request.Header.Add("Accept", media) - } - response, err := s.client().Do(request) - if err != nil { - return false, fmt.Errorf("cannot reach the artifact store at %s: %w", s.Address, err) - } - defer response.Body.Close() - switch response.StatusCode { - case http.StatusOK: - return true, nil - case http.StatusNotFound: - return false, nil - default: - return false, fmt.Errorf("the artifact store answered %s for %s", response.Status, url) - } -} - -func (s Store) url(repository, kind, digest string) string { - return "http://" + s.Address + "/v2/" + repository + "/" + kind + "/" + digest -} - -func (s Store) client() *http.Client { - if s.HTTP != nil { - return s.HTTP - } - return &http.Client{Timeout: 30 * time.Second} -} diff --git a/modules/artifact-store-tools/internal/registry/hold_test.go b/modules/artifact-store-tools/internal/registry/hold_test.go deleted file mode 100644 index 8a3a16fa3..000000000 --- a/modules/artifact-store-tools/internal/registry/hold_test.go +++ /dev/null @@ -1,268 +0,0 @@ -package registry - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "strconv" - "strings" - "sync" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// Every kept archive is held by a manifest (novox/hq issue 253, ADR 0189). -// -// Against an in-memory registry that keeps blobs and manifests per repository and refuses what a -// registry refuses — a blob whose digest does not match, a manifest whose digest does not match -// or whose blobs the repository does not have, a manifest asked for without an Accept naming its -// type. What is asserted is this side's decisions; the live test below asserts the registry's. - -type memRegistry struct { - mu sync.Mutex - blobs map[string][]byte // repository + "@" + digest - manifests map[string][]byte // repository + "@" + digest - writes []string // every PUT and DELETE, as "METHOD path" -} - -func digestOf(body []byte) string { - sum := sha256.Sum256(body) - return "sha256:" + hex.EncodeToString(sum[:]) -} - -func (m *memRegistry) serve(t *testing.T) Store { - t.Helper() - m.blobs = map[string][]byte{} - m.manifests = map[string][]byte{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - m.mu.Lock() - defer m.mu.Unlock() - path := strings.TrimPrefix(r.URL.Path, "/v2/") - if r.Method == http.MethodPut || r.Method == http.MethodDelete { - m.writes = append(m.writes, r.Method+" "+r.URL.Path) - } - switch { - case r.Method == http.MethodPost && strings.HasSuffix(path, "/blobs/uploads/"): - repository := strings.TrimSuffix(path, "/blobs/uploads/") - w.Header().Set("Location", "/upload/"+repository+"?state=x") - w.WriteHeader(http.StatusAccepted) - case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/upload/"): - repository := strings.TrimPrefix(r.URL.Path, "/upload/") - body, _ := io.ReadAll(r.Body) - digest := r.URL.Query().Get("digest") - if digest != digestOf(body) { - w.WriteHeader(http.StatusBadRequest) - return - } - m.blobs[repository+"@"+digest] = body - w.WriteHeader(http.StatusCreated) - case strings.Contains(path, "/blobs/"): - repository, digest, _ := strings.Cut(path, "/blobs/") - key := repository + "@" + digest - body, ok := m.blobs[key] - if !ok { - w.WriteHeader(http.StatusNotFound) - return - } - switch r.Method { - case http.MethodHead: - w.Header().Set("Content-Length", strconv.Itoa(len(body))) - w.WriteHeader(http.StatusOK) - case http.MethodDelete: - delete(m.blobs, key) - w.WriteHeader(http.StatusAccepted) - default: - w.WriteHeader(http.StatusMethodNotAllowed) - } - case strings.Contains(path, "/manifests/"): - repository, digest, _ := strings.Cut(path, "/manifests/") - key := repository + "@" + digest - switch r.Method { - case http.MethodHead: - if _, ok := m.manifests[key]; !ok || !strings.Contains(r.Header.Get("Accept"), mediaManifest) { - w.WriteHeader(http.StatusNotFound) - return - } - w.WriteHeader(http.StatusOK) - case http.MethodPut: - body, _ := io.ReadAll(r.Body) - if digest != digestOf(body) { - w.WriteHeader(http.StatusBadRequest) - return - } - var named holderManifest - if err := json.Unmarshal(body, &named); err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - for _, d := range append([]descriptor{named.Config}, named.Layers...) { - if _, ok := m.blobs[repository+"@"+d.Digest]; !ok { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(w, "MANIFEST_BLOB_UNKNOWN %s", d.Digest) - return - } - } - m.manifests[key] = body - w.WriteHeader(http.StatusCreated) - case http.MethodDelete: - if _, ok := m.manifests[key]; !ok { - w.WriteHeader(http.StatusNotFound) - return - } - delete(m.manifests, key) - w.WriteHeader(http.StatusAccepted) - } - default: - w.WriteHeader(http.StatusNotFound) - } - })) - t.Cleanup(server.Close) - return Store{Address: strings.TrimPrefix(server.URL, "http://")} -} - -// bare puts an archive in the store the way the builder did before holders: a blob, nothing more. -func (m *memRegistry) bare(repository string, body []byte) string { - m.mu.Lock() - defer m.mu.Unlock() - digest := digestOf(body) - m.blobs[repository+"@"+digest] = body - return catalogue.ArtifactStoreScheme + repository + "/blobs/" + digest -} - -func TestTheHolderIsComposedFromTheDigestAndSizeAlone(t *testing.T) { - // The sweep must arrive at the very manifest the builder wrote, with nothing recorded between - // them. Same inputs, same bytes — and a different size is a different holder, so a holder can - // never be mistaken for one of a different blob. - digest := "sha256:" + strings.Repeat("a", 64) - one, first := Holder(digest, 42) - two, second := Holder(digest, 42) - if !bytes.Equal(one, two) || first != second { - t.Fatalf("the same archive composed two holders:\n%s\n%s", one, two) - } - if _, other := Holder(digest, 43); other == first { - t.Fatal("a different size composed the same holder") - } - want := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json",` + - `"config":{"mediaType":"application/vnd.oci.empty.v1+json",` + - `"digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},` + - `"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"` + digest + `","size":42}]}` - if string(one) != want { - t.Fatalf("the holder is\n%s\nwant\n%s", one, want) - } - if digestOf(emptyConfig) != emptyDigest { - t.Fatalf("the empty config's digest is %s, not %s", digestOf(emptyConfig), emptyDigest) - } -} - -func TestHoldBackfillsABareArchiveAndIsIdempotent(t *testing.T) { - // The archives published before this have no holder. Hold, run over every kept archive on - // every sweep, writes one the first time and nothing after. - m := &memRegistry{} - store := m.serve(t) - ctx := context.Background() - body := []byte("a theme") - reference := m.bare("shell/config", body) - - if held, err := store.Held(ctx, reference); err != nil || held { - t.Fatalf("a bare blob reads as held=%v (%v)", held, err) - } - wrote, err := store.Hold(ctx, reference) - if err != nil { - t.Fatal(err) - } - if !wrote { - t.Fatal("holding a bare archive wrote nothing") - } - _, holder := Holder(digestOf(body), int64(len(body))) - if _, ok := m.manifests["shell/config@"+holder]; !ok { - t.Fatalf("the store holds manifests %v; want %s", m.manifests, holder) - } - if held, err := store.Held(ctx, reference); err != nil || !held { - t.Fatalf("after holding, held=%v (%v)", held, err) - } - - writes := len(m.writes) - wrote, err = store.Hold(ctx, reference) - if err != nil { - t.Fatal(err) - } - if wrote || len(m.writes) != writes { - t.Fatalf("holding again wrote %v", m.writes[writes:]) - } -} - -func TestAnImageNeedsNoHolderAndAMissingArchiveIsGone(t *testing.T) { - m := &memRegistry{} - store := m.serve(t) - ctx := context.Background() - - // An image is its own manifest: nothing is asked. - wrote, err := store.Hold(ctx, catalogue.ArtifactStoreScheme+"web/app@sha256:"+strings.Repeat("b", 64)) - if err != nil || wrote || len(m.writes) != 0 { - t.Fatalf("holding an image wrote=%v err=%v writes=%v", wrote, err, m.writes) - } - // An archive the store does not have is a fact to report, not something to invent a holder for. - _, err = store.Hold(ctx, catalogue.ArtifactStoreScheme+"web/config/blobs/sha256:"+strings.Repeat("c", 64)) - if !errors.Is(err, Gone) { - t.Fatalf("holding a missing archive answered %v, want Gone", err) - } - // And a reference that is not the mesh's is refused as such. - if _, err := store.Hold(ctx, "docker.io/library/registry@sha256:abc"); !errors.Is(err, ErrNotOurs) { - t.Fatalf("holding a vendor's image answered %v, want ErrNotOurs", err) - } -} - -func TestLettingGoOfAnArchiveDeletesItsHolderFirst(t *testing.T) { - // A holder left behind would keep the bytes through every collection while the record said - // collected; the link deleted first and the holder failing after would be that exactly. - m := &memRegistry{} - store := m.serve(t) - ctx := context.Background() - body := []byte("an old theme") - reference := m.bare("shell/config", body) - if _, err := store.Hold(ctx, reference); err != nil { - t.Fatal(err) - } - m.writes = nil - - if err := store.LetGo(ctx, reference); err != nil { - t.Fatal(err) - } - _, holder := Holder(digestOf(body), int64(len(body))) - want := []string{ - "DELETE /v2/shell/config/manifests/" + holder, - "DELETE /v2/shell/config/blobs/" + digestOf(body), - } - if strings.Join(m.writes, "\n") != strings.Join(want, "\n") { - t.Fatalf("the store was asked\n%s\nwant\n%s", strings.Join(m.writes, "\n"), strings.Join(want, "\n")) - } - if len(m.manifests) != 0 { - t.Fatalf("a holder survived: %v", m.manifests) - } - // Asked again, the archive is already gone, which is the outcome wanted. - if err := store.LetGo(ctx, reference); !errors.Is(err, Gone) { - t.Fatalf("letting go twice answered %v, want Gone", err) - } -} - -func TestLettingGoOfAnUnheldArchiveStillDeletesIt(t *testing.T) { - // An archive published before holders and let go of before any sweep held it: the holder's - // delete answers 404, which is the outcome wanted, and the blob still goes. - m := &memRegistry{} - store := m.serve(t) - reference := m.bare("shell/config", []byte("never held")) - if err := store.LetGo(context.Background(), reference); err != nil { - t.Fatal(err) - } - if len(m.blobs) != 0 { - t.Fatalf("the blob survived: %v", m.blobs) - } -} diff --git a/modules/artifact-store-tools/internal/registry/platforms_test.go b/modules/artifact-store-tools/internal/registry/platforms_test.go deleted file mode 100644 index a335fb2cd..000000000 --- a/modules/artifact-store-tools/internal/registry/platforms_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package registry - -import ( - "context" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "slices" - "strings" - "sync" - "testing" - - "git.novox.be/novox/mesh-sdk/go/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) - } -} diff --git a/modules/artifact-store-tools/internal/registry/read.go b/modules/artifact-store-tools/internal/registry/read.go new file mode 100644 index 000000000..f759c5e52 --- /dev/null +++ b/modules/artifact-store-tools/internal/registry/read.go @@ -0,0 +1,76 @@ +package registry + +import ( + "context" + "fmt" + "net/http" + "strconv" + "time" +) + +// blobSize is how large the store says a blob is; Gone when it does not have it. +func (s Store) blobSize(ctx context.Context, repository, digest string) (int64, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodHead, s.url(repository, "blobs", digest), nil) + if err != nil { + return 0, err + } + response, err := s.client().Do(request) + if err != nil { + return 0, fmt.Errorf("cannot reach the artifact store at %s: %w", s.Address, err) + } + defer response.Body.Close() + switch response.StatusCode { + case http.StatusOK: + case http.StatusNotFound: + return 0, Gone + default: + return 0, fmt.Errorf("the artifact store answered %s for %s/blobs/%s", response.Status, repository, digest) + } + // Read from the header rather than ContentLength: a HEAD's ContentLength is what the response + // says it would have sent, which Go reports faithfully, but a proxy in between is free to drop + // it, and the header is what the registry itself wrote. + if length := response.Header.Get("Content-Length"); length != "" { + if n, err := strconv.ParseInt(length, 10, 64); err == nil && n >= 0 { + return n, nil + } + } + if response.ContentLength >= 0 { + return response.ContentLength, nil + } + return 0, fmt.Errorf("the artifact store holds %s/blobs/%s and will not say how large it is", repository, digest) +} + +// has is whether the store answers 200 for a HEAD at that URL. +func (s Store) has(ctx context.Context, url string, accept ...string) (bool, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) + if err != nil { + return false, err + } + for _, media := range accept { + request.Header.Add("Accept", media) + } + response, err := s.client().Do(request) + if err != nil { + return false, fmt.Errorf("cannot reach the artifact store at %s: %w", s.Address, err) + } + defer response.Body.Close() + switch response.StatusCode { + case http.StatusOK: + return true, nil + case http.StatusNotFound: + return false, nil + default: + return false, fmt.Errorf("the artifact store answered %s for %s", response.Status, url) + } +} + +func (s Store) url(repository, kind, digest string) string { + return "http://" + s.Address + "/v2/" + repository + "/" + kind + "/" + digest +} + +func (s Store) client() *http.Client { + if s.HTTP != nil { + return s.HTTP + } + return &http.Client{Timeout: 30 * time.Second} +} diff --git a/modules/artifact-store-tools/internal/registry/store.go b/modules/artifact-store-tools/internal/registry/store.go index 8a1fe931a..9e7fcdd06 100644 --- a/modules/artifact-store-tools/internal/registry/store.go +++ b/modules/artifact-store-tools/internal/registry/store.go @@ -1,13 +1,6 @@ -// Package registry speaks to the mesh's artifact store over its own door. -// -// Only what the mesh needs that nothing else does: letting go of something it put there -// (novox/hq ADR 0189, issue 108), and holding every archive it keeps by a manifest so the store's -// own collector does not take it (novox/hq issue 253). Pushing is the builder's, through the container runtime; reading -// is every machine's, through its runtime. This is the one operation that belongs to the thing -// holding the records, because it is the only one that is a decision rather than a transfer. -// -// Copied from mesh-controller internal/artifacts at e04846e9 (novox/hq ADR 0571 clause 2): the seat mesh-artifact-sweep lets -// go of what it no longer keeps exactly as the controller's sweep did; the controller's copy goes with its lease. +// Package registry reads the mesh's artifact store over its own door: whether it holds what a reference names, and +// whether a manifest is an index. It writes nothing: what the artifact sweep holds and removes it asks the registry +// seats the store's holder serves (CAT-AS1, novox/hq ADR 0617 §2). package registry import ( @@ -29,10 +22,6 @@ type Store struct { Address string // HTTP is the client used; nil is a client with a modest timeout. HTTP *http.Client - // Spare says, for one repository, which manifests a kept index there names: the platform - // manifests an index being let go of must leave in place (novox/hq ADR 0257). Nil spares every - // platform manifest — an index is then let go of alone, as it was before. - Spare func(ctx context.Context, repository string) (map[string]bool, error) } // Gone is the answer when the store does not hold it: the outcome wanted, already true. @@ -46,86 +35,6 @@ var Gone = errors.New("the store does not hold it") // error" is how a cautious loop became one that did nothing while reporting the right number. var ErrNotOurs = errors.New("not a reference into the mesh's artifact store") -// LetGo asks the store to drop one artifact the mesh recorded making. -// -// Takes a reference as the mesh records it — `artifact-store:///@sha256:…` for -// an image, `…/blobs/sha256:…` for an archive — because that is the identity every record uses, -// and composes the address here at the moment of use. -// -// An archive is let go of in two deletes, its holder manifest and then the blob's link (novox/hq -// issue 253); an image in one. -// -// Returns Gone when the store answers that it does not have it. That is not a failure: the sweep -// wants the artifact absent, and it is. It is distinguished from success only so a caller can say -// which of the two happened. -func (s Store) LetGo(ctx context.Context, reference string) error { - // **Strict, and deliberately** (novox/hq issue 226). Only a reference the mesh keeps in its - // own vocabulary is addressed here. `Recorded` would read `docker.io/library/registry@sha256:…` - // as the mesh's too — it cannot tell one registry host from another — so normalising belongs - // where the provenance is known, which is the sweep reading its own build records, not here - // where the only job is to refuse anything that is not plainly ours. - path, kept := catalogue.InArtifactStore(reference) - if !kept { - // Nothing the mesh put in its own store. Refused rather than attempted: composing a - // delete for a reference of unknown shape is how a sweep reaches something that is not - // the mesh's. Distinguished from a store that refuses, so a sweep skips this and goes on. - return fmt.Errorf("%w: %s", ErrNotOurs, reference) - } - if s.Address == "" { - return fmt.Errorf("this mesh has no artifact store on its network to ask about %s", reference) - } - repository, kind, digest, err := split(path) - if err != nil { - return err - } - if kind == "blobs" { - // **An archive's holder goes before the archive** (novox/hq issue 253): a manifest left - // naming the blob would keep its bytes through every collection while the record said - // collected. Gone here means the store has no such blob, so there is nothing to let go. - if err := s.letGoOfHolder(ctx, repository, digest); err != nil { - return err - } - } - if kind == "manifests" && s.Spare != nil { - // **An index's platform manifests go before the index** (novox/hq ADR 0257). Each was put - // under its own digest, so each is a manifest of the repository in its own right, and the - // store's collector keeps every manifest a repository holds: an index let go of alone frees - // no byte of the images it names. Before, not after, so a sweep cut short leaves the index - // to be asked about again, and with it the list of what is still to go. - if err := s.letGoOfPlatforms(ctx, repository, digest, reference); err != nil { - return err - } - } - return s.remove(ctx, s.url(repository, kind, digest), reference) -} - -// letGoOfPlatforms deletes the manifests an index names, except those a kept index of the same -// repository also names. A manifest that is not an index names none. -func (s Store) letGoOfPlatforms(ctx context.Context, repository, digest, reference string) error { - children, err := s.Platforms(ctx, repository, digest) - if err != nil || len(children) == 0 { - // Gone: there is no index to read, and the delete that follows says so. - if errors.Is(err, Gone) { - return nil - } - return err - } - spared, err := s.Spare(ctx, repository) - if err != nil { - return fmt.Errorf("cannot say which platform manifests of %s a kept index names, so %s was kept: %w", - repository, reference, err) - } - for _, child := range children { - if spared[child] { - continue - } - if err := s.remove(ctx, s.url(repository, "manifests", child), reference+" (its "+child+")"); err != nil && !errors.Is(err, Gone) { - return err - } - } - return nil -} - // anyManifest is every manifest the store may hold, indexes included, so it answers with the // document as it is. var anyManifest = []string{ @@ -172,84 +81,6 @@ func (s Store) Platforms(ctx context.Context, repository, digest string) ([]stri return out, nil } -// SpareKeptIndexes is a Spare for a sweep, read whole before it starts: for every repository an -// image reference of `touching` is in, each kept manifest there and every platform manifest a kept -// index there names. Any read that fails is an error, and the sweep lets nothing go. A repository not -// read here is answered with an error, so an index in it is kept. A kept index the store no longer -// holds names nothing. -func (s Store) SpareKeptIndexes(ctx context.Context, kept, touching []string) (func(context.Context, string) (map[string]bool, error), error) { - imageIn := func(reference string) (repository, digest string, ok bool) { - path, ours := catalogue.InArtifactStore(reference) - if !ours { - return "", "", false - } - repository, kind, digest, err := split(path) - if err != nil || kind != "manifests" { - return "", "", false - } - return repository, digest, true - } - read := map[string]map[string]bool{} - for _, reference := range touching { - if repository, _, ok := imageIn(reference); ok { - read[repository] = map[string]bool{} - } - } - for _, reference := range kept { - repository, digest, ok := imageIn(reference) - spared, touched := read[repository] - if !ok || !touched { - continue - } - spared[digest] = true - children, err := s.Platforms(ctx, repository, digest) - if errors.Is(err, Gone) { - continue - } - if err != nil { - return nil, fmt.Errorf("reading the kept %s@%s: %w", repository, digest, err) - } - for _, c := range children { - spared[c] = true - } - } - return func(_ context.Context, repository string) (map[string]bool, error) { - spared, done := read[repository] - if !done { - return nil, fmt.Errorf("%s was not read before the sweep began", repository) - } - return spared, nil - }, nil -} - -// remove asks the store to delete what is at url. Gone when it has no such thing. -func (s Store) remove(ctx context.Context, url, what string) error { - request, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil) - if err != nil { - return err - } - response, err := s.client().Do(request) - if err != nil { - return err - } - defer response.Body.Close() - switch response.StatusCode { - case http.StatusAccepted, http.StatusOK, http.StatusNoContent: - return nil - case http.StatusNotFound: - return Gone - case http.StatusMethodNotAllowed: - // The registry was started without deletion enabled. Said plainly, because the remedy is - // a setting on the store's module and not anything about this artifact. - return fmt.Errorf( - "the artifact store refuses deletion: its server was started without it enabled "+ - "(REGISTRY_STORAGE_DELETE_ENABLED), so nothing can be collected until the store "+ - "module is applied again (novox/hq ADR 0189). Asking about %s", what) - default: - return fmt.Errorf("the artifact store answered %s for %s", response.Status, what) - } -} - // split reads a recorded path into the repository, which endpoint names the thing, and the digest. // // Two shapes, which are the two the mesh records: `@sha256:` is a manifest, and @@ -279,22 +110,6 @@ func (s Store) IsIndex(ctx context.Context, reference string) (bool, error) { return len(children) > 0, err } -// HoldsManifest is whether the store holds the manifest a recorded image reference names. -func (s Store) HoldsManifest(ctx context.Context, reference string) (bool, error) { - path, ours := catalogue.InArtifactStore(reference) - if !ours { - return false, fmt.Errorf("%w: %s", ErrNotOurs, reference) - } - repository, kind, digest, err := split(path) - if err != nil { - return false, err - } - if kind != "manifests" { - return false, fmt.Errorf("%w: %s names a blob, not a manifest", ErrNotOurs, reference) - } - return s.has(ctx, s.url(repository, kind, digest), anyManifest...) -} - // Holds is whether the store holds what a recorded reference names: the blob of an archive, the manifest of an image. // Gone when it does not; asks and changes nothing (ADR 0526 clause 3: a registration by hand is refused at bytes that // are not there). diff --git a/modules/artifact-store-tools/internal/registry/store_test.go b/modules/artifact-store-tools/internal/registry/store_test.go index 334b95a5a..094ddbfe5 100644 --- a/modules/artifact-store-tools/internal/registry/store_test.go +++ b/modules/artifact-store-tools/internal/registry/store_test.go @@ -11,124 +11,66 @@ import ( "git.novox.be/novox/mesh-sdk/go/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() +// The store's door is read only (CAT-AS1: what the sweep holds and removes it asks the registry seats): Holds says +// whether the store holds what a reference names, in either reference form, IsIndex whether it is an index; a reference +// that is not the mesh's own is never asked. A fake store answers by path and records what it was asked. +func TestTheDoorReadsWhatTheStoreHoldsAndWritesNothing(t *testing.T) { + const index = `{"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"digest":"sha256:c1"}]}` + const image = `{"mediaType":"application/vnd.oci.image.manifest.v1+json","layers":[]}` 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 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + asked = append(asked, r.Method+" "+r.URL.Path) + switch r.URL.Path { + case "/v2/web/app/manifests/sha256:abc", "/v2/web/files/blobs/sha256:def": + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(image)) + } else { + w.Header().Set("Content-Length", "7") } - w.Header().Set("Content-Length", "7") - w.WriteHeader(http.StatusOK) - return + case "/v2/web/app/manifests/sha256:idx": + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(index)) + } + default: + w.WriteHeader(http.StatusNotFound) } - 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) + defer srv.Close() + store := Store{Address: strings.TrimPrefix(srv.URL, "http://")} 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", + for name, c := range map[string]struct { + reference string + holds error // nil, Gone or ErrNotOurs + index bool + }{ + "an image": {reference: catalogue.ArtifactStoreScheme + "web/app@sha256:abc"}, + "an image in the seat's form": {reference: catalogue.SeatReference("container-image-registry", "web", "app", "sha256:abc")}, + "an archive": {reference: catalogue.ArtifactStoreScheme + "web/files/blobs/sha256:def"}, + "an archive in the seat's form": {reference: catalogue.SeatReference("oci-artifact-registry", "web", "files", "sha256:def")}, + "an index": {reference: catalogue.ArtifactStoreScheme + "web/app@sha256:idx", index: true}, + "an image the store lacks": {reference: catalogue.ArtifactStoreScheme + "web/app@sha256:999", holds: Gone}, + "an archive the store lacks": {reference: catalogue.ArtifactStoreScheme + "web/files/blobs/sha256:999", holds: Gone}, + "another registry's image": {reference: "docker.io/library/alpine@sha256:abc", holds: ErrNotOurs}, + "a reference naming no digest": {reference: catalogue.ArtifactStoreScheme + "web/app:latest", holds: ErrNotOurs}, } { - 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) + t.Run(name, func(t *testing.T) { + asked = nil + if err := store.Holds(ctx, c.reference); !errors.Is(err, c.holds) && (err != nil || c.holds != nil) { + t.Errorf("Holds = %v, want %v", err, c.holds) + } + if c.holds == nil { + if got, err := store.IsIndex(ctx, c.reference); err != nil || got != c.index { + t.Errorf("IsIndex = %v, %v, want %v", got, err, c.index) + } + } + for _, a := range asked { + if !strings.HasPrefix(a, "HEAD ") && !strings.HasPrefix(a, "GET ") { + t.Errorf("the door asked %s: it writes nothing", a) + } + } + if errors.Is(c.holds, ErrNotOurs) && len(asked) != 0 { + t.Errorf("a reference that is not the mesh's own was asked: %v", asked) + } + }) } }