Complete production roadmap: vault, peers, swarm, streams, ops
CI / test (push) Successful in 9m54s

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:
2026-07-10 20:50:55 -04:00
parent 0bb6ba1692
commit 0ee9b67834
56 changed files with 4430 additions and 376 deletions
+160 -16
View File
@@ -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,
}
}