/** * Image RPC handlers. */ import { docker } from '../services/docker.js' import * as validation from '../utils/validation.js' import { Pushes } from '../../shared/protocol.js' import { getSessionAuthconfig } from './system.js' import logger from '../utils/logger.js' export function registerImageHandlers(session) { session.respond('listImages', async (args = {}) => { const listOpts = { all: args.all !== false } if (args.filters) listOpts.filters = args.filters if (args.dangling === true) { listOpts.filters = { ...(listOpts.filters || {}), dangling: ['true'] } } if (args.reference) { listOpts.filters = { ...(listOpts.filters || {}), reference: [String(args.reference)], } } let 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, }) } let imagesWithUsage = images.map((image) => ({ ...image, usage: imageUsage[image.Id] || [], })) 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') } const authconfig = getSessionAuthconfig(session) // dockerode: pull(repoTag, opts, callback, auth) const pullStream = await new Promise((resolve, reject) => { const onPull = (err, stream) => (err ? reject(err) : resolve(stream)) if (authconfig) { docker.pull( imageName, {}, onPull, { username: authconfig.username, password: authconfig.password, serveraddress: authconfig.serveraddress, } ) } 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 }) } } ) }) return { success: true, message: `Image "${imageName}" pulled successfully`, image: imageName } }) 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') const authconfig = getSessionAuthconfig(session) const image = docker.getImage(args.id || imageName) const stream = await image.push({ tag: args.tag || undefined, authconfig: authconfig ? { username: authconfig.username, password: authconfig.password, serveraddress: authconfig.serveraddress, } : undefined, }) await new Promise((resolve, reject) => { docker.modem.followProgress( stream, (err) => (err ? reject(err) : resolve()), (event) => { try { session.push(Pushes.pushProgress, { type: 'pushProgress', 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('push progress failed', { error: e.message }) } } ) }) return { success: true, message: `Image "${imageName}" pushed`, image: imageName } }) 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 } }) }