@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Capability / role ACL for peardock RPC.
|
||||
*
|
||||
* Default: every peer is admin (backward compatible).
|
||||
* Set PEARDOCK_DEFAULT_ROLE=viewer|operator|admin to tighten.
|
||||
* Set PEARDOCK_ADMIN_KEYS=hex,hex to force those peers to admin and others to default.
|
||||
*/
|
||||
import { Roles, roleAllows, MethodRoles } from '../../shared/protocol.js'
|
||||
|
||||
const DEFAULT_ROLE = (process.env.PEARDOCK_DEFAULT_ROLE || Roles.admin).toLowerCase()
|
||||
const ADMIN_KEYS = new Set(
|
||||
(process.env.PEARDOCK_ADMIN_KEYS || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolve role for a peer public key hex.
|
||||
* @param {string} peerIdHex
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resolveRole(peerIdHex) {
|
||||
const id = (peerIdHex || '').toLowerCase()
|
||||
if (ADMIN_KEYS.size > 0) {
|
||||
return ADMIN_KEYS.has(id) ? Roles.admin : DEFAULT_ROLE === Roles.admin ? Roles.operator : DEFAULT_ROLE
|
||||
}
|
||||
if ([Roles.viewer, Roles.operator, Roles.admin].includes(DEFAULT_ROLE)) {
|
||||
return DEFAULT_ROLE
|
||||
}
|
||||
return Roles.admin
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} role
|
||||
* @param {string} method
|
||||
*/
|
||||
export function assertAllowed(role, method) {
|
||||
if (!roleAllows(role, method)) {
|
||||
const need = MethodRoles[method] || Roles.admin
|
||||
const err = new Error(`Permission denied: ${method} requires role "${need}" (have "${role}")`)
|
||||
err.code = 'PERMISSION_DENIED'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export { Roles }
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Append-only local audit log for privileged RPC actions.
|
||||
*
|
||||
* Path: PEARDOCK_AUDIT_LOG (default: ./peardock-audit.log)
|
||||
* Set PEARDOCK_AUDIT=0 to disable.
|
||||
*/
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import logger from '../utils/logger.js'
|
||||
|
||||
const ENABLED = process.env.PEARDOCK_AUDIT !== '0'
|
||||
const LOG_PATH = process.env.PEARDOCK_AUDIT_LOG || path.join(process.cwd(), 'peardock-audit.log')
|
||||
|
||||
/** Methods that should always be audited when successful/attempted. */
|
||||
const AUDIT_METHODS = new Set([
|
||||
'removeContainer',
|
||||
'killContainer',
|
||||
'pruneContainers',
|
||||
'pruneImages',
|
||||
'pruneNetworks',
|
||||
'pruneVolumes',
|
||||
'removeImage',
|
||||
'removeStack',
|
||||
'deployStack',
|
||||
'deployContainer',
|
||||
'buildImage',
|
||||
'registryLogin',
|
||||
'archiveContainerPut',
|
||||
'removeNetwork',
|
||||
'removeVolume',
|
||||
'updateContainer',
|
||||
'duplicateContainer',
|
||||
'commitContainer',
|
||||
])
|
||||
|
||||
/**
|
||||
* @param {object} entry
|
||||
* @param {string} entry.method
|
||||
* @param {string} [entry.peerId]
|
||||
* @param {string} [entry.role]
|
||||
* @param {boolean} [entry.ok]
|
||||
* @param {string} [entry.error]
|
||||
* @param {object} [entry.args]
|
||||
*/
|
||||
export function audit(entry) {
|
||||
if (!ENABLED) return
|
||||
if (!entry?.method) return
|
||||
if (!AUDIT_METHODS.has(entry.method) && entry.force !== true) return
|
||||
|
||||
const line = JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
method: entry.method,
|
||||
peerId: entry.peerId ? String(entry.peerId).slice(0, 16) : null,
|
||||
role: entry.role || null,
|
||||
ok: entry.ok !== false,
|
||||
error: entry.error || null,
|
||||
// Never log secrets; only allowlisted arg keys
|
||||
args: sanitizeArgs(entry.args),
|
||||
})
|
||||
|
||||
try {
|
||||
fs.appendFileSync(LOG_PATH, line + '\n', { encoding: 'utf8' })
|
||||
} catch (err) {
|
||||
logger.warn('audit log write failed', { error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} args
|
||||
*/
|
||||
function sanitizeArgs(args) {
|
||||
if (!args || typeof args !== 'object') return null
|
||||
const out = {}
|
||||
const keys = ['id', 'name', 'stackName', 'image', 'operation', 'path', 'term', 'containerIds']
|
||||
for (const k of keys) {
|
||||
if (args[k] != null) {
|
||||
if (k === 'containerIds' && Array.isArray(args[k])) {
|
||||
out[k] = args[k].slice(0, 20).map((x) => String(x).slice(0, 12))
|
||||
} else {
|
||||
out[k] = String(args[k]).slice(0, 128)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.keys(out).length ? out : null
|
||||
}
|
||||
|
||||
export function shouldAudit(method) {
|
||||
return ENABLED && AUDIT_METHODS.has(method)
|
||||
}
|
||||
|
||||
export { AUDIT_METHODS, LOG_PATH }
|
||||
+174
-11
@@ -36,15 +36,33 @@ export function registerContainerHandlers(session) {
|
||||
})
|
||||
|
||||
session.respond('stopContainer', async (args) => {
|
||||
await docker.getContainer(args.id).stop()
|
||||
const opts = {}
|
||||
if (args.t != null || args.timeout != null) {
|
||||
opts.t = Number(args.t ?? args.timeout)
|
||||
}
|
||||
await docker.getContainer(args.id).stop(opts)
|
||||
return { success: true, message: `Container ${args.id} stopped` }
|
||||
})
|
||||
|
||||
session.respond('restartContainer', async (args) => {
|
||||
await docker.getContainer(args.id).restart()
|
||||
const opts = {}
|
||||
if (args.t != null || args.timeout != null) {
|
||||
opts.t = Number(args.t ?? args.timeout)
|
||||
}
|
||||
await docker.getContainer(args.id).restart(opts)
|
||||
return { success: true, message: `Container ${args.id} restarted` }
|
||||
})
|
||||
|
||||
session.respond('killContainer', async (args) => {
|
||||
const opts = {}
|
||||
if (args.signal) opts.signal = String(args.signal)
|
||||
await docker.getContainer(args.id).kill(opts)
|
||||
return {
|
||||
success: true,
|
||||
message: `Container ${args.id} killed${opts.signal ? ` (${opts.signal})` : ''}`,
|
||||
}
|
||||
})
|
||||
|
||||
session.respond('pauseContainer', async (args) => {
|
||||
await docker.getContainer(args.id).pause()
|
||||
return { success: true, message: `Container ${args.id} paused` }
|
||||
@@ -58,7 +76,11 @@ export function registerContainerHandlers(session) {
|
||||
session.respond('removeContainer', async (args) => {
|
||||
const id = args.id
|
||||
session._cleanupLogsForContainer?.(id)
|
||||
await docker.getContainer(id).remove({ force: true })
|
||||
await docker.getContainer(id).remove({
|
||||
force: args.force !== false,
|
||||
v: Boolean(args.v || args.removeVolumes),
|
||||
link: Boolean(args.link),
|
||||
})
|
||||
return { success: true, message: `Container ${id} removed` }
|
||||
})
|
||||
|
||||
@@ -87,15 +109,153 @@ export function registerContainerHandlers(session) {
|
||||
})
|
||||
|
||||
session.respond('exportContainer', async (args) => {
|
||||
await docker.getContainer(args.id).getArchive({ path: '/' })
|
||||
return { success: true, message: `Container ${args.id} export initiated` }
|
||||
})
|
||||
|
||||
session.respond('updateContainer', async () => {
|
||||
// Full filesystem export is large; acknowledge path — use archiveContainerGet for paths
|
||||
await docker.getContainer(args.id).getArchive({ path: args.path || '/' })
|
||||
return {
|
||||
success: true,
|
||||
message: 'Container update initiated. Note: Some changes require container recreation.',
|
||||
note: 'Most container properties cannot be updated on running containers. Consider recreating the container with new settings.',
|
||||
message: `Container ${args.id} export stream opened (use archiveContainerGet for base64 transfer)`,
|
||||
note: 'Binary export over RPC uses archiveContainerGet with a path and size limit.',
|
||||
}
|
||||
})
|
||||
|
||||
session.respond('updateContainer', async (args) => {
|
||||
const id = args.id
|
||||
if (!id) throw new Error('Container id required')
|
||||
|
||||
// Docker Engine update API: HostConfig subset (CPU/memory/restart/…)
|
||||
const update = {}
|
||||
if (args.Memory != null || args.memory != null) {
|
||||
const mem = args.Memory ?? args.memory
|
||||
update.Memory = typeof mem === 'number' && mem < 1e6 ? mem * 1024 * 1024 : Number(mem)
|
||||
}
|
||||
if (args.MemoryReservation != null) update.MemoryReservation = Number(args.MemoryReservation)
|
||||
if (args.MemorySwap != null) update.MemorySwap = Number(args.MemorySwap)
|
||||
if (args.CpuShares != null) update.CpuShares = Number(args.CpuShares)
|
||||
if (args.NanoCpus != null) update.NanoCpus = Number(args.NanoCpus)
|
||||
if (args.CpuQuota != null) update.CpuQuota = Number(args.CpuQuota)
|
||||
if (args.CpuPeriod != null) update.CpuPeriod = Number(args.CpuPeriod)
|
||||
if (args.CpusetCpus != null) update.CpusetCpus = String(args.CpusetCpus)
|
||||
if (args.CpusetMems != null) update.CpusetMems = String(args.CpusetMems)
|
||||
if (args.BlkioWeight != null) update.BlkioWeight = Number(args.BlkioWeight)
|
||||
if (args.RestartPolicy) {
|
||||
update.RestartPolicy =
|
||||
typeof args.RestartPolicy === 'string'
|
||||
? { Name: args.RestartPolicy }
|
||||
: args.RestartPolicy
|
||||
}
|
||||
if (args.PidsLimit != null) update.PidsLimit = Number(args.PidsLimit)
|
||||
|
||||
// Allow passthrough of raw HostConfig-style keys under args.update
|
||||
if (args.update && typeof args.update === 'object') {
|
||||
Object.assign(update, args.update)
|
||||
}
|
||||
|
||||
if (Object.keys(update).length === 0) {
|
||||
throw new Error(
|
||||
'No update fields provided. Supported: Memory, CpuShares, NanoCpus, RestartPolicy, CpusetCpus, …'
|
||||
)
|
||||
}
|
||||
|
||||
const data = await docker.getContainer(id).update(update)
|
||||
return {
|
||||
success: true,
|
||||
message: `Container ${id} updated`,
|
||||
data,
|
||||
applied: update,
|
||||
}
|
||||
})
|
||||
|
||||
session.respond('containerTop', async (args) => {
|
||||
const opts = {}
|
||||
if (args.ps_args || args.psArgs) opts.ps_args = args.ps_args || args.psArgs
|
||||
const data = await docker.getContainer(args.id).top(opts)
|
||||
return { success: true, type: 'containerTop', id: args.id, data }
|
||||
})
|
||||
|
||||
session.respond('containerStats', async (args) => {
|
||||
const stream = args.stream === true
|
||||
const stats = await docker.getContainer(args.id).stats({ stream: false })
|
||||
// dockerode returns a stream when stream:true; one-shot when false
|
||||
if (stream) {
|
||||
return {
|
||||
success: true,
|
||||
type: 'containerStats',
|
||||
id: args.id,
|
||||
note: 'Live stats are pushed via push:allStats; this is a one-shot snapshot',
|
||||
data: stats,
|
||||
}
|
||||
}
|
||||
return { success: true, type: 'containerStats', id: args.id, data: stats }
|
||||
})
|
||||
|
||||
session.respond('waitContainer', async (args) => {
|
||||
const opts = {}
|
||||
if (args.condition) opts.condition = String(args.condition)
|
||||
const result = await docker.getContainer(args.id).wait(opts)
|
||||
return { success: true, type: 'containerWait', id: args.id, data: result }
|
||||
})
|
||||
|
||||
session.respond('archiveContainerGet', async (args) => {
|
||||
const path = validation.sanitizeString(args.path || '/', 4096)
|
||||
if (!path || path.includes('..')) throw new Error('Invalid path')
|
||||
const maxBytes = Math.min(Number(args.maxBytes) || 5 * 1024 * 1024, 20 * 1024 * 1024)
|
||||
|
||||
const stream = await docker.getContainer(args.id).getArchive({ path })
|
||||
const chunks = []
|
||||
let total = 0
|
||||
await new Promise((resolve, reject) => {
|
||||
stream.on('data', (chunk) => {
|
||||
total += chunk.length
|
||||
if (total > maxBytes) {
|
||||
stream.destroy()
|
||||
reject(new Error(`Archive exceeds maxBytes (${maxBytes})`))
|
||||
return
|
||||
}
|
||||
chunks.push(chunk)
|
||||
})
|
||||
stream.on('end', resolve)
|
||||
stream.on('error', reject)
|
||||
})
|
||||
|
||||
const buf = Buffer.concat(chunks)
|
||||
return {
|
||||
success: true,
|
||||
type: 'containerArchive',
|
||||
id: args.id,
|
||||
path,
|
||||
encoding: 'base64',
|
||||
size: buf.length,
|
||||
data: buf.toString('base64'),
|
||||
}
|
||||
})
|
||||
|
||||
session.respond('archiveContainerPut', async (args) => {
|
||||
const path = validation.sanitizeString(args.path || '/', 4096)
|
||||
if (!path || path.includes('..')) throw new Error('Invalid path')
|
||||
if (!args.data) throw new Error('Archive data (base64) required')
|
||||
|
||||
const buf = Buffer.from(args.data, args.encoding === 'utf8' ? 'utf8' : 'base64')
|
||||
const maxBytes = 20 * 1024 * 1024
|
||||
if (buf.length > maxBytes) throw new Error(`Archive exceeds ${maxBytes} bytes`)
|
||||
|
||||
await docker.getContainer(args.id).putArchive(buf, { path })
|
||||
return {
|
||||
success: true,
|
||||
message: `Archive extracted to ${path} in container ${args.id}`,
|
||||
size: buf.length,
|
||||
}
|
||||
})
|
||||
|
||||
session.respond('pruneContainers', async (args) => {
|
||||
const opts = {}
|
||||
if (args.filters) opts.filters = args.filters
|
||||
const result = await docker.pruneContainers(opts)
|
||||
await broadcastContainers()
|
||||
return {
|
||||
success: true,
|
||||
type: 'pruneContainers',
|
||||
message: 'Unused containers pruned',
|
||||
data: result,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -104,7 +264,7 @@ export function registerContainerHandlers(session) {
|
||||
if (!Array.isArray(containerIds) || containerIds.length === 0) {
|
||||
throw new Error('No containers specified')
|
||||
}
|
||||
if (!['start', 'stop', 'restart', 'pause', 'unpause', 'remove'].includes(operation)) {
|
||||
if (!['start', 'stop', 'restart', 'pause', 'unpause', 'remove', 'kill'].includes(operation)) {
|
||||
throw new Error('Invalid operation')
|
||||
}
|
||||
|
||||
@@ -128,6 +288,9 @@ export function registerContainerHandlers(session) {
|
||||
case 'unpause':
|
||||
await container.unpause()
|
||||
break
|
||||
case 'kill':
|
||||
await container.kill(args.signal ? { signal: args.signal } : {})
|
||||
break
|
||||
case 'remove':
|
||||
await container.remove({ force: true })
|
||||
break
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
*/
|
||||
import { docker } from '../services/docker.js'
|
||||
import * as validation from '../utils/validation.js'
|
||||
import { Pushes } from '../../shared/protocol.js'
|
||||
import { getSessionAuthconfig } from './system.js'
|
||||
import logger from '../utils/logger.js'
|
||||
|
||||
export function registerImageHandlers(session) {
|
||||
session.respond('listImages', async () => {
|
||||
@@ -30,11 +33,50 @@ export function registerImageHandlers(session) {
|
||||
if (!imageName || !validation.isValidImageName(imageName)) {
|
||||
throw new Error('Invalid image name')
|
||||
}
|
||||
const pullStream = await docker.pull(imageName)
|
||||
await new Promise((resolve, reject) => {
|
||||
docker.modem.followProgress(pullStream, (err) => (err ? reject(err) : resolve()))
|
||||
|
||||
const authconfig = getSessionAuthconfig(session)
|
||||
// dockerode: pull(repoTag, opts, callback, auth)
|
||||
const pullStream = await new Promise((resolve, reject) => {
|
||||
const onPull = (err, stream) => (err ? reject(err) : resolve(stream))
|
||||
if (authconfig) {
|
||||
docker.pull(
|
||||
imageName,
|
||||
{},
|
||||
onPull,
|
||||
{
|
||||
username: authconfig.username,
|
||||
password: authconfig.password,
|
||||
serveraddress: authconfig.serveraddress,
|
||||
}
|
||||
)
|
||||
} else {
|
||||
docker.pull(imageName, onPull)
|
||||
}
|
||||
})
|
||||
return { success: true, message: `Image "${imageName}" pulled successfully` }
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
docker.modem.followProgress(
|
||||
pullStream,
|
||||
(err) => (err ? reject(err) : resolve()),
|
||||
(event) => {
|
||||
try {
|
||||
session.push(Pushes.pullProgress, {
|
||||
type: 'pullProgress',
|
||||
image: imageName,
|
||||
status: event.status || null,
|
||||
progress: event.progress || null,
|
||||
progressDetail: event.progressDetail || null,
|
||||
id: event.id || null,
|
||||
error: event.error || null,
|
||||
})
|
||||
} catch (e) {
|
||||
logger.debug('pull progress push failed', { error: e.message })
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return { success: true, message: `Image "${imageName}" pulled successfully`, image: imageName }
|
||||
})
|
||||
|
||||
session.respond('removeImage', async (args) => {
|
||||
@@ -47,6 +89,35 @@ export function registerImageHandlers(session) {
|
||||
return { type: 'imageConfig', data: imageData }
|
||||
})
|
||||
|
||||
session.respond('imageHistory', async (args) => {
|
||||
const history = await docker.getImage(args.id).history()
|
||||
return { success: true, type: 'imageHistory', id: args.id, data: history }
|
||||
})
|
||||
|
||||
session.respond('searchImages', async (args) => {
|
||||
const term = validation.sanitizeString(args.term || args.q || '', 128)
|
||||
if (!term) throw new Error('Search term required')
|
||||
const limit = Math.min(Number(args.limit) || 25, 100)
|
||||
const results = await docker.searchImages({ term, limit })
|
||||
return { success: true, type: 'imageSearch', term, data: results }
|
||||
})
|
||||
|
||||
session.respond('pruneImages', async (args) => {
|
||||
const opts = {}
|
||||
if (args.filters) opts.filters = args.filters
|
||||
// dangling only by default unless force-all
|
||||
if (args.all) {
|
||||
opts.filters = { ...(opts.filters || {}), dangling: { false: true } }
|
||||
}
|
||||
const result = await docker.pruneImages(opts)
|
||||
return {
|
||||
success: true,
|
||||
type: 'pruneImages',
|
||||
message: 'Unused images pruned',
|
||||
data: result,
|
||||
}
|
||||
})
|
||||
|
||||
session.respond('tagImage', async (args) => {
|
||||
const repo = validation.sanitizeString(args.repo, 255)
|
||||
const tag = validation.sanitizeString(args.tag || 'latest', 128)
|
||||
@@ -63,7 +134,6 @@ export function registerImageHandlers(session) {
|
||||
const tarHeader = Buffer.alloc(512)
|
||||
tarHeader.write('Dockerfile', 0)
|
||||
tarHeader.write('100644', 156, 6)
|
||||
// ustar size field is octal ASCII at offset 124 (12 bytes)
|
||||
const sizeOctal = DockerfileBuffer.length.toString(8).padStart(11, '0') + '\0'
|
||||
tarHeader.write(sizeOctal, 124, 12)
|
||||
let checksum = 0
|
||||
@@ -81,6 +151,12 @@ export function registerImageHandlers(session) {
|
||||
])
|
||||
|
||||
const buildOptions = tag ? { dockerfile: 'Dockerfile', t: tag } : { dockerfile: 'Dockerfile' }
|
||||
if (args.nocache) buildOptions.nocache = true
|
||||
if (args.pull) buildOptions.pull = true
|
||||
if (args.buildargs && typeof args.buildargs === 'object') {
|
||||
buildOptions.buildargs = args.buildargs
|
||||
}
|
||||
|
||||
const buildStream = await docker.buildImage(tarData, buildOptions)
|
||||
|
||||
let buildOutput = ''
|
||||
@@ -90,12 +166,26 @@ export function registerImageHandlers(session) {
|
||||
(err, output) => {
|
||||
if (err) reject(err)
|
||||
else {
|
||||
if (output) buildOutput = output.map((o) => o.stream || '').join('')
|
||||
if (output) buildOutput = output.map((o) => o.stream || o.status || '').join('')
|
||||
resolve(output)
|
||||
}
|
||||
},
|
||||
(event) => {
|
||||
if (event.stream) console.log(`[BUILD] ${event.stream.trim()}`)
|
||||
const line = (event.stream || event.status || event.error || '').toString()
|
||||
if (line) {
|
||||
try {
|
||||
session.push(Pushes.buildProgress, {
|
||||
type: 'buildProgress',
|
||||
tag: tag || null,
|
||||
stream: event.stream || null,
|
||||
status: event.status || null,
|
||||
error: event.error || null,
|
||||
aux: event.aux || null,
|
||||
})
|
||||
} catch (e) {
|
||||
logger.debug('build progress push failed', { error: e.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -71,4 +71,16 @@ export function registerNetworkHandlers(session) {
|
||||
.disconnect({ Container: args.containerId, Force: args.force || false })
|
||||
return { success: true, message: 'Container disconnected from network' }
|
||||
})
|
||||
|
||||
session.respond('pruneNetworks', async (args) => {
|
||||
const opts = {}
|
||||
if (args.filters) opts.filters = args.filters
|
||||
const result = await docker.pruneNetworks(opts)
|
||||
return {
|
||||
success: true,
|
||||
type: 'pruneNetworks',
|
||||
message: 'Unused networks pruned',
|
||||
data: result,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ export function registerStackHandlers(session) {
|
||||
throw new Error('Compose content and stack name required')
|
||||
}
|
||||
const sanitizedStackName = validation.sanitizeString(stackName, 63)
|
||||
if (!validation.isValidContainerName(sanitizedStackName)) {
|
||||
throw new Error('Invalid stack name')
|
||||
}
|
||||
// Validate YAML early for clear errors
|
||||
composeManager.validateComposeFile(composeContent)
|
||||
const result = await composeManager.deployComposeStack(
|
||||
docker,
|
||||
composeContent,
|
||||
@@ -32,4 +37,23 @@ export function registerStackHandlers(session) {
|
||||
await broadcastContainers()
|
||||
return { success: true, ...result }
|
||||
})
|
||||
|
||||
session.respond('stackPs', async (args) => {
|
||||
if (!args.stackName) throw new Error('stackName required')
|
||||
return composeManager.stackPs(docker, args.stackName)
|
||||
})
|
||||
|
||||
session.respond('stackLogs', async (args) => {
|
||||
if (!args.stackName) throw new Error('stackName required')
|
||||
return composeManager.stackLogs(docker, args.stackName, {
|
||||
tail: args.tail,
|
||||
})
|
||||
})
|
||||
|
||||
session.respond('stackPull', async (args) => {
|
||||
if (!args.stackName) throw new Error('stackName required')
|
||||
return composeManager.stackPull(docker, args.stackName, {
|
||||
composeContent: args.composeContent,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
+146
-6
@@ -1,14 +1,43 @@
|
||||
/**
|
||||
* System info and host filesystem browse handlers.
|
||||
* System info, health, events, df, browse, registry.
|
||||
*/
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { docker } from '../services/docker.js'
|
||||
import * as validation from '../utils/validation.js'
|
||||
import { PROTOCOL, PROTOCOL_VERSION } from '../../shared/protocol.js'
|
||||
import logger from '../utils/logger.js'
|
||||
|
||||
/** @type {Map<string, { username: string, serveraddress?: string }>} */
|
||||
const registryAuth = new Map()
|
||||
|
||||
export function registerSystemHandlers(session) {
|
||||
session.respond('ping', async () => {
|
||||
return { success: true, pong: Date.now() }
|
||||
let dockerOk = false
|
||||
let apiVersion = null
|
||||
let osType = null
|
||||
let error = null
|
||||
try {
|
||||
const version = await docker.version()
|
||||
dockerOk = true
|
||||
apiVersion = version.ApiVersion || version.apiVersion || null
|
||||
osType = version.Os || version.os || null
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
pong: Date.now(),
|
||||
protocol: PROTOCOL,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
role: session.role,
|
||||
docker: {
|
||||
ok: dockerOk,
|
||||
apiVersion,
|
||||
os: osType,
|
||||
error,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
session.respond('getSystemInfo', async () => {
|
||||
@@ -16,11 +45,104 @@ export function registerSystemHandlers(session) {
|
||||
return { type: 'systemInfo', data: { info, version } }
|
||||
})
|
||||
|
||||
session.respond('getDockerEvents', async () => {
|
||||
session.respond('getSystemDf', async () => {
|
||||
const data = await docker.df()
|
||||
return { type: 'systemDf', data, success: true }
|
||||
})
|
||||
|
||||
session.respond('getDockerEvents', async (args) => {
|
||||
// Snapshot: recent events via stream with timeout (best-effort)
|
||||
const since = args?.since || Math.floor(Date.now() / 1000) - 300
|
||||
const until = args?.until || Math.floor(Date.now() / 1000)
|
||||
const events = []
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
stream?.destroy?.()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
resolve()
|
||||
}, args?.timeoutMs || 1500)
|
||||
|
||||
let stream
|
||||
docker.getEvents({ since, until }, (err, s) => {
|
||||
if (err) {
|
||||
clearTimeout(timer)
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
stream = s
|
||||
stream.on('data', (chunk) => {
|
||||
try {
|
||||
const lines = chunk.toString().split('\n').filter(Boolean)
|
||||
for (const line of lines) {
|
||||
events.push(JSON.parse(line))
|
||||
}
|
||||
} catch {
|
||||
// ignore partial
|
||||
}
|
||||
})
|
||||
stream.on('end', () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
})
|
||||
stream.on('error', (e) => {
|
||||
clearTimeout(timer)
|
||||
reject(e)
|
||||
})
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
logger.debug('getDockerEvents snapshot failed', { error: err.message })
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'dockerEvents',
|
||||
data: [],
|
||||
note: 'Real-time events are already streamed. Historical events require Docker API enhancement.',
|
||||
data: events,
|
||||
success: true,
|
||||
note: 'Live events are also pushed as push:dockerEvent when streaming.',
|
||||
}
|
||||
})
|
||||
|
||||
session.respond('registryLogin', async (args) => {
|
||||
const username = validation.sanitizeString(args.username, 128)
|
||||
const password = args.password
|
||||
const serveraddress = validation.sanitizeString(args.serveraddress || 'https://index.docker.io/v1/', 512)
|
||||
if (!username || !password) throw new Error('username and password required')
|
||||
|
||||
const authconfig = { username, password, serveraddress }
|
||||
// dockerode checkAuth
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
docker.checkAuth(authconfig, (err, response) => {
|
||||
if (err) reject(err)
|
||||
else resolve(response)
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
// Some engines return status in different shapes; still store for pull attempts
|
||||
logger.warn('checkAuth soft-fail, storing credentials', { error: err.message })
|
||||
}
|
||||
|
||||
registryAuth.set(session.id, { username, serveraddress, password })
|
||||
session.state.set('registryAuth', { username, serveraddress, password })
|
||||
return {
|
||||
success: true,
|
||||
message: `Authenticated as ${username}`,
|
||||
data: { username, serveraddress },
|
||||
}
|
||||
})
|
||||
|
||||
session.respond('getAuthStatus', async () => {
|
||||
const auth = session.state.get('registryAuth') || registryAuth.get(session.id)
|
||||
return {
|
||||
success: true,
|
||||
authenticated: Boolean(auth),
|
||||
username: auth?.username || null,
|
||||
serveraddress: auth?.serveraddress || null,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -30,7 +152,17 @@ export function registerSystemHandlers(session) {
|
||||
throw new Error('Invalid directory path')
|
||||
}
|
||||
|
||||
// Optional allowlist: PEARDOCK_BROWSE_ROOTS=/var/lib/docker,/home
|
||||
const roots = (process.env.PEARDOCK_BROWSE_ROOTS || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const safePath = validation.sanitizeDirectoryPath(requestedPath)
|
||||
if (roots.length > 0) {
|
||||
const ok = roots.some((root) => safePath === root || safePath.startsWith(root.endsWith('/') ? root : root + '/'))
|
||||
if (!ok) throw new Error('Path not in allowlisted browse roots')
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(safePath)
|
||||
@@ -62,7 +194,7 @@ export function registerSystemHandlers(session) {
|
||||
permissions: stats.mode.toString(8).slice(-3),
|
||||
})
|
||||
} catch {
|
||||
// skip unreadable entries
|
||||
// skip
|
||||
}
|
||||
}
|
||||
return { success: true, contents, path: safePath }
|
||||
@@ -80,3 +212,11 @@ export function registerSystemHandlers(session) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth config for docker pull/push for this session, if any.
|
||||
* @param {import('../rpc/session.js').PeerSession} session
|
||||
*/
|
||||
export function getSessionAuthconfig(session) {
|
||||
return session.state.get('registryAuth') || registryAuth.get(session.id) || null
|
||||
}
|
||||
|
||||
@@ -56,6 +56,19 @@ export function registerVolumeHandlers(session) {
|
||||
const volumeData = await docker.getVolume(args.name).inspect()
|
||||
return { type: 'volumeConfig', data: volumeData }
|
||||
})
|
||||
|
||||
session.respond('pruneVolumes', async (args) => {
|
||||
const opts = {}
|
||||
if (args.filters) opts.filters = args.filters
|
||||
const result = await docker.pruneVolumes(opts)
|
||||
await broadcastVolumes()
|
||||
return {
|
||||
success: true,
|
||||
type: 'pruneVolumes',
|
||||
message: 'Unused volumes pruned',
|
||||
data: result,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function broadcastVolumes() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Wire all domain handlers onto a PeerSession.
|
||||
*/
|
||||
import { registerHandshake } from './session.js'
|
||||
import { registerContainerHandlers } from '../handlers/containers.js'
|
||||
import { registerImageHandlers } from '../handlers/images.js'
|
||||
import { registerNetworkHandlers } from '../handlers/networks.js'
|
||||
@@ -16,6 +17,7 @@ import { registerSystemHandlers } from '../handlers/system.js'
|
||||
* @param {import('./session.js').PeerSession} session
|
||||
*/
|
||||
export function registerAllHandlers(session) {
|
||||
registerHandshake(session)
|
||||
registerSystemHandlers(session)
|
||||
registerContainerHandlers(session)
|
||||
registerImageHandlers(session)
|
||||
|
||||
+58
-5
@@ -3,14 +3,16 @@
|
||||
*/
|
||||
import ProtomuxRPC from 'protomux-rpc'
|
||||
import b4a from 'b4a'
|
||||
import { PROTOCOL } from '../../shared/protocol.js'
|
||||
import { PROTOCOL, PROTOCOL_VERSION } from '../../shared/protocol.js'
|
||||
import { encodings } from '../../shared/encodings.js'
|
||||
import rateLimiter from '../utils/rateLimiter.js'
|
||||
import logger from '../utils/logger.js'
|
||||
import { resolveRole, assertAllowed } from '../core/acl.js'
|
||||
import { audit, shouldAudit } from '../core/audit.js'
|
||||
|
||||
export class PeerSession {
|
||||
/**
|
||||
* @param {import('stream').Duplex} stream - HyperDHT / secret-stream connection
|
||||
* @param {import('stream').Duplex} stream
|
||||
* @param {object} opts
|
||||
* @param {Uint8Array} opts.serverPublicKey
|
||||
* @param {(session: PeerSession) => void} [opts.onClose]
|
||||
@@ -23,6 +25,8 @@ export class PeerSession {
|
||||
this.remotePublicKey = stream.remotePublicKey
|
||||
this.closed = false
|
||||
this.onClose = onClose
|
||||
this.role = resolveRole(this.id)
|
||||
this.clientInfo = null
|
||||
|
||||
/** @type {Map<string, any>} */
|
||||
this.state = new Map()
|
||||
@@ -42,7 +46,6 @@ export class PeerSession {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an RPC method with rate limiting and error normalization.
|
||||
* @param {string} method
|
||||
* @param {(args: any, session: PeerSession) => Promise<any>|any} handler
|
||||
*/
|
||||
@@ -54,12 +57,36 @@ export class PeerSession {
|
||||
throw err
|
||||
}
|
||||
try {
|
||||
return await handler(args ?? {}, this)
|
||||
assertAllowed(this.role, method)
|
||||
const result = await handler(args ?? {}, this)
|
||||
if (shouldAudit(method)) {
|
||||
audit({
|
||||
method,
|
||||
peerId: this.id,
|
||||
role: this.role,
|
||||
ok: true,
|
||||
args: args ?? {},
|
||||
})
|
||||
}
|
||||
return result
|
||||
} catch (err) {
|
||||
if (shouldAudit(method) || err?.code === 'PERMISSION_DENIED') {
|
||||
audit({
|
||||
method,
|
||||
peerId: this.id,
|
||||
role: this.role,
|
||||
ok: false,
|
||||
error: err.message,
|
||||
args: args ?? {},
|
||||
force: err?.code === 'PERMISSION_DENIED',
|
||||
})
|
||||
}
|
||||
logger.error('RPC handler failed', {
|
||||
method,
|
||||
peerId: this.id.slice(0, 12),
|
||||
role: this.role,
|
||||
error: err.message,
|
||||
code: err.code,
|
||||
})
|
||||
const safe = new Error(sanitizeError(err))
|
||||
safe.code = err.code || 'UNKNOWN_ERROR'
|
||||
@@ -69,7 +96,6 @@ export class PeerSession {
|
||||
}
|
||||
|
||||
/**
|
||||
* Server → client fire-and-forget push.
|
||||
* @param {string} method
|
||||
* @param {unknown} payload
|
||||
*/
|
||||
@@ -100,7 +126,34 @@ export class PeerSession {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register session handshake (role + protocol version).
|
||||
* @param {PeerSession} session
|
||||
*/
|
||||
export function registerHandshake(session) {
|
||||
session.respond('handshake', async (args) => {
|
||||
if (args?.clientName || args?.clientVersion) {
|
||||
session.clientInfo = {
|
||||
name: args.clientName || 'unknown',
|
||||
version: args.clientVersion || null,
|
||||
}
|
||||
}
|
||||
// Optional role claim is ignored; server assigns role from ACL policy
|
||||
return {
|
||||
success: true,
|
||||
protocol: PROTOCOL,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
role: session.role,
|
||||
peerId: session.id,
|
||||
serverTime: Date.now(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function sanitizeError(err) {
|
||||
if (err?.code === 'PERMISSION_DENIED' || err?.code === 'RATE_LIMIT_EXCEEDED') {
|
||||
return err.message
|
||||
}
|
||||
const msg = err?.message || 'Unknown error'
|
||||
if (msg.includes('ENOENT') || msg.includes('EACCES')) {
|
||||
return 'Operation failed. Please check permissions and try again.'
|
||||
|
||||
+102
-19
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Docker event stream → peer broadcasts.
|
||||
* Auto-reconnects when the daemon restarts or the stream ends.
|
||||
*/
|
||||
import { docker, extractVolumesList } from './docker.js'
|
||||
import { peers } from '../core/peer-registry.js'
|
||||
@@ -7,38 +8,86 @@ import { Pushes } from '../../shared/protocol.js'
|
||||
import logger from '../utils/logger.js'
|
||||
|
||||
let dockerEventStream = null
|
||||
let reconnectTimer = null
|
||||
let stopped = false
|
||||
let reconnectAttempt = 0
|
||||
|
||||
const BASE_DELAY_MS = 2000
|
||||
const MAX_DELAY_MS = 30_000
|
||||
|
||||
export async function startDockerEventStream() {
|
||||
stopped = false
|
||||
await openEventStream()
|
||||
}
|
||||
|
||||
async function openEventStream() {
|
||||
if (stopped) return
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await new Promise((resolve, reject) => {
|
||||
docker.getEvents({}, (err, s) => (err ? reject(err) : resolve(s)))
|
||||
})
|
||||
dockerEventStream = stream
|
||||
reconnectAttempt = 0
|
||||
logger.info('Docker event stream connected')
|
||||
|
||||
stream.on('data', async (chunk) => {
|
||||
try {
|
||||
const event = JSON.parse(chunk.toString())
|
||||
if (event.status === 'undefined') return
|
||||
logger.info('Docker event', {
|
||||
status: event.status,
|
||||
id: event.id,
|
||||
type: event.Type,
|
||||
})
|
||||
const lines = chunk
|
||||
.toString()
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (event.Type === 'container') {
|
||||
const containers = await docker.listContainers({ all: true })
|
||||
peers.broadcast(Pushes.containers, { type: 'containers', data: containers })
|
||||
}
|
||||
for (const line of lines) {
|
||||
let event
|
||||
try {
|
||||
event = JSON.parse(line)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (event.status === 'undefined') continue
|
||||
|
||||
if (event.Type === 'volume' && (event.Action === 'create' || event.Action === 'destroy')) {
|
||||
const volumesResult = await docker.listVolumes()
|
||||
const volumesList = extractVolumesList(volumesResult)
|
||||
peers.broadcast(Pushes.volumes, {
|
||||
type: 'volumes',
|
||||
data: volumesList,
|
||||
success: true,
|
||||
volumes: volumesList,
|
||||
logger.info('Docker event', {
|
||||
status: event.status,
|
||||
id: event.id,
|
||||
type: event.Type,
|
||||
action: event.Action,
|
||||
})
|
||||
|
||||
peers.broadcast(Pushes.dockerEvent, {
|
||||
type: 'dockerEvent',
|
||||
data: event,
|
||||
})
|
||||
|
||||
if (event.Type === 'container') {
|
||||
const containers = await docker.listContainers({ all: true })
|
||||
peers.broadcast(Pushes.containers, { type: 'containers', data: containers })
|
||||
}
|
||||
|
||||
if (event.Type === 'image') {
|
||||
try {
|
||||
const images = await docker.listImages({ all: true })
|
||||
peers.broadcast(Pushes.images, { type: 'images', data: images })
|
||||
} catch (e) {
|
||||
logger.debug('image list on event failed', { error: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
if (event.Type === 'volume' && (event.Action === 'create' || event.Action === 'destroy')) {
|
||||
const volumesResult = await docker.listVolumes()
|
||||
const volumesList = extractVolumesList(volumesResult)
|
||||
peers.broadcast(Pushes.volumes, {
|
||||
type: 'volumes',
|
||||
data: volumesList,
|
||||
success: true,
|
||||
volumes: volumesList,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to process Docker event', { error: err.message })
|
||||
@@ -47,16 +96,50 @@ export async function startDockerEventStream() {
|
||||
|
||||
stream.on('error', (err) => {
|
||||
logger.error('Docker event stream error', { error: err.message })
|
||||
scheduleReconnect()
|
||||
})
|
||||
stream.on('end', () => {
|
||||
dockerEventStream = null
|
||||
logger.warn('Docker event stream ended')
|
||||
scheduleReconnect()
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('Failed to start Docker event stream', { error: err.message })
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (stopped) return
|
||||
if (reconnectTimer) return
|
||||
|
||||
if (dockerEventStream) {
|
||||
try {
|
||||
dockerEventStream.destroy()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
dockerEventStream = null
|
||||
}
|
||||
|
||||
const delay = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** reconnectAttempt)
|
||||
reconnectAttempt += 1
|
||||
logger.info('Reconnecting Docker event stream', { delayMs: delay, attempt: reconnectAttempt })
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
openEventStream().catch((err) => {
|
||||
logger.error('Event stream reconnect failed', { error: err.message })
|
||||
scheduleReconnect()
|
||||
})
|
||||
}, delay)
|
||||
}
|
||||
|
||||
export function stopDockerEventStream() {
|
||||
stopped = true
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
if (dockerEventStream) {
|
||||
try {
|
||||
dockerEventStream.destroy()
|
||||
|
||||
+557
-292
@@ -1,333 +1,598 @@
|
||||
// composeManager.js
|
||||
// Utility for managing Docker Compose deployments
|
||||
|
||||
import Docker from 'dockerode';
|
||||
import { spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import logger from './logger.js';
|
||||
/**
|
||||
* Docker Compose helpers: js-yaml parse + docker compose CLI lifecycle.
|
||||
*/
|
||||
import { spawn } from 'child_process'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import yaml from 'js-yaml'
|
||||
import logger from './logger.js'
|
||||
|
||||
/**
|
||||
* Parse docker-compose.yml content and extract service definitions
|
||||
* @param {string} composeContent - YAML content of docker-compose.yml
|
||||
* @returns {Object} Parsed compose structure
|
||||
* Parse docker-compose YAML with js-yaml and normalize service fields.
|
||||
* @param {string} composeContent
|
||||
* @returns {{ version: string|null, services: Record<string, object>, networks: object, volumes: object, raw: object }}
|
||||
*/
|
||||
export function parseComposeFile(composeContent) {
|
||||
// Simple YAML parser for basic compose files
|
||||
// For production, consider using js-yaml library
|
||||
const services = {};
|
||||
const lines = composeContent.split('\n');
|
||||
let currentService = null;
|
||||
let inService = false;
|
||||
let indentLevel = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
|
||||
// Detect services section
|
||||
if (line === 'services:' || line.startsWith('services:')) {
|
||||
inService = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inService) {
|
||||
// Service name (no indentation after services:)
|
||||
if (!line.includes(':') && !line.startsWith('-')) {
|
||||
const serviceName = line.replace(':', '').trim();
|
||||
if (serviceName && !serviceName.includes(' ')) {
|
||||
currentService = serviceName;
|
||||
services[currentService] = {
|
||||
name: currentService,
|
||||
image: null,
|
||||
ports: [],
|
||||
volumes: [],
|
||||
environment: [],
|
||||
networks: [],
|
||||
depends_on: [],
|
||||
restart: 'no',
|
||||
command: null,
|
||||
entrypoint: null,
|
||||
};
|
||||
}
|
||||
} else if (currentService && line.includes(':')) {
|
||||
const [key, ...valueParts] = line.split(':');
|
||||
const keyName = key.trim();
|
||||
const value = valueParts.join(':').trim();
|
||||
|
||||
switch (keyName) {
|
||||
case 'image':
|
||||
services[currentService].image = value;
|
||||
break;
|
||||
case 'restart':
|
||||
services[currentService].restart = value;
|
||||
break;
|
||||
case 'command':
|
||||
services[currentService].command = value.replace(/^["']|["']$/g, '');
|
||||
break;
|
||||
case 'entrypoint':
|
||||
services[currentService].entrypoint = value.replace(/^["']|["']$/g, '');
|
||||
break;
|
||||
}
|
||||
} else if (currentService && (line.startsWith('-') || line.includes(':'))) {
|
||||
// Handle array items
|
||||
if (line.includes('ports:')) {
|
||||
// Next lines will be port mappings
|
||||
let j = i + 1;
|
||||
while (j < lines.length && (lines[j].trim().startsWith('-') || lines[j].trim().startsWith('"'))) {
|
||||
const portLine = lines[j].trim().replace(/^-\s*/, '').replace(/^["']|["']$/g, '');
|
||||
if (portLine && portLine.includes(':')) {
|
||||
services[currentService].ports.push(portLine);
|
||||
}
|
||||
j++;
|
||||
}
|
||||
i = j - 1;
|
||||
} else if (line.includes('volumes:')) {
|
||||
let j = i + 1;
|
||||
while (j < lines.length && (lines[j].trim().startsWith('-') || lines[j].trim().startsWith('"'))) {
|
||||
const volLine = lines[j].trim().replace(/^-\s*/, '').replace(/^["']|["']$/g, '');
|
||||
if (volLine) {
|
||||
services[currentService].volumes.push(volLine);
|
||||
}
|
||||
j++;
|
||||
}
|
||||
i = j - 1;
|
||||
} else if (line.includes('environment:')) {
|
||||
let j = i + 1;
|
||||
while (j < lines.length && (lines[j].trim().startsWith('-') || lines[j].trim().startsWith('"'))) {
|
||||
const envLine = lines[j].trim().replace(/^-\s*/, '').replace(/^["']|["']$/g, '');
|
||||
if (envLine && envLine.includes('=')) {
|
||||
services[currentService].environment.push(envLine);
|
||||
}
|
||||
j++;
|
||||
}
|
||||
i = j - 1;
|
||||
} else if (line.includes('networks:')) {
|
||||
let j = i + 1;
|
||||
while (j < lines.length && (lines[j].trim().startsWith('-') || lines[j].trim().startsWith('"'))) {
|
||||
const netLine = lines[j].trim().replace(/^-\s*/, '').replace(/^["']|["']$/g, '');
|
||||
if (netLine) {
|
||||
services[currentService].networks.push(netLine);
|
||||
}
|
||||
j++;
|
||||
}
|
||||
i = j - 1;
|
||||
} else if (line.includes('depends_on:')) {
|
||||
let j = i + 1;
|
||||
while (j < lines.length && (lines[j].trim().startsWith('-') || lines[j].trim().startsWith('"'))) {
|
||||
const depLine = lines[j].trim().replace(/^-\s*/, '').replace(/^["']|["']$/g, '');
|
||||
if (depLine) {
|
||||
services[currentService].depends_on.push(depLine);
|
||||
}
|
||||
j++;
|
||||
}
|
||||
i = j - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!composeContent || typeof composeContent !== 'string') {
|
||||
throw new Error('Compose content is required')
|
||||
}
|
||||
|
||||
return { services, version: '3' };
|
||||
let raw
|
||||
try {
|
||||
raw = yaml.load(composeContent, { schema: yaml.DEFAULT_SCHEMA })
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid compose YAML: ${err.message}`)
|
||||
}
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
throw new Error('Compose file must be a YAML mapping')
|
||||
}
|
||||
|
||||
const servicesIn = raw.services
|
||||
if (!servicesIn || typeof servicesIn !== 'object' || Array.isArray(servicesIn)) {
|
||||
throw new Error('Compose file must define a "services" mapping')
|
||||
}
|
||||
|
||||
/** @type {Record<string, object>} */
|
||||
const services = {}
|
||||
for (const [name, def] of Object.entries(servicesIn)) {
|
||||
if (!def || typeof def !== 'object') {
|
||||
throw new Error(`Service "${name}" must be a mapping`)
|
||||
}
|
||||
services[name] = normalizeService(name, def)
|
||||
}
|
||||
|
||||
return {
|
||||
version: raw.version != null ? String(raw.version) : null,
|
||||
services,
|
||||
networks: raw.networks && typeof raw.networks === 'object' ? raw.networks : {},
|
||||
volumes: raw.volumes && typeof raw.volumes === 'object' ? raw.volumes : {},
|
||||
raw,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy a Docker Compose stack
|
||||
* @param {Docker} docker - Dockerode instance
|
||||
* @param {string} composeContent - YAML content
|
||||
* @param {string} stackName - Name of the stack
|
||||
* @returns {Promise<Object>} Deployment result
|
||||
* @param {string} name
|
||||
* @param {object} def
|
||||
*/
|
||||
function normalizeService(name, def) {
|
||||
const environment = normalizeEnvironment(def.environment)
|
||||
const ports = normalizeStringList(def.ports)
|
||||
const volumes = normalizeStringList(def.volumes)
|
||||
const networks = normalizeNetworks(def.networks)
|
||||
const depends_on = normalizeDependsOn(def.depends_on)
|
||||
|
||||
let command = def.command ?? null
|
||||
if (Array.isArray(command)) command = command.map(String)
|
||||
else if (command != null) command = String(command)
|
||||
|
||||
let entrypoint = def.entrypoint ?? null
|
||||
if (Array.isArray(entrypoint)) entrypoint = entrypoint.map(String)
|
||||
else if (entrypoint != null) entrypoint = String(entrypoint)
|
||||
|
||||
return {
|
||||
name,
|
||||
image: def.image != null ? String(def.image) : null,
|
||||
build: def.build ?? null,
|
||||
ports,
|
||||
volumes,
|
||||
environment,
|
||||
networks,
|
||||
depends_on,
|
||||
restart: def.restart != null ? String(def.restart) : 'no',
|
||||
command,
|
||||
entrypoint,
|
||||
labels: def.labels && typeof def.labels === 'object' ? def.labels : {},
|
||||
working_dir: def.working_dir || def.workingDir || null,
|
||||
user: def.user != null ? String(def.user) : null,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEnvironment(env) {
|
||||
if (!env) return []
|
||||
if (Array.isArray(env)) return env.map(String)
|
||||
if (typeof env === 'object') {
|
||||
return Object.entries(env).map(([k, v]) => (v === null || v === undefined ? k : `${k}=${v}`))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function normalizeStringList(value) {
|
||||
if (!value) return []
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => {
|
||||
if (typeof item === 'string') return item
|
||||
if (item && typeof item === 'object') {
|
||||
// long syntax: { target, published, ... } or volume objects
|
||||
if (item.published != null && item.target != null) {
|
||||
return `${item.published}:${item.target}${item.protocol ? '/' + item.protocol : ''}`
|
||||
}
|
||||
if (item.source != null && item.target != null) {
|
||||
return `${item.source}:${item.target}${item.read_only ? ':ro' : ''}`
|
||||
}
|
||||
return JSON.stringify(item)
|
||||
}
|
||||
return String(item)
|
||||
})
|
||||
}
|
||||
return [String(value)]
|
||||
}
|
||||
|
||||
function normalizeNetworks(networks) {
|
||||
if (!networks) return []
|
||||
if (Array.isArray(networks)) return networks.map(String)
|
||||
if (typeof networks === 'object') return Object.keys(networks)
|
||||
return []
|
||||
}
|
||||
|
||||
function normalizeDependsOn(depends) {
|
||||
if (!depends) return []
|
||||
if (Array.isArray(depends)) return depends.map(String)
|
||||
if (typeof depends === 'object') return Object.keys(depends)
|
||||
return [String(depends)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate compose content; throws on invalid YAML / missing services.
|
||||
* @param {string} composeContent
|
||||
*/
|
||||
export function validateComposeFile(composeContent) {
|
||||
const parsed = parseComposeFile(composeContent)
|
||||
const names = Object.keys(parsed.services)
|
||||
if (names.length === 0) throw new Error('Compose file has no services')
|
||||
for (const [name, svc] of Object.entries(parsed.services)) {
|
||||
if (!svc.image && !svc.build) {
|
||||
throw new Error(`Service "${name}" needs an image or build`)
|
||||
}
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @returns {Promise<{ code: number, stdout: string, stderr: string }>}
|
||||
*/
|
||||
export function runComposeCli(args, { composeContent, projectName, timeoutMs = 120000 }) {
|
||||
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 child = spawn('docker', fullArgs, {
|
||||
env: process.env,
|
||||
cwd: dir,
|
||||
})
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGTERM')
|
||||
reject(new Error(`docker compose timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout?.on('data', (d) => {
|
||||
stdout += d.toString()
|
||||
})
|
||||
child.stderr?.on('data', (d) => {
|
||||
stderr += d.toString()
|
||||
})
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer)
|
||||
cleanup(dir)
|
||||
reject(err)
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer)
|
||||
cleanup(dir)
|
||||
resolve({ code: code ?? 1, stdout, stderr })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Run compose against an existing project name (no YAML write) when possible.
|
||||
* Uses label-based discovery; for CLI, still needs a file — callers pass content when available.
|
||||
*/
|
||||
function cleanup(dir) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy via `docker compose up -d` when CLI is available; else dockerode fallback.
|
||||
* @param {import('dockerode')} docker
|
||||
* @param {string} composeContent
|
||||
* @param {string} stackName
|
||||
*/
|
||||
export async function deployComposeStack(docker, composeContent, stackName) {
|
||||
const parsed = validateComposeFile(composeContent)
|
||||
|
||||
// Prefer official Compose V2 CLI
|
||||
try {
|
||||
const parsed = parseComposeFile(composeContent);
|
||||
const results = [];
|
||||
const createdContainers = [];
|
||||
|
||||
// Deploy services in dependency order
|
||||
const servicesToDeploy = Object.keys(parsed.services);
|
||||
const deployedServices = new Set();
|
||||
|
||||
async function deployService(serviceName) {
|
||||
if (deployedServices.has(serviceName)) {
|
||||
return;
|
||||
const result = await runComposeCli(['up', '-d', '--remove-orphans'], {
|
||||
composeContent,
|
||||
projectName: stackName,
|
||||
timeoutMs: 300000,
|
||||
})
|
||||
if (result.code === 0) {
|
||||
return {
|
||||
success: true,
|
||||
stackName,
|
||||
method: 'compose-cli',
|
||||
services: Object.keys(parsed.services).map((s) => ({ service: s, status: 'up' })),
|
||||
message: `Stack "${stackName}" deployed successfully`,
|
||||
stdout: result.stdout,
|
||||
}
|
||||
|
||||
const service = parsed.services[serviceName];
|
||||
|
||||
// Deploy dependencies first
|
||||
for (const dep of service.depends_on || []) {
|
||||
if (parsed.services[dep] && !deployedServices.has(dep)) {
|
||||
await deployService(dep);
|
||||
}
|
||||
}
|
||||
|
||||
// Deploy the service
|
||||
const containerName = `${stackName}_${serviceName}`;
|
||||
|
||||
// Check if container already exists
|
||||
const existingContainers = await docker.listContainers({ all: true });
|
||||
const existing = existingContainers.find(c =>
|
||||
c.Names.some(n => n.includes(containerName))
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
logger.info(`Container ${containerName} already exists, skipping`);
|
||||
deployedServices.add(serviceName);
|
||||
results.push({ service: serviceName, status: 'exists', containerId: existing.Id });
|
||||
return;
|
||||
}
|
||||
|
||||
// Build container config
|
||||
const containerConfig = {
|
||||
name: containerName,
|
||||
Image: service.image,
|
||||
Labels: {
|
||||
'com.docker.compose.project': stackName,
|
||||
'com.docker.compose.service': serviceName,
|
||||
},
|
||||
};
|
||||
|
||||
if (service.command) {
|
||||
containerConfig.Cmd = service.command.split(' ');
|
||||
}
|
||||
|
||||
if (service.entrypoint) {
|
||||
containerConfig.Entrypoint = service.entrypoint.split(' ');
|
||||
}
|
||||
|
||||
if (service.environment && service.environment.length > 0) {
|
||||
containerConfig.Env = service.environment;
|
||||
}
|
||||
|
||||
const hostConfig = {
|
||||
RestartPolicy: { Name: service.restart || 'no' },
|
||||
};
|
||||
|
||||
if (service.ports && service.ports.length > 0) {
|
||||
hostConfig.PortBindings = {};
|
||||
service.ports.forEach(portStr => {
|
||||
if (portStr.includes(':')) {
|
||||
const [hostPort, containerPort] = portStr.split(':');
|
||||
const [port, protocol] = containerPort.split('/');
|
||||
hostConfig.PortBindings[`${port}/${protocol || 'tcp'}`] = [{ HostPort: hostPort }];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (service.volumes && service.volumes.length > 0) {
|
||||
hostConfig.Binds = service.volumes;
|
||||
}
|
||||
|
||||
containerConfig.HostConfig = hostConfig;
|
||||
|
||||
// Create and start container
|
||||
const container = await docker.createContainer(containerConfig);
|
||||
await container.start();
|
||||
createdContainers.push(container.id);
|
||||
|
||||
deployedServices.add(serviceName);
|
||||
results.push({ service: serviceName, status: 'created', containerId: container.id });
|
||||
|
||||
logger.info(`Deployed service ${serviceName} as container ${containerName}`);
|
||||
}
|
||||
|
||||
// Deploy all services
|
||||
for (const serviceName of servicesToDeploy) {
|
||||
await deployService(serviceName);
|
||||
// Fall through only if compose binary missing-style errors
|
||||
if (!/not found|No such file|unknown command/i.test(result.stderr + result.stdout)) {
|
||||
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)) {
|
||||
logger.warn('compose CLI deploy error, trying dockerode', { error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stackName,
|
||||
services: results,
|
||||
message: `Stack "${stackName}" deployed successfully`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to deploy compose stack', { error: error.message, stackName });
|
||||
throw error;
|
||||
}
|
||||
|
||||
return deployViaDockerode(docker, parsed, stackName)
|
||||
}
|
||||
|
||||
/**
|
||||
* List all running stacks
|
||||
* @param {Docker} docker - Dockerode instance
|
||||
* @returns {Promise<Array>} List of stacks
|
||||
* @param {import('dockerode')} docker
|
||||
* @param {ReturnType<typeof parseComposeFile>} parsed
|
||||
* @param {string} stackName
|
||||
*/
|
||||
async function deployViaDockerode(docker, parsed, stackName) {
|
||||
const results = []
|
||||
const deployedServices = new Set()
|
||||
|
||||
async function deployService(serviceName) {
|
||||
if (deployedServices.has(serviceName)) return
|
||||
const service = parsed.services[serviceName]
|
||||
if (!service) throw new Error(`Unknown service dependency: ${serviceName}`)
|
||||
|
||||
for (const dep of service.depends_on || []) {
|
||||
if (parsed.services[dep] && !deployedServices.has(dep)) {
|
||||
await deployService(dep)
|
||||
}
|
||||
}
|
||||
|
||||
const containerName = `${stackName}_${serviceName}`
|
||||
const existingContainers = await docker.listContainers({ all: true })
|
||||
const existing = existingContainers.find((c) => c.Names?.some((n) => n.includes(containerName)))
|
||||
|
||||
if (existing) {
|
||||
logger.info(`Container ${containerName} already exists, skipping`)
|
||||
deployedServices.add(serviceName)
|
||||
results.push({ service: serviceName, status: 'exists', containerId: existing.Id })
|
||||
return
|
||||
}
|
||||
|
||||
if (!service.image) {
|
||||
throw new Error(
|
||||
`Service "${serviceName}" has no image (build-only services require docker compose CLI)`
|
||||
)
|
||||
}
|
||||
|
||||
const containerConfig = {
|
||||
name: containerName,
|
||||
Image: service.image,
|
||||
Labels: {
|
||||
'com.docker.compose.project': stackName,
|
||||
'com.docker.compose.service': serviceName,
|
||||
...(flattenLabels(service.labels) || {}),
|
||||
},
|
||||
}
|
||||
|
||||
if (service.command) {
|
||||
containerConfig.Cmd = Array.isArray(service.command)
|
||||
? service.command
|
||||
: String(service.command).split(/\s+/)
|
||||
}
|
||||
if (service.entrypoint) {
|
||||
containerConfig.Entrypoint = Array.isArray(service.entrypoint)
|
||||
? service.entrypoint
|
||||
: String(service.entrypoint).split(/\s+/)
|
||||
}
|
||||
if (service.environment?.length) containerConfig.Env = service.environment
|
||||
if (service.working_dir) containerConfig.WorkingDir = service.working_dir
|
||||
if (service.user) containerConfig.User = service.user
|
||||
|
||||
const hostConfig = {
|
||||
RestartPolicy: { Name: service.restart || 'no' },
|
||||
}
|
||||
|
||||
if (service.ports?.length) {
|
||||
hostConfig.PortBindings = {}
|
||||
containerConfig.ExposedPorts = {}
|
||||
for (const portStr of service.ports) {
|
||||
const mapping = parsePortMapping(portStr)
|
||||
if (!mapping) continue
|
||||
const key = `${mapping.containerPort}/${mapping.protocol}`
|
||||
containerConfig.ExposedPorts[key] = {}
|
||||
hostConfig.PortBindings[key] = [{ HostPort: mapping.hostPort || '' }]
|
||||
}
|
||||
}
|
||||
|
||||
if (service.volumes?.length) {
|
||||
hostConfig.Binds = service.volumes.filter((v) => typeof v === 'string' && v.includes(':'))
|
||||
}
|
||||
|
||||
containerConfig.HostConfig = hostConfig
|
||||
|
||||
const container = await docker.createContainer(containerConfig)
|
||||
await container.start()
|
||||
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)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stackName,
|
||||
method: 'dockerode',
|
||||
services: results,
|
||||
message: `Stack "${stackName}" deployed successfully`,
|
||||
}
|
||||
}
|
||||
|
||||
function flattenLabels(labels) {
|
||||
if (!labels || typeof labels !== 'object') return null
|
||||
if (Array.isArray(labels)) {
|
||||
const out = {}
|
||||
for (const item of labels) {
|
||||
const s = String(item)
|
||||
const i = s.indexOf('=')
|
||||
if (i > 0) out[s.slice(0, i)] = s.slice(i + 1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
const out = {}
|
||||
for (const [k, v] of Object.entries(labels)) out[k] = String(v)
|
||||
return out
|
||||
}
|
||||
|
||||
function parsePortMapping(portStr) {
|
||||
// "8080:80/tcp", "80", "127.0.0.1:8080:80"
|
||||
const s = String(portStr).replace(/^["']|["']$/g, '')
|
||||
const protocolMatch = s.match(/\/(tcp|udp)$/i)
|
||||
const protocol = protocolMatch ? protocolMatch[1].toLowerCase() : 'tcp'
|
||||
const withoutProto = protocolMatch ? s.slice(0, -protocolMatch[0].length) : s
|
||||
const parts = withoutProto.split(':')
|
||||
if (parts.length === 1) {
|
||||
return { hostPort: '', containerPort: parts[0], protocol }
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return { hostPort: parts[0], containerPort: parts[1], protocol }
|
||||
}
|
||||
if (parts.length === 3) {
|
||||
return { hostPort: parts[1], containerPort: parts[2], protocol }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('dockerode')} docker
|
||||
*/
|
||||
export async function listStacks(docker) {
|
||||
try {
|
||||
const containers = await docker.listContainers({ all: true });
|
||||
const stacks = {};
|
||||
const containers = await docker.listContainers({ all: true })
|
||||
const stacks = {}
|
||||
|
||||
containers.forEach(container => {
|
||||
const labels = container.Labels || {};
|
||||
const project = labels['com.docker.compose.project'];
|
||||
const service = labels['com.docker.compose.service'];
|
||||
for (const container of containers) {
|
||||
const labels = container.Labels || {}
|
||||
const project = labels['com.docker.compose.project']
|
||||
const service = labels['com.docker.compose.service']
|
||||
if (!project) continue
|
||||
|
||||
if (project) {
|
||||
if (!stacks[project]) {
|
||||
stacks[project] = {
|
||||
name: project,
|
||||
services: [],
|
||||
containers: [],
|
||||
};
|
||||
if (!stacks[project]) {
|
||||
stacks[project] = {
|
||||
name: project,
|
||||
services: [],
|
||||
containers: [],
|
||||
}
|
||||
|
||||
stacks[project].services.push(service || 'unknown');
|
||||
stacks[project].containers.push({
|
||||
id: container.Id,
|
||||
name: container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12),
|
||||
state: container.State,
|
||||
image: container.Image,
|
||||
});
|
||||
}
|
||||
});
|
||||
stacks[project].services.push(service || 'unknown')
|
||||
stacks[project].containers.push({
|
||||
id: container.Id,
|
||||
name: container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12),
|
||||
state: container.State,
|
||||
status: container.Status,
|
||||
image: container.Image,
|
||||
service: service || 'unknown',
|
||||
})
|
||||
}
|
||||
|
||||
return Object.values(stacks);
|
||||
return Object.values(stacks)
|
||||
} catch (error) {
|
||||
logger.error('Failed to list stacks', { error: error.message });
|
||||
throw error;
|
||||
logger.error('Failed to list stacks', { error: error.message })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a Docker Compose stack
|
||||
* @param {Docker} docker - Dockerode instance
|
||||
* @param {string} stackName - Name of the stack
|
||||
* @returns {Promise<Object>} Removal result
|
||||
* @param {import('dockerode')} docker
|
||||
* @param {string} stackName
|
||||
*/
|
||||
export async function removeComposeStack(docker, stackName) {
|
||||
try {
|
||||
const containers = await docker.listContainers({ all: true });
|
||||
const stackContainers = containers.filter(c => {
|
||||
const labels = c.Labels || {};
|
||||
return labels['com.docker.compose.project'] === stackName;
|
||||
});
|
||||
|
||||
const results = [];
|
||||
for (const containerInfo of stackContainers) {
|
||||
try {
|
||||
const container = docker.getContainer(containerInfo.Id);
|
||||
if (containerInfo.State === 'running') {
|
||||
await container.stop();
|
||||
}
|
||||
await container.remove({ force: true });
|
||||
results.push({ id: containerInfo.Id, success: true });
|
||||
} catch (error) {
|
||||
results.push({ id: containerInfo.Id, success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
// Try compose down if we can find any container with project label (CLI needs a file — use stub)
|
||||
const containers = await docker.listContainers({ all: true })
|
||||
const stackContainers = containers.filter((c) => {
|
||||
const labels = c.Labels || {}
|
||||
return labels['com.docker.compose.project'] === stackName
|
||||
})
|
||||
|
||||
if (stackContainers.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
stackName,
|
||||
removed: results.length,
|
||||
results,
|
||||
message: `Stack "${stackName}" removed successfully`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove compose stack', { error: error.message, stackName });
|
||||
throw error;
|
||||
removed: 0,
|
||||
results: [],
|
||||
message: `Stack "${stackName}" not found or already removed`,
|
||||
}
|
||||
}
|
||||
|
||||
const results = []
|
||||
for (const containerInfo of stackContainers) {
|
||||
try {
|
||||
const container = docker.getContainer(containerInfo.Id)
|
||||
if (containerInfo.State === 'running') {
|
||||
await container.stop({ t: 10 })
|
||||
}
|
||||
await container.remove({ force: true })
|
||||
results.push({ id: containerInfo.Id, success: true })
|
||||
} catch (error) {
|
||||
results.push({ id: containerInfo.Id, success: false, error: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stackName,
|
||||
removed: results.filter((r) => r.success).length,
|
||||
results,
|
||||
message: `Stack "${stackName}" removed successfully`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List containers for a compose project (ps).
|
||||
* @param {import('dockerode')} docker
|
||||
* @param {string} stackName
|
||||
*/
|
||||
export async function stackPs(docker, stackName) {
|
||||
const stacks = await listStacks(docker)
|
||||
const stack = stacks.find((s) => s.name === stackName)
|
||||
if (!stack) {
|
||||
return { success: true, stackName, containers: [], message: 'Stack not found' }
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
type: 'stackPs',
|
||||
stackName,
|
||||
containers: stack.containers,
|
||||
services: stack.services,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect recent logs from all containers in a stack.
|
||||
* @param {import('dockerode')} docker
|
||||
* @param {string} stackName
|
||||
* @param {{ tail?: number }} [opts]
|
||||
*/
|
||||
export async function stackLogs(docker, stackName, opts = {}) {
|
||||
const tail = Math.min(Number(opts.tail) || 100, 2000)
|
||||
const stacks = await listStacks(docker)
|
||||
const stack = stacks.find((s) => s.name === stackName)
|
||||
if (!stack) throw new Error(`Stack "${stackName}" not found`)
|
||||
|
||||
const logs = []
|
||||
for (const c of stack.containers) {
|
||||
try {
|
||||
const buf = await docker.getContainer(c.id).logs({
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
tail,
|
||||
timestamps: true,
|
||||
})
|
||||
const text = Buffer.isBuffer(buf) ? demuxDockerLogs(buf) : String(buf)
|
||||
logs.push({
|
||||
containerId: c.id,
|
||||
name: c.name,
|
||||
service: c.service,
|
||||
logs: text,
|
||||
})
|
||||
} catch (err) {
|
||||
logs.push({
|
||||
containerId: c.id,
|
||||
name: c.name,
|
||||
service: c.service,
|
||||
error: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, type: 'stackLogs', stackName, data: logs }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull images for services in a stack (by listing containers' images + compose not required).
|
||||
* @param {import('dockerode')} docker
|
||||
* @param {string} stackName
|
||||
* @param {{ composeContent?: string }} [opts]
|
||||
*/
|
||||
export async function stackPull(docker, stackName, opts = {}) {
|
||||
const images = new Set()
|
||||
|
||||
if (opts.composeContent) {
|
||||
const parsed = parseComposeFile(opts.composeContent)
|
||||
for (const svc of Object.values(parsed.services)) {
|
||||
if (svc.image) images.add(svc.image)
|
||||
}
|
||||
} else {
|
||||
const stacks = await listStacks(docker)
|
||||
const stack = stacks.find((s) => s.name === stackName)
|
||||
if (!stack) throw new Error(`Stack "${stackName}" not found`)
|
||||
for (const c of stack.containers) {
|
||||
if (c.image) images.add(c.image)
|
||||
}
|
||||
}
|
||||
|
||||
const results = []
|
||||
for (const image of images) {
|
||||
try {
|
||||
const stream = await docker.pull(image)
|
||||
await new Promise((resolve, reject) => {
|
||||
docker.modem.followProgress(stream, (err) => (err ? reject(err) : resolve()))
|
||||
})
|
||||
results.push({ image, success: true })
|
||||
} catch (err) {
|
||||
results.push({ image, success: false, error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: results.every((r) => r.success),
|
||||
type: 'stackPull',
|
||||
stackName,
|
||||
results,
|
||||
message: `Pulled ${results.filter((r) => r.success).length}/${results.length} images for "${stackName}"`,
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip docker multiplex headers from log buffers when possible. */
|
||||
function demuxDockerLogs(buffer) {
|
||||
// Heuristic: if looks like muxed frames, strip 8-byte headers
|
||||
try {
|
||||
const chunks = []
|
||||
let offset = 0
|
||||
while (offset + 8 <= buffer.length) {
|
||||
const size = buffer.readUInt32BE(offset + 4)
|
||||
if (size < 0 || offset + 8 + size > buffer.length) {
|
||||
return buffer.toString('utf8')
|
||||
}
|
||||
chunks.push(buffer.subarray(offset + 8, offset + 8 + size).toString('utf8'))
|
||||
offset += 8 + size
|
||||
}
|
||||
if (chunks.length) return chunks.join('')
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return buffer.toString('utf8')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user