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:
@@ -25,6 +25,7 @@ import {
|
||||
} from './libs/templateDeploy.js';
|
||||
import { initAddContainerPage } from './libs/addContainer.js';
|
||||
import { initRegistryManager, openPushImageModal, pullImageWithAuth } from './libs/registryManager.js';
|
||||
import { initContainerFlattener, openContainerFlattenerModal } from './libs/containerFlattener.js';
|
||||
import { showContainerSkeleton, createProgressBar, updateProgressBar, removeProgressBar } from './libs/loadingStates.js';
|
||||
import {
|
||||
closeAllModals,
|
||||
@@ -9480,6 +9481,14 @@ function handleRpcMessage(response, conn) {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'flattenProgress': {
|
||||
// Job tray listens via manager message; keep status indicator lightly in sync
|
||||
if (response?.message) {
|
||||
updateStatusIndicator(String(response.message).slice(0, 140));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'buildProgress': {
|
||||
const line = (response.stream || response.status || response.error || '').trim();
|
||||
if (line) {
|
||||
@@ -10142,6 +10151,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Images registry manager (vault, push/pull auth)
|
||||
initRegistryManager();
|
||||
initContainerFlattener();
|
||||
document.getElementById('registry-refresh-btn')?.addEventListener('click', () => {
|
||||
if (typeof window.refreshRegistryPanel === 'function') window.refreshRegistryPanel();
|
||||
});
|
||||
@@ -12098,6 +12108,15 @@ function bindContainerActionsModalOnce() {
|
||||
window.peardockOps?.openResourceEditor?.(container.Id, { name });
|
||||
break;
|
||||
}
|
||||
case 'flatten': {
|
||||
hide();
|
||||
if (typeof openContainerFlattenerModal === 'function') {
|
||||
openContainerFlattenerModal(container);
|
||||
} else {
|
||||
showAlert('info', 'Container Flattener is unavailable in this build.');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'remove': {
|
||||
hide();
|
||||
const deleteModalEl = document.getElementById('deleteModal');
|
||||
|
||||
@@ -85,6 +85,23 @@ export const api = {
|
||||
return connOrActive(connection).request(Methods.renameContainer, { id, name })
|
||||
},
|
||||
|
||||
/**
|
||||
* Squash container FS into a single-layer image (host-side export|import).
|
||||
* @param {string|object} idOrArgs
|
||||
* @param {object} [opts]
|
||||
*/
|
||||
flattenContainer(idOrArgs, opts = {}, connection) {
|
||||
if (opts && typeof opts.request === 'function') {
|
||||
connection = opts
|
||||
opts = {}
|
||||
}
|
||||
const body =
|
||||
typeof idOrArgs === 'object' && idOrArgs
|
||||
? { ...idOrArgs }
|
||||
: { id: idOrArgs, ...opts }
|
||||
return connOrActive(connection).request(Methods.flattenContainer, body)
|
||||
},
|
||||
|
||||
updateContainer(id, update, connection) {
|
||||
return connOrActive(connection).request(Methods.updateContainer, { id, ...update })
|
||||
},
|
||||
|
||||
@@ -453,6 +453,195 @@ export async function buildImageJob(args) {
|
||||
], { peerId: manager.active?.id, icon: 'fa-hammer', subtitle: tag })
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten (squash) a container into a single-layer image, optionally push.
|
||||
* @param {{
|
||||
* id: string,
|
||||
* name?: string,
|
||||
* repo: string,
|
||||
* tag?: string,
|
||||
* message?: string,
|
||||
* pause?: boolean,
|
||||
* cmd?: string,
|
||||
* entrypoint?: string,
|
||||
* workdir?: string,
|
||||
* user?: string,
|
||||
* env?: string,
|
||||
* push?: boolean,
|
||||
* credentialId?: string,
|
||||
* }} args
|
||||
*/
|
||||
export async function flattenContainerJob(args) {
|
||||
const id = String(args.id || '').trim()
|
||||
const name = String(args.name || shortId(id))
|
||||
const repo = String(args.repo || '').trim()
|
||||
const tag = String(args.tag || 'latest').trim() || 'latest'
|
||||
if (!id) throw new Error('Container id required')
|
||||
if (!repo) throw new Error('Repository name required')
|
||||
const ref = `${repo}:${tag}`
|
||||
const doPush = Boolean(args.push)
|
||||
|
||||
const steps = [
|
||||
{
|
||||
id: 'plan',
|
||||
label: 'Plan flatten',
|
||||
run: async ({ log }) => {
|
||||
log(`Source container: ${name} (${shortId(id)})`)
|
||||
log(`Target image: ${ref}`)
|
||||
log(`Mode: docker export | docker import (single layer)`)
|
||||
log(`Pause during export: ${args.pause !== false ? 'yes' : 'no'}`)
|
||||
if (args.message) log(`Message: ${args.message}`)
|
||||
if (args.cmd) log(`CMD: ${args.cmd}`)
|
||||
if (args.entrypoint) log(`ENTRYPOINT: ${args.entrypoint}`)
|
||||
if (args.workdir) log(`WORKDIR: ${args.workdir}`)
|
||||
if (args.user) log(`USER: ${args.user}`)
|
||||
if (args.env) {
|
||||
const lines = String(args.env)
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith('#'))
|
||||
log(`ENV: ${lines.length} variable(s)`)
|
||||
}
|
||||
log(`Push after flatten: ${doPush ? 'yes' : 'no'}`)
|
||||
if (doPush && args.credentialId) log(`Credential: ${args.credentialId}`)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'flatten',
|
||||
label: 'Export → import',
|
||||
run: async ({ job, log }) => {
|
||||
log(`Flattening ${name} → ${ref}…`)
|
||||
setStep(job.id, 'flatten', 'active', { detail: 'streaming filesystem' })
|
||||
|
||||
// Live progress from server flattenProgress pushes
|
||||
const onMsg = (msg) => {
|
||||
if (msg?.type !== 'flattenProgress' && msg?.type !== 'push:flattenProgress') return
|
||||
if (msg.containerId && msg.containerId !== id && !String(id).startsWith(String(msg.containerId).slice(0, 12))) {
|
||||
// still accept if ref matches
|
||||
if (msg.ref !== ref) return
|
||||
}
|
||||
const phase = msg.phase || ''
|
||||
const bytes = Number(msg.bytes) || 0
|
||||
const detail =
|
||||
bytes > 0
|
||||
? `${phase || 'work'} · ${formatBytesJob(bytes)}`
|
||||
: phase || msg.message || ''
|
||||
if (detail) setStep(job.id, 'flatten', 'active', { detail })
|
||||
if (msg.message) {
|
||||
// Avoid spamming identical lines
|
||||
const last = job.log?.[job.log.length - 1]?.line
|
||||
if (last !== msg.message) log(msg.message)
|
||||
}
|
||||
if (bytes > 0) {
|
||||
setJobProgress(
|
||||
job.id,
|
||||
{
|
||||
kind: 'flatten',
|
||||
phase,
|
||||
bytes,
|
||||
label: msg.message || `Streaming ${formatBytesJob(bytes)}`,
|
||||
percent: null,
|
||||
},
|
||||
{ stepId: 'flatten' }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
manager.on?.('message', onMsg)
|
||||
try {
|
||||
const body = {
|
||||
id,
|
||||
repo,
|
||||
tag,
|
||||
pause: args.pause !== false,
|
||||
}
|
||||
if (args.message) body.message = args.message
|
||||
if (args.cmd) body.cmd = args.cmd
|
||||
if (args.entrypoint) body.entrypoint = args.entrypoint
|
||||
if (args.workdir) body.workdir = args.workdir
|
||||
if (args.user) body.user = args.user
|
||||
if (args.env) body.env = args.env
|
||||
|
||||
const res = await manager.request(Methods.flattenContainer, body)
|
||||
if (res?.exportBytes != null) {
|
||||
log(`Streamed ${formatBytesJob(res.exportBytes)} of filesystem tar`)
|
||||
}
|
||||
if (res?.imageId) log(`Image id: ${shortId(res.imageId)}`)
|
||||
if (res?.paused) log('Container was paused during export and resumed')
|
||||
if (res?.elapsedMs != null) log(`Elapsed: ${(res.elapsedMs / 1000).toFixed(1)}s`)
|
||||
log(res?.message || `Created ${ref}`)
|
||||
setJobProgress(job.id, null)
|
||||
return res
|
||||
} catch (err) {
|
||||
setJobProgress(job.id, null)
|
||||
rethrowJobError(err, 'flattenContainer')
|
||||
} finally {
|
||||
try {
|
||||
manager.off?.('message', onMsg)
|
||||
manager.removeListener?.('message', onMsg)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (doPush) {
|
||||
steps.push({
|
||||
id: 'push',
|
||||
label: 'Push to registry',
|
||||
run: async ({ job, log }) => {
|
||||
const trackKey = ref
|
||||
const tracker = beginPullProgress(trackKey, job.id, 'push', {
|
||||
kind: 'image-push',
|
||||
})
|
||||
setJobProgress(job.id, tracker.snapshot(), { stepId: 'push' })
|
||||
log(`Pushing ${ref}…`)
|
||||
try {
|
||||
const body = {
|
||||
image: ref,
|
||||
id: ref,
|
||||
autoVault: true,
|
||||
}
|
||||
if (args.credentialId) body.credentialId = args.credentialId
|
||||
const res = await manager.request(Methods.pushImage, body)
|
||||
const final = finalizePullProgress(trackKey, { ok: true })
|
||||
if (final) {
|
||||
setJobProgress(final.jobId, final.snapshot, { stepId: 'push' })
|
||||
if (final.milestoneLine) appendJobLog(final.jobId, final.milestoneLine)
|
||||
}
|
||||
log(res?.message || `Pushed ${ref}`)
|
||||
return res
|
||||
} catch (err) {
|
||||
finalizePullProgress(trackKey, {
|
||||
ok: false,
|
||||
message: err?.message || String(err),
|
||||
})
|
||||
endPullProgress(trackKey, job.id)
|
||||
rethrowJobError(err, 'pushImage')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
steps.push({
|
||||
id: 'done',
|
||||
label: 'Finish',
|
||||
run: async ({ log }) => {
|
||||
log(`Flatten complete: ${ref}`)
|
||||
if (doPush) log('Image is available locally and on the registry.')
|
||||
else log('Image is available on the local Docker engine.')
|
||||
},
|
||||
})
|
||||
|
||||
return runJob(`Flatten ${name}`, steps, {
|
||||
peerId: manager.active?.id,
|
||||
icon: 'fa-compress',
|
||||
subtitle: ref,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Push image with hybrid progress card (same UX as pull).
|
||||
* @param {{ image?: string, id?: string, repo?: string, tag?: string, credentialId?: string, body?: object }} args
|
||||
@@ -566,6 +755,7 @@ export default {
|
||||
removeStackJob,
|
||||
containerActionJob,
|
||||
buildImageJob,
|
||||
flattenContainerJob,
|
||||
pushImageJob,
|
||||
createVolumeJob,
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ const METHOD_TIMEOUT_MS = {
|
||||
deployContainer: Math.max(OP_TIMEOUT_MS, 300000),
|
||||
pullImage: 600000,
|
||||
pushImage: 600000,
|
||||
flattenContainer: 600000,
|
||||
checkImageUpdates: 120000,
|
||||
buildImage: 600000,
|
||||
deployStack: 600000,
|
||||
|
||||
+4
-1
@@ -9,6 +9,7 @@ What PearDock can do today, mapped to code and protocol surfaces.
|
||||
| Area | Status | Default | Primary code |
|
||||
|------|--------|---------|--------------|
|
||||
| Container lifecycle | shipped | on | `handlers/containers.js` |
|
||||
| Container Flattener (export\|import) | shipped | on | `handlers/containers.js`, `libs/containerFlattener.js` |
|
||||
| Image pull/build/load/save | shipped | on | `handlers/images.js` |
|
||||
| Networks / volumes | shipped | on | `handlers/networks.js`, `volumes.js` |
|
||||
| Compose stacks + GitOps | shipped | on | `handlers/stacks.js`, `utils/gitops.js` |
|
||||
@@ -43,7 +44,9 @@ What PearDock can do today, mapped to code and protocol surfaces.
|
||||
## Containers
|
||||
|
||||
**UI:** Containers view + detail pane + **Add container** page (header button).
|
||||
**RPC:** list/inspect/start/stop/restart/kill/pause/unpause/remove/recreate/rename/update/deploy/bulk/top/stats/logs/exec/attach/commit/export/archive/duplicate/prune.
|
||||
**RPC:** list/inspect/start/stop/restart/kill/pause/unpause/remove/recreate/rename/update/deploy/bulk/top/stats/logs/exec/attach/commit/export/flatten/archive/duplicate/prune.
|
||||
|
||||
**Container Flattener** (Manage → Flatten in the container ⋮ actions modal): host-side `docker export | docker import` that squashes the container filesystem into a **single-layer** image. UI sets repo/tag, optional message, pause-during-export, re-applies CMD/ENTRYPOINT/WORKDIR/USER/ENV from inspect, and can **push** to a registry with vault credentials. Progress runs in the **job tray** (`flattenContainer` + optional `pushImage`). Unlike Commit, history/parent layers are discarded.
|
||||
|
||||
**Image update indicators** (style): **Updates** column compares each container’s local image `RepoDigest` to the remote registry manifest digest for the same tag. Green check = up to date, orange up-arrow = update available (click to pull), grey dash = unknown/skipped. **Check updates** lives in the containers action bar and reports results in the **job tray**. Server caches digests (~5m). Uses vault credentials for private registries. RPC: `checkImageUpdates`.
|
||||
|
||||
|
||||
+3
-1
@@ -129,7 +129,9 @@ Below is a **catalog** of `Methods` names. Minimum roles are in `MethodRoles` (s
|
||||
|--------|----------|
|
||||
| `listContainers`, `inspectContainer`, `checkImageUpdates`, `containerTop`, `containerStats`, `getStatsHistory`, `getContainerLogs` | viewer |
|
||||
| `startContainer`, `stopContainer`, `restartContainer`, `killContainer`, `pauseContainer`, `unpauseContainer`, `renameContainer`, `updateContainer`, `createContainer`, `deployContainer`, `bulkContainerOperation`, `waitContainer`, `attachContainer`, `attachInput`, `execContainer`, `execInput` | operator |
|
||||
| `removeContainer`, `recreateContainer`, `commitContainer`, `exportContainer`, `duplicateContainer`, `pruneContainers`, `archiveContainerGet`, `archiveContainerPut` | admin |
|
||||
| `removeContainer`, `recreateContainer`, `commitContainer`, `exportContainer`, `flattenContainer`, `duplicateContainer`, `pruneContainers`, `archiveContainerGet`, `archiveContainerPut` | admin |
|
||||
|
||||
`flattenContainer` runs host-side `export | import` (single-layer image). Optional `pause` (default true), `message`, and Dockerfile-style changes via `cmd` / `entrypoint` / `workdir` / `user` / `env`. Streams progress on `push:flattenProgress`.
|
||||
|
||||
`checkImageUpdates` compares local image digests to remote registry manifests (vault credentials for private hosts). `removeContainer` stops gracefully (short grace + kill fallback) before delete so force-remove does not hang.
|
||||
|
||||
|
||||
+140
@@ -5434,6 +5434,13 @@ services:
|
||||
<small>CPU & memory limits</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="cam-action" data-cam-action="flatten" data-min-role="admin">
|
||||
<span class="cam-action-icon cam-action-icon-flatten"><i class="fas fa-compress"></i></span>
|
||||
<span class="cam-action-text">
|
||||
<strong>Flatten</strong>
|
||||
<small>Squash into single-layer image</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cam-section-label mt-3">Danger zone</div>
|
||||
@@ -5510,6 +5517,139 @@ services:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container Flattener Modal (export | import → single-layer image) -->
|
||||
<div class="modal fade" id="containerFlattenerModal" tabindex="-1" aria-labelledby="containerFlattenerModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
|
||||
<div class="modal-content flatten-modal bg-dark text-white">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<div>
|
||||
<h5 class="modal-title mb-1" id="containerFlattenerModalLabel">
|
||||
<i class="fas fa-compress me-2"></i>Container Flattener
|
||||
</h5>
|
||||
<div id="flatten-modal-subtitle" class="small text-muted">Squash a container into a single-layer image</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body pt-3">
|
||||
<div class="flatten-callout mb-3">
|
||||
<div class="flatten-callout-icon"><i class="fas fa-layer-group"></i></div>
|
||||
<div>
|
||||
<strong>What this does</strong>
|
||||
<p class="mb-0 small text-muted">
|
||||
Runs <code class="flatten-code">docker export | docker import</code> on the host —
|
||||
collapsing every image layer and container write into <em>one</em> new layer.
|
||||
History and parent layers are discarded (unlike Commit). Ideal for baking runtime
|
||||
state into a slim, pushable artifact.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="flatten-source-card" class="flatten-source-card mb-3"></div>
|
||||
|
||||
<div class="flatten-section-label">Destination image</div>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-8">
|
||||
<label for="flatten-repo" class="form-label">Repository</label>
|
||||
<input type="text" class="form-control bg-dark text-white font-monospace" id="flatten-repo"
|
||||
placeholder="192.168.0.12:5555/myapp" autocomplete="off" spellcheck="false" required>
|
||||
<small class="text-muted">Include registry host/port when you plan to push (e.g. <code class="flatten-code">host:5555/name</code>).</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="flatten-tag" class="form-label">Tag</label>
|
||||
<input type="text" class="form-control bg-dark text-white font-monospace" id="flatten-tag"
|
||||
value="flat" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flatten-ref-bar mb-3">
|
||||
<span class="text-muted small me-2">Full reference</span>
|
||||
<code id="flatten-ref-preview" class="flatten-ref-preview text-muted">repository:tag</code>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="flatten-message" class="form-label">Message <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<input type="text" class="form-control bg-dark text-white" id="flatten-message"
|
||||
placeholder="Why this flat image was created" maxlength="500" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="flatten-options mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="flatten-pause" checked>
|
||||
<label class="form-check-label" for="flatten-pause">
|
||||
Pause container during export
|
||||
<small class="d-block text-muted">Best-effort FS consistency; auto-resumes when done</small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check form-switch mt-2">
|
||||
<input class="form-check-input" type="checkbox" id="flatten-preserve-config" checked>
|
||||
<label class="form-check-label" for="flatten-preserve-config">
|
||||
Re-apply runtime config (CMD, ENTRYPOINT, …)
|
||||
<small class="d-block text-muted">Import drops image config — we restore it from the container</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flatten-section-label d-flex align-items-center justify-content-between">
|
||||
<span>Push to registry</span>
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="flatten-push" role="switch">
|
||||
<label class="form-check-label small" for="flatten-push">Enable</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="flatten-push-fields" class="flatten-push-panel mb-3" style="display: none;">
|
||||
<label for="flatten-credential" class="form-label">Registry credential</label>
|
||||
<select id="flatten-credential" class="form-select bg-dark text-white registry-cred-select">
|
||||
<option value="">No credential (public / session auto)</option>
|
||||
</select>
|
||||
<small class="text-muted">Uses vault credentials from <strong>Registry</strong>. Required for private registries.</small>
|
||||
</div>
|
||||
|
||||
<div class="flatten-section-label d-flex align-items-center justify-content-between mt-1">
|
||||
<span>Advanced changes</span>
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="flatten-advanced-toggle" role="switch">
|
||||
<label class="form-check-label small" for="flatten-advanced-toggle">Edit</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="flatten-advanced-fields" class="flatten-advanced-panel" style="display: none;">
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-6">
|
||||
<label for="flatten-cmd" class="form-label">CMD</label>
|
||||
<input type="text" class="form-control bg-dark text-white font-monospace form-control-sm" id="flatten-cmd"
|
||||
placeholder='["/bin/sh"] or nginx -g "daemon off;"' autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="flatten-entrypoint" class="form-label">ENTRYPOINT</label>
|
||||
<input type="text" class="form-control bg-dark text-white font-monospace form-control-sm" id="flatten-entrypoint"
|
||||
placeholder='["/docker-entrypoint.sh"]' autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="flatten-workdir" class="form-label">WORKDIR</label>
|
||||
<input type="text" class="form-control bg-dark text-white font-monospace form-control-sm" id="flatten-workdir"
|
||||
placeholder="/app" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="flatten-user" class="form-label">USER</label>
|
||||
<input type="text" class="form-control bg-dark text-white font-monospace form-control-sm" id="flatten-user"
|
||||
placeholder="1000:1000" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label for="flatten-env" class="form-label">ENV <span class="text-muted fw-normal">(one KEY=value per line)</span></label>
|
||||
<textarea class="form-control bg-dark text-white font-monospace form-control-sm" id="flatten-env" rows="3"
|
||||
placeholder="PATH=/usr/local/bin NODE_ENV=production" spellcheck="false"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0 pt-0">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="confirm-flatten-btn" data-min-role="admin">
|
||||
<i class="fas fa-compress me-2"></i>Flatten image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exec Container Modal -->
|
||||
<div class="modal fade" id="execContainerModal" tabindex="-1" aria-labelledby="execContainerModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* Container Flattener — squash a container into a single-layer image
|
||||
* (docker export | docker import), with optional registry push.
|
||||
*/
|
||||
import { manager, Methods } from '../client/manager.js'
|
||||
import { presentError } from '../client/errors.js'
|
||||
import {
|
||||
showAlert,
|
||||
showStatusIndicator,
|
||||
hideStatusIndicator,
|
||||
} from './uiUtils.js'
|
||||
import {
|
||||
loadVaultCredentials,
|
||||
loadAuthStatus,
|
||||
fillCredentialSelect,
|
||||
credentialIdFromSelect,
|
||||
} from './registryManager.js'
|
||||
|
||||
/** @type {{ container: object|null, inspect: object|null }} */
|
||||
const state = {
|
||||
container: null,
|
||||
inspect: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
*/
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number|null|undefined} n
|
||||
*/
|
||||
function formatBytes(n) {
|
||||
const v = Number(n)
|
||||
if (!Number.isFinite(v) || v < 0) return '—'
|
||||
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`
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest repo/tag from container name + image.
|
||||
* @param {object} container
|
||||
*/
|
||||
function suggestRepoTag(container) {
|
||||
const name = (container.Names?.[0] || '').replace(/^\//, '') || 'container'
|
||||
const image = String(container.Image || '')
|
||||
let repo = name.toLowerCase().replace(/[^a-z0-9._/-]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
if (!repo) repo = 'flattened'
|
||||
// Prefer a sensible default without registry host (user can add 192.168.x:port/)
|
||||
let tag = 'flat'
|
||||
if (image && !image.startsWith('sha256:')) {
|
||||
const lastSlash = image.lastIndexOf('/')
|
||||
const base = lastSlash >= 0 ? image.slice(lastSlash + 1) : image
|
||||
const colon = base.lastIndexOf(':')
|
||||
if (colon > 0) {
|
||||
const imgName = base.slice(0, colon)
|
||||
const imgTag = base.slice(colon + 1)
|
||||
if (imgName && !repo.includes(imgName)) {
|
||||
// keep container-name based repo; use image tag as suffix
|
||||
tag = `flat-${imgTag}`.replace(/[^a-zA-Z0-9_.-]+/g, '-').slice(0, 120)
|
||||
} else if (imgTag) {
|
||||
tag = `flat-${imgTag}`.replace(/[^a-zA-Z0-9_.-]+/g, '-').slice(0, 120)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { repo, tag }
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Config.Cmd / Entrypoint for Dockerfile change lines.
|
||||
* @param {unknown} val
|
||||
*/
|
||||
function formatConfigArray(val) {
|
||||
if (val == null) return ''
|
||||
if (Array.isArray(val)) {
|
||||
if (!val.length) return ''
|
||||
try {
|
||||
return JSON.stringify(val)
|
||||
} catch {
|
||||
return val.map(String).join(' ')
|
||||
}
|
||||
}
|
||||
return String(val)
|
||||
}
|
||||
|
||||
function updateRefPreview() {
|
||||
const repo = document.getElementById('flatten-repo')?.value?.trim() || ''
|
||||
const tag = document.getElementById('flatten-tag')?.value?.trim() || 'latest'
|
||||
const el = document.getElementById('flatten-ref-preview')
|
||||
if (!el) return
|
||||
if (!repo) {
|
||||
el.textContent = 'repository:tag'
|
||||
el.classList.add('text-muted')
|
||||
return
|
||||
}
|
||||
el.textContent = `${repo}:${tag || 'latest'}`
|
||||
el.classList.remove('text-muted')
|
||||
}
|
||||
|
||||
function togglePushFields() {
|
||||
const on = document.getElementById('flatten-push')?.checked
|
||||
const wrap = document.getElementById('flatten-push-fields')
|
||||
if (wrap) wrap.style.display = on ? '' : 'none'
|
||||
}
|
||||
|
||||
function toggleAdvanced() {
|
||||
const on = document.getElementById('flatten-advanced-toggle')?.checked
|
||||
const wrap = document.getElementById('flatten-advanced-fields')
|
||||
if (wrap) wrap.style.display = on ? '' : 'none'
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill source summary card.
|
||||
* @param {object} container
|
||||
* @param {object|null} inspect
|
||||
*/
|
||||
function renderSourceCard(container, inspect) {
|
||||
const name = (container.Names?.[0] || '').replace(/^\//, '') || container.Id?.slice(0, 12)
|
||||
const stateStr = container.State || inspect?.State?.Status || 'unknown'
|
||||
const image = container.Image || inspect?.Config?.Image || '—'
|
||||
const shortId = (container.Id || '').slice(0, 12)
|
||||
const sizeRw = container.SizeRw ?? inspect?.SizeRw
|
||||
const sizeRoot = container.SizeRootFs ?? inspect?.SizeRootFs
|
||||
const layerHint =
|
||||
sizeRoot != null || sizeRw != null
|
||||
? `Writable ${formatBytes(sizeRw)} · RootFS ${formatBytes(sizeRoot)}`
|
||||
: 'Size unknown until export'
|
||||
|
||||
const host = document.getElementById('flatten-source-card')
|
||||
if (!host) return
|
||||
host.innerHTML = `
|
||||
<div class="flatten-source-grid">
|
||||
<div class="flatten-source-icon" aria-hidden="true"><i class="fas fa-cube"></i></div>
|
||||
<div class="flatten-source-meta">
|
||||
<div class="flatten-source-name">${escapeHtml(name)}</div>
|
||||
<div class="flatten-source-sub">
|
||||
<span class="mono">${escapeHtml(shortId)}</span>
|
||||
<span class="flatten-dot">·</span>
|
||||
<span class="badge ${stateBadgeClass(stateStr)}">${escapeHtml(stateStr)}</span>
|
||||
<span class="flatten-dot">·</span>
|
||||
<span class="text-muted text-truncate" title="${escapeHtml(image)}">${escapeHtml(image)}</span>
|
||||
</div>
|
||||
<div class="flatten-source-size text-muted small">${escapeHtml(layerHint)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} state
|
||||
*/
|
||||
function stateBadgeClass(state) {
|
||||
const s = String(state || '').toLowerCase()
|
||||
if (s === 'running') return 'bg-success'
|
||||
if (s === 'paused') return 'bg-warning text-dark'
|
||||
if (s === 'exited' || s === 'dead') return 'bg-secondary'
|
||||
if (s === 'restarting') return 'bg-info text-dark'
|
||||
return 'bg-secondary'
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefill runtime changes from inspect Config.
|
||||
* @param {object|null} inspect
|
||||
*/
|
||||
function prefillConfig(inspect) {
|
||||
const cfg = inspect?.Config || {}
|
||||
const cmdEl = document.getElementById('flatten-cmd')
|
||||
const epEl = document.getElementById('flatten-entrypoint')
|
||||
const wdEl = document.getElementById('flatten-workdir')
|
||||
const userEl = document.getElementById('flatten-user')
|
||||
const envEl = document.getElementById('flatten-env')
|
||||
|
||||
if (cmdEl) cmdEl.value = formatConfigArray(cfg.Cmd)
|
||||
if (epEl) epEl.value = formatConfigArray(cfg.Entrypoint)
|
||||
if (wdEl) wdEl.value = cfg.WorkingDir || ''
|
||||
if (userEl) userEl.value = cfg.User || ''
|
||||
if (envEl) {
|
||||
const env = Array.isArray(cfg.Env) ? cfg.Env : []
|
||||
envEl.value = env.join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the Container Flattener modal for a container.
|
||||
* @param {object} container
|
||||
*/
|
||||
export async function openContainerFlattenerModal(container) {
|
||||
if (!container?.Id) {
|
||||
showAlert('danger', 'No container selected')
|
||||
return
|
||||
}
|
||||
const modalEl = document.getElementById('containerFlattenerModal')
|
||||
if (!modalEl || typeof bootstrap === 'undefined') {
|
||||
showAlert('danger', 'Container Flattener modal unavailable')
|
||||
return
|
||||
}
|
||||
|
||||
state.container = container
|
||||
state.inspect = null
|
||||
|
||||
const name = (container.Names?.[0] || '').replace(/^\//, '') || container.Id.slice(0, 12)
|
||||
const titleEl = document.getElementById('containerFlattenerModalLabel')
|
||||
if (titleEl) {
|
||||
titleEl.innerHTML = `<i class="fas fa-compress me-2"></i>Container Flattener`
|
||||
}
|
||||
const subtitle = document.getElementById('flatten-modal-subtitle')
|
||||
if (subtitle) subtitle.textContent = `Squash “${name}” into a single-layer image`
|
||||
|
||||
renderSourceCard(container, null)
|
||||
|
||||
const { repo, tag } = suggestRepoTag(container)
|
||||
const repoEl = document.getElementById('flatten-repo')
|
||||
const tagEl = document.getElementById('flatten-tag')
|
||||
const msgEl = document.getElementById('flatten-message')
|
||||
const pauseEl = document.getElementById('flatten-pause')
|
||||
const pushEl = document.getElementById('flatten-push')
|
||||
const advEl = document.getElementById('flatten-advanced-toggle')
|
||||
const preserveEl = document.getElementById('flatten-preserve-config')
|
||||
|
||||
if (repoEl) repoEl.value = repo
|
||||
if (tagEl) tagEl.value = tag
|
||||
if (msgEl) msgEl.value = `Flattened from ${name} via PearDock`
|
||||
if (pauseEl) pauseEl.checked = String(container.State || '').toLowerCase() === 'running'
|
||||
if (pushEl) pushEl.checked = false
|
||||
if (advEl) advEl.checked = false
|
||||
if (preserveEl) preserveEl.checked = true
|
||||
togglePushFields()
|
||||
toggleAdvanced()
|
||||
updateRefPreview()
|
||||
|
||||
// Credentials for optional push
|
||||
try {
|
||||
await Promise.all([loadVaultCredentials(), loadAuthStatus()])
|
||||
} catch {
|
||||
// continue without vault
|
||||
}
|
||||
fillCredentialSelect(document.getElementById('flatten-credential'))
|
||||
|
||||
bootstrap.Modal.getOrCreateInstance(modalEl).show()
|
||||
|
||||
// Inspect in background for size + config prefill
|
||||
try {
|
||||
const res = await manager.request(Methods.inspectContainer, { id: container.Id })
|
||||
const data = res?.data || res
|
||||
if (data && state.container?.Id === container.Id) {
|
||||
state.inspect = data
|
||||
renderSourceCard(container, data)
|
||||
if (preserveEl?.checked) prefillConfig(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[flatten] inspect failed', err?.message || err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect form → job args.
|
||||
*/
|
||||
function collectFlattenArgs() {
|
||||
const container = state.container
|
||||
if (!container?.Id) throw new Error('No container selected')
|
||||
|
||||
const repo = document.getElementById('flatten-repo')?.value?.trim() || ''
|
||||
const tag = document.getElementById('flatten-tag')?.value?.trim() || 'latest'
|
||||
if (!repo) throw new Error('Repository name is required')
|
||||
|
||||
const name = (container.Names?.[0] || '').replace(/^\//, '') || container.Id.slice(0, 12)
|
||||
const args = {
|
||||
id: container.Id,
|
||||
name,
|
||||
repo,
|
||||
tag,
|
||||
pause: document.getElementById('flatten-pause')?.checked !== false,
|
||||
push: Boolean(document.getElementById('flatten-push')?.checked),
|
||||
}
|
||||
|
||||
const message = document.getElementById('flatten-message')?.value?.trim()
|
||||
if (message) args.message = message
|
||||
|
||||
const credVal = document.getElementById('flatten-credential')?.value
|
||||
const credId = credentialIdFromSelect(credVal)
|
||||
if (args.push && credId) args.credentialId = credId
|
||||
|
||||
const advanced = document.getElementById('flatten-advanced-toggle')?.checked
|
||||
const preserve = document.getElementById('flatten-preserve-config')?.checked
|
||||
if (advanced || preserve) {
|
||||
const cmd = document.getElementById('flatten-cmd')?.value?.trim()
|
||||
const entrypoint = document.getElementById('flatten-entrypoint')?.value?.trim()
|
||||
const workdir = document.getElementById('flatten-workdir')?.value?.trim()
|
||||
const user = document.getElementById('flatten-user')?.value?.trim()
|
||||
const env = document.getElementById('flatten-env')?.value?.trim()
|
||||
if (cmd) args.cmd = cmd
|
||||
if (entrypoint) args.entrypoint = entrypoint
|
||||
if (workdir) args.workdir = workdir
|
||||
if (user) args.user = user
|
||||
if (env) args.env = env
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
/**
|
||||
* Start flatten job from modal.
|
||||
*/
|
||||
async function startFlattenFromModal() {
|
||||
let args
|
||||
try {
|
||||
args = collectFlattenArgs()
|
||||
} catch (err) {
|
||||
showAlert('danger', err?.message || 'Invalid form')
|
||||
return
|
||||
}
|
||||
|
||||
const modalEl = document.getElementById('containerFlattenerModal')
|
||||
if (modalEl && typeof bootstrap !== 'undefined') {
|
||||
bootstrap.Modal.getInstance(modalEl)?.hide()
|
||||
}
|
||||
|
||||
const run =
|
||||
typeof window.peardockOps?.flattenContainerJob === 'function'
|
||||
? window.peardockOps.flattenContainerJob
|
||||
: null
|
||||
|
||||
if (!run) {
|
||||
// Fallback without job tray
|
||||
showStatusIndicator(`Flattening → ${args.repo}:${args.tag}…`)
|
||||
try {
|
||||
const res = await manager.request(Methods.flattenContainer, {
|
||||
id: args.id,
|
||||
repo: args.repo,
|
||||
tag: args.tag,
|
||||
message: args.message,
|
||||
pause: args.pause,
|
||||
cmd: args.cmd,
|
||||
entrypoint: args.entrypoint,
|
||||
workdir: args.workdir,
|
||||
user: args.user,
|
||||
env: args.env,
|
||||
})
|
||||
showAlert('success', res?.message || 'Flatten complete')
|
||||
if (args.push) {
|
||||
await manager.request(Methods.pushImage, {
|
||||
image: `${args.repo}:${args.tag}`,
|
||||
id: `${args.repo}:${args.tag}`,
|
||||
credentialId: args.credentialId,
|
||||
autoVault: true,
|
||||
})
|
||||
showAlert('success', `Pushed ${args.repo}:${args.tag}`)
|
||||
}
|
||||
if (typeof window.loadImages === 'function') window.loadImages()
|
||||
} catch (err) {
|
||||
presentError(err, Methods.flattenContainer, { showAlert })
|
||||
} finally {
|
||||
hideStatusIndicator()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await run(args)
|
||||
showAlert('success', `Flattened as ${args.repo}:${args.tag}${args.push ? ' and pushed' : ''}`)
|
||||
if (typeof window.loadImages === 'function') window.loadImages()
|
||||
} catch (err) {
|
||||
if (!err?.viaJob) {
|
||||
presentError(err, Methods.flattenContainer, { showAlert })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire modal controls once.
|
||||
*/
|
||||
export function initContainerFlattener() {
|
||||
const modalEl = document.getElementById('containerFlattenerModal')
|
||||
if (!modalEl || modalEl.dataset.bound === '1') return
|
||||
modalEl.dataset.bound = '1'
|
||||
|
||||
document.getElementById('flatten-repo')?.addEventListener('input', updateRefPreview)
|
||||
document.getElementById('flatten-tag')?.addEventListener('input', updateRefPreview)
|
||||
document.getElementById('flatten-push')?.addEventListener('change', togglePushFields)
|
||||
document.getElementById('flatten-advanced-toggle')?.addEventListener('change', toggleAdvanced)
|
||||
|
||||
document.getElementById('flatten-preserve-config')?.addEventListener('change', (e) => {
|
||||
if (e.target.checked && state.inspect) prefillConfig(state.inspect)
|
||||
})
|
||||
|
||||
document.getElementById('confirm-flatten-btn')?.addEventListener('click', () => {
|
||||
startFlattenFromModal()
|
||||
})
|
||||
|
||||
// Enter in repo/tag submits
|
||||
for (const id of ['flatten-repo', 'flatten-tag']) {
|
||||
document.getElementById(id)?.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
startFlattenFromModal()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
modalEl.addEventListener('hidden.bs.modal', () => {
|
||||
state.container = null
|
||||
state.inspect = null
|
||||
})
|
||||
|
||||
window.openContainerFlattenerModal = openContainerFlattenerModal
|
||||
}
|
||||
|
||||
export default {
|
||||
openContainerFlattenerModal,
|
||||
initContainerFlattener,
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -160,6 +160,7 @@ export const MethodRoles = Object.freeze({
|
||||
fireManualAlert: Roles.admin,
|
||||
commitContainer: Roles.admin,
|
||||
exportContainer: Roles.admin,
|
||||
flattenContainer: Roles.admin,
|
||||
archiveContainerGet: Roles.admin,
|
||||
archiveContainerPut: Roles.admin,
|
||||
duplicateContainer: Roles.admin,
|
||||
@@ -242,6 +243,7 @@ export const Methods = Object.freeze({
|
||||
renameContainer: 'renameContainer',
|
||||
commitContainer: 'commitContainer',
|
||||
exportContainer: 'exportContainer',
|
||||
flattenContainer: 'flattenContainer',
|
||||
execContainer: 'execContainer',
|
||||
duplicateContainer: 'duplicateContainer',
|
||||
deployContainer: 'deployContainer',
|
||||
@@ -448,6 +450,7 @@ export const Pushes = Object.freeze({
|
||||
pullProgress: 'push:pullProgress',
|
||||
buildProgress: 'push:buildProgress',
|
||||
pushProgress: 'push:pushProgress',
|
||||
flattenProgress: 'push:flattenProgress',
|
||||
dockerEvent: 'push:dockerEvent',
|
||||
alert: 'push:alert',
|
||||
/** Live session metadata (role changes, etc.) — no reconnect required */
|
||||
@@ -473,6 +476,7 @@ export const PushToType = Object.freeze({
|
||||
[Pushes.pullProgress]: 'pullProgress',
|
||||
[Pushes.buildProgress]: 'buildProgress',
|
||||
[Pushes.pushProgress]: 'pushProgress',
|
||||
[Pushes.flattenProgress]: 'flattenProgress',
|
||||
[Pushes.dockerEvent]: 'dockerEvent',
|
||||
[Pushes.alert]: 'alert',
|
||||
[Pushes.session]: 'session',
|
||||
|
||||
@@ -48,6 +48,18 @@ export const MethodSchemas = Object.freeze({
|
||||
credentialId: { type: 'string', required: false, maxLen: 64 },
|
||||
autoVault: { type: 'boolean', required: false },
|
||||
},
|
||||
flattenContainer: {
|
||||
id: { type: 'string', required: true, maxLen: 128 },
|
||||
repo: { type: 'string', required: true, maxLen: 255 },
|
||||
tag: { type: 'string', required: false, maxLen: 128 },
|
||||
message: { type: 'string', required: false, maxLen: 500 },
|
||||
pause: { type: 'boolean', required: false },
|
||||
cmd: { type: 'string', required: false, maxLen: 2000 },
|
||||
entrypoint: { type: 'string', required: false, maxLen: 2000 },
|
||||
workdir: { type: 'string', required: false, maxLen: 1024 },
|
||||
user: { type: 'string', required: false, maxLen: 256 },
|
||||
env: { type: 'string', required: false, maxLen: 8000 },
|
||||
},
|
||||
deployStack: {
|
||||
composeContent: { type: 'string', required: true, maxLen: 2_000_000 },
|
||||
stackName: { type: 'string', required: true, maxLen: 63 },
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Unit tests for Container Flattener helpers (import changes + repo validation).
|
||||
*/
|
||||
import test from 'brittle'
|
||||
import {
|
||||
buildFlattenImportChanges,
|
||||
isValidFlattenRepo,
|
||||
} from '../server/handlers/containers.js'
|
||||
import { Methods, MethodRoles, Pushes, Roles } from '../shared/protocol.js'
|
||||
import { validateMethodArgs } from '../shared/schema.js'
|
||||
|
||||
test('Methods.flattenContainer is registered as admin', (t) => {
|
||||
t.is(Methods.flattenContainer, 'flattenContainer')
|
||||
t.is(MethodRoles.flattenContainer, Roles.admin)
|
||||
})
|
||||
|
||||
test('Pushes.flattenProgress exists', (t) => {
|
||||
t.is(Pushes.flattenProgress, 'push:flattenProgress')
|
||||
})
|
||||
|
||||
test('isValidFlattenRepo accepts registry:port/path', (t) => {
|
||||
t.ok(isValidFlattenRepo('myapp'))
|
||||
t.ok(isValidFlattenRepo('org/myapp'))
|
||||
t.ok(isValidFlattenRepo('192.168.0.12:5555/myapp'))
|
||||
t.ok(isValidFlattenRepo('ghcr.io/org/app'))
|
||||
t.ok(isValidFlattenRepo('registry.example.com:5000/ns/name'))
|
||||
})
|
||||
|
||||
test('isValidFlattenRepo rejects junk', (t) => {
|
||||
t.not(isValidFlattenRepo(''))
|
||||
t.not(isValidFlattenRepo('has space'))
|
||||
t.not(isValidFlattenRepo('a'.repeat(256)))
|
||||
})
|
||||
|
||||
test('buildFlattenImportChanges from convenience fields', (t) => {
|
||||
const changes = buildFlattenImportChanges({
|
||||
cmd: '["/bin/sh"]',
|
||||
entrypoint: '["/entry"]',
|
||||
workdir: '/app',
|
||||
user: '1000',
|
||||
env: 'FOO=bar\n# comment\nBAZ=qux\n',
|
||||
})
|
||||
t.ok(changes.includes('CMD ["/bin/sh"]'))
|
||||
t.ok(changes.includes('ENTRYPOINT ["/entry"]'))
|
||||
t.ok(changes.includes('WORKDIR /app'))
|
||||
t.ok(changes.includes('USER 1000'))
|
||||
t.ok(changes.includes('ENV FOO=bar'))
|
||||
t.ok(changes.includes('ENV BAZ=qux'))
|
||||
t.not(changes.some((c) => c.includes('comment')))
|
||||
})
|
||||
|
||||
test('buildFlattenImportChanges preserves full instruction lines', (t) => {
|
||||
const changes = buildFlattenImportChanges({
|
||||
cmd: 'CMD ["nginx","-g","daemon off;"]',
|
||||
changes: ['EXPOSE 80', 'VOLUME /data'],
|
||||
})
|
||||
t.ok(changes.includes('CMD ["nginx","-g","daemon off;"]'))
|
||||
t.ok(changes.includes('EXPOSE 80'))
|
||||
t.ok(changes.includes('VOLUME /data'))
|
||||
})
|
||||
|
||||
test('schema validates flattenContainer args', (t) => {
|
||||
const ok = validateMethodArgs('flattenContainer', {
|
||||
id: 'abc123',
|
||||
repo: '192.168.0.12:5555/myapp',
|
||||
tag: 'flat',
|
||||
pause: true,
|
||||
message: 'test',
|
||||
})
|
||||
t.ok(ok.ok)
|
||||
|
||||
const missing = validateMethodArgs('flattenContainer', { repo: 'x' })
|
||||
t.not(missing.ok)
|
||||
})
|
||||
@@ -30,6 +30,11 @@ const REQUIRED_IDS = [
|
||||
'pushImageModal',
|
||||
'pull-image-credential',
|
||||
'check-image-updates-btn',
|
||||
'containerFlattenerModal',
|
||||
'containerActionsModal',
|
||||
'flatten-repo',
|
||||
'flatten-tag',
|
||||
'confirm-flatten-btn',
|
||||
'containers-table',
|
||||
'deploy-view',
|
||||
'tunnels-view',
|
||||
|
||||
+146
@@ -4341,6 +4341,152 @@ table.pd-col-table td.pd-col-hidden {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.cam-action-icon-flatten {
|
||||
background: rgba(167, 139, 250, 0.14) !important;
|
||||
color: #a78bfa !important;
|
||||
}
|
||||
|
||||
/* ── Container Flattener modal ── */
|
||||
.flatten-modal {
|
||||
border-radius: 16px !important;
|
||||
}
|
||||
|
||||
.flatten-callout {
|
||||
display: flex;
|
||||
gap: 0.85rem;
|
||||
align-items: flex-start;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(167, 139, 250, 0.28);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(167, 139, 250, 0.1) 0%,
|
||||
rgba(56, 189, 248, 0.06) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.flatten-callout-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(167, 139, 250, 0.18);
|
||||
color: #a78bfa;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.flatten-code {
|
||||
font-size: 0.78em;
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: var(--accent-info, #38bdf8);
|
||||
}
|
||||
|
||||
.flatten-section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
.flatten-source-card {
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-surface, rgba(255, 255, 255, 0.03));
|
||||
}
|
||||
|
||||
.flatten-source-grid {
|
||||
display: flex;
|
||||
gap: 0.85rem;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.flatten-source-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
color: var(--accent-info, #38bdf8);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.flatten-source-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.flatten-source-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.flatten-source-sub {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.3rem 0.4rem;
|
||||
font-size: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.flatten-source-sub .mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.flatten-dot {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.flatten-source-size {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.flatten-ref-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
border: 1px dashed var(--border-color);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.flatten-ref-preview {
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
color: var(--accent-info, #38bdf8) !important;
|
||||
}
|
||||
|
||||
.flatten-options {
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-surface, rgba(255, 255, 255, 0.02));
|
||||
}
|
||||
|
||||
.flatten-push-panel,
|
||||
.flatten-advanced-panel {
|
||||
padding: 0.85rem 0.9rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-surface, rgba(255, 255, 255, 0.02));
|
||||
}
|
||||
|
||||
/* Accordion bodies must stay collapsed when not .show (defensive vs BS CDN lag) */
|
||||
.accordion-collapse.collapse:not(.show) {
|
||||
display: none !important;
|
||||
|
||||
@@ -3761,6 +3761,7 @@ export function initOpsApp({ navigateToView, sendCommand }) {
|
||||
removeStackJob: jobActions.removeStackJob,
|
||||
containerActionJob: jobActions.containerActionJob,
|
||||
buildImageJob: jobActions.buildImageJob,
|
||||
flattenContainerJob: jobActions.flattenContainerJob,
|
||||
pushImageJob: jobActions.pushImageJob,
|
||||
createVolumeJob: jobActions.createVolumeJob,
|
||||
openSmartNetworkModal,
|
||||
|
||||
Reference in New Issue
Block a user