diff --git a/packages/backend-common/src/util/docker.test.ts b/packages/backend-common/src/util/docker.test.ts index 7f9bb2da33..8af6a7366f 100644 --- a/packages/backend-common/src/util/docker.test.ts +++ b/packages/backend-common/src/util/docker.test.ts @@ -14,15 +14,24 @@ * limitations under the License. */ import Docker from 'dockerode'; -import fs from 'fs'; +import mockFs from 'mock-fs'; import os from 'os'; +import path from 'path'; import Stream, { PassThrough } from 'stream'; import { runDockerContainer, UserOptions } from './docker'; const mockDocker = new Docker() as jest.Mocked; +const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; describe('runDockerContainer', () => { beforeEach(() => { + mockFs({ + [rootDir]: { + input: mockFs.directory(), + output: mockFs.directory(), + }, + }); + jest.spyOn(mockDocker, 'pull').mockImplementation((async ( _image: string, _something: any, @@ -42,17 +51,23 @@ describe('runDockerContainer', () => { .mockResolvedValue(Buffer.from('OK', 'utf-8')); }); + afterEach(() => { + mockFs.restore(); + }); + const imageName = 'dockerOrg/image'; const args = ['bash', '-c', 'echo test']; - const inputDir = os.tmpdir(); - const outputDir = os.tmpdir(); + const mountDirs = new Map([ + [path.join(rootDir, 'input'), '/input'], + [path.join(rootDir, 'output'), '/output'], + ]); + const workingDir = path.join(rootDir, 'input'); + const envVars = ['HOME=/tmp', 'LOG_LEVEL=debug']; it('should pull the docker container', async () => { await runDockerContainer({ imageName, args, - inputDir, - outputDir, dockerClient: mockDocker, }); @@ -61,14 +76,17 @@ describe('runDockerContainer', () => { {}, expect.any(Function), ); + + expect(mockDocker.run).toHaveBeenCalled(); }); it('should call the dockerClient run command with the correct arguments passed through', async () => { await runDockerContainer({ imageName, args, - inputDir, - outputDir, + mountDirs, + envVars, + workingDir, dockerClient: mockDocker, }); @@ -77,10 +95,12 @@ describe('runDockerContainer', () => { args, expect.any(Stream), expect.objectContaining({ + Env: envVars, + WorkingDir: workingDir, HostConfig: { Binds: expect.arrayContaining([ - `${await fs.promises.realpath(inputDir)}:/input`, - `${await fs.promises.realpath(outputDir)}:/output`, + `${path.join(rootDir, 'input')}:/input`, + `${path.join(rootDir, 'output')}:/output`, ]), }, Volumes: { @@ -95,8 +115,6 @@ describe('runDockerContainer', () => { await runDockerContainer({ imageName, args, - inputDir, - outputDir, dockerClient: mockDocker, }); @@ -107,8 +125,6 @@ describe('runDockerContainer', () => { await runDockerContainer({ imageName, args, - inputDir, - outputDir, dockerClient: mockDocker, }); @@ -139,8 +155,6 @@ describe('runDockerContainer', () => { runDockerContainer({ imageName, args, - inputDir, - outputDir, dockerClient: mockDocker, }), ).rejects.toThrow(/Something went wrong with docker/); @@ -160,8 +174,6 @@ describe('runDockerContainer', () => { runDockerContainer({ imageName, args, - inputDir, - outputDir, dockerClient: mockDocker, }), ).rejects.toThrow(new RegExp(`.+: ${dockerError}`)); @@ -173,8 +185,6 @@ describe('runDockerContainer', () => { await runDockerContainer({ imageName, args, - inputDir, - outputDir, logStream, dockerClient: mockDocker, }); @@ -185,15 +195,9 @@ describe('runDockerContainer', () => { logStream, expect.objectContaining({ HostConfig: { - Binds: expect.arrayContaining([ - `${await fs.promises.realpath(inputDir)}:/input`, - `${await fs.promises.realpath(outputDir)}:/output`, - ]), - }, - Volumes: { - '/input': {}, - '/output': {}, + Binds: [], }, + Volumes: {}, }), ); }); diff --git a/packages/backend-common/src/util/docker.ts b/packages/backend-common/src/util/docker.ts index 9d2bccd549..f2d1ff6993 100644 --- a/packages/backend-common/src/util/docker.ts +++ b/packages/backend-common/src/util/docker.ts @@ -15,7 +15,6 @@ */ import Docker from 'dockerode'; -import fs from 'fs'; import { PassThrough, Writable } from 'stream'; export type UserOptions = { @@ -26,9 +25,10 @@ export type RunDockerContainerOptions = { imageName: string; args: string[]; logStream?: Writable; - inputDir: string; - outputDir: string; dockerClient: Docker; + mountDirs?: Map; + workingDir?: string; + envVars?: string[]; createOptions?: Docker.ContainerCreateOptions; }; @@ -38,17 +38,20 @@ export type RunDockerContainerOptions = { * @param options.imageName the image to run * @param options.args the arguments to pass the container * @param options.logStream the log streamer to capture log messages - * @param options.inputDir the /input path inside the container - * @param options.outputDir the /output path inside the container * @param options.dockerClient the dockerClient to use + * @param options.mountDirs A map of host directories to mount on the container. + * Map Key: Path on host machine, Value: Path on Docker container + * @param options.workingDir Working dir in the container + * @param options.envVars Environment variables to set in the container. e.g. ['HOME=/tmp'] */ export const runDockerContainer = async ({ imageName, args, logStream = new PassThrough(), - inputDir, - outputDir, dockerClient, + mountDirs = new Map(), + workingDir, + envVars = [], createOptions = {}, }: RunDockerContainerOptions) => { // Show a better error message when Docker is unavailable. @@ -80,25 +83,30 @@ export const runDockerContainer = async ({ userOptions.User = `${process.getuid()}:${process.getgid()}`; } + // Initialize volumes to mount based on mountDirs map + const Volumes: { [T: string]: object } = {}; + for (const containerDir of mountDirs.values()) { + Volumes[containerDir] = {}; + } + + // Create bind volumes + const Binds: string[] = []; + for (const [hostDir, containerDir] of mountDirs.entries()) { + Binds.push(`${hostDir}:${containerDir}`); + } + const [{ Error: error, StatusCode: statusCode }] = await dockerClient.run( imageName, args, logStream, { - Volumes: { '/output': {}, '/input': {} }, - WorkingDir: '/input', + Volumes, HostConfig: { - Binds: [ - // Need to use realpath here as Docker mounting does not like - // symlinks for binding volumes - `${await fs.promises.realpath(outputDir)}:/output`, - `${await fs.promises.realpath(inputDir)}:/input`, - ], + Binds, }, + ...(workingDir ? { WorkingDir: workingDir } : {}), + Env: envVars, ...userOptions, - // Set the home directory inside the container as something that applications can - // write to, otherwise they will just flop and fail trying to write to / - Env: ['HOME=/tmp'], ...createOptions, }, );