backend-common: Add runDockerContainer shared by Scaffolder and TechDocs

Signed-off-by: Himanshu Mishra <himanshu@orkohunter.net>
This commit is contained in:
Himanshu Mishra
2021-03-01 17:33:48 +01:00
parent d7a5bfd57f
commit e9b7db7b29
12 changed files with 359 additions and 517 deletions
+2
View File
@@ -35,12 +35,14 @@
"@backstage/integration": "^0.5.0",
"@octokit/rest": "^18.0.12",
"@types/cors": "^2.8.6",
"@types/dockerode": "^3.2.1",
"@types/express": "^4.17.6",
"archiver": "^5.0.2",
"compression": "^1.7.4",
"concat-stream": "^2.0.0",
"cors": "^2.8.5",
"cross-fetch": "^3.0.6",
"dockerode": "^3.2.1",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.1",
+4 -3
View File
@@ -18,10 +18,11 @@ export * from './config';
export * from './database';
export * from './discovery';
export * from './errors';
export * from './hot';
export * from './logging';
export * from './middleware';
export * from './reading';
export * from './service';
export * from './paths';
export * from './hot';
export * from './reading';
export * from './scm';
export * from './service';
export * from './util';
@@ -0,0 +1,200 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Docker from 'dockerode';
import fs from 'fs';
import os from 'os';
import Stream, { PassThrough } from 'stream';
import { runDockerContainer, UserOptions } from './docker';
const mockDocker = new Docker() as jest.Mocked<Docker>;
describe('runDockerContainer', () => {
beforeEach(() => {
jest.spyOn(mockDocker, 'pull').mockImplementation((async (
_image: string,
_something: any,
handler: (err: Error | undefined, stream: PassThrough) => void,
) => {
const mockStream = new PassThrough();
handler(undefined, mockStream);
mockStream.end();
}) as any);
jest
.spyOn(mockDocker, 'run')
.mockResolvedValue([{ Error: null, StatusCode: 0 }]);
jest
.spyOn(mockDocker, 'ping')
.mockResolvedValue(Buffer.from('OK', 'utf-8'));
});
const imageName = 'dockerOrg/image';
const args = ['bash', '-c', 'echo test'];
const inputDir = os.tmpdir();
const outputDir = os.tmpdir();
it('should pull the docker container', async () => {
await runDockerContainer({
imageName,
args,
inputDir,
outputDir,
dockerClient: mockDocker,
});
expect(mockDocker.pull).toHaveBeenCalledWith(
imageName,
{},
expect.any(Function),
);
});
it('should call the dockerClient run command with the correct arguments passed through', async () => {
await runDockerContainer({
imageName,
args,
inputDir,
outputDir,
dockerClient: mockDocker,
});
expect(mockDocker.run).toHaveBeenCalledWith(
imageName,
args,
expect.any(Stream),
expect.objectContaining({
HostConfig: {
Binds: expect.arrayContaining([
`${await fs.promises.realpath(inputDir)}:/input`,
`${await fs.promises.realpath(outputDir)}:/output`,
]),
},
Volumes: {
'/input': {},
'/output': {},
},
}),
);
});
it('should ping docker to test availability', async () => {
await runDockerContainer({
imageName,
args,
inputDir,
outputDir,
dockerClient: mockDocker,
});
expect(mockDocker.ping).toHaveBeenCalled();
});
it('should pass through the user and group id from the host machine and set the home dir', async () => {
await runDockerContainer({
imageName,
args,
inputDir,
outputDir,
dockerClient: mockDocker,
});
const userOptions: UserOptions = {};
if (process.getuid && process.getgid) {
userOptions.User = `${process.getuid()}:${process.getgid()}`;
}
expect(mockDocker.run).toHaveBeenCalledWith(
imageName,
args,
expect.any(Stream),
expect.objectContaining({
...userOptions,
}),
);
});
it('throws a correct error if the command fails in docker', async () => {
mockDocker.run.mockResolvedValueOnce([
{
Error: new Error('Something went wrong with docker'),
StatusCode: 0,
},
]);
await expect(
runDockerContainer({
imageName,
args,
inputDir,
outputDir,
dockerClient: mockDocker,
}),
).rejects.toThrow(/Something went wrong with docker/);
});
describe('where docker is unavailable', () => {
const dockerError = 'a docker error';
beforeEach(() => {
jest.spyOn(mockDocker, 'ping').mockImplementationOnce(() => {
throw new Error(dockerError);
});
});
it('should throw with a descriptive error message including the docker error message', async () => {
await expect(
runDockerContainer({
imageName,
args,
inputDir,
outputDir,
dockerClient: mockDocker,
}),
).rejects.toThrow(new RegExp(`.+: ${dockerError}`));
});
});
it('should pass through the log stream to the docker client', async () => {
const logStream = new PassThrough();
await runDockerContainer({
imageName,
args,
inputDir,
outputDir,
logStream,
dockerClient: mockDocker,
});
expect(mockDocker.run).toHaveBeenCalledWith(
imageName,
args,
logStream,
expect.objectContaining({
HostConfig: {
Binds: expect.arrayContaining([
`${await fs.promises.realpath(inputDir)}:/input`,
`${await fs.promises.realpath(outputDir)}:/output`,
]),
},
Volumes: {
'/input': {},
'/output': {},
},
}),
);
});
});
+119
View File
@@ -0,0 +1,119 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Docker from 'dockerode';
import fs from 'fs';
import { PassThrough, Writable } from 'stream';
export type UserOptions = {
User?: string;
};
export type RunDockerContainerOptions = {
imageName: string;
args: string[];
logStream?: Writable;
inputDir: string;
outputDir: string;
dockerClient: Docker;
createOptions?: Docker.ContainerCreateOptions;
};
/**
*
* @param options the options object
* @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
*/
export const runDockerContainer = async ({
imageName,
args,
logStream = new PassThrough(),
inputDir,
outputDir,
dockerClient,
createOptions = {},
}: RunDockerContainerOptions) => {
// Show a better error message when Docker is unavailable.
try {
await dockerClient.ping();
} catch (e) {
throw new Error(
`This operation requires Docker. Docker does not appear to be available. Docker.ping() failed with: ${e.message}`,
);
}
await new Promise<void>((resolve, reject) => {
dockerClient.pull(imageName, {}, (err, stream) => {
if (err) return reject(err);
stream.pipe(logStream, { end: false });
stream.on('end', () => resolve());
stream.on('error', (error: Error) => reject(error));
return undefined;
});
});
const userOptions: UserOptions = {};
// @ts-ignore
if (process.getuid && process.getgid) {
// Files that are created inside the Docker container will be owned by
// root on the host system on non Mac systems, because of reasons. Mainly the fact that
// volume sharing is done using NFS on Mac and actual mounts in Linux world.
// So we set the user in the container as the same user and group id as the host.
// On Windows we don't have process.getuid nor process.getgid
userOptions.User = `${process.getuid()}:${process.getgid()}`;
}
const [{ Error: error, StatusCode: statusCode }] = await dockerClient.run(
imageName,
args,
logStream,
{
Volumes: { '/output': {}, '/input': {} },
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`,
],
},
...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,
},
);
if (error) {
throw new Error(
`Docker failed to run with the following error message: ${error}`,
);
}
if (statusCode !== 0) {
throw new Error(
`Docker container returned a non-zero exit code (${statusCode})`,
);
}
return { error, statusCode };
};
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { runDockerContainer } from './docker';
@@ -14,12 +14,10 @@
* limitations under the License.
*/
import { getVoidLogger } from '@backstage/backend-common';
import Docker from 'dockerode';
import fs from 'fs-extra';
import mockFs from 'mock-fs';
import os from 'os';
import path, { resolve as resolvePath } from 'path';
import Stream, { PassThrough } from 'stream';
import { ParsedLocationAnnotation } from '../../helpers';
import { RemoteProtocol } from '../prepare/types';
import {
@@ -28,9 +26,7 @@ import {
getRepoUrlFromLocationAnnotation,
isValidRepoUrlForMkdocs,
patchMkdocsYmlPreBuild,
runDockerContainer,
storeEtagMetadata,
UserOptions,
} from './helpers';
const mockEntity = {
@@ -41,8 +37,6 @@ const mockEntity = {
},
};
const mockDocker = new Docker() as jest.Mocked<Docker>;
const mkdocsYml = fs.readFileSync(
resolvePath(__filename, '../__fixtures__/mkdocs.yml'),
);
@@ -60,133 +54,6 @@ describe('helpers', () => {
});
});
describe('runDockerContainer', () => {
beforeEach(() => {
jest.spyOn(mockDocker, 'pull').mockImplementation((async (
_image: string,
_something: any,
handler: (err: Error | undefined, stream: PassThrough) => void,
) => {
const mockStream = new PassThrough();
handler(undefined, mockStream);
mockStream.end();
}) as any);
jest
.spyOn(mockDocker, 'run')
.mockResolvedValue([{ Error: null, StatusCode: 0 }]);
jest
.spyOn(mockDocker, 'ping')
.mockResolvedValue(Buffer.from('OK', 'utf-8'));
});
const imageName = 'spotify/techdocs';
const args = ['build', '-d', '/result'];
const docsDir = os.tmpdir();
const outputDir = os.tmpdir();
it('should pull the techdocs docker container', async () => {
await runDockerContainer({
imageName,
args,
docsDir,
outputDir,
dockerClient: mockDocker,
});
expect(mockDocker.pull).toHaveBeenCalledWith(
imageName,
{},
expect.any(Function),
);
});
it('should run the techdocs docker container', async () => {
await runDockerContainer({
imageName,
args,
docsDir,
outputDir,
dockerClient: mockDocker,
});
expect(mockDocker.run).toHaveBeenCalledWith(
imageName,
args,
expect.any(Stream),
expect.objectContaining({
Volumes: {
'/content': {},
'/result': {},
},
WorkingDir: '/content',
HostConfig: {
Binds: [`${docsDir}:/content`, `${outputDir}:/result`],
},
}),
);
});
it('should ping docker to test availability', async () => {
await runDockerContainer({
imageName,
args,
docsDir,
outputDir,
dockerClient: mockDocker,
});
expect(mockDocker.ping).toHaveBeenCalled();
});
it('should pass through the user and group id from the host machine and set the home dir', async () => {
await runDockerContainer({
imageName,
args,
docsDir,
outputDir,
dockerClient: mockDocker,
});
const userOptions: UserOptions = {};
if (process.getuid && process.getgid) {
userOptions.User = `${process.getuid()}:${process.getgid()}`;
}
expect(mockDocker.run).toHaveBeenCalledWith(
imageName,
args,
expect.any(Stream),
expect.objectContaining({
...userOptions,
}),
);
});
describe('where docker is unavailable', () => {
const dockerError = 'a docker error';
beforeEach(() => {
jest.spyOn(mockDocker, 'ping').mockImplementationOnce(() => {
throw new Error(dockerError);
});
});
it('should throw with a descriptive error message including the docker error message', async () => {
await expect(
runDockerContainer({
imageName,
args,
docsDir,
outputDir,
dockerClient: mockDocker,
}),
).rejects.toThrow(new RegExp(`.+: ${dockerError}`));
});
});
});
describe('isValidRepoUrlForMkdocs', () => {
it('should return true for valid repo_url values for mkdocs', () => {
const validRepoUrls = [
@@ -16,7 +16,6 @@
import { Entity } from '@backstage/catalog-model';
import { spawn } from 'child_process';
import Docker from 'dockerode';
import fs from 'fs-extra';
import yaml from 'js-yaml';
import { PassThrough, Writable } from 'stream';
@@ -34,16 +33,6 @@ export function getGeneratorKey(entity: Entity): SupportedGeneratorKey {
return 'techdocs';
}
type RunDockerContainerOptions = {
imageName: string;
args: string[];
logStream?: Writable;
docsDir: string;
outputDir: string;
dockerClient: Docker;
createOptions?: Docker.ContainerCreateOptions;
};
export type RunCommandOptions = {
command: string;
args: string[];
@@ -51,83 +40,6 @@ export type RunCommandOptions = {
logStream?: Writable;
};
export type UserOptions = {
User?: string;
};
// To be replaced by a runDockerContainer from backend-common
// shared between Scaffolder and TechDocs and any other plugin.
export async function runDockerContainer({
imageName,
args,
logStream = new PassThrough(),
docsDir,
outputDir,
dockerClient,
createOptions,
}: RunDockerContainerOptions) {
try {
await dockerClient.ping();
} catch (e) {
throw new Error(
`This operation requires Docker. Docker does not appear to be available. Docker.ping() failed with: ${e.message}`,
);
}
await new Promise<void>((resolve, reject) => {
dockerClient.pull(imageName, {}, (err, stream) => {
if (err) return reject(err);
stream.pipe(logStream, { end: false });
stream.on('end', () => resolve());
stream.on('error', (error: Error) => reject(error));
return undefined;
});
});
const userOptions: UserOptions = {};
// @ts-ignore
if (process.getuid && process.getgid) {
// Files that are created inside the Docker container will be owned by
// root on the host system on non Mac systems, because of reasons. Mainly the fact that
// volume sharing is done using NFS on Mac and actual mounts in Linux world.
// So we set the user in the container as the same user and group id as the host.
// On Windows we don't have process.getuid nor process.getgid
userOptions.User = `${process.getuid()}:${process.getgid()}`;
}
const [{ Error: error, StatusCode: statusCode }] = await dockerClient.run(
imageName,
args,
logStream,
{
Volumes: {
'/content': {},
'/result': {},
},
WorkingDir: '/content',
HostConfig: {
Binds: [`${docsDir}:/content`, `${outputDir}:/result`],
},
...userOptions,
...createOptions,
},
);
if (error) {
throw new Error(
`Docker failed to run with the following error message: ${error}`,
);
}
if (statusCode !== 0) {
throw new Error(
`Docker container returned a non-zero exit code (${statusCode})`,
);
}
return { error, statusCode };
}
/**
*
* @param options the options object
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { runDockerContainer } from '@backstage/backend-common';
import { Config } from '@backstage/config';
import path from 'path';
import { PassThrough } from 'stream';
@@ -22,7 +23,6 @@ import {
addBuildTimestampMetadata,
patchMkdocsYmlPreBuild,
runCommand,
runDockerContainer,
storeEtagMetadata,
} from './helpers';
import { GeneratorBase, GeneratorRunOptions } from './types';
@@ -96,9 +96,9 @@ export class TechdocsGenerator implements GeneratorBase {
case 'docker':
await runDockerContainer({
imageName: 'spotify/techdocs',
args: ['build', '-d', '/result'],
args: ['build', '-d', '/output'],
logStream,
docsDir: inputDir,
inputDir,
outputDir,
dockerClient,
});
@@ -13,11 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import { runDockerContainer } from '@backstage/backend-common';
import { JsonValue } from '@backstage/config';
import { runDockerContainer, runCommand } from './helpers';
import { TemplaterBase, TemplaterRunOptions } from '.';
import fs from 'fs-extra';
import path from 'path';
import { TemplaterBase, TemplaterRunOptions } from '.';
import { runCommand } from './helpers';
const commandExists = require('command-exists-promise');
@@ -71,12 +72,12 @@ export class CookieCutter implements TemplaterBase {
'cookiecutter',
'--no-input',
'-o',
'/result',
'/template',
'/output',
'/input',
'--verbose',
],
templateDir,
resultDir: intermediateDir,
inputDir: templateDir,
outputDir: intermediateDir,
logStream,
dockerClient,
});
@@ -13,11 +13,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { runDockerContainer } from '@backstage/backend-common';
import fs from 'fs-extra';
import { runDockerContainer } from '../helpers';
import { TemplaterBase, TemplaterRunOptions } from '..';
import path from 'path';
import * as yaml from 'yaml';
import { TemplaterBase, TemplaterRunOptions } from '..';
// TODO(blam): Replace with the universal import from github-actions after a release
// As it will break the E2E without it
@@ -49,8 +49,8 @@ export class CreateReactAppTemplater implements TemplaterBase {
componentName as string,
withTypescript ? ' --template typescript' : '',
],
templateDir: intermediateDir,
resultDir: intermediateDir,
inputDir: intermediateDir,
outputDir: intermediateDir,
logStream: logStream,
dockerClient: dockerClient,
createOptions: {
@@ -1,184 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Stream, { PassThrough } from 'stream';
import os from 'os';
import fs from 'fs';
import Docker from 'dockerode';
import { UserOptions, runDockerContainer } from './helpers';
describe('helpers', () => {
const mockDocker = new Docker() as jest.Mocked<Docker>;
beforeEach(() => {
jest
.spyOn(mockDocker, 'run')
.mockResolvedValue([{ Error: null, StatusCode: 0 }]);
jest.spyOn(mockDocker, 'pull').mockImplementation((async (
_image: string,
_something: any,
handler: (err: Error | undefined, stream: PassThrough) => void,
) => {
const mockStream = new PassThrough();
handler(undefined, mockStream);
mockStream.end();
}) as any);
});
describe('runDockerContainer', () => {
const imageName = 'blam/github:ben';
const args = ['bash', '-c', 'echo lol'];
const templateDir = os.tmpdir();
const resultDir = os.tmpdir();
it('will pull the docker container before running', async () => {
await runDockerContainer({
imageName,
args,
templateDir,
resultDir,
dockerClient: mockDocker,
});
expect(mockDocker.pull).toHaveBeenCalledWith(
imageName,
{},
expect.any(Function),
);
});
it('should call the dockerClient run command with the correct arguments passed through', async () => {
await runDockerContainer({
imageName,
args,
templateDir,
resultDir,
dockerClient: mockDocker,
});
expect(mockDocker.run).toHaveBeenCalledWith(
imageName,
args,
expect.any(Stream),
expect.objectContaining({
HostConfig: {
Binds: expect.arrayContaining([
`${await fs.promises.realpath(templateDir)}:/template`,
`${await fs.promises.realpath(resultDir)}:/result`,
]),
},
Volumes: {
'/template': {},
'/result': {},
},
}),
);
});
it('throws a correct error if the templating fails in docker', async () => {
mockDocker.run.mockResolvedValueOnce([
{
Error: new Error('Something went wrong with docker'),
StatusCode: 0,
},
]);
await expect(
runDockerContainer({
imageName,
args,
templateDir,
resultDir,
dockerClient: mockDocker,
}),
).rejects.toThrow(/Something went wrong with docker/);
});
it('throws a correct error when the response code of the container is non-zero', async () => {
mockDocker.run.mockResolvedValueOnce([
{
Error: null,
StatusCode: 123,
},
]);
await expect(
runDockerContainer({
imageName,
args,
templateDir,
resultDir,
dockerClient: mockDocker,
}),
).rejects.toThrow(
/Docker container returned a non-zero exit code \(123\)/,
);
});
it('should pass through the user and group id from the host machine and set the home dir', async () => {
await runDockerContainer({
imageName,
args,
templateDir,
resultDir,
dockerClient: mockDocker,
});
const userOptions: UserOptions = {};
if (process.getuid && process.getgid) {
userOptions.User = `${process.getuid()}:${process.getgid()}`;
}
expect(mockDocker.run).toHaveBeenCalledWith(
imageName,
args,
expect.any(Stream),
expect.objectContaining({
...userOptions,
Env: ['HOME=/tmp'],
}),
);
});
it('should pass through the log stream to the docker client', async () => {
const logStream = new PassThrough();
await runDockerContainer({
imageName,
args,
templateDir,
resultDir,
logStream,
dockerClient: mockDocker,
});
expect(mockDocker.run).toHaveBeenCalledWith(
imageName,
args,
logStream,
expect.objectContaining({
HostConfig: {
Binds: expect.arrayContaining([
`${await fs.promises.realpath(templateDir)}:/template`,
`${await fs.promises.realpath(resultDir)}:/result`,
]),
},
Volumes: {
'/template': {},
'/result': {},
},
}),
);
});
});
});
@@ -13,22 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Writable, PassThrough } from 'stream';
import Docker from 'dockerode';
import fs from 'fs';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { InputError } from '@backstage/backend-common';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { spawn } from 'child_process';
export type RunDockerContainerOptions = {
imageName: string;
args: string[];
logStream?: Writable;
resultDir: string;
templateDir: string;
dockerClient: Docker;
createOptions?: Docker.ContainerCreateOptions;
};
import { PassThrough, Writable } from 'stream';
export type RunCommandOptions = {
command: string;
@@ -36,10 +24,6 @@ export type RunCommandOptions = {
logStream?: Writable;
};
export type UserOptions = {
User?: string;
};
/**
* Gets the templater key to use for templating from the entity
* @param entity Template entity
@@ -89,80 +73,3 @@ export const runCommand = async ({
});
});
};
/**
*
* @param options the options object
* @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.resultDir the /result path inside the container
* @param options.templateDir the /template path inside the container
* @param options.dockerClient the dockerClient to use
*/
export const runDockerContainer = async ({
imageName,
args,
logStream = new PassThrough(),
resultDir,
templateDir,
dockerClient,
createOptions = {},
}: RunDockerContainerOptions) => {
await new Promise<void>((resolve, reject) => {
dockerClient.pull(imageName, {}, (err, stream) => {
if (err) return reject(err);
stream.pipe(logStream, { end: false });
stream.on('end', () => resolve());
stream.on('error', (error: Error) => reject(error));
return undefined;
});
});
const userOptions: UserOptions = {};
// @ts-ignore
if (process.getuid && process.getgid) {
// Files that are created inside the Docker container will be owned by
// root on the host system on non Mac systems, because of reasons. Mainly the fact that
// volume sharing is done using NFS on Mac and actual mounts in Linux world.
// So we set the user in the container as the same user and group id as the host.
// On Windows we don't have process.getuid nor process.getgid
userOptions.User = `${process.getuid()}:${process.getgid()}`;
}
const [{ Error: error, StatusCode: statusCode }] = await dockerClient.run(
imageName,
args,
logStream,
{
Volumes: { '/result': {}, '/template': {} },
HostConfig: {
Binds: [
// Need to use realpath here as Docker mounting does not like
// symlinks for binding volumes
`${await fs.promises.realpath(resultDir)}:/result`,
`${await fs.promises.realpath(templateDir)}:/template`,
],
},
...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,
},
);
if (error) {
throw new Error(
`Docker failed to run with the following error message: ${error}`,
);
}
if (statusCode !== 0) {
throw new Error(
`Docker container returned a non-zero exit code (${statusCode})`,
);
}
return { error, statusCode };
};