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.
269 lines
9.2 KiB
Go
269 lines
9.2 KiB
Go
package artifacts
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-controller/internal/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)
|
|
}
|
|
}
|