Release rolling / release (push) Successful in 8m55s
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.
636 lines
21 KiB
JavaScript
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 }
|
|
})
|
|
}
|