Manifest delete (DELETE /v2/<name>/manifests/<digest>): rejects tag references with 405 UNSUPPORTED per OCI spec, cascades to tags and manifest_blobs via ON DELETE CASCADE, returns 202 Accepted. Blob delete (DELETE /v2/<name>/blobs/<digest>): removes manifest_blobs associations only — blob row and file are preserved for GC to handle, since other repos may reference the same content-addressed blob. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
350 lines
8.4 KiB
Go
350 lines
8.4 KiB
Go
package oci
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sort"
|
|
"sync"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"git.wntrmute.dev/kyle/mcr/internal/auth"
|
|
"git.wntrmute.dev/kyle/mcr/internal/db"
|
|
"git.wntrmute.dev/kyle/mcr/internal/policy"
|
|
"git.wntrmute.dev/kyle/mcr/internal/storage"
|
|
)
|
|
|
|
// manifestKey uniquely identifies a manifest for test lookup.
|
|
type manifestKey struct {
|
|
repoID int64
|
|
reference string // tag or digest
|
|
}
|
|
|
|
// fakeDB implements DBQuerier for tests.
|
|
type fakeDB struct {
|
|
mu sync.Mutex
|
|
repos map[string]int64 // name -> id
|
|
manifests map[manifestKey]*db.ManifestRow // (repoID, ref) -> manifest
|
|
blobs map[int64]map[string]bool // repoID -> set of digests
|
|
allBlobs map[string]bool // global blob digests
|
|
tags map[int64][]string // repoID -> sorted tag names
|
|
repoNames []string // sorted repo names
|
|
uploads map[string]*db.UploadRow // uuid -> upload
|
|
nextID int64 // auto-increment counter
|
|
pushed []db.PushManifestParams // record of pushed manifests
|
|
}
|
|
|
|
func newFakeDB() *fakeDB {
|
|
return &fakeDB{
|
|
repos: make(map[string]int64),
|
|
manifests: make(map[manifestKey]*db.ManifestRow),
|
|
blobs: make(map[int64]map[string]bool),
|
|
allBlobs: make(map[string]bool),
|
|
tags: make(map[int64][]string),
|
|
uploads: make(map[string]*db.UploadRow),
|
|
nextID: 1,
|
|
}
|
|
}
|
|
|
|
func (f *fakeDB) GetRepositoryByName(name string) (int64, error) {
|
|
id, ok := f.repos[name]
|
|
if !ok {
|
|
return 0, db.ErrRepoNotFound
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
func (f *fakeDB) GetManifestByTag(repoID int64, tag string) (*db.ManifestRow, error) {
|
|
m, ok := f.manifests[manifestKey{repoID, tag}]
|
|
if !ok {
|
|
return nil, db.ErrManifestNotFound
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (f *fakeDB) GetManifestByDigest(repoID int64, digest string) (*db.ManifestRow, error) {
|
|
m, ok := f.manifests[manifestKey{repoID, digest}]
|
|
if !ok {
|
|
return nil, db.ErrManifestNotFound
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (f *fakeDB) BlobExistsInRepo(repoID int64, digest string) (bool, error) {
|
|
digests, ok := f.blobs[repoID]
|
|
if !ok {
|
|
return false, nil
|
|
}
|
|
return digests[digest], nil
|
|
}
|
|
|
|
func (f *fakeDB) ListTags(repoID int64, after string, limit int) ([]string, error) {
|
|
allTags := f.tags[repoID]
|
|
var result []string
|
|
for _, t := range allTags {
|
|
if after != "" && t <= after {
|
|
continue
|
|
}
|
|
result = append(result, t)
|
|
if len(result) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (f *fakeDB) ListRepositoryNames(after string, limit int) ([]string, error) {
|
|
var result []string
|
|
for _, n := range f.repoNames {
|
|
if after != "" && n <= after {
|
|
continue
|
|
}
|
|
result = append(result, n)
|
|
if len(result) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (f *fakeDB) GetOrCreateRepository(name string) (int64, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
id, ok := f.repos[name]
|
|
if ok {
|
|
return id, nil
|
|
}
|
|
id = f.nextID
|
|
f.nextID++
|
|
f.repos[name] = id
|
|
f.repoNames = append(f.repoNames, name)
|
|
sort.Strings(f.repoNames)
|
|
return id, nil
|
|
}
|
|
|
|
func (f *fakeDB) BlobExists(digest string) (bool, error) {
|
|
return f.allBlobs[digest], nil
|
|
}
|
|
|
|
func (f *fakeDB) InsertBlob(digest string, size int64) error {
|
|
f.allBlobs[digest] = true
|
|
_ = size
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeDB) PushManifest(p db.PushManifestParams) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.pushed = append(f.pushed, p)
|
|
// Simulate creating the manifest in our fake data.
|
|
repoID, ok := f.repos[p.RepoName]
|
|
if !ok {
|
|
repoID = f.nextID
|
|
f.nextID++
|
|
f.repos[p.RepoName] = repoID
|
|
f.repoNames = append(f.repoNames, p.RepoName)
|
|
sort.Strings(f.repoNames)
|
|
}
|
|
m := &db.ManifestRow{
|
|
ID: f.nextID,
|
|
RepositoryID: repoID,
|
|
Digest: p.Digest,
|
|
MediaType: p.MediaType,
|
|
Content: p.Content,
|
|
Size: p.Size,
|
|
}
|
|
f.nextID++
|
|
f.manifests[manifestKey{repoID, p.Digest}] = m
|
|
if p.Tag != "" {
|
|
f.manifests[manifestKey{repoID, p.Tag}] = m
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeDB) CreateUpload(uuid string, repoID int64) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.uploads[uuid] = &db.UploadRow{
|
|
ID: f.nextID,
|
|
UUID: uuid,
|
|
RepositoryID: repoID,
|
|
ByteOffset: 0,
|
|
}
|
|
f.nextID++
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeDB) GetUpload(uuid string) (*db.UploadRow, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
u, ok := f.uploads[uuid]
|
|
if !ok {
|
|
return nil, db.ErrUploadNotFound
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func (f *fakeDB) UpdateUploadOffset(uuid string, offset int64) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
u, ok := f.uploads[uuid]
|
|
if !ok {
|
|
return db.ErrUploadNotFound
|
|
}
|
|
u.ByteOffset = offset
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeDB) DeleteUpload(uuid string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if _, ok := f.uploads[uuid]; !ok {
|
|
return db.ErrUploadNotFound
|
|
}
|
|
delete(f.uploads, uuid)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeDB) DeleteManifest(repoID int64, digest string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
key := manifestKey{repoID, digest}
|
|
if _, ok := f.manifests[key]; !ok {
|
|
return db.ErrManifestNotFound
|
|
}
|
|
delete(f.manifests, key)
|
|
// Also remove any tag entries pointing to this digest.
|
|
for k, m := range f.manifests {
|
|
if k.repoID == repoID && m.Digest == digest {
|
|
delete(f.manifests, k)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeDB) DeleteBlobFromRepo(repoID int64, digest string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
digests, ok := f.blobs[repoID]
|
|
if !ok || !digests[digest] {
|
|
return db.ErrBlobNotFound
|
|
}
|
|
delete(digests, digest)
|
|
return nil
|
|
}
|
|
|
|
// addRepo adds a repo to the fakeDB and returns its ID.
|
|
func (f *fakeDB) addRepo(name string, id int64) {
|
|
f.repos[name] = id
|
|
f.repoNames = append(f.repoNames, name)
|
|
sort.Strings(f.repoNames)
|
|
if id >= f.nextID {
|
|
f.nextID = id + 1
|
|
}
|
|
}
|
|
|
|
// addManifest adds a manifest accessible by both tag and digest.
|
|
func (f *fakeDB) addManifest(repoID int64, tag, digest, mediaType string, content []byte) {
|
|
m := &db.ManifestRow{
|
|
ID: f.nextID,
|
|
RepositoryID: repoID,
|
|
Digest: digest,
|
|
MediaType: mediaType,
|
|
Content: content,
|
|
Size: int64(len(content)),
|
|
}
|
|
f.nextID++
|
|
if tag != "" {
|
|
f.manifests[manifestKey{repoID, tag}] = m
|
|
}
|
|
f.manifests[manifestKey{repoID, digest}] = m
|
|
}
|
|
|
|
// addBlob registers a blob digest in a repository.
|
|
func (f *fakeDB) addBlob(repoID int64, digest string) {
|
|
if f.blobs[repoID] == nil {
|
|
f.blobs[repoID] = make(map[string]bool)
|
|
}
|
|
f.blobs[repoID][digest] = true
|
|
}
|
|
|
|
// addGlobalBlob registers a blob in the global blob table.
|
|
func (f *fakeDB) addGlobalBlob(digest string) {
|
|
f.allBlobs[digest] = true
|
|
}
|
|
|
|
// addTag adds a tag to a repository's tag list.
|
|
func (f *fakeDB) addTag(repoID int64, tag string) {
|
|
f.tags[repoID] = append(f.tags[repoID], tag)
|
|
sort.Strings(f.tags[repoID])
|
|
}
|
|
|
|
// fakeBlobs implements BlobStore for tests.
|
|
type fakeBlobs struct {
|
|
data map[string][]byte // digest -> content
|
|
uploads map[string]*bytes.Buffer
|
|
}
|
|
|
|
func newFakeBlobs() *fakeBlobs {
|
|
return &fakeBlobs{
|
|
data: make(map[string][]byte),
|
|
uploads: make(map[string]*bytes.Buffer),
|
|
}
|
|
}
|
|
|
|
func (f *fakeBlobs) Open(digest string) (io.ReadCloser, error) {
|
|
data, ok := f.data[digest]
|
|
if !ok {
|
|
return nil, io.ErrUnexpectedEOF
|
|
}
|
|
return io.NopCloser(bytes.NewReader(data)), nil
|
|
}
|
|
|
|
func (f *fakeBlobs) Stat(digest string) (int64, error) {
|
|
data, ok := f.data[digest]
|
|
if !ok {
|
|
return 0, io.ErrUnexpectedEOF
|
|
}
|
|
return int64(len(data)), nil
|
|
}
|
|
|
|
func (f *fakeBlobs) StartUpload(uuid string) (*storage.BlobWriter, error) {
|
|
// For tests that need real storage, use a real Store in t.TempDir().
|
|
// This fake panics to catch unintended usage.
|
|
panic("fakeBlobs.StartUpload should not be called; use a real storage.Store for upload tests")
|
|
}
|
|
|
|
// fakePolicy implements PolicyEval, always returning Allow.
|
|
type fakePolicy struct {
|
|
effect policy.Effect
|
|
}
|
|
|
|
func (f *fakePolicy) Evaluate(_ policy.PolicyInput) (policy.Effect, *policy.Rule) {
|
|
return f.effect, nil
|
|
}
|
|
|
|
// allowAll returns a fakePolicy that allows all requests.
|
|
func allowAll() *fakePolicy {
|
|
return &fakePolicy{effect: policy.Allow}
|
|
}
|
|
|
|
// testRouter creates a chi.Mux with the OCI handler mounted at /v2.
|
|
func testRouter(h *Handler) *chi.Mux {
|
|
parent := chi.NewRouter()
|
|
parent.Mount("/v2", h.Router())
|
|
return parent
|
|
}
|
|
|
|
// authedRequest creates an HTTP request with authenticated claims in the context.
|
|
func authedRequest(method, path string, body io.Reader) *http.Request {
|
|
req := httptest.NewRequest(method, path, body)
|
|
claims := &auth.Claims{
|
|
Subject: "test-user",
|
|
AccountType: "human",
|
|
Roles: []string{"user"},
|
|
}
|
|
return req.WithContext(auth.ContextWithClaims(req.Context(), claims))
|
|
}
|