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:
@@ -46,6 +46,9 @@ const AUDIT_METHODS = new Set([
|
||||
'importImage',
|
||||
'registryLogin',
|
||||
'registryLogout',
|
||||
'registryDeleteTag',
|
||||
'registryDeleteTags',
|
||||
'registryDeleteRepository',
|
||||
'vaultStoreCredential',
|
||||
'vaultDeleteCredential',
|
||||
'vaultUseCredential',
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user