Merge pull request #1418 from spotify/mob/cookiecutter-templater

Implement the CookieCutter Templater with Docker
This commit is contained in:
Ben Lambert
2020-06-25 04:52:18 +02:00
committed by GitHub
17 changed files with 457 additions and 35 deletions
+3 -1
View File
@@ -26,6 +26,7 @@
"@backstage/plugin-identity-backend": "^0.1.1-alpha.12",
"@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.12",
"@backstage/plugin-sentry-backend": "^0.1.1-alpha.12",
"dockerode": "^3.2.0",
"express": "^4.17.1",
"knex": "^0.21.1",
"sqlite3": "^4.2.0",
@@ -35,6 +36,7 @@
"@backstage/cli": "^0.1.1-alpha.12",
"@types/express": "^4.17.6",
"@types/express-serve-static-core": "^4.17.5",
"@types/helmet": "^0.0.47"
"@types/helmet": "^0.0.47",
"@types/dockerode": "^2.5.32"
}
}
+3 -1
View File
@@ -22,15 +22,17 @@ import {
Preparers,
} from '@backstage/plugin-scaffolder-backend';
import type { PluginEnvironment } from '../types';
import Docker from 'dockerode';
export default async function createPlugin({ logger }: PluginEnvironment) {
const templater = new CookieCutter();
const filePreparer = new FilePreparer();
const githubPreparer = new GithubPreparer();
const preparers = new Preparers();
const dockerClient = new Docker();
preparers.register('file', filePreparer);
preparers.register('github', githubPreparer);
return await createRouter({ preparers, templater, logger });
return await createRouter({ preparers, templater, logger, dockerClient });
}
+2
View File
@@ -23,7 +23,9 @@
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.12",
"@backstage/catalog-model": "^0.1.1-alpha.12",
"@backstage/config": "^0.1.1-alpha.12",
"@types/express": "^4.17.6",
"@types/dockerode": "^2.5.32",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dockerode": "^3.2.0",
@@ -0,0 +1,3 @@
{
"_copy_without_render": [".github/workflows/*"]
}
@@ -1,12 +1,7 @@
#!/bin/bash
#!/bin/sh
# package name is "__component_id__" so that yarn doesn't throw an error
# about invalid characters when running yarn commands. here we replace it with the actual name
sed -i -e "s/__component_id__/{{ cookiecutter.component_id }}/g" package.json
# node_modules was moved out of the template folder, during the pre_gen hook,
# to avoid cookie_cutter from copying all of them. time to move it back
mv ../../node_modules.tmp ../../\{\{cookiecutter.component_id\}\}/node_modules 2>/dev/null ||:
# move back the build directory that was moved out in the pre_gen hook (if it exists)
mv ../../build.tmp ../../\{\{cookiecutter.component_id\}\}/build 2>/dev/null ||:
# Move all template files to the root folder
mv ./* ../
cd ..
rm -rf {{cookiecutter.component_id}}
# # # # # # # #
@@ -1,9 +0,0 @@
#!/bin/bash
# no way to ignore files in cookiecutter, so move node_modules out while building
# to avoid cookiecutter from copying all of them
mv ../../\{\{cookiecutter.component_id\}\}/node_modules ../../node_modules.tmp 2>/dev/null ||:
# cookicutter really doesn't like the next.js build directory, so if the app has
# been built from inside the template folder, that folders needs to be moved out as well
mv ../../\{\{cookiecutter.component_id\}\}/build ../../build.tmp 2>/dev/null ||:
@@ -7,4 +7,3 @@ metadata:
spec:
type: cookiecutter
path: '.'
@@ -1,5 +1,5 @@
{
"name": "__component_id__",
"name": "{{ cookiecutter.component_id }}",
"version": "0.0.0",
"description": "{{ cookiecutter.description }}",
"license": "UNLICENSED",
@@ -0,0 +1,11 @@
FROM alpine:3.7
RUN apk add --update \
git \
python \
python-dev \
py-pip \
g++ && \
pip install cookiecutter && \
apk del g++ py-pip python-dev && \
rm -rf /var/cache/apk/*
+1
View File
@@ -16,3 +16,4 @@
export * from './scaffolder';
export * from './service/router';
@@ -0,0 +1,151 @@
/*
* 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.
*/
jest.mock('./helpers', () => ({ runDockerContainer: jest.fn() }));
import { CookieCutter } from './cookiecutter';
import fs from 'fs-extra';
import os from 'os';
import { RunDockerContainerOptions } from './helpers';
import { PassThrough } from 'stream';
import Docker from 'dockerode';
describe('CookieCutter Templater', () => {
const cookie = new CookieCutter();
const mockDocker = {} as Docker;
const {
runDockerContainer,
}: {
runDockerContainer: jest.Mock<RunDockerContainerOptions>;
} = require('./helpers');
beforeEach(async () => {
jest.clearAllMocks();
await fs.remove(`${os.tmpdir()}/cookiecutter.json`);
});
it('should write a cookiecutter.json file with the values from the entitiy', async () => {
const tempdir = os.tmpdir();
const values = {
component_id: 'test',
description: 'description',
};
await cookie.run({ directory: tempdir, values, dockerClient: mockDocker });
const cookieCutterJson = await fs.readJSON(`${tempdir}/cookiecutter.json`);
expect(cookieCutterJson).toEqual(expect.objectContaining(values));
});
it('should merge any value that is in the cookiecutter.json path already', async () => {
const tempdir = os.tmpdir();
const existingJson = {
_copy_without_render: ['./github/workflows/*'],
};
await fs.writeJSON(`${tempdir}/cookiecutter.json`, existingJson);
const values = {
component_id: 'hello',
description: 'im something cool',
};
await cookie.run({ directory: tempdir, values, dockerClient: mockDocker });
const cookieCutterJson = await fs.readJSON(`${tempdir}/cookiecutter.json`);
expect(cookieCutterJson).toEqual({ ...existingJson, ...values });
});
it('should throw an error if the cookiecutter json is malformed and not missing', async () => {
const tempdir = os.tmpdir();
await fs.writeFile(`${tempdir}/cookiecutter.json`, "{'");
const values = {
component_id: 'hello',
description: 'im something cool',
};
await expect(
cookie.run({ directory: tempdir, values, dockerClient: mockDocker }),
).rejects.toThrow(/Unexpected token ' in JSON at position 1/);
});
it('should run the correct docker container with the correct bindings for the volumes', async () => {
const tempdir = os.tmpdir();
const values = {
component_id: 'test',
description: 'description',
};
await cookie.run({ directory: tempdir, values, dockerClient: mockDocker });
expect(runDockerContainer).toHaveBeenCalledWith({
imageName: 'backstage/cookiecutter',
args: ['cookiecutter', '--no-input', '-o', '/result', '/template'],
templateDir: tempdir,
resultDir: `${tempdir}/result`,
logStream: undefined,
dockerClient: mockDocker,
});
});
it('should return the result path to the end templated folder', async () => {
const tempdir = os.tmpdir();
const values = {
component_id: 'test',
description: 'description',
};
const path = await cookie.run({
directory: tempdir,
values,
dockerClient: mockDocker,
});
expect(path).toBe(`${tempdir}/result`);
});
it('should pass through the streamer to the run docker helper', async () => {
const stream = new PassThrough();
const tempdir = os.tmpdir();
const values = {
component_id: 'test',
description: 'description',
};
await cookie.run({
directory: tempdir,
values,
logStream: stream,
dockerClient: mockDocker,
});
expect(runDockerContainer).toHaveBeenCalledWith({
imageName: 'backstage/cookiecutter',
args: ['cookiecutter', '--no-input', '-o', '/result', '/template'],
templateDir: tempdir,
resultDir: `${tempdir}/result`,
logStream: stream,
dockerClient: mockDocker,
});
});
});
@@ -16,17 +16,52 @@ import { TemplaterBase, TemplaterRunOptions } from '.';
* limitations under the License.
*/
import fs from 'fs-extra';
import { JsonValue } from '@backstage/config';
import { runDockerContainer } from './helpers';
export class CookieCutter implements TemplaterBase {
private async fetchTemplateCookieCutter(
directory: string,
): Promise<Record<string, JsonValue>> {
try {
return await fs.readJSON(`${directory}/cookiecutter.json`);
} catch (ex) {
if (ex.code !== 'ENOENT') {
throw ex;
}
return {};
}
}
public async run(options: TemplaterRunOptions): Promise<string> {
// first we need to make cookiecutter.json in the directory provided with the input values.
// First lets grab the default cookiecutter.json file
const cookieCutterJson = await this.fetchTemplateCookieCutter(
options.directory,
);
const cookieInfo = {
_copy_without_render: ['.github/workflows/*'],
...cookieCutterJson,
...options.values,
};
await fs.writeJSON(`${options.directory}/cookiecutter.json`, cookieInfo);
return '';
// run cookie cutter with new json
const templateDir = options.directory;
// TODO(blam): This should be an entirely different directory on the host machine
// not in the template directory
const resultDir = `${templateDir}/result`;
await runDockerContainer({
imageName: 'backstage/cookiecutter',
args: ['cookiecutter', '--no-input', '-o', '/result', '/template'],
templateDir,
resultDir,
logStream: options.logStream,
dockerClient: options.dockerClient,
});
return resultDir;
}
}
@@ -0,0 +1,135 @@
/*
* 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 { runDockerContainer } from './helpers';
describe('helpers', () => {
const mockDocker = new Docker() as jest.Mocked<Docker>;
beforeEach(() => {
jest
.spyOn(mockDocker, 'run')
.mockResolvedValue([{ Error: null, StatusCode: 0 }]);
});
describe('runDockerContainer', () => {
const imageName = 'blam/github:ben';
const args = ['bash', '-c', 'echo lol'];
const templateDir = os.tmpdir();
const resultDir = os.tmpdir();
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 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': {},
},
}),
);
});
});
});
@@ -0,0 +1,77 @@
/*
* 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 { Writable, PassThrough } from 'stream';
import Docker from 'dockerode';
import fs from 'fs';
export type RunDockerContainerOptions = {
imageName: string;
args: string[];
logStream?: Writable;
resultDir: string;
templateDir: string;
dockerClient: Docker;
};
/**
*
* @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,
}: RunDockerContainerOptions) => {
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`,
],
},
},
);
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 };
};
@@ -14,6 +14,9 @@
* limitations under the License.
*/
import type { Writable } from 'stream';
import Docker from 'dockerode';
export interface RequiredTemplateValues {
component_id: string;
}
@@ -21,12 +24,14 @@ export interface RequiredTemplateValues {
export interface TemplaterRunOptions {
directory: string;
values: RequiredTemplateValues & object;
logStream?: Writable;
dockerClient: Docker;
}
export abstract class TemplaterBase {
export type TemplaterBase = {
// runs the templating with the values and returns the directory to push the VCS
abstract async run(opts: TemplaterRunOptions): Promise<string>;
}
run(opts: TemplaterRunOptions): Promise<string>;
};
export interface TemplaterConfig {
templater?: TemplaterBase;
@@ -19,18 +19,20 @@ import Router from 'express-promise-router';
import express from 'express';
import { PreparerBuilder, TemplaterBase } from '../scaffolder';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import Docker from 'dockerode';
export interface RouterOptions {
preparers: PreparerBuilder;
templater: TemplaterBase;
logger: Logger;
dockerClient: Docker;
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const router = Router();
const { preparers, templater, logger: parentLogger } = options;
const { preparers, templater, logger: parentLogger, dockerClient } = options;
const logger = parentLogger.child({ plugin: 'scaffolder' });
router.post('/v1/jobs', async (_, res) => {
@@ -66,12 +68,16 @@ export async function createRouter(
const preparer = preparers.get(mockEntity);
// Run the preparer for the mock entity to produce a temporary directory with template in
const path = await preparer.prepare(mockEntity);
const skeletonPath = await preparer.prepare(mockEntity);
// Run the templater on the mock directory with values from the post body
await templater.run({ directory: path, values: { component_id: 'test' } });
const templatedPath = await templater.run({
directory: skeletonPath,
values: { component_id: 'test' },
dockerClient,
});
console.warn(path);
console.warn(templatedPath);
});
const app = express();
+7
View File
@@ -3433,6 +3433,13 @@
resolved "https://registry.npmjs.org/@types/diff/-/diff-4.0.2.tgz#2e9bb89f9acc3ab0108f0f3dc4dbdcf2fff8a99c"
integrity sha512-mIenTfsIe586/yzsyfql69KRnA75S8SVXQbTLpDejRrjH0QSJcpu3AUOi/Vjnt9IOsXKxPhJfGpQUNMueIU1fQ==
"@types/dockerode@^2.5.32":
version "2.5.32"
resolved "https://registry.npmjs.org/@types/dockerode/-/dockerode-2.5.32.tgz#52d3628f605f8ea65202541c59a8a6dd166384fd"
integrity sha512-TfaGOoOHxsjkWRj2sPoQ3FLmTC5mVMhZ4kzZy13U7mjtIDoloE4e7AMj5jPLbffWB6Csy5DF5e0lC9M+tnKz/A==
dependencies:
"@types/node" "*"
"@types/eslint-visitor-keys@^1.0.0":
version "1.0.0"
resolved "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d"