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:
46
PROGRESS.md
46
PROGRESS.md
@@ -6,7 +6,7 @@ See `PROJECT_PLAN.md` for the implementation roadmap and
|
|||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
**Phase:** 6 complete, ready for Phase 7
|
**Phase:** 7 complete, ready for Phase 9
|
||||||
**Last updated:** 2026-03-19
|
**Last updated:** 2026-03-19
|
||||||
|
|
||||||
### Completed
|
### Completed
|
||||||
@@ -18,6 +18,7 @@ See `PROJECT_PLAN.md` for the implementation roadmap and
|
|||||||
- Phase 4: Policy engine (all 4 steps)
|
- Phase 4: Policy engine (all 4 steps)
|
||||||
- Phase 5: OCI pull path (all 5 steps)
|
- Phase 5: OCI pull path (all 5 steps)
|
||||||
- Phase 6: OCI push path (all 3 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)
|
- Phase 8: Admin REST API (all 5 steps)
|
||||||
- `ARCHITECTURE.md` — Full design specification (18 sections)
|
- `ARCHITECTURE.md` — Full design specification (18 sections)
|
||||||
- `CLAUDE.md` — AI development guidance
|
- `CLAUDE.md` — AI development guidance
|
||||||
@@ -26,14 +27,51 @@ See `PROJECT_PLAN.md` for the implementation roadmap and
|
|||||||
|
|
||||||
### Next Steps
|
### Next Steps
|
||||||
|
|
||||||
1. Phase 7 (OCI delete)
|
1. Phase 9 (garbage collection)
|
||||||
2. After Phase 7, Phase 9 (garbage collection)
|
2. Phase 10 (gRPC admin API)
|
||||||
3. Phase 10 (gRPC admin API)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Log
|
## 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
|
### 2026-03-19 — Phase 6: OCI push path
|
||||||
|
|
||||||
**Task:** Implement blob uploads (monolithic and chunked) and manifest
|
**Task:** Implement blob uploads (monolithic and chunked) and manifest
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ design specification.
|
|||||||
| 4 | Policy engine | **Complete** |
|
| 4 | Policy engine | **Complete** |
|
||||||
| 5 | OCI API — pull path | **Complete** |
|
| 5 | OCI API — pull path | **Complete** |
|
||||||
| 6 | OCI API — push 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** |
|
| 8 | Admin REST API | **Complete** |
|
||||||
| 9 | Garbage collection | Not started |
|
| 9 | Garbage collection | Not started |
|
||||||
| 10 | gRPC admin API | Not started |
|
| 10 | gRPC admin API | Not started |
|
||||||
|
|||||||
62
internal/db/delete.go
Normal file
62
internal/db/delete.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
203
internal/db/delete_test.go
Normal file
203
internal/db/delete_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
GetUpload(uuid string) (*db.UploadRow, error)
|
||||||
UpdateUploadOffset(uuid string, offset int64) error
|
UpdateUploadOffset(uuid string, offset int64) error
|
||||||
DeleteUpload(uuid string) 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.
|
// BlobStore provides read and write access to blob storage.
|
||||||
|
|||||||
@@ -207,6 +207,34 @@ func (f *fakeDB) DeleteUpload(uuid string) error {
|
|||||||
return nil
|
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.
|
// addRepo adds a repo to the fakeDB and returns its ID.
|
||||||
func (f *fakeDB) addRepo(name string, id int64) {
|
func (f *fakeDB) addRepo(name string, id int64) {
|
||||||
f.repos[name] = id
|
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)
|
h.handleManifestHead(w, r, info.name, info.reference)
|
||||||
case http.MethodPut:
|
case http.MethodPut:
|
||||||
h.handleManifestPut(w, r, info.name, info.reference)
|
h.handleManifestPut(w, r, info.name, info.reference)
|
||||||
|
case http.MethodDelete:
|
||||||
|
h.handleManifestDelete(w, r, info.name, info.reference)
|
||||||
default:
|
default:
|
||||||
w.Header().Set("Allow", "GET, HEAD, PUT")
|
w.Header().Set("Allow", "GET, HEAD, PUT, DELETE")
|
||||||
writeOCIError(w, "UNSUPPORTED", http.StatusMethodNotAllowed, "method not allowed")
|
writeOCIError(w, "UNSUPPORTED", http.StatusMethodNotAllowed, "method not allowed")
|
||||||
}
|
}
|
||||||
case "blobs":
|
case "blobs":
|
||||||
@@ -123,8 +125,10 @@ func (h *Handler) dispatch(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.handleBlobGet(w, r, info.name, info.reference)
|
h.handleBlobGet(w, r, info.name, info.reference)
|
||||||
case http.MethodHead:
|
case http.MethodHead:
|
||||||
h.handleBlobHead(w, r, info.name, info.reference)
|
h.handleBlobHead(w, r, info.name, info.reference)
|
||||||
|
case http.MethodDelete:
|
||||||
|
h.handleBlobDelete(w, r, info.name, info.reference)
|
||||||
default:
|
default:
|
||||||
w.Header().Set("Allow", "GET, HEAD")
|
w.Header().Set("Allow", "GET, HEAD, DELETE")
|
||||||
writeOCIError(w, "UNSUPPORTED", http.StatusMethodNotAllowed, "method not allowed")
|
writeOCIError(w, "UNSUPPORTED", http.StatusMethodNotAllowed, "method not allowed")
|
||||||
}
|
}
|
||||||
case "uploads":
|
case "uploads":
|
||||||
|
|||||||
Reference in New Issue
Block a user