Release rolling / release (push) Successful in 9m39s
Peers live under Settings; Images is local-only. Registry is a top-level view with vault credentials, Hub search, and Registry API V2 catalog/tags/manifest/delete over the wire.
573 lines
17 KiB
JavaScript
573 lines
17 KiB
JavaScript
/**
|
|
* Docker Registry HTTP API V2 client (catalog, tags, manifests, delete).
|
|
*
|
|
* Works on Node and Bare (host + agent:false — see image-updates notes).
|
|
* Auth: Basic and Bearer (WWW-Authenticate), using vault/session credentials.
|
|
*/
|
|
import https from 'https'
|
|
import http from 'http'
|
|
|
|
const DEFAULT_TIMEOUT_MS = Math.min(
|
|
45_000,
|
|
Number(process.env.PEARDOCK_REGISTRY_TIMEOUT_MS) || 20_000
|
|
)
|
|
|
|
const MANIFEST_ACCEPT = [
|
|
'application/vnd.oci.image.index.v1+json',
|
|
'application/vnd.docker.distribution.manifest.list.v2+json',
|
|
'application/vnd.oci.image.manifest.v1+json',
|
|
'application/vnd.docker.distribution.manifest.v2+json',
|
|
'application/vnd.docker.distribution.manifest.v1+json',
|
|
].join(', ')
|
|
|
|
/**
|
|
* Normalize a registry server address from vault / Docker login form to
|
|
* { baseUrl, host, registryApiHost, isDockerHub }.
|
|
* @param {string} [serveraddress]
|
|
*/
|
|
export function normalizeRegistryEndpoint(serveraddress) {
|
|
let raw = String(serveraddress || 'https://index.docker.io/v1/').trim()
|
|
if (!raw) raw = 'https://index.docker.io/v1/'
|
|
|
|
// Bare host without scheme
|
|
if (!/^https?:\/\//i.test(raw)) {
|
|
raw = `https://${raw}`
|
|
}
|
|
|
|
let u
|
|
try {
|
|
u = new URL(raw)
|
|
} catch {
|
|
throw new Error(`Invalid registry URL: ${serveraddress}`)
|
|
}
|
|
|
|
let host = u.hostname
|
|
const port = u.port
|
|
const hostWithPort = port ? `${host}:${port}` : host
|
|
|
|
const isDockerHub =
|
|
host === 'docker.io' ||
|
|
host === 'index.docker.io' ||
|
|
host === 'registry-1.docker.io' ||
|
|
host === 'registry.hub.docker.com' ||
|
|
raw.includes('index.docker.io')
|
|
|
|
// Registry API host for Hub is registry-1.docker.io
|
|
const registryApiHost = isDockerHub
|
|
? 'registry-1.docker.io'
|
|
: hostWithPort
|
|
|
|
const scheme = u.protocol === 'http:' ? 'http:' : 'https:'
|
|
const baseUrl = isDockerHub
|
|
? 'https://registry-1.docker.io'
|
|
: `${scheme}//${registryApiHost}`
|
|
|
|
return {
|
|
baseUrl,
|
|
host: isDockerHub ? 'docker.io' : host,
|
|
registryApiHost,
|
|
isDockerHub,
|
|
serveraddress: isDockerHub ? 'https://index.docker.io/v1/' : `${scheme}//${registryApiHost}`,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} url
|
|
* @param {{ method?: string, headers?: Record<string,string>, timeoutMs?: number, body?: string|null }} opts
|
|
* @returns {Promise<{ status: number, headers: Record<string,string>, body: string }>}
|
|
*/
|
|
export function registryHttpRequest(url, opts = {}) {
|
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
return new Promise((resolve, reject) => {
|
|
let u
|
|
try {
|
|
u = new URL(url)
|
|
} catch (err) {
|
|
reject(err)
|
|
return
|
|
}
|
|
const lib = u.protocol === 'http:' ? http : https
|
|
const port = u.port
|
|
? Number(u.port)
|
|
: u.protocol === 'http:'
|
|
? 80
|
|
: 443
|
|
const headers = {
|
|
Connection: 'close',
|
|
...(opts.headers || {}),
|
|
}
|
|
if (opts.body != null && headers['Content-Length'] == null) {
|
|
headers['Content-Length'] = String(Buffer.byteLength(opts.body))
|
|
}
|
|
const reqOpts = {
|
|
protocol: u.protocol,
|
|
host: u.hostname,
|
|
hostname: u.hostname,
|
|
servername: u.hostname,
|
|
port,
|
|
path: u.pathname + u.search,
|
|
method: opts.method || 'GET',
|
|
headers,
|
|
agent: false,
|
|
timeout: timeoutMs,
|
|
}
|
|
|
|
let settled = false
|
|
const finish = (err, val) => {
|
|
if (settled) return
|
|
settled = true
|
|
clearTimeout(timer)
|
|
if (err) reject(err)
|
|
else resolve(val)
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
try {
|
|
req.destroy(new Error('Registry request timed out'))
|
|
} catch {
|
|
// ignore
|
|
}
|
|
finish(new Error('Registry request timed out'))
|
|
}, timeoutMs)
|
|
|
|
let req
|
|
try {
|
|
req = lib.request(reqOpts, (res) => {
|
|
const chunks = []
|
|
res.on('data', (c) => chunks.push(c))
|
|
res.on('end', () => {
|
|
const headersOut = {}
|
|
for (const [k, v] of Object.entries(res.headers || {})) {
|
|
headersOut[String(k).toLowerCase()] = Array.isArray(v)
|
|
? v.join(', ')
|
|
: String(v ?? '')
|
|
}
|
|
finish(null, {
|
|
status: res.statusCode || 0,
|
|
headers: headersOut,
|
|
body: Buffer.concat(chunks).toString('utf8'),
|
|
})
|
|
})
|
|
res.on('error', (err) => finish(err))
|
|
})
|
|
} catch (err) {
|
|
finish(err)
|
|
return
|
|
}
|
|
|
|
req.on('timeout', () => {
|
|
try {
|
|
req.destroy(new Error('Registry request timed out'))
|
|
} catch {
|
|
// ignore
|
|
}
|
|
})
|
|
req.on('error', (err) => finish(err))
|
|
try {
|
|
if (typeof req.setTimeout === 'function') req.setTimeout(timeoutMs)
|
|
} catch {
|
|
// ignore
|
|
}
|
|
if (opts.body != null) req.write(opts.body)
|
|
req.end()
|
|
})
|
|
}
|
|
|
|
/**
|
|
* @param {string} header
|
|
*/
|
|
function parseWwwAuthenticate(header) {
|
|
if (!header || !/bearer/i.test(header)) return null
|
|
const params = {}
|
|
for (const m of header.matchAll(/(\w+)="([^"]*)"/g)) {
|
|
params[m[1].toLowerCase()] = m[2]
|
|
}
|
|
if (!params.realm) return null
|
|
return params
|
|
}
|
|
|
|
/**
|
|
* @param {ReturnType<typeof normalizeRegistryEndpoint>} endpoint
|
|
* @param {{ username?: string, password?: string }|null} auth
|
|
* @param {string} [wwwAuthHeader]
|
|
* @param {string} [scope]
|
|
*/
|
|
async function getBearerToken(endpoint, auth, wwwAuthHeader, scope) {
|
|
const params = parseWwwAuthenticate(wwwAuthHeader) || {}
|
|
let realm = params.realm
|
|
let service = params.service
|
|
let tokenScope = params.scope || scope
|
|
|
|
if (!realm && endpoint.isDockerHub) {
|
|
realm = 'https://auth.docker.io/token'
|
|
service = 'registry.docker.io'
|
|
}
|
|
if (!realm) return null
|
|
|
|
const u = new URL(realm)
|
|
if (service) u.searchParams.set('service', service)
|
|
if (tokenScope) u.searchParams.set('scope', tokenScope)
|
|
|
|
const headers = { Accept: 'application/json', Connection: 'close' }
|
|
if (auth?.username && auth?.password) {
|
|
headers.Authorization =
|
|
'Basic ' + Buffer.from(`${auth.username}:${auth.password}`).toString('base64')
|
|
}
|
|
|
|
const res = await registryHttpRequest(u.toString(), { headers })
|
|
if (res.status < 200 || res.status >= 300) {
|
|
throw new Error(`Token request failed (${res.status})`)
|
|
}
|
|
let json
|
|
try {
|
|
json = JSON.parse(res.body)
|
|
} catch {
|
|
throw new Error('Invalid token response')
|
|
}
|
|
return json.token || json.access_token || null
|
|
}
|
|
|
|
/**
|
|
* @param {object} opts
|
|
* @param {ReturnType<typeof normalizeRegistryEndpoint>} opts.endpoint
|
|
* @param {string} opts.path — path under registry host, starts with /
|
|
* @param {string} [opts.method]
|
|
* @param {Record<string,string>} [opts.headers]
|
|
* @param {{ username?: string, password?: string }|null} [opts.auth]
|
|
* @param {string} [opts.scope] — bearer scope if known
|
|
* @param {string|null} [opts.body]
|
|
*/
|
|
export async function registryRequest(opts) {
|
|
const { endpoint, path: apiPath, method = 'GET', auth = null, scope, body = null } = opts
|
|
const url = new URL(
|
|
apiPath.startsWith('/') ? apiPath : `/${apiPath}`,
|
|
endpoint.baseUrl.endsWith('/') ? endpoint.baseUrl : endpoint.baseUrl + '/'
|
|
).toString()
|
|
|
|
/** @type {Record<string, string>} */
|
|
const headers = {
|
|
Accept: opts.headers?.Accept || 'application/json',
|
|
...(opts.headers || {}),
|
|
}
|
|
if (auth?.username && auth?.password) {
|
|
headers.Authorization =
|
|
'Basic ' + Buffer.from(`${auth.username}:${auth.password}`).toString('base64')
|
|
}
|
|
|
|
async function once(hdrs) {
|
|
return registryHttpRequest(url, { method, headers: hdrs, body })
|
|
}
|
|
|
|
let res = await once(headers)
|
|
if (res.status === 401 || res.status === 403) {
|
|
const token = await getBearerToken(endpoint, auth, res.headers['www-authenticate'], scope)
|
|
if (token) {
|
|
const next = { ...headers, Authorization: `Bearer ${token}` }
|
|
res = await once(next)
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
/**
|
|
* @param {string} [serveraddress]
|
|
* @param {{ username?: string, password?: string }|null} auth
|
|
* @param {{ n?: number, last?: string }} [paging]
|
|
*/
|
|
export async function fetchCatalog(serveraddress, auth = null, paging = {}) {
|
|
const endpoint = normalizeRegistryEndpoint(serveraddress)
|
|
const n = Math.min(1000, Math.max(1, Number(paging.n) || 100))
|
|
let path = `/v2/_catalog?n=${n}`
|
|
if (paging.last) path += `&last=${encodeURIComponent(paging.last)}`
|
|
|
|
const res = await registryRequest({
|
|
endpoint,
|
|
path,
|
|
auth,
|
|
scope: 'registry:catalog:*',
|
|
headers: { Accept: 'application/json' },
|
|
})
|
|
|
|
if (res.status === 404 || res.status === 401 || res.status === 403) {
|
|
return {
|
|
endpoint,
|
|
repositories: [],
|
|
supported: false,
|
|
error:
|
|
res.status === 404
|
|
? 'This registry does not expose _catalog (common for Docker Hub / GHCR). Open a repository by name instead.'
|
|
: `Catalog not available (${res.status}). Check credentials or open a repository by name.`,
|
|
status: res.status,
|
|
}
|
|
}
|
|
if (res.status < 200 || res.status >= 300) {
|
|
throw new Error(`Catalog failed (${res.status}): ${res.body.slice(0, 200)}`)
|
|
}
|
|
|
|
let json
|
|
try {
|
|
json = JSON.parse(res.body)
|
|
} catch {
|
|
throw new Error('Invalid catalog response')
|
|
}
|
|
const repositories = Array.isArray(json.repositories) ? json.repositories : []
|
|
// Link header pagination (RFC 5988) — extract last= if present
|
|
let nextLast = null
|
|
const link = res.headers.link || ''
|
|
const m = link.match(/[?&]last=([^&>]+)/)
|
|
if (m) nextLast = decodeURIComponent(m[1])
|
|
else if (repositories.length >= n) {
|
|
nextLast = repositories[repositories.length - 1]
|
|
}
|
|
|
|
return {
|
|
endpoint,
|
|
repositories,
|
|
supported: true,
|
|
error: null,
|
|
status: res.status,
|
|
nextLast: nextLast && repositories.length >= n ? nextLast : null,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} repository
|
|
* @param {string} [serveraddress]
|
|
* @param {{ username?: string, password?: string }|null} auth
|
|
*/
|
|
export async function fetchTags(repository, serveraddress, auth = null) {
|
|
const repo = String(repository || '').replace(/^\/+|\/+$/g, '')
|
|
if (!repo) throw new Error('repository required')
|
|
const endpoint = normalizeRegistryEndpoint(serveraddress)
|
|
|
|
// Docker Hub library images: nginx → library/nginx
|
|
let name = repo
|
|
if (endpoint.isDockerHub && !name.includes('/')) {
|
|
name = `library/${name}`
|
|
}
|
|
|
|
const res = await registryRequest({
|
|
endpoint,
|
|
path: `/v2/${name}/tags/list`,
|
|
auth,
|
|
scope: `repository:${name}:pull`,
|
|
headers: { Accept: 'application/json' },
|
|
})
|
|
|
|
if (res.status === 404) {
|
|
throw Object.assign(new Error(`Repository not found: ${name}`), { code: 'REPO_NOT_FOUND' })
|
|
}
|
|
if (res.status < 200 || res.status >= 300) {
|
|
throw new Error(`List tags failed (${res.status}): ${res.body.slice(0, 200)}`)
|
|
}
|
|
|
|
let json
|
|
try {
|
|
json = JSON.parse(res.body)
|
|
} catch {
|
|
throw new Error('Invalid tags list response')
|
|
}
|
|
const tags = Array.isArray(json.tags) ? json.tags.filter(Boolean).sort() : []
|
|
return {
|
|
endpoint,
|
|
repository: name,
|
|
name: json.name || name,
|
|
tags,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} repository
|
|
* @param {string} reference — tag or digest
|
|
* @param {string} [serveraddress]
|
|
* @param {{ username?: string, password?: string }|null} auth
|
|
*/
|
|
export async function fetchManifest(repository, reference, serveraddress, auth = null) {
|
|
const repo = String(repository || '').replace(/^\/+|\/+$/g, '')
|
|
const ref = String(reference || '').trim()
|
|
if (!repo || !ref) throw new Error('repository and reference required')
|
|
const endpoint = normalizeRegistryEndpoint(serveraddress)
|
|
|
|
let name = repo
|
|
if (endpoint.isDockerHub && !name.includes('/')) {
|
|
name = `library/${name}`
|
|
}
|
|
|
|
const res = await registryRequest({
|
|
endpoint,
|
|
path: `/v2/${name}/manifests/${encodeURIComponent(ref)}`,
|
|
auth,
|
|
scope: `repository:${name}:pull`,
|
|
headers: { Accept: MANIFEST_ACCEPT },
|
|
})
|
|
|
|
if (res.status === 404) {
|
|
throw Object.assign(new Error(`Manifest not found: ${name}:${ref}`), {
|
|
code: 'MANIFEST_NOT_FOUND',
|
|
})
|
|
}
|
|
if (res.status < 200 || res.status >= 300) {
|
|
throw new Error(`Get manifest failed (${res.status}): ${res.body.slice(0, 200)}`)
|
|
}
|
|
|
|
const digest =
|
|
res.headers['docker-content-digest'] || res.headers['oci-content-digest'] || null
|
|
let manifest = null
|
|
try {
|
|
manifest = JSON.parse(res.body)
|
|
} catch {
|
|
manifest = { raw: res.body.slice(0, 4000) }
|
|
}
|
|
|
|
/** @type {string[]} */
|
|
const childDigests = []
|
|
if (Array.isArray(manifest?.manifests)) {
|
|
for (const m of manifest.manifests) {
|
|
if (m?.digest) childDigests.push(String(m.digest))
|
|
}
|
|
}
|
|
|
|
// Approximate size from config + layers when single-arch
|
|
let sizeBytes = null
|
|
if (manifest?.config?.size) sizeBytes = Number(manifest.config.size) || 0
|
|
if (Array.isArray(manifest?.layers)) {
|
|
sizeBytes = (sizeBytes || 0) + manifest.layers.reduce((a, l) => a + (Number(l.size) || 0), 0)
|
|
}
|
|
|
|
return {
|
|
endpoint,
|
|
repository: name,
|
|
reference: ref,
|
|
digest,
|
|
mediaType: manifest?.mediaType || res.headers['content-type'] || null,
|
|
schemaVersion: manifest?.schemaVersion ?? null,
|
|
architecture: manifest?.architecture || null,
|
|
os: manifest?.os || null,
|
|
childDigests,
|
|
platformCount: Array.isArray(manifest?.manifests) ? manifest.manifests.length : null,
|
|
sizeBytes,
|
|
manifest,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a tag or digest from the registry (requires delete permission).
|
|
* Tags are resolved to digest first (DELETE by tag is not in V2).
|
|
*
|
|
* @param {string} repository
|
|
* @param {string} reference — tag or sha256:…
|
|
* @param {string} [serveraddress]
|
|
* @param {{ username?: string, password?: string }|null} auth
|
|
*/
|
|
export async function deleteManifest(repository, reference, serveraddress, auth = null) {
|
|
const repo = String(repository || '').replace(/^\/+|\/+$/g, '')
|
|
const ref = String(reference || '').trim()
|
|
if (!repo || !ref) throw new Error('repository and reference required')
|
|
const endpoint = normalizeRegistryEndpoint(serveraddress)
|
|
|
|
let name = repo
|
|
if (endpoint.isDockerHub && !name.includes('/')) {
|
|
name = `library/${name}`
|
|
}
|
|
|
|
let digest = ref.startsWith('sha256:') ? ref : null
|
|
if (!digest) {
|
|
const m = await fetchManifest(name, ref, serveraddress, auth)
|
|
digest = m.digest
|
|
if (!digest) throw new Error('Could not resolve tag to digest for delete')
|
|
}
|
|
|
|
const res = await registryRequest({
|
|
endpoint,
|
|
path: `/v2/${name}/manifests/${encodeURIComponent(digest)}`,
|
|
method: 'DELETE',
|
|
auth,
|
|
scope: `repository:${name}:delete`,
|
|
headers: { Accept: MANIFEST_ACCEPT },
|
|
})
|
|
|
|
// Some registries want pull+delete scope; retry with combined if 401
|
|
if (res.status === 401 || res.status === 403) {
|
|
const retry = await registryRequest({
|
|
endpoint,
|
|
path: `/v2/${name}/manifests/${encodeURIComponent(digest)}`,
|
|
method: 'DELETE',
|
|
auth,
|
|
scope: `repository:${name}:*`,
|
|
headers: { Accept: MANIFEST_ACCEPT },
|
|
})
|
|
if (retry.status === 202 || retry.status === 200 || retry.status === 204) {
|
|
return { endpoint, repository: name, reference: ref, digest, deleted: true, status: retry.status }
|
|
}
|
|
if (retry.status === 404) {
|
|
return { endpoint, repository: name, reference: ref, digest, deleted: false, status: 404, error: 'Already gone' }
|
|
}
|
|
throw new Error(
|
|
`Delete failed (${retry.status}). Registry may disallow remote delete or credential lacks delete scope.`
|
|
)
|
|
}
|
|
|
|
if (res.status === 202 || res.status === 200 || res.status === 204) {
|
|
return { endpoint, repository: name, reference: ref, digest, deleted: true, status: res.status }
|
|
}
|
|
if (res.status === 404) {
|
|
return { endpoint, repository: name, reference: ref, digest, deleted: false, status: 404, error: 'Already gone' }
|
|
}
|
|
if (res.status === 405) {
|
|
throw new Error(
|
|
'Registry does not allow remote delete (HTTP 405). Enable delete on the registry or use its native GC tools.'
|
|
)
|
|
}
|
|
throw new Error(`Delete failed (${res.status}): ${res.body.slice(0, 200)}`)
|
|
}
|
|
|
|
/**
|
|
* Enrich tags with digests (best-effort, capped concurrency).
|
|
* @param {string} repository
|
|
* @param {string[]} tags
|
|
* @param {string} [serveraddress]
|
|
* @param {{ username?: string, password?: string }|null} auth
|
|
* @param {{ limit?: number }} [opts]
|
|
*/
|
|
export async function enrichTagsWithDigests(
|
|
repository,
|
|
tags,
|
|
serveraddress,
|
|
auth = null,
|
|
opts = {}
|
|
) {
|
|
const limit = Math.min(tags.length, Math.max(1, Number(opts.limit) || 40))
|
|
const slice = tags.slice(0, limit)
|
|
/** @type {Array<{ tag: string, digest: string|null, sizeBytes: number|null, mediaType: string|null, error?: string }>} */
|
|
const out = []
|
|
let i = 0
|
|
const workers = Math.min(4, slice.length)
|
|
|
|
async function worker() {
|
|
while (i < slice.length) {
|
|
const idx = i++
|
|
const tag = slice[idx]
|
|
try {
|
|
const m = await fetchManifest(repository, tag, serveraddress, auth)
|
|
out[idx] = {
|
|
tag,
|
|
digest: m.digest,
|
|
sizeBytes: m.sizeBytes,
|
|
mediaType: m.mediaType,
|
|
}
|
|
} catch (err) {
|
|
out[idx] = {
|
|
tag,
|
|
digest: null,
|
|
sizeBytes: null,
|
|
mediaType: null,
|
|
error: err.message,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
await Promise.all(Array.from({ length: workers }, () => worker()))
|
|
return out.filter(Boolean)
|
|
}
|