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
@@ -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,
});