diff --git a/lib/util/docker/index.ts b/lib/util/docker/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..095bb579c835558514913dd81897e04a54fd8251 --- /dev/null +++ b/lib/util/docker/index.ts @@ -0,0 +1,43 @@ +type Opt<T> = T | null | undefined; + +export interface DockerOptions { + image: string; + dockerUser?: Opt<string>; + volumes?: Opt<Opt<string>[]>; + envVars?: Opt<Opt<string>[]>; + cwd?: Opt<string>; + tag?: Opt<string>; + cmdWrap?: Opt<string>; +} + +export function dockerCmd(cmd: string, options: DockerOptions): string { + const { dockerUser, volumes, envVars, cwd, image, tag, cmdWrap } = options; + + const result = ['docker run --rm']; + if (dockerUser) result.push(`--user=${dockerUser}`); + + if (volumes) + result.push(...volumes.filter(x => !!x).map(vol => `-v "${vol}":"${vol}"`)); + + if (envVars) result.push(...envVars.filter(x => !!x).map(e => `-e ${e}`)); + + if (cwd) result.push(`-w "${cwd}"`); + + const taggedImage = tag ? `${image}:${tag}` : `${image}`; + result.push(taggedImage); + + if (cmdWrap) { + const regex = /{{\s*cmd\s*}}/; + if (regex.test(cmdWrap)) { + result.push(cmdWrap.replace(regex, cmd)); + } /* istanbul ignore next */ else { + throw new Error( + 'dockerCmd(): Provide {{ cmd }} placeholder inside `wrapCmd` parameter' + ); + } + } else { + result.push(cmd); + } + + return result.join(' '); +} diff --git a/lib/util/exec.ts b/lib/util/exec.ts index d9220217f3be5c9d78760d681a504d99dcdfa609..b8bcb59969ae697738fd4d51a81d498114303840 100644 --- a/lib/util/exec.ts +++ b/lib/util/exec.ts @@ -1,14 +1,36 @@ -// istanbul ignore file import { promisify } from 'util'; -import { exec as cpExec, ExecOptions } from 'child_process'; +import { + exec as cpExec, + ExecOptions as ChildProcessExecOptions, +} from 'child_process'; +import { dockerCmd, DockerOptions } from './docker'; -const pExec = promisify(cpExec); +const pExec: ( + cmd: string, + opts: ChildProcessExecOptions & { encoding: string } +) => Promise<ExecResult> = promisify(cpExec); + +export interface ExecOptions extends ChildProcessExecOptions { + docker?: DockerOptions; +} export interface ExecResult { stdout: string; stderr: string; } -export function exec(cmd: string, options?: ExecOptions): Promise<ExecResult> { - return pExec(cmd, { ...options, encoding: 'utf-8' }); +export function exec( + cmd: string, + options?: ExecOptions & { docker?: DockerOptions } +): Promise<ExecResult> { + let pExecCommand = cmd; + const pExecOptions = { ...options, encoding: 'utf-8' }; + + if (options && options.docker) { + const { cwd, docker } = options; + pExecCommand = dockerCmd(cmd, { ...docker, cwd }); + delete pExecOptions.docker; + } + + return pExec(pExecCommand, pExecOptions); } diff --git a/test/util/exec.spec.ts b/test/util/exec.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..5a48003716971905ccf9e2866ec9bfe584699de2 --- /dev/null +++ b/test/util/exec.spec.ts @@ -0,0 +1,77 @@ +import { + exec as _cpExec, + ExecOptions as ChildProcessExecOptions, +} from 'child_process'; +import { exec, ExecOptions } from '../../lib/util/exec'; + +const cpExec: jest.Mock<typeof _cpExec> = _cpExec as any; +jest.mock('child_process'); + +describe('exec()', () => { + it('wraps original exec() from "child_process" module', async () => { + const cases = [ + ['foo', {}, 'foo', { encoding: 'utf-8' }], + [ + 'foo', + { docker: { image: 'bar' } }, + 'docker run --rm bar foo', + { encoding: 'utf-8' }, + ], + [ + 'foo', + { docker: { image: 'bar', cmdWrap: 'su user -c {{ cmd }}' } }, + 'docker run --rm bar su user -c foo', + { encoding: 'utf-8' }, + ], + [ + 'foo', + { docker: { image: 'bar', tag: 'latest' } }, + 'docker run --rm bar:latest foo', + { encoding: 'utf-8' }, + ], + [ + 'foo', + { docker: { image: 'bar' }, cwd: '/current/working/directory' }, + 'docker run --rm -w "/current/working/directory" bar foo', + { encoding: 'utf-8', cwd: '/current/working/directory' }, + ], + [ + 'foo', + { + docker: { image: 'bar', dockerUser: 'baz' }, + }, + 'docker run --rm --user=baz bar foo', + { encoding: 'utf-8' }, + ], + [ + 'foo', + { + docker: { image: 'bar', volumes: ['/path/to/volume'] }, + }, + 'docker run --rm -v "/path/to/volume":"/path/to/volume" bar foo', + { encoding: 'utf-8' }, + ], + [ + 'foo', + { + docker: { image: 'bar', envVars: ['SOMETHING_SENSIBLE'] }, + }, + 'docker run --rm -e SOMETHING_SENSIBLE bar foo', + { encoding: 'utf-8' }, + ], + ]; + for (const [cmd, opts, expectedCmd, expectedOpts] of cases) { + let actualCmd: string | null = null; + let actualOpts: ChildProcessExecOptions | null = null; + cpExec.mockImplementationOnce((execCmd, execOpts, callback) => { + actualCmd = execCmd; + actualOpts = execOpts; + callback(null, { stdout: '', stderr: '' }); + return undefined; + }); + await exec(cmd as string, opts as ExecOptions); + expect(actualCmd).toEqual(expectedCmd); + expect(actualOpts).toEqual(expectedOpts); + } + }); +});