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:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user