Files
peardock/server/handlers/images.js
T
snxraven 0ee9b67834 Complete production roadmap: vault, peers, swarm, streams, ops
Ship remaining roadmap items: encrypted registry vault, peer invite/revoke,
Swarm/plugins behind flags, binary streams, engine create validation, deploy
rollback, schema validation, fleet/access UI, metrics, fuzz/load/soak tests,
systemd packaging, and release tooling. Mark ROADMAP fully complete.
2026-07-10 20:50:55 -04:00

443 lines
14 KiB
JavaScript

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