Files
peardock/server/handlers/images.js
T
Raven Scott dcb5b3db4f
Release rolling / release (push) Successful in 8m55s
Prevent removing in-use images and keep Unused filter stable during bulk delete.
Disable select/delete for images with container usage, and broadcast image lists with usage attached so the Unused tab no longer flashes the full inventory mid-removal.
2026-07-25 08:31:36 -04:00

636 lines
21 KiB
JavaScript

/**
* Image RPC handlers.
*/
import { docker } from '../services/docker.js'
import { peers } from '../core/peer-registry.js'
import * as validation from '../utils/validation.js'
import { Pushes } from '../../shared/protocol.js'
import {
resolveRegistryAuth,
isUsableDockerAuth,
authMatchesImage,
retargetImageRefForRegistry,
parseImageRepoTag,
registryPrefixFromServeraddress,
normalizeRegistryHost,
registryHostFromImage,
} from './vault.js'
import logger from '../utils/logger.js'
/**
* @param {object|null} authconfig
* @returns {object|undefined}
*/
function dockerAuthOpts(authconfig) {
// Incomplete auth (e.g. username without password) breaks Docker Hub anonymous pulls
if (!isUsableDockerAuth(authconfig)) return undefined
return {
username: String(authconfig.username).trim(),
password: String(authconfig.password),
serveraddress: authconfig.serveraddress || 'https://index.docker.io/v1/',
}
}
/**
* Docker Engine requires X-Registry-Auth on image push.
* Omitting it (or sending an empty header) yields:
* "missing X-Registry-Auth: invalid X-Registry-Auth header: EOF"
* Anonymous registries (ttl.sh, public Hub) need a valid empty-cred payload.
* @returns {{ username: string, password: string, email: string, serveraddress: string }}
*/
export function anonymousRegistryAuth() {
return {
username: '',
password: '',
email: '',
serveraddress: '',
}
}
/**
* @param {unknown} err
* @returns {boolean}
*/
function isRegistryAuthFailure(err) {
const msg = String(err?.message || err || '')
const status = Number(err?.statusCode || err?.status || 0)
if (status === 401 || status === 403) return true
return /unauthorized|authentication required|incorrect username or password|denied:\s*requested access|needs?\s*authentication|toomanyrequests/i.test(
msg
)
}
/**
* Pull an image with optional auth. Progress events pushed to the session.
* @param {import('../rpc/session.js').PeerSession} session
* @param {string} imageName
* @param {object|undefined} auth — dockerode authconfig or undefined for anonymous
*/
async function pullImageStream(session, imageName, auth) {
const pullStream = await new Promise((resolve, reject) => {
const onPull = (err, stream) => (err ? reject(err) : resolve(stream))
if (auth) {
docker.pull(imageName, {}, onPull, auth)
} else {
docker.pull(imageName, onPull)
}
})
await new Promise((resolve, reject) => {
docker.modem.followProgress(
pullStream,
(err) => (err ? reject(err) : resolve()),
(event) => {
try {
session.push(Pushes.pullProgress, {
type: 'pullProgress',
image: imageName,
status: event.status || null,
progress: event.progress || null,
progressDetail: event.progressDetail || null,
id: event.id || null,
error: event.error || null,
})
} catch (e) {
logger.debug('pull progress push failed', { error: e.message })
}
}
)
})
}
/**
* Build dockerode image.push() options.
*
* Never includes keys with undefined values. Under Bare, bare-querystring
* stringifies undefined as the literal "undefined", which Docker treats as a
* real tag (e.g. repo:undefined → "tag does not exist").
*
* Always sets authconfig: usable credentials when present, otherwise anonymous
* empty credentials so Docker still gets a valid X-Registry-Auth header.
*
* @param {{ tag?: string|null, authconfig?: object|null }} opts
* @returns {Record<string, unknown>}
*/
export function buildImagePushOpts(opts = {}) {
const out = {}
const tag = opts.tag != null ? String(opts.tag).trim() : ''
if (tag) out.tag = tag
const auth = dockerAuthOpts(opts.authconfig || null)
out.authconfig = auth || anonymousRegistryAuth()
return out
}
/**
* List Docker images with per-image container usage attached.
* Shared by listImages RPC and live event broadcasts so Used/Unused filters stay correct.
* @param {object} [opts]
* @returns {Promise<object[]>}
*/
export async function listImagesWithUsage(opts = {}) {
const listOpts = { all: opts.all !== false }
if (opts.filters) listOpts.filters = opts.filters
if (opts.dangling === true) {
listOpts.filters = { ...(listOpts.filters || {}), dangling: ['true'] }
}
if (opts.reference) {
listOpts.filters = {
...(listOpts.filters || {}),
reference: [String(opts.reference)],
}
}
const images = await docker.listImages(listOpts)
const containers = await docker.listContainers({ all: true })
const imageUsage = {}
for (const container of containers) {
const imageId = container.ImageID
if (!imageUsage[imageId]) imageUsage[imageId] = []
imageUsage[imageId].push({
id: container.Id,
name: container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12),
state: container.State,
})
}
return images.map((image) => ({
...image,
usage: imageUsage[image.Id] || [],
}))
}
export async function broadcastImages() {
try {
const images = await listImagesWithUsage({ all: true })
peers.broadcast(Pushes.images, { type: 'images', data: images })
} catch (err) {
logger.error('Failed to broadcast images', { error: err.message })
}
}
export function registerImageHandlers(session) {
session.respond('listImages', async (args = {}) => {
let imagesWithUsage = await listImagesWithUsage(args)
const total = imagesWithUsage.length
const offset = Math.max(0, Number(args.offset) || 0)
const limit = args.limit != null ? Math.min(Number(args.limit) || 50, 1000) : null
if (limit != null) {
imagesWithUsage = imagesWithUsage.slice(offset, offset + limit)
} else if (offset > 0) {
imagesWithUsage = imagesWithUsage.slice(offset)
}
return {
type: 'images',
data: imagesWithUsage,
total,
offset,
limit: limit ?? imagesWithUsage.length,
hasMore: limit != null ? offset + limit < total : false,
}
})
session.respond('pullImage', async (args) => {
const imageName = validation.sanitizeString(args.image, 255)
if (!imageName || !validation.isValidImageName(imageName)) {
throw new Error('Invalid image name')
}
// Explicit credential/auth from client must not fall back to anonymous on failure
const explicitAuth = Boolean(
args.credentialId ||
(args.auth && args.auth.username && args.auth.password)
)
const authconfig = resolveRegistryAuth(session, {
credentialId: args.credentialId,
auth: args.auth,
autoVault: args.autoVault,
image: imageName,
})
let auth = dockerAuthOpts(authconfig)
let anonymousFallback = false
try {
await pullImageStream(session, imageName, auth)
} catch (err) {
// Ambient session/vault auth often breaks public Docker Hub pulls when
// credentials are stale. Retry once with no auth when pull wasn't explicit.
if (auth && !explicitAuth && isRegistryAuthFailure(err)) {
logger.warn('pullImage: auth failed, retrying anonymously', {
image: imageName,
error: err.message,
serveraddress: auth.serveraddress,
})
await pullImageStream(session, imageName, undefined)
auth = undefined
anonymousFallback = true
} else {
throw err
}
}
return {
success: true,
message: `Image "${imageName}" pulled successfully`,
image: imageName,
usedAuth: Boolean(auth),
anonymousFallback,
}
})
session.respond('removeImage', async (args) => {
await docker.getImage(args.id).remove({ force: args.force || false })
return { success: true, message: `Image ${args.id} removed` }
})
session.respond('inspectImage', async (args) => {
const imageData = await docker.getImage(args.id).inspect()
return { type: 'imageConfig', data: imageData }
})
session.respond('imageHistory', async (args) => {
const history = await docker.getImage(args.id).history()
return { success: true, type: 'imageHistory', id: args.id, data: history }
})
session.respond('searchImages', async (args) => {
const term = validation.sanitizeString(args.term || args.q || '', 128)
if (!term) throw new Error('Search term required')
const limit = Math.min(Number(args.limit) || 25, 100)
const results = await docker.searchImages({ term, limit })
return { success: true, type: 'imageSearch', term, data: results }
})
session.respond('pruneImages', async (args) => {
const opts = {}
if (args.filters) opts.filters = args.filters
// dangling only by default unless force-all
if (args.all) {
opts.filters = { ...(opts.filters || {}), dangling: { false: true } }
}
const result = await docker.pruneImages(opts)
return {
success: true,
type: 'pruneImages',
message: 'Unused images pruned',
data: result,
}
})
session.respond('tagImage', async (args) => {
const repo = validation.sanitizeString(args.repo, 255)
const tag = validation.sanitizeString(args.tag || 'latest', 128)
if (!repo) throw new Error('Repository name required')
await docker.getImage(args.id).tag({ repo, tag })
return { success: true, message: `Image tagged as ${repo}:${tag}` }
})
session.respond('buildImage', async (args) => {
const { dockerfile, tag } = args
if (!dockerfile) throw new Error('Dockerfile content required')
const DockerfileBuffer = Buffer.from(dockerfile)
const tarHeader = Buffer.alloc(512)
tarHeader.write('Dockerfile', 0)
tarHeader.write('100644', 156, 6)
const sizeOctal = DockerfileBuffer.length.toString(8).padStart(11, '0') + '\0'
tarHeader.write(sizeOctal, 124, 12)
let checksum = 0
for (let i = 0; i < 512; i++) {
checksum += i >= 148 && i < 156 ? 32 : tarHeader[i]
}
tarHeader.write(checksum.toString(8).padStart(6, '0') + '\0 ', 148)
const padding = (512 - (DockerfileBuffer.length % 512)) % 512
const tarData = Buffer.concat([
tarHeader,
DockerfileBuffer,
Buffer.alloc(padding),
Buffer.alloc(1024),
])
const buildOptions = tag ? { dockerfile: 'Dockerfile', t: tag } : { dockerfile: 'Dockerfile' }
if (args.nocache) buildOptions.nocache = true
if (args.pull) buildOptions.pull = true
if (args.buildargs && typeof args.buildargs === 'object') {
buildOptions.buildargs = args.buildargs
}
const buildStream = await docker.buildImage(tarData, buildOptions)
let buildOutput = ''
await new Promise((resolve, reject) => {
docker.modem.followProgress(
buildStream,
(err, output) => {
if (err) reject(err)
else {
if (output) buildOutput = output.map((o) => o.stream || o.status || '').join('')
resolve(output)
}
},
(event) => {
const line = (event.stream || event.status || event.error || '').toString()
if (line) {
try {
session.push(Pushes.buildProgress, {
type: 'buildProgress',
tag: tag || null,
stream: event.stream || null,
status: event.status || null,
error: event.error || null,
aux: event.aux || null,
})
} catch (e) {
logger.debug('build progress push failed', { error: e.message })
}
}
}
)
})
return {
success: true,
message: `Image built successfully: ${tag || 'untagged:latest'}`,
output: buildOutput,
}
})
// —— Binary image transfer (chunked base64 over RPC) ——
session.respond('pushImage', async (args) => {
const imageName = validation.sanitizeString(args.image || args.id, 255)
if (!imageName) throw new Error('image name/id required')
// Optional: retag before push (repo:tag destination)
let pushRef = imageName
/** Explicit tag query only when set — never pass undefined (see below). */
let pushTag = null
let retargeted = false
if (args.repo) {
const repo = validation.sanitizeString(args.repo, 255)
const tag = validation.sanitizeString(args.tag || 'latest', 128)
if (!repo) throw new Error('repo required when tagging for push')
if (!tag) throw new Error('tag required when tagging for push')
await docker.getImage(args.id || imageName).tag({ repo, tag })
pushRef = `${repo}:${tag}`
// Name already includes the tag; omit tag query param.
pushTag = null
} else if (args.tag) {
pushTag = validation.sanitizeString(args.tag, 128) || null
}
const authconfig = resolveRegistryAuth(session, {
credentialId: args.credentialId,
auth: args.auth,
autoVault: args.autoVault,
image: pushRef,
})
// Short name + private registry credential → auto-retag under that registry.
// Otherwise Docker pushes to docker.io with the private username/password → 401.
if (authconfig && !authMatchesImage(authconfig, pushRef)) {
const desired = retargetImageRefForRegistry(pushRef, authconfig.serveraddress)
if (desired !== pushRef) {
const { repo, tag } = parseImageRepoTag(desired)
await docker.getImage(args.id || imageName || pushRef).tag({ repo, tag })
logger.info('pushImage: retagged short name for credential registry', {
from: pushRef,
to: desired,
serveraddress: authconfig.serveraddress,
})
pushRef = desired
pushTag = null
retargeted = true
} else {
const authHost = normalizeRegistryHost(authconfig.serveraddress)
const imageHost = registryHostFromImage(pushRef) || 'unknown'
const prefix = registryPrefixFromServeraddress(authconfig.serveraddress)
throw Object.assign(
new Error(
`Credential is for registry "${authHost}" but image "${pushRef}" targets "${imageHost}". ` +
`Retag to ${prefix ? `${prefix}/` : ''}your/image:tag before pushing, or pick a matching credential.`
),
{ code: 'REGISTRY_HOST_MISMATCH' }
)
}
}
const pushOpts = buildImagePushOpts({ tag: pushTag, authconfig })
const image = docker.getImage(pushRef)
const stream = await image.push(pushOpts)
// Docker may return HTTP 200 and stream { error } in the body — treat as failure.
let streamError = null
await new Promise((resolve, reject) => {
docker.modem.followProgress(
stream,
(err) => {
if (err) reject(err)
else if (streamError) reject(new Error(streamError))
else resolve()
},
(event) => {
try {
if (event?.error) {
streamError = String(event.error)
logger.debug('push layer error event', { error: event.error, image: pushRef })
}
session.push(Pushes.pushProgress, {
type: 'pushProgress',
image: pushRef,
status: event.status || null,
progress: event.progress || null,
progressDetail: event.progressDetail || null,
id: event.id || null,
error: event.error || null,
})
} catch (e) {
logger.debug('push progress failed', { error: e.message })
}
}
)
})
return {
success: true,
message: `Image "${pushRef}" pushed`,
image: pushRef,
usedAuth: Boolean(authconfig),
retargeted,
}
})
session.respond('saveImage', async (args) => {
const id = args.id
if (!id) throw new Error('Image id required')
const maxBytes = Math.min(Number(args.maxBytes) || 50 * 1024 * 1024, 100 * 1024 * 1024)
const chunkSize = Math.min(Number(args.chunkSize) || 256 * 1024, 512 * 1024)
const stream = await docker.getImage(id).get()
const transferId = `save-${id.slice(0, 12)}-${Date.now()}`
const chunks = []
let total = 0
let index = 0
await new Promise((resolve, reject) => {
stream.on('data', (chunk) => {
total += chunk.length
if (total > maxBytes) {
stream.destroy()
reject(new Error(`Image exceeds maxBytes (${maxBytes}). Use smaller image or raise maxBytes.`))
return
}
chunks.push(chunk)
})
stream.on('end', resolve)
stream.on('error', reject)
})
const buf = Buffer.concat(chunks)
// Stream chunks to client via push, also return first chunk summary
for (let offset = 0; offset < buf.length; offset += chunkSize) {
const slice = buf.subarray(offset, Math.min(offset + chunkSize, buf.length))
const done = offset + chunkSize >= buf.length
session.push(Pushes.binaryChunk, {
type: 'binaryChunk',
transferId,
kind: 'imageSave',
imageId: id,
index,
totalBytes: buf.length,
encoding: 'base64',
data: slice.toString('base64'),
done,
})
index += 1
}
// Small images: also return inline for convenience
if (buf.length <= 2 * 1024 * 1024) {
return {
success: true,
type: 'imageSave',
transferId,
id,
encoding: 'base64',
size: buf.length,
chunks: index,
data: buf.toString('base64'),
}
}
return {
success: true,
type: 'imageSave',
transferId,
id,
encoding: 'base64',
size: buf.length,
chunks: index,
data: null,
note: 'Large image delivered via push:binaryChunk',
}
})
session.respond('loadImageStart', async () => {
const transferId = `load-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
session.state.set(`load:${transferId}`, { chunks: [], total: 0, startedAt: Date.now() })
return { success: true, transferId }
})
session.respond('loadImageChunk', async (args) => {
const transferId = args.transferId
const entry = session.state.get(`load:${transferId}`)
if (!entry) throw new Error('Unknown transferId; call loadImageStart first')
if (!args.data) throw new Error('chunk data required')
const buf = Buffer.from(args.data, args.encoding === 'utf8' ? 'utf8' : 'base64')
const maxTotal = 100 * 1024 * 1024
entry.total += buf.length
if (entry.total > maxTotal) {
session.state.delete(`load:${transferId}`)
throw new Error(`Load exceeds ${maxTotal} bytes`)
}
entry.chunks.push(buf)
return { success: true, transferId, received: entry.total, chunks: entry.chunks.length }
})
session.respond('loadImageFinish', async (args) => {
const transferId = args.transferId
const entry = session.state.get(`load:${transferId}`)
if (!entry) throw new Error('Unknown transferId')
session.state.delete(`load:${transferId}`)
const buf = Buffer.concat(entry.chunks)
if (!buf.length) throw new Error('No image data received')
const stream = await docker.loadImage(buf)
let output = ''
await new Promise((resolve, reject) => {
docker.modem.followProgress(
stream,
(err, res) => {
if (err) reject(err)
else {
if (res) output = JSON.stringify(res)
resolve(res)
}
},
() => {}
)
})
return {
success: true,
message: 'Image loaded into Docker engine',
size: buf.length,
output,
}
})
session.respond('importImage', async (args) => {
// Import raw rootfs tarball as a new image
if (!args.data) throw new Error('base64 tar data required')
const buf = Buffer.from(args.data, args.encoding === 'utf8' ? 'utf8' : 'base64')
const maxBytes = 100 * 1024 * 1024
if (buf.length > maxBytes) throw new Error(`Import exceeds ${maxBytes} bytes`)
const opts = {}
if (args.repo) opts.repo = validation.sanitizeString(args.repo, 255)
if (args.tag) opts.tag = validation.sanitizeString(args.tag, 128)
if (args.message) opts.message = validation.sanitizeString(args.message, 500)
const stream = await docker.importImage(buf, opts)
let output = ''
await new Promise((resolve, reject) => {
docker.modem.followProgress(
stream,
(err, res) => {
if (err) reject(err)
else {
if (res) output = JSON.stringify(res)
resolve(res)
}
},
() => {}
)
})
return {
success: true,
message: `Image imported${opts.repo ? ` as ${opts.repo}:${opts.tag || 'latest'}` : ''}`,
size: buf.length,
output,
}
})
session.respond('pruneBuilder', async () => {
// dockerode may expose pruneBuilder; fall back to modem dial
let result
if (typeof docker.pruneBuilder === 'function') {
result = await docker.pruneBuilder()
} else {
result = await new Promise((resolve, reject) => {
docker.modem.dial(
{ path: '/build/prune', method: 'POST', statusCodes: { 200: true, 500: 'fatal' } },
(err, data) => (err ? reject(err) : resolve(data))
)
})
}
return { success: true, type: 'pruneBuilder', message: 'Build cache pruned', data: result }
})
}