Files
peardock/server/handlers/registry.js
T
Raven Scott f6f059ab94
Release rolling / release (push) Has been cancelled
Add remote registry repository delete
Delete all tags/manifests for a repository via Registry V2 digests, with
admin RPC, audit logging, and catalog/tags UI actions.
2026-07-17 19:43:53 -04:00

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 }
})
}