Phase 7: OCI delete path for manifests and blobs
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>
This commit is contained in:
84
internal/oci/delete.go
Normal file
84
internal/oci/delete.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcr/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcr/internal/policy"
|
||||
)
|
||||
|
||||
// handleManifestDelete handles DELETE /v2/<name>/manifests/<digest>.
|
||||
// Per OCI spec, deletion by tag is not supported — only by digest.
|
||||
func (h *Handler) handleManifestDelete(w http.ResponseWriter, r *http.Request, repo, reference string) {
|
||||
if !h.checkPolicy(w, r, policy.ActionDelete, repo) {
|
||||
return
|
||||
}
|
||||
|
||||
// Reference must be a digest, not a tag.
|
||||
if !isDigest(reference) {
|
||||
writeOCIError(w, "UNSUPPORTED", http.StatusMethodNotAllowed,
|
||||
"manifest deletion by tag is not supported; use digest")
|
||||
return
|
||||
}
|
||||
|
||||
repoID, err := h.db.GetRepositoryByName(repo)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrRepoNotFound) {
|
||||
writeOCIError(w, "NAME_UNKNOWN", http.StatusNotFound,
|
||||
fmt.Sprintf("repository %q not found", repo))
|
||||
return
|
||||
}
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.DeleteManifest(repoID, reference); err != nil {
|
||||
if errors.Is(err, db.ErrManifestNotFound) {
|
||||
writeOCIError(w, "MANIFEST_UNKNOWN", http.StatusNotFound,
|
||||
fmt.Sprintf("manifest %q not found", reference))
|
||||
return
|
||||
}
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
h.audit(r, "manifest_deleted", repo, reference)
|
||||
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
|
||||
// handleBlobDelete handles DELETE /v2/<name>/blobs/<digest>.
|
||||
// Removes manifest_blobs associations for this repo only. Does not delete
|
||||
// the blob row or file — that is GC's responsibility.
|
||||
func (h *Handler) handleBlobDelete(w http.ResponseWriter, r *http.Request, repo, digest string) {
|
||||
if !h.checkPolicy(w, r, policy.ActionDelete, repo) {
|
||||
return
|
||||
}
|
||||
|
||||
repoID, err := h.db.GetRepositoryByName(repo)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrRepoNotFound) {
|
||||
writeOCIError(w, "NAME_UNKNOWN", http.StatusNotFound,
|
||||
fmt.Sprintf("repository %q not found", repo))
|
||||
return
|
||||
}
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.DeleteBlobFromRepo(repoID, digest); err != nil {
|
||||
if errors.Is(err, db.ErrBlobNotFound) {
|
||||
writeOCIError(w, "BLOB_UNKNOWN", http.StatusNotFound,
|
||||
fmt.Sprintf("blob %q not found in repository", digest))
|
||||
return
|
||||
}
|
||||
writeOCIError(w, "UNKNOWN", http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
h.audit(r, "blob_deleted", repo, digest)
|
||||
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
193
internal/oci/delete_test.go
Normal file
193
internal/oci/delete_test.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package oci
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestManifestDeleteByDigest(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
content := []byte(`{"schemaVersion":2}`)
|
||||
fdb.addManifest(1, "latest", "sha256:aaaa", "application/vnd.oci.image.manifest.v1+json", content)
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("DELETE", "/v2/myrepo/manifests/sha256:aaaa", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusAccepted {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusAccepted)
|
||||
}
|
||||
|
||||
// Verify manifest was deleted from fakeDB.
|
||||
_, err := fdb.GetManifestByDigest(1, "sha256:aaaa")
|
||||
if err == nil {
|
||||
t.Fatal("manifest should have been deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestDeleteByTagUnsupported(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
content := []byte(`{"schemaVersion":2}`)
|
||||
fdb.addManifest(1, "latest", "sha256:aaaa", "application/vnd.oci.image.manifest.v1+json", content)
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("DELETE", "/v2/myrepo/manifests/latest", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
var body ociErrorResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if len(body.Errors) != 1 || body.Errors[0].Code != "UNSUPPORTED" {
|
||||
t.Fatalf("error code: got %+v, want UNSUPPORTED", body.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestDeleteNotFound(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
// No manifests added.
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("DELETE", "/v2/myrepo/manifests/sha256:nonexistent", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusNotFound)
|
||||
}
|
||||
|
||||
var body ociErrorResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if len(body.Errors) != 1 || body.Errors[0].Code != "MANIFEST_UNKNOWN" {
|
||||
t.Fatalf("error code: got %+v, want MANIFEST_UNKNOWN", body.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestDeleteRepoNotFound(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("DELETE", "/v2/nosuchrepo/manifests/sha256:aaaa", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusNotFound)
|
||||
}
|
||||
|
||||
var body ociErrorResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if len(body.Errors) != 1 || body.Errors[0].Code != "NAME_UNKNOWN" {
|
||||
t.Fatalf("error code: got %+v, want NAME_UNKNOWN", body.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestDeleteCascadesTag(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
content := []byte(`{"schemaVersion":2}`)
|
||||
fdb.addManifest(1, "latest", "sha256:aaaa", "application/vnd.oci.image.manifest.v1+json", content)
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("DELETE", "/v2/myrepo/manifests/sha256:aaaa", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusAccepted {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusAccepted)
|
||||
}
|
||||
|
||||
// The tag "latest" should also be gone (cascading delete in fakeDB).
|
||||
_, err := fdb.GetManifestByTag(1, "latest")
|
||||
if err == nil {
|
||||
t.Fatal("tag 'latest' should have been cascaded on manifest delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlobDelete(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
fdb.addBlob(1, "sha256:b1")
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("DELETE", "/v2/myrepo/blobs/sha256:b1", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusAccepted {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusAccepted)
|
||||
}
|
||||
|
||||
// Blob should be removed from repo in fakeDB.
|
||||
exists, _ := fdb.BlobExistsInRepo(1, "sha256:b1")
|
||||
if exists {
|
||||
t.Fatal("blob should have been removed from repo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlobDeleteNotInRepo(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
fdb.addRepo("myrepo", 1)
|
||||
// Blob not added to this repo.
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("DELETE", "/v2/myrepo/blobs/sha256:nonexistent", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusNotFound)
|
||||
}
|
||||
|
||||
var body ociErrorResponse
|
||||
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if len(body.Errors) != 1 || body.Errors[0].Code != "BLOB_UNKNOWN" {
|
||||
t.Fatalf("error code: got %+v, want BLOB_UNKNOWN", body.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlobDeleteRepoNotFound(t *testing.T) {
|
||||
fdb := newFakeDB()
|
||||
|
||||
h := NewHandler(fdb, newFakeBlobs(), allowAll(), nil)
|
||||
router := testRouter(h)
|
||||
|
||||
req := authedRequest("DELETE", "/v2/nosuchrepo/blobs/sha256:b1", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status: got %d, want %d", rr.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,10 @@ type DBQuerier interface {
|
||||
GetUpload(uuid string) (*db.UploadRow, error)
|
||||
UpdateUploadOffset(uuid string, offset int64) error
|
||||
DeleteUpload(uuid string) error
|
||||
|
||||
// Delete operations
|
||||
DeleteManifest(repoID int64, digest string) error
|
||||
DeleteBlobFromRepo(repoID int64, digest string) error
|
||||
}
|
||||
|
||||
// BlobStore provides read and write access to blob storage.
|
||||
|
||||
@@ -207,6 +207,34 @@ func (f *fakeDB) DeleteUpload(uuid string) error {
|
||||
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
|
||||
|
||||
@@ -113,8 +113,10 @@ func (h *Handler) dispatch(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleManifestHead(w, r, info.name, info.reference)
|
||||
case http.MethodPut:
|
||||
h.handleManifestPut(w, r, info.name, info.reference)
|
||||
case http.MethodDelete:
|
||||
h.handleManifestDelete(w, r, info.name, info.reference)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, HEAD, PUT")
|
||||
w.Header().Set("Allow", "GET, HEAD, PUT, DELETE")
|
||||
writeOCIError(w, "UNSUPPORTED", http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
case "blobs":
|
||||
@@ -123,8 +125,10 @@ func (h *Handler) dispatch(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleBlobGet(w, r, info.name, info.reference)
|
||||
case http.MethodHead:
|
||||
h.handleBlobHead(w, r, info.name, info.reference)
|
||||
case http.MethodDelete:
|
||||
h.handleBlobDelete(w, r, info.name, info.reference)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
w.Header().Set("Allow", "GET, HEAD, DELETE")
|
||||
writeOCIError(w, "UNSUPPORTED", http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
case "uploads":
|
||||
|
||||
Reference in New Issue
Block a user