backend-common: Add runDockerContainer shared by Scaffolder and TechDocs
Signed-off-by: Himanshu Mishra <himanshu@orkohunter.net>
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user