forked from snxraven/peardock
Wire credential-aware pull/push, session auth, and an Images Registries tab for vault management.
474 lines
15 KiB
JavaScript
474 lines
15 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 { resolveRegistryAuth } from './vault.js'
|
|
import logger from '../utils/logger.js'
|
|
|
|
/**
|
|
* @param {object|null} authconfig
|
|
* @returns {object|undefined}
|
|
*/
|
|
function dockerAuthOpts(authconfig) {
|
|
if (!authconfig?.username) return undefined
|
|
return {
|
|
username: authconfig.username,
|
|
password: authconfig.password,
|
|
serveraddress: authconfig.serveraddress || 'https://index.docker.io/v1/',
|
|
}
|
|
}
|
|
|
|
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 = resolveRegistryAuth(session, {
|
|
credentialId: args.credentialId,
|
|
auth: args.auth,
|
|
autoVault: args.autoVault,
|
|
image: imageName,
|
|
})
|
|
const auth = dockerAuthOpts(authconfig)
|
|
// dockerode: pull(repoTag, opts, callback, 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 })
|
|
}
|
|
}
|
|
)
|
|
})
|
|
|
|
return {
|
|
success: true,
|
|
message: `Image "${imageName}" pulled successfully`,
|
|
image: imageName,
|
|
usedAuth: Boolean(auth),
|
|
}
|
|
})
|
|
|
|
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
|
|
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')
|
|
await docker.getImage(args.id || imageName).tag({ repo, tag })
|
|
pushRef = `${repo}:${tag}`
|
|
}
|
|
const authconfig = resolveRegistryAuth(session, {
|
|
credentialId: args.credentialId,
|
|
auth: args.auth,
|
|
autoVault: args.autoVault,
|
|
image: pushRef,
|
|
})
|
|
const image = docker.getImage(pushRef)
|
|
const stream = await image.push({
|
|
tag: args.repo ? undefined : args.tag || undefined,
|
|
authconfig: dockerAuthOpts(authconfig),
|
|
})
|
|
await new Promise((resolve, reject) => {
|
|
docker.modem.followProgress(
|
|
stream,
|
|
(err) => (err ? reject(err) : resolve()),
|
|
(event) => {
|
|
try {
|
|
if (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),
|
|
}
|
|
})
|
|
|
|
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 }
|
|
})
|
|
}
|