diff --git a/PROGRESS.md b/PROGRESS.md index 95a8eb7..2c9aae6 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -6,7 +6,7 @@ See `PROJECT_PLAN.md` for the implementation roadmap and ## Current State -**Phase:** 6 complete, ready for Phase 7 +**Phase:** 7 complete, ready for Phase 9 **Last updated:** 2026-03-19 ### Completed @@ -18,6 +18,7 @@ See `PROJECT_PLAN.md` for the implementation roadmap and - Phase 4: Policy engine (all 4 steps) - Phase 5: OCI pull path (all 5 steps) - Phase 6: OCI push path (all 3 steps) +- Phase 7: OCI delete path (all 2 steps) - Phase 8: Admin REST API (all 5 steps) - `ARCHITECTURE.md` — Full design specification (18 sections) - `CLAUDE.md` — AI development guidance @@ -26,14 +27,51 @@ See `PROJECT_PLAN.md` for the implementation roadmap and ### Next Steps -1. Phase 7 (OCI delete) -2. After Phase 7, Phase 9 (garbage collection) -3. Phase 10 (gRPC admin API) +1. Phase 9 (garbage collection) +2. Phase 10 (gRPC admin API) --- ## Log +### 2026-03-19 — Phase 7: OCI delete path + +**Task:** Implement manifest and blob deletion per OCI Distribution Spec. + +**Changes:** + +Step 7.1 — Manifest delete: +- `db/delete.go`: `DeleteManifest(repoID, digest)` — deletes manifest + row; ON DELETE CASCADE handles manifest_blobs and tags +- `oci/delete.go`: `handleManifestDelete()` — policy check + (registry:delete), rejects deletion by tag (405 UNSUPPORTED per OCI + spec), returns 202 Accepted, writes `manifest_deleted` audit event +- Updated `oci/routes.go` dispatch to handle DELETE on manifests + +Step 7.2 — Blob delete: +- `db/delete.go`: `DeleteBlobFromRepo(repoID, digest)` — removes + manifest_blobs associations only; does NOT delete the blob row or + file (GC's responsibility, since other repos may reference it) +- `oci/delete.go`: `handleBlobDelete()` — policy check, returns 202, + writes `blob_deleted` audit event +- Updated `oci/routes.go` dispatch to handle DELETE on blobs +- Extended `DBQuerier` interface with delete methods + +**Verification:** +- `make all` passes: vet clean, lint 0 issues, all tests passing, + all 3 binaries built +- DB delete tests (5 new): delete manifest (verify cascade to tags and + manifest_blobs, blob row preserved), manifest not found, delete blob + from repo (manifest_blobs removed, blob row preserved, manifest + preserved), blob not found, blob exists globally but not in repo +- OCI delete tests (8 new): manifest delete by digest (202), delete by + tag (405 UNSUPPORTED), manifest not found (404 MANIFEST_UNKNOWN), + repo not found (404 NAME_UNKNOWN), cascading tag deletion verified, + blob delete (202), blob not in repo (404 BLOB_UNKNOWN), blob delete + repo not found + +--- + ### 2026-03-19 — Phase 6: OCI push path **Task:** Implement blob uploads (monolithic and chunked) and manifest diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 3dd010b..c7921ea 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -16,7 +16,7 @@ design specification. | 4 | Policy engine | **Complete** | | 5 | OCI API — pull path | **Complete** | | 6 | OCI API — push path | **Complete** | -| 7 | OCI API — delete path | Not started | +| 7 | OCI API — delete path | **Complete** | | 8 | Admin REST API | **Complete** | | 9 | Garbage collection | Not started | | 10 | gRPC admin API | Not started | diff --git a/internal/db/delete.go b/internal/db/delete.go new file mode 100644 index 0000000..02d7dd0 --- /dev/null +++ b/internal/db/delete.go @@ -0,0 +1,62 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" +) + +// DeleteManifest deletes the manifest with the given digest from a repository. +// ON DELETE CASCADE handles manifest_blobs and tags rows. +func (d *DB) DeleteManifest(repoID int64, digest string) error { + result, err := d.Exec( + `DELETE FROM manifests WHERE repository_id = ? AND digest = ?`, + repoID, digest, + ) + if err != nil { + return fmt.Errorf("db: delete manifest: %w", err) + } + n, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("db: delete manifest rows affected: %w", err) + } + if n == 0 { + return ErrManifestNotFound + } + return nil +} + +// DeleteBlobFromRepo removes the manifest_blobs associations for a blob +// in the given repository. It does NOT delete the blob row or file — that +// is GC's job, since other repositories may reference the same blob. +// Returns ErrBlobNotFound if the blob is not referenced in this repo. +func (d *DB) DeleteBlobFromRepo(repoID int64, digest string) error { + // First check that the blob exists and is in this repo. + var blobID int64 + err := d.QueryRow(`SELECT id FROM blobs WHERE digest = ?`, digest).Scan(&blobID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrBlobNotFound + } + return fmt.Errorf("db: find blob: %w", err) + } + + result, err := d.Exec( + `DELETE FROM manifest_blobs + WHERE blob_id = ? AND manifest_id IN ( + SELECT id FROM manifests WHERE repository_id = ? + )`, + blobID, repoID, + ) + if err != nil { + return fmt.Errorf("db: delete blob from repo: %w", err) + } + n, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("db: delete blob from repo rows affected: %w", err) + } + if n == 0 { + return ErrBlobNotFound + } + return nil +} diff --git a/internal/db/delete_test.go b/internal/db/delete_test.go new file mode 100644 index 0000000..0a3e138 --- /dev/null +++ b/internal/db/delete_test.go @@ -0,0 +1,203 @@ +package db + +import ( + "errors" + "testing" +) + +func TestDeleteManifest(t *testing.T) { + d := openTestDB(t) + if err := d.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + // Set up repo, manifest, tag, blob, manifest_blob. + _, err := d.Exec(`INSERT INTO repositories (name) VALUES ('testrepo')`) + if err != nil { + t.Fatalf("insert repo: %v", err) + } + _, err = d.Exec(`INSERT INTO manifests (repository_id, digest, media_type, content, size) + VALUES (1, 'sha256:m1', 'application/vnd.oci.image.manifest.v1+json', '{}', 2)`) + if err != nil { + t.Fatalf("insert manifest: %v", err) + } + _, err = d.Exec(`INSERT INTO tags (repository_id, name, manifest_id) VALUES (1, 'latest', 1)`) + if err != nil { + t.Fatalf("insert tag: %v", err) + } + _, err = d.Exec(`INSERT INTO blobs (digest, size) VALUES ('sha256:b1', 100)`) + if err != nil { + t.Fatalf("insert blob: %v", err) + } + _, err = d.Exec(`INSERT INTO manifest_blobs (manifest_id, blob_id) VALUES (1, 1)`) + if err != nil { + t.Fatalf("insert manifest_blob: %v", err) + } + + // Delete the manifest. + if err := d.DeleteManifest(1, "sha256:m1"); err != nil { + t.Fatalf("DeleteManifest: %v", err) + } + + // Verify manifest is gone. + _, err = d.GetManifestByDigest(1, "sha256:m1") + if !errors.Is(err, ErrManifestNotFound) { + t.Fatalf("expected ErrManifestNotFound, got %v", err) + } + + // Verify tag was cascaded. + var tagCount int + if err := d.QueryRow(`SELECT COUNT(*) FROM tags`).Scan(&tagCount); err != nil { + t.Fatalf("count tags: %v", err) + } + if tagCount != 0 { + t.Fatalf("tag count: got %d, want 0", tagCount) + } + + // Verify manifest_blobs was cascaded. + var mbCount int + if err := d.QueryRow(`SELECT COUNT(*) FROM manifest_blobs`).Scan(&mbCount); err != nil { + t.Fatalf("count manifest_blobs: %v", err) + } + if mbCount != 0 { + t.Fatalf("manifest_blobs count: got %d, want 0", mbCount) + } + + // Verify blob row still exists (GC handles file cleanup). + var blobCount int + if err := d.QueryRow(`SELECT COUNT(*) FROM blobs`).Scan(&blobCount); err != nil { + t.Fatalf("count blobs: %v", err) + } + if blobCount != 1 { + t.Fatalf("blob count: got %d, want 1 (should not be deleted)", blobCount) + } +} + +func TestDeleteManifestNotFound(t *testing.T) { + d := openTestDB(t) + if err := d.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + _, err := d.Exec(`INSERT INTO repositories (name) VALUES ('testrepo')`) + if err != nil { + t.Fatalf("insert repo: %v", err) + } + + err = d.DeleteManifest(1, "sha256:nonexistent") + if !errors.Is(err, ErrManifestNotFound) { + t.Fatalf("expected ErrManifestNotFound, got %v", err) + } +} + +func TestDeleteBlobFromRepo(t *testing.T) { + d := openTestDB(t) + if err := d.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + // Set up: repo, manifest, blob, manifest_blob. + _, err := d.Exec(`INSERT INTO repositories (name) VALUES ('testrepo')`) + if err != nil { + t.Fatalf("insert repo: %v", err) + } + _, err = d.Exec(`INSERT INTO manifests (repository_id, digest, media_type, content, size) + VALUES (1, 'sha256:m1', 'application/vnd.oci.image.manifest.v1+json', '{}', 2)`) + if err != nil { + t.Fatalf("insert manifest: %v", err) + } + _, err = d.Exec(`INSERT INTO blobs (digest, size) VALUES ('sha256:b1', 100)`) + if err != nil { + t.Fatalf("insert blob: %v", err) + } + _, err = d.Exec(`INSERT INTO manifest_blobs (manifest_id, blob_id) VALUES (1, 1)`) + if err != nil { + t.Fatalf("insert manifest_blob: %v", err) + } + + // Delete blob from repo. + if err := d.DeleteBlobFromRepo(1, "sha256:b1"); err != nil { + t.Fatalf("DeleteBlobFromRepo: %v", err) + } + + // Verify manifest_blobs is gone. + var mbCount int + if err := d.QueryRow(`SELECT COUNT(*) FROM manifest_blobs`).Scan(&mbCount); err != nil { + t.Fatalf("count manifest_blobs: %v", err) + } + if mbCount != 0 { + t.Fatalf("manifest_blobs count: got %d, want 0", mbCount) + } + + // Verify blob row still exists (GC responsibility). + var blobCount int + if err := d.QueryRow(`SELECT COUNT(*) FROM blobs`).Scan(&blobCount); err != nil { + t.Fatalf("count blobs: %v", err) + } + if blobCount != 1 { + t.Fatalf("blob count: got %d, want 1", blobCount) + } + + // Verify manifest still exists. + var mCount int + if err := d.QueryRow(`SELECT COUNT(*) FROM manifests`).Scan(&mCount); err != nil { + t.Fatalf("count manifests: %v", err) + } + if mCount != 1 { + t.Fatalf("manifest count: got %d, want 1", mCount) + } +} + +func TestDeleteBlobFromRepoNotFound(t *testing.T) { + d := openTestDB(t) + if err := d.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + _, err := d.Exec(`INSERT INTO repositories (name) VALUES ('testrepo')`) + if err != nil { + t.Fatalf("insert repo: %v", err) + } + + // Blob doesn't exist at all. + err = d.DeleteBlobFromRepo(1, "sha256:nonexistent") + if !errors.Is(err, ErrBlobNotFound) { + t.Fatalf("expected ErrBlobNotFound, got %v", err) + } +} + +func TestDeleteBlobFromRepoExistsGloballyButNotInRepo(t *testing.T) { + d := openTestDB(t) + if err := d.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + // Two repos: blob only referenced in repo 2. + _, err := d.Exec(`INSERT INTO repositories (name) VALUES ('repo1')`) + if err != nil { + t.Fatalf("insert repo1: %v", err) + } + _, err = d.Exec(`INSERT INTO repositories (name) VALUES ('repo2')`) + if err != nil { + t.Fatalf("insert repo2: %v", err) + } + _, err = d.Exec(`INSERT INTO manifests (repository_id, digest, media_type, content, size) + VALUES (2, 'sha256:m2', 'application/vnd.oci.image.manifest.v1+json', '{}', 2)`) + if err != nil { + t.Fatalf("insert manifest: %v", err) + } + _, err = d.Exec(`INSERT INTO blobs (digest, size) VALUES ('sha256:shared', 500)`) + if err != nil { + t.Fatalf("insert blob: %v", err) + } + _, err = d.Exec(`INSERT INTO manifest_blobs (manifest_id, blob_id) VALUES (1, 1)`) + if err != nil { + t.Fatalf("insert manifest_blob: %v", err) + } + + // Try to delete from repo1 — blob exists globally but not in repo1. + err = d.DeleteBlobFromRepo(1, "sha256:shared") + if !errors.Is(err, ErrBlobNotFound) { + t.Fatalf("expected ErrBlobNotFound for blob not in repo1, got %v", err) + } +} diff --git a/internal/oci/delete.go b/internal/oci/delete.go new file mode 100644 index 0000000..e9ef5ff --- /dev/null +++ b/internal/oci/delete.go @@ -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//manifests/. +// 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//blobs/. +// 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) +} diff --git a/internal/oci/delete_test.go b/internal/oci/delete_test.go new file mode 100644 index 0000000..dec957a --- /dev/null +++ b/internal/oci/delete_test.go @@ -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) + } +} diff --git a/internal/oci/handler.go b/internal/oci/handler.go index 21609b1..1f1992f 100644 --- a/internal/oci/handler.go +++ b/internal/oci/handler.go @@ -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. diff --git a/internal/oci/handler_test.go b/internal/oci/handler_test.go index 042f3ee..251dc69 100644 --- a/internal/oci/handler_test.go +++ b/internal/oci/handler_test.go @@ -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 diff --git a/internal/oci/routes.go b/internal/oci/routes.go index db11060..f5fbc06 100644 --- a/internal/oci/routes.go +++ b/internal/oci/routes.go @@ -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":