Add remote registry repository delete
Release rolling / release (push) Has been cancelled

Delete all tags/manifests for a repository via Registry V2 digests, with
admin RPC, audit logging, and catalog/tags UI actions.
This commit is contained in:
Raven Scott
2026-07-17 19:43:53 -04:00
parent 2ae38b83bf
commit f6f059ab94
6 changed files with 248 additions and 3 deletions
+3
View File
@@ -46,6 +46,9 @@ const AUDIT_METHODS = new Set([
'importImage',
'registryLogin',
'registryLogout',
'registryDeleteTag',
'registryDeleteTags',
'registryDeleteRepository',
'vaultStoreCredential',
'vaultDeleteCredential',
'vaultUseCredential',
+33
View File
@@ -7,6 +7,7 @@ import {
fetchTags,
fetchManifest,
deleteManifest,
deleteRepository,
enrichTagsWithDigests,
normalizeRegistryEndpoint,
} from '../services/registry-client.js'
@@ -212,6 +213,38 @@ export function registerRegistryHandlers(session) {
}
})
/**
* Delete all tags/manifests for a repository (admin).
* Registry V2 has no single-shot repo delete — we remove unique digests.
*/
session.respond('registryDeleteRepository', async (args = {}) => {
const repository = validation.sanitizeString(args.repository || args.name || '', 512)
if (!repository) throw new Error('repository required')
const { auth, serveraddress } = resolveBrowserAuth(session, {
...args,
repository,
})
if (!auth) {
throw Object.assign(
new Error('Authenticated credential required to delete remote repositories'),
{ code: 'AUTH_REQUIRED' }
)
}
const maxTags = Math.min(2000, Math.max(1, Number(args.maxTags) || 500))
const result = await deleteRepository(repository, serveraddress, auth, { maxTags })
return {
success: true,
type: 'registryDeleteRepository',
...result,
host: result.endpoint?.host,
serveraddress: result.endpoint?.serveraddress,
message: result.empty
? result.message || 'Repository empty'
: `Deleted ${result.deleted} manifest(s) across ${result.tagCount} tag(s)` +
(result.failed ? `; ${result.failed} failed` : ''),
}
})
session.respond('registryNormalizeEndpoint', async (args = {}) => {
const ep = normalizeRegistryEndpoint(args.serveraddress || args.server || '')
return { success: true, type: 'registryEndpoint', ...ep }
+112
View File
@@ -521,6 +521,118 @@ export async function deleteManifest(repository, reference, serveraddress, auth
throw new Error(`Delete failed (${res.status}): ${res.body.slice(0, 200)}`)
}
/**
* Delete an entire repository by removing all tags (unique digests).
* Registry HTTP API v2 has no "DELETE /v2/<name>/" — GC of untagged blobs
* is registry-specific after manifests are gone.
*
* @param {string} repository
* @param {string} [serveraddress]
* @param {{ username?: string, password?: string }|null} auth
* @param {{ maxTags?: number }} [opts]
* @returns {Promise<object>}
*/
export async function deleteRepository(repository, serveraddress, auth = null, opts = {}) {
const repo = String(repository || '').replace(/^\/+|\/+$/g, '')
if (!repo) throw new Error('repository required')
const maxTags = Math.min(2000, Math.max(1, Number(opts.maxTags) || 500))
const listed = await fetchTags(repo, serveraddress, auth)
const tags = (listed.tags || []).slice(0, maxTags)
const name = listed.repository || repo
const endpoint = listed.endpoint
if (!tags.length) {
return {
endpoint,
repository: name,
deleted: 0,
failed: 0,
skipped: 0,
tagCount: 0,
results: [],
empty: true,
message: 'Repository has no tags (already empty or inaccessible)',
}
}
// Resolve unique digests first so multi-tag images are only DELETE'd once
/** @type {Map<string, string[]>} digest → tags */
const byDigest = new Map()
/** @type {Array<object>} */
const resolveErrors = []
for (const tag of tags) {
try {
const m = await fetchManifest(name, tag, serveraddress, auth)
const digest = m.digest
if (!digest) {
resolveErrors.push({ tag, ok: false, error: 'No digest' })
continue
}
const list = byDigest.get(digest) || []
list.push(tag)
byDigest.set(digest, list)
} catch (err) {
resolveErrors.push({ tag, ok: false, error: err.message || String(err) })
}
}
/** @type {Array<object>} */
const results = [...resolveErrors]
let deleted = 0
let failed = 0
let skipped = 0
for (const [digest, digTags] of byDigest) {
try {
const r = await deleteManifest(name, digest, serveraddress, auth)
if (r.deleted) {
deleted += 1
results.push({
digest,
tags: digTags,
ok: true,
deleted: true,
status: r.status,
})
} else {
skipped += 1
results.push({
digest,
tags: digTags,
ok: true,
deleted: false,
status: r.status,
error: r.error || 'Already gone',
})
}
} catch (err) {
failed += 1
results.push({
digest,
tags: digTags,
ok: false,
deleted: false,
error: err.message || String(err),
})
}
}
return {
endpoint,
repository: name,
deleted,
failed: failed + resolveErrors.length,
skipped,
tagCount: tags.length,
digestCount: byDigest.size,
truncated: (listed.tags || []).length > maxTags,
results,
empty: false,
}
}
/**
* Enrich tags with digests (best-effort, capped concurrency).
* @param {string} repository