@dashevo/docker-compose
Version:
Manage docker-compose from Node.js
605 lines (527 loc) • 14.6 kB
text/typescript
import childProcess from 'child_process'
import yaml from 'yaml'
import mapPorts from './map-ports'
export interface IDockerComposeOptions {
cwd?: string
executablePath?: string
config?: string | string[]
configAsString?: string
log?: boolean
composeOptions?: string[] | (string | string[])[]
commandOptions?: string[] | (string | string[])[]
env?: NodeJS.ProcessEnv
callback?: (chunk: Buffer, streamSource?: 'stdout' | 'stderr') => void
}
export type DockerComposePortResult = {
address: string
port: number
}
export type DockerComposeVersionResult = {
version: string
}
export type DockerComposeConfigResult = {
config: {
version: Record<string, string>
services: Record<string, string | Record<string, string>>
volumes: Record<string, string>
}
}
export type DockerComposeConfigServicesResult = {
services: string[]
}
export type DockerComposeConfigVolumesResult = {
volumes: string[]
}
export interface IDockerComposeLogOptions extends IDockerComposeOptions {
follow?: boolean
}
export interface IDockerComposeBuildOptions extends IDockerComposeOptions {
parallel?: boolean
}
export interface IDockerComposePushOptions extends IDockerComposeOptions {
ignorePushFailures?: boolean
}
export interface IDockerComposeResult {
exitCode: number | null
out: string
err: string
}
export type TypedDockerComposeResult<T> = {
exitCode: number | null
out: string
err: string
data: T
}
const nonEmptyString = (v: string) => v !== ''
export type DockerComposePsResult = {
services?: Array<{
name: string
command: string
status: string
ports: Array<{
mapped?: { address: string; port: number }
exposed: { port: number; protocol: string }
}>
}>
json?: any
}
export const mapPsOutput = (
output: string,
options?: IDockerComposeOptions
): DockerComposePsResult => {
let isQuiet = false
let isJson = false
if (options?.commandOptions) {
isQuiet =
options.commandOptions.includes('-q') ||
options.commandOptions.includes('--quiet') ||
options.commandOptions.includes('--services')
isJson =
options.commandOptions.includes('--format') &&
options.commandOptions.includes('json')
}
const lines = output.split(`\n`).filter(nonEmptyString)
// Handle format json
if (isJson) {
// handle previous json output docker-compose less than v2.21.0
if (lines.length === 1) {
const json = JSON.parse(output)
if (Array.isArray(json)) {
return { json }
}
}
// handle new json format
const json = lines.map((line) => JSON.parse(line))
return { json }
}
if (!isQuiet) {
// remove docker output column titles
lines.shift()
}
const services = lines.map((line) => {
let nameFragment = line
let imageFragment = ''
let commandFragment = ''
let serviceFragment = ''
let createdAtFragment = ''
let statusFragment = ''
let untypedPortsFragment = ''
if (!isQuiet) {
;[
nameFragment,
imageFragment,
commandFragment,
serviceFragment,
createdAtFragment,
statusFragment,
untypedPortsFragment
] = line.split(/\s{3,}/)
}
return {
name: nameFragment.trim(),
image: imageFragment.trim(),
createdAt: createdAtFragment.trim(),
status: statusFragment.trim(),
command: commandFragment.trim(),
service: serviceFragment.trim(),
ports: mapPorts(untypedPortsFragment.trim())
}
})
return { services }
}
/**
* Converts supplied yml files to cli arguments
* https://docs.docker.com/compose/reference/overview/#use--f-to-specify-name-and-path-of-one-or-more-compose-files
*/
const configToArgs = (config): string[] => {
if (typeof config === 'undefined') {
return []
} else if (typeof config === 'string') {
return ['-f', config]
} else if (config instanceof Array) {
return config.reduce(
(args, item): string[] => args.concat(['-f', item]),
[]
)
}
throw new Error(`Invalid argument supplied: ${config}`)
}
/**
* Converts docker-compose commandline options to cli arguments
*/
const composeOptionsToArgs = (composeOptions): string[] => {
let composeArgs: string[] = []
composeOptions.forEach((option: string[] | string): void => {
if (option instanceof Array) {
composeArgs = composeArgs.concat(option)
}
if (typeof option === 'string') {
composeArgs = composeArgs.concat([option])
}
})
return composeArgs
}
/**
* Executes docker-compose command with common options
*/
export const execCompose = (
command,
args,
options: IDockerComposeOptions = {}
): Promise<IDockerComposeResult> =>
new Promise((resolve, reject): void => {
const composeOptions = options.composeOptions || []
const commandOptions = options.commandOptions || []
let composeArgs = composeOptionsToArgs(composeOptions)
const isConfigProvidedAsString = !!options.configAsString
const configArgs = isConfigProvidedAsString
? ['-f', '-']
: configToArgs(options.config)
composeArgs = composeArgs.concat(
configArgs.concat(
[command].concat(composeOptionsToArgs(commandOptions), args)
)
)
const cwd = options.cwd
const env = options.env || undefined
const executablePath = options.executablePath || 'docker'
const childProc = childProcess.spawn(
executablePath,
['compose', ...composeArgs],
{
cwd,
env
}
)
childProc.on('error', (err): void => {
reject(err)
})
const result: IDockerComposeResult = {
exitCode: null,
err: '',
out: ''
}
childProc.stdout.on('data', (chunk): void => {
result.out += chunk.toString()
options.callback?.(chunk, 'stdout')
})
childProc.stderr.on('data', (chunk): void => {
result.err += chunk.toString()
options.callback?.(chunk, 'stderr')
})
childProc.on('exit', (exitCode): void => {
result.exitCode = exitCode
if (exitCode === 0) {
resolve(result)
} else {
reject(result)
}
})
if (isConfigProvidedAsString) {
childProc.stdin.write(options.configAsString)
childProc.stdin.end()
}
if (options.log) {
childProc.stdout.pipe(process.stdout)
childProc.stderr.pipe(process.stderr)
}
})
/**
* Determines whether or not to use the default non-interactive flag -d for up commands
*/
const shouldUseDefaultNonInteractiveFlag = function (
options: IDockerComposeOptions = {}
): boolean {
const commandOptions = options.commandOptions || []
const containsOtherNonInteractiveFlag = commandOptions.reduce(
(memo: boolean, item: string | string[]) => {
return (
memo &&
!item.includes('--abort-on-container-exit') &&
!item.includes('--no-start')
)
},
true
)
return containsOtherNonInteractiveFlag
}
export const upAll = function (
options?: IDockerComposeOptions
): Promise<IDockerComposeResult> {
const args = shouldUseDefaultNonInteractiveFlag(options) ? ['-d'] : []
return execCompose('up', args, options)
}
export const upMany = function (
services: string[],
options?: IDockerComposeOptions
): Promise<IDockerComposeResult> {
const args = shouldUseDefaultNonInteractiveFlag(options)
? ['-d'].concat(services)
: services
return execCompose('up', args, options)
}
export const upOne = function (
service: string,
options?: IDockerComposeOptions
): Promise<IDockerComposeResult> {
const args = shouldUseDefaultNonInteractiveFlag(options)
? ['-d', service]
: [service]
return execCompose('up', args, options)
}
export const down = function (
options?: IDockerComposeOptions
): Promise<IDockerComposeResult> {
return execCompose('down', [], options)
}
export const stop = function (
options?: IDockerComposeOptions
): Promise<IDockerComposeResult> {
return execCompose('stop', [], options)
}
export const stopOne = function (
service: string,
options?: IDockerComposeOptions
): Promise<IDockerComposeResult> {
return execCompose('stop', [service], options)
}
export const stopMany = function (
options?: IDockerComposeOptions,
...services: string[]
): Promise<IDockerComposeResult> {
return execCompose('stop', [...services], options)
}
export const pauseOne = function (
service: string,
options?: IDockerComposeOptions
): Promise<IDockerComposeResult> {
return execCompose('pause', [service], options)
}
export const unpauseOne = function (
service: string,
options?: IDockerComposeOptions
): Promise<IDockerComposeResult> {
return execCompose('unpause', [service], options)
}
export const kill = function (
options?: IDockerComposeOptions
): Promise<IDockerComposeResult> {
return execCompose('kill', [], options)
}
export const rm = function (
options?: IDockerComposeOptions,
...services: string[]
): Promise<IDockerComposeResult> {
return execCompose('rm', ['-f', ...services], options)
}
export const exec = function (
container: string,
command: string | string[],
options?: IDockerComposeOptions
): Promise<IDockerComposeResult> {
const args = Array.isArray(command) ? command : command.split(/\s+/)
return execCompose('exec', ['-T', container].concat(args), options)
}
export const run = function (
container: string,
command: string | string[],
options?: IDockerComposeOptions
): Promise<IDockerComposeResult> {
const args = Array.isArray(command) ? command : command.split(/\s+/)
return execCompose('run', ['-T', container].concat(args), options)
}
export const buildAll = function (
options: IDockerComposeBuildOptions = {}
): Promise<IDockerComposeResult> {
return execCompose('build', options.parallel ? ['--parallel'] : [], options)
}
export const buildMany = function (
services: string[],
options: IDockerComposeBuildOptions = {}
): Promise<IDockerComposeResult> {
return execCompose(
'build',
options.parallel ? ['--parallel'].concat(services) : services,
options
)
}
export const buildOne = function (
service: string,
options?: IDockerComposeBuildOptions
): Promise<IDockerComposeResult> {
return execCompose('build', [service], options)
}
export const pullAll = function (
options: IDockerComposeOptions = {}
): Promise<IDockerComposeResult> {
return execCompose('pull', [], options)
}
export const pullMany = function (
services: string[],
options: IDockerComposeOptions = {}
): Promise<IDockerComposeResult> {
return execCompose('pull', services, options)
}
export const pullOne = function (
service: string,
options?: IDockerComposeOptions
): Promise<IDockerComposeResult> {
return execCompose('pull', [service], options)
}
export const config = async function (
options?: IDockerComposeOptions
): Promise<TypedDockerComposeResult<DockerComposeConfigResult>> {
try {
const result = await execCompose('config', [], options)
const config = yaml.parse(result.out)
return {
...result,
data: { config }
}
} catch (error) {
return Promise.reject(error)
}
}
export const configServices = async function (
options?: IDockerComposeOptions
): Promise<TypedDockerComposeResult<DockerComposeConfigServicesResult>> {
try {
const result = await execCompose('config', ['--services'], options)
const services = result.out.split('\n').filter(nonEmptyString)
return {
...result,
data: { services }
}
} catch (error) {
return Promise.reject(error)
}
}
export const configVolumes = async function (
options?: IDockerComposeOptions
): Promise<TypedDockerComposeResult<DockerComposeConfigVolumesResult>> {
try {
const result = await execCompose('config', ['--volumes'], options)
const volumes = result.out.split('\n').filter(nonEmptyString)
return {
...result,
data: { volumes }
}
} catch (error) {
return Promise.reject(error)
}
}
export const ps = async function (
options?: IDockerComposeOptions
): Promise<TypedDockerComposeResult<DockerComposePsResult>> {
try {
const result = await execCompose('ps', [], options)
const data = mapPsOutput(result.out, options)
return {
...result,
data
}
} catch (error) {
return Promise.reject(error)
}
}
export const push = function (
options: IDockerComposePushOptions = {}
): Promise<IDockerComposeResult> {
return execCompose(
'push',
options.ignorePushFailures ? ['--ignore-push-failures'] : [],
options
)
}
export const restartAll = function (
options?: IDockerComposeOptions
): Promise<IDockerComposeResult> {
return execCompose('restart', [], options)
}
export const restartMany = function (
services: string[],
options?: IDockerComposeOptions
): Promise<IDockerComposeResult> {
return execCompose('restart', services, options)
}
export const restartOne = function (
service: string,
options?: IDockerComposeOptions
): Promise<IDockerComposeResult> {
return restartMany([service], options)
}
export const logs = function (
services: string | string[],
options: IDockerComposeLogOptions = {}
): Promise<IDockerComposeResult> {
let args = Array.isArray(services) ? services : [services]
if (options.follow) {
args = ['--follow', ...args]
}
return execCompose('logs', args, options)
}
export const port = async function (
service: string,
containerPort: string | number,
options?: IDockerComposeOptions
): Promise<TypedDockerComposeResult<DockerComposePortResult>> {
const args = [service, containerPort]
try {
const result = await execCompose('port', args, options)
const [address, port] = result.out.split(':')
return {
...result,
data: {
address,
port: Number(port)
}
}
} catch (error) {
return Promise.reject(error)
}
}
export const version = async function (
options?: IDockerComposeOptions
): Promise<TypedDockerComposeResult<DockerComposeVersionResult>> {
try {
const result = await execCompose('version', ['--short'], options)
const version = result.out.replace('\n', '').trim()
return {
...result,
data: { version }
}
} catch (error) {
return Promise.reject(error)
}
}
export default {
upAll,
upMany,
upOne,
down,
stop,
stopOne,
pauseOne,
unpauseOne,
kill,
rm,
exec,
run,
buildAll,
buildMany,
buildOne,
pullAll,
pullMany,
pullOne,
config,
configServices,
configVolumes,
ps,
push,
restartAll,
restartMany,
restartOne,
logs,
port,
version
}