Ship remaining roadmap items: encrypted registry vault, peer invite/revoke, Swarm/plugins behind flags, binary streams, engine create validation, deploy rollback, schema validation, fleet/access UI, metrics, fuzz/load/soak tests, systemd packaging, and release tooling. Mark ROADMAP fully complete.
This commit is contained in:
+160
-16
@@ -152,18 +152,49 @@ export function validateComposeFile(composeContent) {
|
||||
/**
|
||||
* Run `docker compose` in a temp dir with the given YAML.
|
||||
* @param {string[]} args - args after `docker compose -f file -p project`
|
||||
* @param {{ composeContent: string, projectName: string, timeoutMs?: number }} opts
|
||||
* @param {{
|
||||
* composeContent: string,
|
||||
* projectName: string,
|
||||
* timeoutMs?: number,
|
||||
* overrideContent?: string|null,
|
||||
* envFileContent?: string|null,
|
||||
* profiles?: string[],
|
||||
* env?: Record<string, string>,
|
||||
* }} opts
|
||||
* @returns {Promise<{ code: number, stdout: string, stderr: string }>}
|
||||
*/
|
||||
export function runComposeCli(args, { composeContent, projectName, timeoutMs = 120000 }) {
|
||||
export function runComposeCli(args, {
|
||||
composeContent,
|
||||
projectName,
|
||||
timeoutMs = 120000,
|
||||
overrideContent = null,
|
||||
envFileContent = null,
|
||||
profiles = [],
|
||||
env = {},
|
||||
}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'peardock-compose-'))
|
||||
const file = path.join(dir, 'docker-compose.yml')
|
||||
fs.writeFileSync(file, composeContent, 'utf8')
|
||||
|
||||
const fullArgs = ['compose', '-f', file, '-p', projectName, ...args]
|
||||
const fileArgs = ['-f', file]
|
||||
if (overrideContent) {
|
||||
const overrideFile = path.join(dir, 'docker-compose.override.yml')
|
||||
fs.writeFileSync(overrideFile, overrideContent, 'utf8')
|
||||
fileArgs.push('-f', overrideFile)
|
||||
}
|
||||
if (envFileContent) {
|
||||
fs.writeFileSync(path.join(dir, '.env'), envFileContent, 'utf8')
|
||||
}
|
||||
|
||||
const profileArgs = []
|
||||
for (const p of profiles || []) {
|
||||
if (p) profileArgs.push('--profile', String(p))
|
||||
}
|
||||
|
||||
const fullArgs = ['compose', ...fileArgs, '-p', projectName, ...profileArgs, ...args]
|
||||
const child = spawn('docker', fullArgs, {
|
||||
env: process.env,
|
||||
env: { ...process.env, ...env },
|
||||
cwd: dir,
|
||||
})
|
||||
|
||||
@@ -210,17 +241,46 @@ function cleanup(dir) {
|
||||
* @param {import('dockerode')} docker
|
||||
* @param {string} composeContent
|
||||
* @param {string} stackName
|
||||
* @param {{
|
||||
* overrideContent?: string|null,
|
||||
* envFileContent?: string|null,
|
||||
* profiles?: string[],
|
||||
* env?: Record<string, string>,
|
||||
* build?: boolean,
|
||||
* }} [options]
|
||||
*/
|
||||
export async function deployComposeStack(docker, composeContent, stackName) {
|
||||
export async function deployComposeStack(docker, composeContent, stackName, options = {}) {
|
||||
// Validate base compose; overrides may add services so validate base at minimum
|
||||
const parsed = validateComposeFile(composeContent)
|
||||
if (options.overrideContent) {
|
||||
try {
|
||||
parseComposeFile(options.overrideContent)
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid override YAML: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const rollback = options.rollback !== false
|
||||
const cliOpts = {
|
||||
composeContent,
|
||||
projectName: stackName,
|
||||
timeoutMs: 300000,
|
||||
overrideContent: options.overrideContent || null,
|
||||
envFileContent: options.envFileContent || null,
|
||||
profiles: options.profiles || [],
|
||||
env: options.env || {},
|
||||
}
|
||||
|
||||
// Snapshot existing project containers for rollback
|
||||
const beforeIds = new Set(
|
||||
(await listProjectContainerIds(docker, stackName))
|
||||
)
|
||||
|
||||
// Prefer official Compose V2 CLI
|
||||
try {
|
||||
const result = await runComposeCli(['up', '-d', '--remove-orphans'], {
|
||||
composeContent,
|
||||
projectName: stackName,
|
||||
timeoutMs: 300000,
|
||||
})
|
||||
const upArgs = ['up', '-d', '--remove-orphans']
|
||||
if (options.build) upArgs.push('--build')
|
||||
const result = await runComposeCli(upArgs, cliOpts)
|
||||
if (result.code === 0) {
|
||||
return {
|
||||
success: true,
|
||||
@@ -229,33 +289,85 @@ export async function deployComposeStack(docker, composeContent, stackName) {
|
||||
services: Object.keys(parsed.services).map((s) => ({ service: s, status: 'up' })),
|
||||
message: `Stack "${stackName}" deployed successfully`,
|
||||
stdout: result.stdout,
|
||||
rollback: false,
|
||||
}
|
||||
}
|
||||
// Fall through only if compose binary missing-style errors
|
||||
if (!/not found|No such file|unknown command/i.test(result.stderr + result.stdout)) {
|
||||
if (rollback) {
|
||||
await rollbackNewContainers(docker, stackName, beforeIds)
|
||||
}
|
||||
throw new Error(result.stderr || result.stdout || `docker compose exited ${result.code}`)
|
||||
}
|
||||
logger.warn('docker compose CLI failed, using dockerode fallback', { stderr: result.stderr })
|
||||
} catch (err) {
|
||||
if (err.message && !/ENOENT|not found|spawn/i.test(err.message)) {
|
||||
// compose ran but failed
|
||||
if (!/ENOENT|spawn docker/i.test(err.message)) {
|
||||
// Already rolled back if CLI returned non-zero above; rethrow deploy errors
|
||||
if (err.message.includes('docker compose exited') || err.message.includes('rolled back')) {
|
||||
throw err
|
||||
}
|
||||
logger.warn('compose CLI deploy error, trying dockerode', { error: err.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deployViaDockerode(docker, parsed, stackName)
|
||||
return deployViaDockerode(docker, parsed, stackName, { rollback, beforeIds })
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dockerode')} docker
|
||||
* @param {string} stackName
|
||||
*/
|
||||
async function listProjectContainerIds(docker, stackName) {
|
||||
const containers = await docker.listContainers({ all: true })
|
||||
return containers
|
||||
.filter((c) => c.Labels?.['com.docker.compose.project'] === stackName)
|
||||
.map((c) => c.Id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove containers created after `beforeIds` for a project (partial deploy cleanup).
|
||||
* @param {import('dockerode')} docker
|
||||
* @param {string} stackName
|
||||
* @param {Set<string>} beforeIds
|
||||
*/
|
||||
async function rollbackNewContainers(docker, stackName, beforeIds) {
|
||||
const ids = await listProjectContainerIds(docker, stackName)
|
||||
const removed = []
|
||||
for (const id of ids) {
|
||||
if (beforeIds.has(id)) continue
|
||||
try {
|
||||
const c = docker.getContainer(id)
|
||||
try {
|
||||
await c.stop({ t: 5 })
|
||||
} catch {
|
||||
// may already be stopped
|
||||
}
|
||||
await c.remove({ force: true })
|
||||
removed.push(id)
|
||||
} catch (err) {
|
||||
logger.warn('rollback remove failed', { id, error: err.message })
|
||||
}
|
||||
}
|
||||
if (removed.length) {
|
||||
logger.info('Rolled back partial stack deploy', { stackName, removed: removed.length })
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dockerode')} docker
|
||||
* @param {ReturnType<typeof parseComposeFile>} parsed
|
||||
* @param {string} stackName
|
||||
* @param {{ rollback?: boolean, beforeIds?: Set<string> }} [opts]
|
||||
*/
|
||||
async function deployViaDockerode(docker, parsed, stackName) {
|
||||
async function deployViaDockerode(docker, parsed, stackName, opts = {}) {
|
||||
const results = []
|
||||
const deployedServices = new Set()
|
||||
/** @type {string[]} containers we created this deploy (for rollback) */
|
||||
const createdIds = []
|
||||
const rollback = opts.rollback !== false
|
||||
|
||||
async function deployService(serviceName) {
|
||||
if (deployedServices.has(serviceName)) return
|
||||
@@ -332,14 +444,45 @@ async function deployViaDockerode(docker, parsed, stackName) {
|
||||
containerConfig.HostConfig = hostConfig
|
||||
|
||||
const container = await docker.createContainer(containerConfig)
|
||||
await container.start()
|
||||
createdIds.push(container.id)
|
||||
try {
|
||||
await container.start()
|
||||
} catch (startErr) {
|
||||
if (rollback) {
|
||||
for (const id of createdIds) {
|
||||
try {
|
||||
await docker.getContainer(id).remove({ force: true })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Partial deploy rolled back after service "${serviceName}" failed to start: ${startErr.message}`
|
||||
)
|
||||
}
|
||||
throw startErr
|
||||
}
|
||||
deployedServices.add(serviceName)
|
||||
results.push({ service: serviceName, status: 'created', containerId: container.id })
|
||||
logger.info(`Deployed service ${serviceName} as container ${containerName}`)
|
||||
}
|
||||
|
||||
for (const serviceName of Object.keys(parsed.services)) {
|
||||
await deployService(serviceName)
|
||||
try {
|
||||
for (const serviceName of Object.keys(parsed.services)) {
|
||||
await deployService(serviceName)
|
||||
}
|
||||
} catch (err) {
|
||||
if (rollback && createdIds.length && !/rolled back/i.test(err.message)) {
|
||||
for (const id of createdIds) {
|
||||
try {
|
||||
await docker.getContainer(id).remove({ force: true })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
throw new Error(`Partial deploy rolled back: ${err.message}`)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -348,6 +491,7 @@ async function deployViaDockerode(docker, parsed, stackName) {
|
||||
method: 'dockerode',
|
||||
services: results,
|
||||
message: `Stack "${stackName}" deployed successfully`,
|
||||
rollback: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user