Add Container Flattener (export|import squash with job tray push)
Release rolling / release (push) Successful in 7m49s
Release rolling / release (push) Successful in 7m49s
Squash a container into a single-layer image from Manage → Flatten, with repo/tag, pause, config re-apply, vault credentials, and optional registry push.
This commit is contained in:
@@ -65,6 +65,7 @@ const AUDIT_METHODS = new Set([
|
||||
'updateContainer',
|
||||
'duplicateContainer',
|
||||
'commitContainer',
|
||||
'flattenContainer',
|
||||
'swarmInit',
|
||||
'swarmLeave',
|
||||
'createService',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Container RPC handlers.
|
||||
*/
|
||||
import { PassThrough } from 'stream'
|
||||
import { PassThrough, Transform } from 'stream'
|
||||
import {
|
||||
docker,
|
||||
startContainerNoBody,
|
||||
@@ -33,6 +33,91 @@ function isNoSuchContainer(err) {
|
||||
return /no such container/i.test(String(err?.message || err || ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Dockerfile-style change instructions for `docker import -c`.
|
||||
* Flatten discards image config; changes re-apply essential runtime metadata.
|
||||
* @param {object} args
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function buildFlattenImportChanges(args = {}) {
|
||||
const out = []
|
||||
const pushChange = (line) => {
|
||||
const s = validation.sanitizeString(String(line || '').trim(), 2000)
|
||||
if (s) out.push(s)
|
||||
}
|
||||
|
||||
if (Array.isArray(args.changes)) {
|
||||
for (const c of args.changes) pushChange(c)
|
||||
}
|
||||
|
||||
if (args.cmd != null && String(args.cmd).trim()) {
|
||||
const cmd = String(args.cmd).trim()
|
||||
pushChange(cmd.toUpperCase().startsWith('CMD') ? cmd : `CMD ${cmd}`)
|
||||
}
|
||||
if (args.entrypoint != null && String(args.entrypoint).trim()) {
|
||||
const ep = String(args.entrypoint).trim()
|
||||
pushChange(ep.toUpperCase().startsWith('ENTRYPOINT') ? ep : `ENTRYPOINT ${ep}`)
|
||||
}
|
||||
if (args.workdir != null && String(args.workdir).trim()) {
|
||||
const wd = String(args.workdir).trim()
|
||||
pushChange(wd.toUpperCase().startsWith('WORKDIR') ? wd : `WORKDIR ${wd}`)
|
||||
}
|
||||
if (args.user != null && String(args.user).trim()) {
|
||||
const user = String(args.user).trim()
|
||||
pushChange(user.toUpperCase().startsWith('USER') ? user : `USER ${user}`)
|
||||
}
|
||||
if (args.env != null && String(args.env).trim()) {
|
||||
for (const line of String(args.env).split(/\r?\n/)) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
if (trimmed.toUpperCase().startsWith('ENV ')) {
|
||||
pushChange(trimmed)
|
||||
continue
|
||||
}
|
||||
// KEY=value or KEY value
|
||||
const eq = trimmed.indexOf('=')
|
||||
if (eq > 0) {
|
||||
const key = trimmed.slice(0, eq).trim()
|
||||
const val = trimmed.slice(eq + 1).trim()
|
||||
if (key) pushChange(`ENV ${key}=${val}`)
|
||||
} else {
|
||||
const parts = trimmed.split(/\s+/)
|
||||
if (parts[0]) pushChange(`ENV ${parts[0]}=${parts.slice(1).join(' ')}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cap to keep querystring reasonable
|
||||
return out.slice(0, 48)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loose repo validation for flatten targets (supports registry:port/path).
|
||||
* @param {string} repo
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isValidFlattenRepo(repo) {
|
||||
if (!repo || typeof repo !== 'string') return false
|
||||
if (repo.length > 255 || /\s/.test(repo)) return false
|
||||
// host[:port]/name(/name)* OR name(/name)*
|
||||
return /^[a-z0-9][a-z0-9._-]*(?::[0-9]+)?(?:\/[a-z0-9][a-z0-9._-]*)+$|^[a-z0-9][a-z0-9._-]*$/i.test(
|
||||
repo
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format byte count for progress logs.
|
||||
* @param {number} n
|
||||
*/
|
||||
function formatFlattenBytes(n) {
|
||||
const v = Number(n)
|
||||
if (!Number.isFinite(v) || v < 0) return String(n)
|
||||
if (v < 1024) return `${Math.round(v)} B`
|
||||
if (v < 1024 ** 2) return `${(v / 1024).toFixed(1)} KiB`
|
||||
if (v < 1024 ** 3) return `${(v / 1024 ** 2).toFixed(1)} MiB`
|
||||
return `${(v / 1024 ** 3).toFixed(2)} GiB`
|
||||
}
|
||||
|
||||
/**
|
||||
* Release attachments that can delay Docker remove, stop the container, then delete.
|
||||
* Uses raw Engine socket calls with hard deadlines so remove never hangs on dockerode.
|
||||
@@ -380,6 +465,266 @@ export function registerContainerHandlers(session) {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Squash container filesystem into a single-layer image (export | import).
|
||||
* Runs entirely on the host — no tar over the wire. Optional pause for FS consistency.
|
||||
*/
|
||||
session.respond('flattenContainer', async (args) => {
|
||||
const id = validation.sanitizeString(args.id, 128)
|
||||
if (!id) throw new Error('Container id required')
|
||||
|
||||
// Docker repository names must be lowercase
|
||||
const repo = validation.sanitizeString(String(args.repo || '').toLowerCase(), 255)
|
||||
const tag = validation.sanitizeString(args.tag || 'latest', 128) || 'latest'
|
||||
if (!repo || !isValidFlattenRepo(repo)) {
|
||||
throw new Error(
|
||||
'Invalid repository. Use lowercase name or registry/path (e.g. 192.168.0.12:5555/myapp).'
|
||||
)
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$/.test(tag)) {
|
||||
throw new Error('Invalid tag')
|
||||
}
|
||||
|
||||
const ref = `${repo}:${tag}`
|
||||
const message = args.message
|
||||
? validation.sanitizeString(args.message, 500)
|
||||
: undefined
|
||||
const changes = buildFlattenImportChanges(args)
|
||||
const wantPause = args.pause !== false // default true for consistency
|
||||
const container = docker.getContainer(id)
|
||||
|
||||
let inspect
|
||||
try {
|
||||
inspect = await container.inspect()
|
||||
} catch (err) {
|
||||
if (isNoSuchContainer(err)) throw new Error(`No such container: ${id.slice(0, 12)}`)
|
||||
throw err
|
||||
}
|
||||
|
||||
const containerName =
|
||||
(inspect.Name || '').replace(/^\//, '') || id.slice(0, 12)
|
||||
const wasRunning = String(inspect.State?.Status || '').toLowerCase() === 'running'
|
||||
const wasPaused = Boolean(inspect.State?.Paused)
|
||||
let didPause = false
|
||||
|
||||
const pushProgress = (phase, extra = {}) => {
|
||||
try {
|
||||
session.push(Pushes.flattenProgress, {
|
||||
type: 'flattenProgress',
|
||||
containerId: id,
|
||||
repo,
|
||||
tag,
|
||||
ref,
|
||||
phase,
|
||||
...extra,
|
||||
})
|
||||
} catch (e) {
|
||||
logger.debug('flatten progress push failed', { error: e?.message })
|
||||
}
|
||||
}
|
||||
|
||||
pushProgress('prepare', {
|
||||
containerName,
|
||||
message: `Flattening ${containerName} → ${ref}`,
|
||||
})
|
||||
|
||||
if (wantPause && wasRunning && !wasPaused) {
|
||||
try {
|
||||
pushProgress('pause', { message: 'Pausing container for consistent snapshot…' })
|
||||
await container.pause()
|
||||
didPause = true
|
||||
} catch (err) {
|
||||
logger.warn('flattenContainer: pause failed, continuing', {
|
||||
id: id.slice(0, 12),
|
||||
error: err?.message,
|
||||
})
|
||||
pushProgress('pause', {
|
||||
message: `Pause skipped: ${err?.message || 'failed'}`,
|
||||
warning: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let exportBytes = 0
|
||||
let imageId = null
|
||||
const startedAt = Date.now()
|
||||
|
||||
try {
|
||||
pushProgress('export', { message: 'Exporting container filesystem…', bytes: 0 })
|
||||
|
||||
const exportStream = await container.export()
|
||||
let lastPushAt = 0
|
||||
const counter = new Transform({
|
||||
transform(chunk, _enc, cb) {
|
||||
exportBytes += chunk.length
|
||||
const now = Date.now()
|
||||
if (now - lastPushAt > 250) {
|
||||
lastPushAt = now
|
||||
pushProgress('export', {
|
||||
message: `Streaming filesystem… ${formatFlattenBytes(exportBytes)}`,
|
||||
bytes: exportBytes,
|
||||
phase: 'export',
|
||||
})
|
||||
}
|
||||
cb(null, chunk)
|
||||
},
|
||||
})
|
||||
|
||||
const importOpts = {
|
||||
fromSrc: '-',
|
||||
repo,
|
||||
tag,
|
||||
}
|
||||
if (message) importOpts.message = message
|
||||
if (changes.length) importOpts.changes = changes
|
||||
|
||||
// dial is async; do NOT await importImage before piping or we deadlock
|
||||
// (import response only arrives after the full tar body is sent).
|
||||
const importDone = new Promise((resolve, reject) => {
|
||||
docker.modem.dial(
|
||||
{
|
||||
path: '/images/create?',
|
||||
method: 'POST',
|
||||
options: importOpts,
|
||||
file: counter,
|
||||
isStream: true,
|
||||
statusCodes: {
|
||||
200: true,
|
||||
500: 'server error',
|
||||
},
|
||||
},
|
||||
(err, importStream) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
docker.modem.followProgress(
|
||||
importStream,
|
||||
(progressErr, output) => {
|
||||
if (progressErr) reject(progressErr)
|
||||
else {
|
||||
try {
|
||||
const last = Array.isArray(output)
|
||||
? output[output.length - 1]
|
||||
: output
|
||||
const status = last?.status || last?.stream || ''
|
||||
const m = String(status).match(/sha256:[a-f0-9]+/i)
|
||||
if (m) imageId = m[0]
|
||||
else if (typeof last?.aux?.ID === 'string') imageId = last.aux.ID
|
||||
} catch {
|
||||
// ignore parse
|
||||
}
|
||||
resolve(output)
|
||||
}
|
||||
},
|
||||
(event) => {
|
||||
if (event?.error) {
|
||||
logger.debug('flatten import event error', { error: event.error })
|
||||
}
|
||||
if (event?.status || event?.stream) {
|
||||
pushProgress('import', {
|
||||
message: String(event.status || event.stream || '')
|
||||
.trim()
|
||||
.slice(0, 200),
|
||||
bytes: exportBytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
exportStream.on('error', (err) => {
|
||||
try {
|
||||
counter.destroy(err)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
counter.on('error', (err) => {
|
||||
try {
|
||||
exportStream.destroy(err)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
|
||||
// Kick the pipe immediately so dial's counter→req consumer receives data
|
||||
exportStream.pipe(counter)
|
||||
pushProgress('import', {
|
||||
message: 'Piping export → import (single layer)…',
|
||||
bytes: 0,
|
||||
})
|
||||
|
||||
await importDone
|
||||
} finally {
|
||||
if (didPause) {
|
||||
try {
|
||||
pushProgress('unpause', { message: 'Resuming container…' })
|
||||
await container.unpause()
|
||||
} catch (err) {
|
||||
logger.warn('flattenContainer: unpause failed', {
|
||||
id: id.slice(0, 12),
|
||||
error: err?.message,
|
||||
})
|
||||
// Surface but don't mask successful flatten
|
||||
pushProgress('unpause', {
|
||||
message: `Warning: unpause failed — ${err?.message || 'unknown'}`,
|
||||
warning: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve image id via inspect if not parsed from stream
|
||||
if (!imageId) {
|
||||
try {
|
||||
const img = await docker.getImage(ref).inspect()
|
||||
imageId = img.Id
|
||||
} catch {
|
||||
// leave null
|
||||
}
|
||||
}
|
||||
|
||||
let virtualSize = null
|
||||
let size = null
|
||||
try {
|
||||
const img = await docker.getImage(imageId || ref).inspect()
|
||||
imageId = img.Id || imageId
|
||||
virtualSize = img.VirtualSize ?? img.Size ?? null
|
||||
size = img.Size ?? null
|
||||
} catch {
|
||||
// optional
|
||||
}
|
||||
|
||||
const elapsedMs = Date.now() - startedAt
|
||||
pushProgress('done', {
|
||||
message: `Created ${ref}`,
|
||||
bytes: exportBytes,
|
||||
imageId,
|
||||
elapsedMs,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
type: 'flattenContainer',
|
||||
message: `Flattened container "${containerName}" as ${ref}`,
|
||||
id,
|
||||
containerName,
|
||||
repo,
|
||||
tag,
|
||||
ref,
|
||||
imageId,
|
||||
exportBytes,
|
||||
size,
|
||||
virtualSize,
|
||||
changes,
|
||||
paused: didPause,
|
||||
elapsedMs,
|
||||
}
|
||||
})
|
||||
|
||||
session.respond('attachContainer', async (args) => {
|
||||
const container = docker.getContainer(args.id)
|
||||
const stream = await container.attach({
|
||||
|
||||
Reference in New Issue
Block a user