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 } export type DockerComposePortResult = { address: string port: number } export type DockerComposeVersionResult = { version: string } export type DockerComposeConfigResult = { config: { version: Record services: Record> volumes: Record } } 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 = { exitCode: number | null out: string err: string data: T } const nonEmptyString = (v: string) => v !== '' export type DockerComposePsResult = { services: Array<{ name: string command: string state: string ports: Array<{ mapped?: { address: string; port: number } exposed: { port: number; protocol: string } }> }> } export const mapPsOutput = (output: string): DockerComposePsResult => { const services = output .split(`\n`) .filter(nonEmptyString) .filter((_, index) => index > 1) .map((line) => { const [ nameFragment, commandFragment, stateFragment, untypedPortsFragment ] = line.split(/\s{3,}/) return { name: nameFragment.trim(), command: commandFragment.trim(), state: stateFragment.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[] = ['compose'] composeOptions.forEach((option: string[] | string): void => { if (option instanceof Array) { composeArgs = composeArgs.concat(option) } if (typeof option === 'string') { composeArgs = composeArgs.concat([option]) } }) return composeArgs } /** * Converts docker-compose commandline options to cli arguments */ const commandOptionsToArgs = (commandOptions): string[] => { let commandArgs: string[] = [] commandOptions.forEach((option: string[] | string): void => { if (option instanceof Array) { commandArgs = commandArgs.concat(option) } if (typeof option === 'string') { commandArgs = commandArgs.concat([option]) } }) return commandArgs } /** * Executes docker-compose command with common options */ export const execCompose = ( command, args, options: IDockerComposeOptions = {} ): Promise => 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(commandOptionsToArgs(commandOptions), args) ) ) const cwd = options.cwd const env = options.env || undefined const executablePath = 'docker' || options.executablePath || 'docker-compose' const childProc = childProcess.spawn(executablePath, 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() }) childProc.stderr.on('data', (chunk): void => { result.err += chunk.toString() }) 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 { const args = shouldUseDefaultNonInteractiveFlag(options) ? ['-d'] : [] return execCompose('up', args, options) } export const upMany = function ( services: string[], options?: IDockerComposeOptions ): Promise { const args = shouldUseDefaultNonInteractiveFlag(options) ? ['-d'].concat(services) : services return execCompose('up', args, options) } export const upOne = function ( service: string, options?: IDockerComposeOptions ): Promise { const args = shouldUseDefaultNonInteractiveFlag(options) ? ['-d', service] : [service] return execCompose('up', args, options) } export const down = function ( options?: IDockerComposeOptions ): Promise { return execCompose('down', [], options) } export const stop = function ( options?: IDockerComposeOptions ): Promise { return execCompose('stop', [], options) } export const stopOne = function ( service: string, options?: IDockerComposeOptions ): Promise { return execCompose('stop', [service], options) } export const kill = function ( options?: IDockerComposeOptions ): Promise { return execCompose('kill', [], options) } export const rm = function ( options?: IDockerComposeOptions, ...services: string[] ): Promise { return execCompose('rm', ['-f', ...services], options) } export const exec = function ( container: string, command: string | string[], options?: IDockerComposeOptions ): Promise { 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 { const args = Array.isArray(command) ? command : command.split(/\s+/) return execCompose('run', ['-T', container].concat(args), options) } export const buildAll = function ( options: IDockerComposeBuildOptions = {} ): Promise { return execCompose('build', options.parallel ? ['--parallel'] : [], options) } export const buildMany = function ( services: string[], options: IDockerComposeBuildOptions = {} ): Promise { return execCompose( 'build', options.parallel ? ['--parallel'].concat(services) : services, options ) } export const buildOne = function ( service: string, options?: IDockerComposeBuildOptions ): Promise { return execCompose('build', [service], options) } export const pullAll = function ( options: IDockerComposeOptions = {} ): Promise { return execCompose('pull', [], options) } export const pullMany = function ( services: string[], options: IDockerComposeOptions = {} ): Promise { return execCompose('pull', services, options) } export const pullOne = function ( service: string, options?: IDockerComposeOptions ): Promise { return execCompose('pull', [service], options) } export const config = async function ( options?: IDockerComposeOptions ): Promise> { 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> { 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> { 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> { try { const result = await execCompose('ps', [], options) const data = mapPsOutput(result.out) return { ...result, data } } catch (error) { return Promise.reject(error) } } export const push = function ( options: IDockerComposePushOptions = {} ): Promise { return execCompose( 'push', options.ignorePushFailures ? ['--ignore-push-failures'] : [], options ) } export const restartAll = function ( options?: IDockerComposeOptions ): Promise { return execCompose('restart', [], options) } export const restartMany = function ( services: string[], options?: IDockerComposeOptions ): Promise { return execCompose('restart', services, options) } export const restartOne = function ( service: string, options?: IDockerComposeOptions ): Promise { return restartMany([service], options) } export const logs = function ( services: string | string[], options: IDockerComposeLogOptions = {} ): Promise { 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> { 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> { try { const result = await execCompose('version', ['--short'], options) const version = result.out.replace('\n', '') return { ...result, data: { version } } } catch (error) { return Promise.reject(error) } }