mesh/delivery-group group feat/774-l3-reland rejected: composed together: the checker's verdict at the members' heads (b48c90fea31f): integration: mesh-self-ch…
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: integration: mesh-self-check/cmd/mesh-self-check.TestTheSelfCheckWholeRunsOnTheBus: the run answered {Run:doctor-1793351737-1 At:20…
One commit with exactly the tree of the reviewed head35b4509c0a, on main12c2574c9d, so two compiled programs added and removed on the branch leave no trace in history (issue 1577).
269 lines
9.2 KiB
Go
269 lines
9.2 KiB
Go
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)
|
|
}
|
|
}
|