forked from snxraven/peardock
Delete all tags/manifests for a repository via Registry V2 digests, with admin RPC, audit logging, and catalog/tags UI actions.
253 lines
8.4 KiB
JavaScript
253 lines
8.4 KiB
JavaScript
/**
|
|
* Remote registry browser RPC — catalog, tags, manifests, delete.
|
|
*/
|
|
import * as validation from '../utils/validation.js'
|
|
import {
|
|
fetchCatalog,
|
|
fetchTags,
|
|
fetchManifest,
|
|
deleteManifest,
|
|
deleteRepository,
|
|
enrichTagsWithDigests,
|
|
normalizeRegistryEndpoint,
|
|
} from '../services/registry-client.js'
|
|
import { resolveRegistryAuth } from './vault.js'
|
|
|
|
/**
|
|
* Resolve auth + server address for browser ops.
|
|
* @param {import('../rpc/session.js').PeerSession} session
|
|
* @param {object} args
|
|
*/
|
|
function resolveBrowserAuth(session, args = {}) {
|
|
const auth = resolveRegistryAuth(session, {
|
|
credentialId: args.credentialId,
|
|
auth: args.auth,
|
|
autoVault: args.autoVault !== false,
|
|
image: args.repository
|
|
? `${args.serveraddress || 'docker.io'}/${args.repository}`
|
|
: args.image,
|
|
})
|
|
const serveraddress =
|
|
args.serveraddress ||
|
|
auth?.serveraddress ||
|
|
'https://index.docker.io/v1/'
|
|
return {
|
|
auth: auth
|
|
? { username: auth.username, password: auth.password }
|
|
: null,
|
|
serveraddress: validation.sanitizeString(serveraddress, 512),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {import('../rpc/session.js').PeerSession} session
|
|
*/
|
|
export function registerRegistryHandlers(session) {
|
|
session.respond('registryCatalog', async (args = {}) => {
|
|
const { auth, serveraddress } = resolveBrowserAuth(session, args)
|
|
const result = await fetchCatalog(serveraddress, auth, {
|
|
n: args.n,
|
|
last: args.last,
|
|
})
|
|
return {
|
|
success: true,
|
|
type: 'registryCatalog',
|
|
serveraddress: result.endpoint.serveraddress,
|
|
host: result.endpoint.host,
|
|
isDockerHub: result.endpoint.isDockerHub,
|
|
repositories: result.repositories,
|
|
supported: result.supported,
|
|
error: result.error,
|
|
nextLast: result.nextLast,
|
|
}
|
|
})
|
|
|
|
session.respond('registryListTags', 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,
|
|
})
|
|
const result = await fetchTags(repository, serveraddress, auth)
|
|
let tagsDetail = null
|
|
if (args.enrich && result.tags.length) {
|
|
tagsDetail = await enrichTagsWithDigests(
|
|
result.repository,
|
|
result.tags,
|
|
serveraddress,
|
|
auth,
|
|
{ limit: args.enrichLimit || 40 }
|
|
)
|
|
}
|
|
return {
|
|
success: true,
|
|
type: 'registryTags',
|
|
serveraddress: result.endpoint.serveraddress,
|
|
host: result.endpoint.host,
|
|
repository: result.repository,
|
|
tags: result.tags,
|
|
tagsDetail,
|
|
tagCount: result.tags.length,
|
|
}
|
|
})
|
|
|
|
session.respond('registryGetManifest', async (args = {}) => {
|
|
const repository = validation.sanitizeString(args.repository || args.name || '', 512)
|
|
const reference = validation.sanitizeString(
|
|
args.reference || args.tag || args.digest || '',
|
|
256
|
|
)
|
|
if (!repository || !reference) throw new Error('repository and reference required')
|
|
const { auth, serveraddress } = resolveBrowserAuth(session, {
|
|
...args,
|
|
repository,
|
|
})
|
|
const result = await fetchManifest(repository, reference, serveraddress, auth)
|
|
// Don't ship full layer blobs — trim manifest for UI
|
|
let manifestSummary = result.manifest
|
|
if (manifestSummary && typeof manifestSummary === 'object') {
|
|
manifestSummary = {
|
|
schemaVersion: manifestSummary.schemaVersion,
|
|
mediaType: manifestSummary.mediaType,
|
|
architecture: manifestSummary.architecture,
|
|
os: manifestSummary.os,
|
|
config: manifestSummary.config
|
|
? { digest: manifestSummary.config.digest, size: manifestSummary.config.size, mediaType: manifestSummary.config.mediaType }
|
|
: undefined,
|
|
layers: Array.isArray(manifestSummary.layers)
|
|
? manifestSummary.layers.map((l) => ({
|
|
digest: l.digest,
|
|
size: l.size,
|
|
mediaType: l.mediaType,
|
|
}))
|
|
: undefined,
|
|
manifests: Array.isArray(manifestSummary.manifests)
|
|
? manifestSummary.manifests.map((m) => ({
|
|
digest: m.digest,
|
|
mediaType: m.mediaType,
|
|
platform: m.platform,
|
|
size: m.size,
|
|
}))
|
|
: undefined,
|
|
}
|
|
}
|
|
return {
|
|
success: true,
|
|
type: 'registryManifest',
|
|
serveraddress: result.endpoint.serveraddress,
|
|
host: result.endpoint.host,
|
|
repository: result.repository,
|
|
reference: result.reference,
|
|
digest: result.digest,
|
|
mediaType: result.mediaType,
|
|
sizeBytes: result.sizeBytes,
|
|
platformCount: result.platformCount,
|
|
architecture: result.architecture,
|
|
os: result.os,
|
|
childDigests: result.childDigests,
|
|
manifest: manifestSummary,
|
|
}
|
|
})
|
|
|
|
session.respond('registryDeleteTag', async (args = {}) => {
|
|
const repository = validation.sanitizeString(args.repository || args.name || '', 512)
|
|
const reference = validation.sanitizeString(
|
|
args.reference || args.tag || args.digest || '',
|
|
256
|
|
)
|
|
if (!repository || !reference) throw new Error('repository and reference required')
|
|
const { auth, serveraddress } = resolveBrowserAuth(session, {
|
|
...args,
|
|
repository,
|
|
})
|
|
if (!auth) {
|
|
throw Object.assign(new Error('Authenticated credential required to delete remote tags'), {
|
|
code: 'AUTH_REQUIRED',
|
|
})
|
|
}
|
|
const result = await deleteManifest(repository, reference, serveraddress, auth)
|
|
return {
|
|
success: true,
|
|
type: 'registryDelete',
|
|
...result,
|
|
host: result.endpoint?.host,
|
|
serveraddress: result.endpoint?.serveraddress,
|
|
}
|
|
})
|
|
|
|
session.respond('registryDeleteTags', async (args = {}) => {
|
|
const repository = validation.sanitizeString(args.repository || args.name || '', 512)
|
|
const tags = Array.isArray(args.tags)
|
|
? args.tags.map((t) => String(t || '').trim()).filter(Boolean)
|
|
: []
|
|
if (!repository || !tags.length) throw new Error('repository and tags[] required')
|
|
if (tags.length > 50) throw new Error('Max 50 tags per bulk delete')
|
|
const { auth, serveraddress } = resolveBrowserAuth(session, {
|
|
...args,
|
|
repository,
|
|
})
|
|
if (!auth) {
|
|
throw Object.assign(new Error('Authenticated credential required to delete remote tags'), {
|
|
code: 'AUTH_REQUIRED',
|
|
})
|
|
}
|
|
/** @type {Array<object>} */
|
|
const results = []
|
|
for (const tag of tags) {
|
|
try {
|
|
const r = await deleteManifest(repository, tag, serveraddress, auth)
|
|
results.push({ tag, ok: true, digest: r.digest, deleted: r.deleted })
|
|
} catch (err) {
|
|
results.push({ tag, ok: false, error: err.message })
|
|
}
|
|
}
|
|
const deleted = results.filter((r) => r.ok && r.deleted).length
|
|
return {
|
|
success: true,
|
|
type: 'registryBulkDelete',
|
|
repository,
|
|
deleted,
|
|
failed: results.filter((r) => !r.ok).length,
|
|
results,
|
|
}
|
|
})
|
|
|
|
/**
|
|
* 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 }
|
|
})
|
|
}
|