Files
peardock/client/jobActions.js
T
Raven Scott b79c3bc97b Keep flattener header visible and auto-copy pull ref after push
Cap scrollable centered modals so only the body scrolls when push
options expand, and copy the image pull ref to the clipboard on success.
2026-07-18 08:20:17 -04:00

848 lines
27 KiB
JavaScript

/**
* Multi-step job-tray actions for long Docker ops.
* Prefer these over showStatusIndicator so the tray shows clear steps + logs.
*/
import { manager, Methods } from './manager.js'
import { runJob, setStep, appendJobLog, setJobProgress } from './jobs.js'
import {
beginPullProgress,
endPullProgress,
finalizePullProgress,
} from './pullProgress.js'
import { explainError } from './errors.js'
/**
* @param {unknown} err
* @param {string} method
*/
function rethrowJobError(err, method) {
const info = explainError(err, method)
const full = [info.title, info.message, info.recovery ? `How to fix: ${info.recovery}` : '']
.filter(Boolean)
.join(' — ')
const e = new Error(full || err?.message || String(err))
e.code = info.code || err?.code
e.cause = err
throw e
}
/**
* Short id for logs.
* @param {string} id
*/
function shortId(id) {
const s = String(id || '')
return s.length > 12 ? s.slice(0, 12) : s
}
/**
* Bulk start/stop/restart/kill/pause/unpause/remove containers.
* @param {{
* operation: string,
* label: string,
* containerIds: string[],
* names?: string[],
* force?: boolean,
* }} args
*/
export async function bulkContainerJob(args) {
const op = String(args.operation || '')
const label = String(args.label || op)
const ids = Array.isArray(args.containerIds) ? args.containerIds.filter(Boolean) : []
const names = Array.isArray(args.names) ? args.names : []
if (!ids.length) throw new Error('No containers selected')
return runJob(
`${label} ${ids.length} container${ids.length === 1 ? '' : 's'}`,
[
{
id: 'plan',
label: 'Review targets',
run: async ({ log }) => {
log(`Operation: ${label.toLowerCase()}`)
log(`Targets: ${ids.length}`)
for (let i = 0; i < ids.length; i++) {
const name = names[i] || shortId(ids[i])
log(` · ${name} (${shortId(ids[i])})`)
}
},
},
{
id: 'run',
label: `${label} containers`,
run: async ({ job, log }) => {
// Prefer bulk RPC when available (atomic-ish batch)
if (
op !== 'remove' &&
Methods.bulkContainerOperation &&
ids.length > 1
) {
log(`Batch ${label.toLowerCase()} via bulk API…`)
try {
const res = await manager.request(Methods.bulkContainerOperation, {
containerIds: ids,
operation: op,
})
const results = res?.results || []
let ok = 0
let fail = 0
for (let i = 0; i < results.length; i++) {
const r = results[i]
const name = names[i] || shortId(r.id || ids[i])
if (r.success) {
ok++
log(`✓ ${name}`)
} else {
fail++
log(`✗ ${name}: ${r.error || r.message || 'failed'}`, 'error')
}
}
setStep(job.id, 'run', 'active', {
detail: `${ok} ok · ${fail} failed`,
})
log(`Done: ${ok} ok${fail ? `, ${fail} failed` : ''}`)
if (fail && !ok) {
throw new Error(`${label} failed for all ${fail} container(s)`)
}
return res
} catch (err) {
// Fall through to per-container if bulk missing
if (!/unknown method|not found|not implemented/i.test(String(err?.message || ''))) {
rethrowJobError(err, 'bulkContainerOperation')
}
log('Bulk API unavailable — running one-by-one…', 'warning')
}
}
let ok = 0
let fail = 0
const methodMap = {
start: Methods.startContainer,
stop: Methods.stopContainer,
kill: Methods.killContainer,
restart: Methods.restartContainer,
pause: Methods.pauseContainer,
unpause: Methods.unpauseContainer,
resume: Methods.unpauseContainer,
remove: Methods.removeContainer,
}
const method = methodMap[op]
if (!method) throw new Error(`Unknown operation: ${op}`)
for (let i = 0; i < ids.length; i++) {
const id = ids[i]
const name = names[i] || shortId(id)
setStep(job.id, 'run', 'active', {
detail: `${i + 1}/${ids.length} · ${name}`,
})
log(`[${i + 1}/${ids.length}] ${label} ${name}…`)
try {
const payload =
op === 'remove' ? { id, force: args.force !== false } : { id }
await manager.request(method, payload)
ok++
log(`✓ ${name}`)
} catch (err) {
fail++
log(`✗ ${name}: ${err?.message || err}`, 'error')
}
}
log(`Done: ${ok} ok${fail ? `, ${fail} failed` : ''}`)
if (fail && !ok) throw new Error(`${label} failed for all targets`)
return { ok, fail }
},
},
],
{
peerId: manager.active?.id,
icon: op === 'remove' ? 'fa-trash' : 'fa-cubes',
subtitle: `${ids.length} selected`,
}
)
}
/**
* Recreate one or many containers (stop/remove/create with same config).
* @param {{ ids: string[], names?: string[], start?: boolean }} args
*/
export async function recreateContainersJob(args) {
const ids = Array.isArray(args.ids) ? args.ids.filter(Boolean) : []
const names = Array.isArray(args.names) ? args.names : []
const start = args.start !== false
if (!ids.length) throw new Error('No containers to recreate')
return runJob(
ids.length === 1
? `Recreate ${names[0] || shortId(ids[0])}`
: `Recreate ${ids.length} containers`,
[
{
id: 'plan',
label: 'Plan recreate',
run: async ({ log }) => {
log('Each container will be stopped, removed, and recreated with the same configuration.')
log(`Start after recreate: ${start ? 'yes' : 'no'}`)
for (let i = 0; i < ids.length; i++) {
log(` · ${names[i] || shortId(ids[i])}`)
}
},
},
{
id: 'recreate',
label: 'Recreate',
run: async ({ job, log }) => {
let ok = 0
let fail = 0
for (let i = 0; i < ids.length; i++) {
const id = ids[i]
const name = names[i] || shortId(id)
setStep(job.id, 'recreate', 'active', {
detail: `${i + 1}/${ids.length} · ${name}`,
})
log(`[${i + 1}/${ids.length}] Recreating ${name}…`)
try {
const res = await manager.request(Methods.recreateContainer, {
id,
start,
})
ok++
log(`✓ ${name}${res?.id ? ` → ${shortId(res.id)}` : ''}`)
} catch (err) {
fail++
log(`✗ ${name}: ${err?.message || err}`, 'error')
}
}
log(`Recreate finished: ${ok} ok${fail ? `, ${fail} failed` : ''}`)
if (fail && !ok) throw new Error('Recreate failed for all containers')
return { ok, fail }
},
},
],
{
peerId: manager.active?.id,
icon: 'fa-rotate',
subtitle: start ? 'with start' : 'without start',
}
)
}
/**
* Remove images with progress in the tray.
* @param {{ ids: string[], labels?: string[], force?: boolean }} args
*/
export async function removeImagesJob(args) {
const ids = Array.isArray(args.ids) ? args.ids.filter(Boolean) : []
const labels = Array.isArray(args.labels) ? args.labels : []
if (!ids.length) throw new Error('No images selected')
return runJob(
`Remove ${ids.length} image${ids.length === 1 ? '' : 's'}`,
[
{
id: 'plan',
label: 'Review images',
run: async ({ log }) => {
log(`Force remove: ${args.force !== false ? 'yes' : 'no'}`)
for (let i = 0; i < ids.length; i++) {
log(` · ${labels[i] || shortId(ids[i])}`)
}
},
},
{
id: 'remove',
label: 'Delete images',
run: async ({ job, log }) => {
let ok = 0
let fail = 0
for (let i = 0; i < ids.length; i++) {
const id = ids[i]
const label = labels[i] || shortId(id)
setStep(job.id, 'remove', 'active', {
detail: `${i + 1}/${ids.length} · ${label}`,
})
log(`[${i + 1}/${ids.length}] Removing ${label}…`)
try {
await manager.request(Methods.removeImage, {
id,
force: args.force !== false,
})
ok++
log(`✓ ${label}`)
} catch (err) {
fail++
log(`✗ ${label}: ${err?.message || err}`, 'error')
}
}
log(`Removed ${ok}${fail ? `, ${fail} failed` : ''}`)
if (fail && !ok) throw new Error('Failed to remove all images')
return { ok, fail }
},
},
],
{ peerId: manager.active?.id, icon: 'fa-layer-group', subtitle: 'local engine' }
)
}
/**
* Prune a resource class or full system prune.
* @param {{ kind: string, label: string, method: string, args?: object }} args
*/
export async function pruneJob(args) {
const label = String(args.label || args.kind || 'resources')
const method = args.method
if (!method) throw new Error('Prune method required')
return runJob(`Prune ${label}`, [
{
id: 'confirm',
label: 'Prepare',
run: async ({ log }) => {
log(`Pruning: ${label}`)
log('This removes unused Docker data matching the prune scope.')
if (args.args && Object.keys(args.args).length) {
log(`Options: ${JSON.stringify(args.args)}`)
}
},
},
{
id: 'prune',
label: `Prune ${label}`,
run: async ({ log }) => {
log('Running prune…')
try {
const res = await manager.request(method, args.args || {})
log(res?.message || `Pruned ${label}`)
if (res?.SpaceReclaimed != null) {
log(`Space reclaimed: ${formatBytesJob(res.SpaceReclaimed)}`)
}
if (res?.spaceReclaimed != null) {
log(`Space reclaimed: ${formatBytesJob(res.spaceReclaimed)}`)
}
// Common docker prune report fields
for (const key of [
'ContainersDeleted',
'ImagesDeleted',
'VolumesDeleted',
'NetworksDeleted',
'containersDeleted',
'imagesDeleted',
'volumesDeleted',
'networksDeleted',
]) {
const v = res?.[key]
if (Array.isArray(v) && v.length) log(`${key}: ${v.length}`)
else if (typeof v === 'number' && v > 0) log(`${key}: ${v}`)
}
return res
} catch (err) {
rethrowJobError(err, method)
}
},
},
], { peerId: manager.active?.id, icon: 'fa-broom', subtitle: label })
}
/**
* Remove a compose stack with tray steps.
* @param {{ stackName: string }} args
*/
export async function removeStackJob(args) {
const stackName = String(args.stackName || '').trim()
if (!stackName) throw new Error('Stack name required')
return runJob(`Remove stack ${stackName}`, [
{
id: 'plan',
label: 'Prepare',
run: async ({ log }) => {
log(`Stack: ${stackName}`)
log('This removes the compose project and its containers.')
},
},
{
id: 'remove',
label: 'Compose down',
run: async ({ log }) => {
log(`Removing stack “${stackName}”…`)
try {
const res = await manager.request(Methods.removeStack, { stackName })
log(res?.message || `Stack "${stackName}" removed`)
return res
} catch (err) {
rethrowJobError(err, 'removeStack')
}
},
},
], { peerId: manager.active?.id, icon: 'fa-layer-group', subtitle: 'compose project' })
}
/**
* Single-container lifecycle action with a clear tray job.
* @param {{ action: string, id: string, name?: string, force?: boolean }} args
*/
export async function containerActionJob(args) {
const action = String(args.action || '')
const id = String(args.id || '')
const name = String(args.name || shortId(id))
const map = {
start: { method: Methods.startContainer, label: 'Start', icon: 'fa-play' },
stop: { method: Methods.stopContainer, label: 'Stop', icon: 'fa-stop' },
kill: { method: Methods.killContainer, label: 'Kill', icon: 'fa-skull' },
restart: { method: Methods.restartContainer, label: 'Restart', icon: 'fa-rotate' },
pause: { method: Methods.pauseContainer, label: 'Pause', icon: 'fa-pause' },
resume: { method: Methods.unpauseContainer, label: 'Resume', icon: 'fa-play' },
unpause: { method: Methods.unpauseContainer, label: 'Resume', icon: 'fa-play' },
remove: { method: Methods.removeContainer, label: 'Remove', icon: 'fa-trash' },
}
const spec = map[action]
if (!spec || !id) throw new Error('Invalid container action')
return runJob(`${spec.label} ${name}`, [
{
id: 'run',
label: `${spec.label} container`,
run: async ({ log }) => {
log(`${spec.label}ing ${name} (${shortId(id)})…`)
try {
const payload =
action === 'remove' ? { id, force: args.force !== false } : { id }
const res = await manager.request(spec.method, payload)
log(res?.message || `${spec.label}ed ${name}`)
return res
} catch (err) {
rethrowJobError(err, spec.method)
}
},
},
], { peerId: manager.active?.id, icon: spec.icon, subtitle: shortId(id) })
}
/**
* Build image with live log in the tray (server streams buildProgress separately).
* @param {{ tag: string, dockerfile: string, buildFn: () => Promise<object> }} args
*/
export async function buildImageJob(args) {
const tag = String(args.tag || '').trim()
if (!tag) throw new Error('Image tag required')
if (!args.dockerfile?.trim()) throw new Error('Dockerfile content required')
return runJob(`Build ${tag}`, [
{
id: 'prepare',
label: 'Prepare build',
run: async ({ log }) => {
log(`Tag: ${tag}`)
log(`Dockerfile: ${String(args.dockerfile).split('\n').length} lines`)
},
},
{
id: 'build',
label: 'docker build',
run: async ({ log }) => {
log('Building image…')
log('(streamed build output may also appear in the build modal)')
try {
const res = await args.buildFn()
log(res?.message || `Image "${tag}" built`)
return res
} catch (err) {
rethrowJobError(err, 'buildImage')
}
},
},
], { 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.pushTarget === 'ttl.sh') {
log(`Push target: ttl.sh (anonymous ephemeral · tag is TTL)`)
} else 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}…`)
if (args.pushTarget === 'ttl.sh') {
log('Anonymous push to ttl.sh — image is public and will expire with the TTL tag')
} else if (args.credentialId) {
log(`Using vault credential ${args.credentialId} (auto-retags short names onto that registry)`)
}
try {
const body = {
image: ref,
id: ref,
// ttl.sh is anonymous; skip vault matching that could attach wrong Hub creds
autoVault: args.pushTarget === 'ttl.sh' ? false : 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)
}
if (res?.retargeted && res?.image && res.image !== ref) {
log(`Retagged for registry: ${ref}${res.image}`)
}
log(res?.message || `Pushed ${res?.image || 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 ({ job, log }) => {
// Prefer final pushed ref (may differ after registry retarget)
const pullRef = String(job.result?.image || job.result?.ref || ref).trim()
log(`Flatten complete: ${pullRef}`)
if (!doPush) {
log('Image is available on the local Docker engine.')
return { pullRef, pullCmd: '', copiedToClipboard: false }
}
log('Image is available locally and on the registry.')
const { pullCmd } = pullCommandForImage(pullRef)
log(`Pull: ${pullCmd}`)
let copied = false
// Prefer the bare image ref (works in compose/k8s/pull); also log full command
if (pullRef) {
copied = await copyTextToClipboard(pullRef)
if (copied) {
log(`✓ Pull ref copied to clipboard: ${pullRef}`)
} else {
log(`Could not auto-copy — use: ${pullRef}`, 'warning')
}
}
return {
pullRef,
pullCmd,
copiedToClipboard: copied,
}
},
})
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
*/
export async function pushImageJob(args) {
const image = String(args.image || args.id || '').trim()
const label = args.repo
? `${args.repo}:${args.tag || 'latest'}`
: image
if (!image && !args.repo) throw new Error('Image reference required')
const body = args.body || {
image: image || undefined,
id: args.id || image || undefined,
autoVault: true,
repo: args.repo,
tag: args.tag,
credentialId: args.credentialId,
}
return runJob(`Push ${label}`, [
{
id: 'prepare',
label: 'Prepare push',
run: async ({ log }) => {
log(`Target: ${label}`)
if (args.credentialId) log(`Credential: ${args.credentialId}`)
else log('Auth: vault / session')
},
},
{
id: 'push',
label: 'Upload layers',
run: async ({ job, log }) => {
const trackKey = label
const tracker = beginPullProgress(trackKey, job.id, 'push', {
kind: 'image-push',
})
setJobProgress(job.id, tracker.snapshot(), { stepId: 'push' })
log(`Pushing ${label}…`)
try {
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 ${label}`)
return res
} catch (err) {
finalizePullProgress(trackKey, {
ok: false,
message: err?.message || String(err),
})
endPullProgress(trackKey, job.id)
rethrowJobError(err, 'pushImage')
}
},
},
], { peerId: manager.active?.id, icon: 'fa-cloud-arrow-up', subtitle: label })
}
/**
* Create a named volume.
* @param {{ name: string, driver?: string, labels?: object }} args
*/
export async function createVolumeJob(args) {
const name = String(args.name || '').trim()
if (!name) throw new Error('Volume name required')
return runJob(`Create volume ${name}`, [
{
id: 'create',
label: 'Create volume',
run: async ({ log }) => {
log(`Name: ${name}`)
if (args.driver) log(`Driver: ${args.driver}`)
try {
const res = await manager.request(Methods.createVolume, {
name,
driver: args.driver,
labels: args.labels,
})
log(res?.message || `Volume "${name}" created`)
return res
} catch (err) {
rethrowJobError(err, 'createVolume')
}
},
},
], { peerId: manager.active?.id, icon: 'fa-hard-drive', subtitle: 'volume' })
}
/**
* @param {number} n
*/
function formatBytesJob(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`
}
/**
* Copy text to the system clipboard (Electron / browser).
* @param {string} text
* @returns {Promise<boolean>}
*/
export async function copyTextToClipboard(text) {
const s = String(text || '')
if (!s) return false
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(s)
return true
}
} catch {
// fall through
}
try {
if (typeof document === 'undefined') return false
const ta = document.createElement('textarea')
ta.value = s
ta.setAttribute('readonly', '')
ta.style.position = 'fixed'
ta.style.left = '-9999px'
ta.style.top = '0'
document.body.appendChild(ta)
ta.select()
const ok = document.execCommand('copy')
document.body.removeChild(ta)
return Boolean(ok)
} catch {
return false
}
}
/**
* Build a ready-to-paste pull command for a registry image ref.
* @param {string} imageRef
* @returns {{ pullRef: string, pullCmd: string }}
*/
export function pullCommandForImage(imageRef) {
const pullRef = String(imageRef || '').trim()
return {
pullRef,
pullCmd: pullRef ? `docker pull ${pullRef}` : '',
}
}
export default {
bulkContainerJob,
recreateContainersJob,
removeImagesJob,
pruneJob,
removeStackJob,
containerActionJob,
buildImageJob,
flattenContainerJob,
pushImageJob,
createVolumeJob,
copyTextToClipboard,
pullCommandForImage,
}