Add Container Flattener (export|import squash with job tray push)
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:
Raven Scott
2026-07-18 07:31:58 -04:00
parent a6fea51eaf
commit e9fb26153a
16 changed files with 1382 additions and 3 deletions
+1
View File
@@ -65,6 +65,7 @@ const AUDIT_METHODS = new Set([
'updateContainer',
'duplicateContainer',
'commitContainer',
'flattenContainer',
'swarmInit',
'swarmLeave',
'createService',
+346 -1
View File
@@ -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({