Merge branch 'master' of github.com:spotify/backstage into shmidt-i/load-scaffolder-templates-from-api

This commit is contained in:
Ivan Shmidt
2020-06-24 23:14:32 +02:00
293 changed files with 5141 additions and 2208 deletions
@@ -13,22 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getRootLogger } from '@backstage/backend-common';
import { startStandaloneServer } from './service/standaloneServer';
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3004;
const enableCors = process.env.PLUGIN_CORS
? Boolean(process.env.PLUGIN_CORS)
: false;
const logger = getRootLogger();
startStandaloneServer({ port, enableCors, logger }).catch((err) => {
logger.error(err);
process.exit(1);
});
process.on('SIGINT', () => {
logger.info('CTRL+C pressed; exiting.');
process.exit(0);
});
console.warn('here!');
@@ -0,0 +1,9 @@
apiVersion: backstage.io/v1alpha1
kind: Template
metadata:
name: react-ssr-template
title: React SSR Template
description: Next.js application skeleton for creating isomorphic web applications.
spec:
type: cookiecutter
path: ./template-1
@@ -0,0 +1,16 @@
/*
* 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.
*/
console.warn('here!');
@@ -0,0 +1,8 @@
apiVersion: backstage.io/v1alpha1
kind: Template
metadata:
name: react-ssr-template
title: React SSR Template
description: Next.js application skeleton for creating isomorphic web applications.
spec:
type: cookiecutter
+11 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-scaffolder-backend",
"version": "0.1.1-alpha.9",
"version": "0.1.1-alpha.12",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -21,7 +21,8 @@
"mock-data": "./scripts/mock-data"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.9",
"@backstage/backend-common": "^0.1.1-alpha.12",
"@backstage/catalog-model": "^0.1.1-alpha.12",
"@types/express": "^4.17.6",
"compression": "^1.7.4",
"cors": "^2.8.5",
@@ -29,18 +30,23 @@
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.0",
"git-url-parse": "^11.1.2",
"globby": "^11.0.0",
"helmet": "^3.22.0",
"morgan": "^1.10.0",
"nodegit": "0.26.5",
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.9",
"@backstage/cli": "^0.1.1-alpha.12",
"@types/fs-extra": "^9.0.1",
"@types/git-url-parse": "^9.0.0",
"@types/nodegit": "0.26.5",
"@types/supertest": "^2.0.8",
"supertest": "^4.0.2"
"supertest": "^4.0.2",
"yaml": "^1.10.0"
},
"files": [
"dist/**/*.{js,d.ts}"
"dist"
]
}
@@ -9,4 +9,4 @@ sed -i -e "s/__component_id__/{{ cookiecutter.component_id }}/g" package.json
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 ||:
mv ../../build.tmp ../../\{\{cookiecutter.component_id\}\}/build 2>/dev/null ||:
@@ -1,6 +0,0 @@
{
"id": "react-ssr-template",
"name": "SSR React Website",
"description": "Next.js application skeleton for creating isomorphic web applications.",
"ownerId": "something"
}
@@ -13,8 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './storage';
export * from './templater';
export * from './storage/disk';
export * from './prepare';
export * from './templater/cookiecutter';
@@ -0,0 +1,65 @@
/*
* 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 fs from 'fs-extra';
import YAML from 'yaml';
import { FilePreparer } from './file';
import path from 'path';
import {
TemplateEntityV1alpha1,
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
const setupTest = async (fixturePath: string) => {
const locationForTemplateYaml = path.resolve(
path.dirname(
require.resolve('@backstage/plugin-scaffolder-backend/package'),
),
'fixtures',
fixturePath,
);
const [parsedDocument] = YAML.parseAllDocuments(
await fs.readFile(locationForTemplateYaml, 'utf-8'),
);
const template: TemplateEntityV1alpha1 = parsedDocument.toJSON();
template.metadata.annotations = {
[LOCATION_ANNOTATION]: `file:${locationForTemplateYaml}`,
};
const filePreparer = new FilePreparer();
const resultDir = await filePreparer.prepare(template);
return { filePreparer, template, resultDir };
};
describe('File preparer', () => {
it('excludes the yaml file from the temp folder', async () => {
const { resultDir } = await setupTest('test-simple-template/template.yaml');
expect(fs.existsSync(`${resultDir}/template.yaml`)).toBe(false);
});
it('resolves relative path from the template', async () => {
const { resultDir } = await setupTest('test-simple-template/template.yaml');
expect(fs.existsSync(`${resultDir}/expected_file.ts`)).toBe(true);
});
it('resolves relative path from the nested template', async () => {
const { resultDir } = await setupTest('test-nested-template/template.yaml');
expect(fs.existsSync(`${resultDir}/expected_file.ts`)).toBe(true);
});
});
@@ -0,0 +1,50 @@
/*
* 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 fs from 'fs-extra';
import path from 'path';
import os from 'os';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { parseLocationAnnotation } from './helpers';
import { InputError } from '@backstage/backend-common';
import { PreparerBase } from './types';
export class FilePreparer implements PreparerBase {
async prepare(template: TemplateEntityV1alpha1): Promise<string> {
const { protocol, location } = parseLocationAnnotation(template);
if (protocol !== 'file') {
throw new InputError(
`Wrong location protocol: ${protocol}, should be 'file'`,
);
}
const templateId = template.metadata.name;
const tempDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), templateId),
);
const parentDirectory = path.resolve(
path.dirname(location),
template.spec.path ?? '.',
);
await fs.copy(parentDirectory, tempDir, {
filter: src => src !== location,
});
return tempDir;
}
}
@@ -0,0 +1,85 @@
/*
* 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.
*/
const mocks = {
Clone: { clone: jest.fn() },
CheckoutOptions: jest.fn(() => {}),
};
jest.doMock('nodegit', () => mocks);
// require('nodegit');
import { GithubPreparer } from './github';
import {
TemplateEntityV1alpha1,
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
describe('GitHubPreparer', () => {
let mockEntity: TemplateEntityV1alpha1;
beforeEach(() => {
jest.clearAllMocks();
mockEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
[LOCATION_ANNOTATION]:
'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml',
},
name: 'graphql-starter',
title: 'GraphQL Service',
description:
'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n',
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
generation: 1,
},
spec: {
type: 'cookiecutter',
path: './template',
},
};
});
it('calls the clone command with the correct arguments for a repository', async () => {
const preparer = new GithubPreparer();
await preparer.prepare(mockEntity);
expect(mocks.Clone.clone).toHaveBeenNthCalledWith(
1,
'https://github.com/benjdlambert/backstage-graphql-template',
expect.any(String),
{ checkoutOpts: { paths: ['template'] } },
);
});
it('calls the clone command with the correct arguments for a repository when no path is provided', async () => {
const preparer = new GithubPreparer();
delete mockEntity.spec.path;
await preparer.prepare(mockEntity);
expect(mocks.Clone.clone).toHaveBeenNthCalledWith(
1,
'https://github.com/benjdlambert/backstage-graphql-template',
expect.any(String),
{ checkoutOpts: {} },
);
});
it('return the temp directory with the path to the folder if it is specified', async () => {
const preparer = new GithubPreparer();
mockEntity.spec.path = './template/test/1/2/3';
const response = await preparer.prepare(mockEntity);
expect(response).toMatch(new RegExp(/\/template\/test\/1\/2\/3$/));
});
});
@@ -0,0 +1,60 @@
/*
* 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 fs from 'fs-extra';
import path from 'path';
import os from 'os';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { parseLocationAnnotation } from './helpers';
import { InputError } from '@backstage/backend-common';
import { PreparerBase } from './types';
import GitUriParser from 'git-url-parse';
import { Clone, CheckoutOptions } from 'nodegit';
export class GithubPreparer implements PreparerBase {
async prepare(template: TemplateEntityV1alpha1): Promise<string> {
const { protocol, location } = parseLocationAnnotation(template);
if (protocol !== 'github') {
throw new InputError(
`Wrong location protocol: ${protocol}, should be 'github'`,
);
}
const templateId = template.metadata.name;
const parsedGitLocation = GitUriParser(location);
const repositoryCheckoutUrl = parsedGitLocation.toString('https');
const tempDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), templateId),
);
const templateDirectory = path.join(
`${path.dirname(parsedGitLocation.filepath)}`,
template.spec.path ?? '.',
);
const checkoutOptions = new CheckoutOptions();
if (template.spec.path) {
checkoutOptions.paths = [templateDirectory];
}
await Clone.clone(repositoryCheckoutUrl, tempDir, {
checkoutOpts: checkoutOptions,
// TODO(blam): Maybe need some auth here?
});
return path.resolve(tempDir, templateDirectory);
}
}
@@ -0,0 +1,171 @@
/*
* 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 { parseLocationAnnotation } from './helpers';
import {
TemplateEntityV1alpha1,
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
describe('Helpers', () => {
describe('parseLocationAnnotation', () => {
it('throws an exception when no annotation location', () => {
const mockEntity: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
// [LOCATION_ANNOTATION]:
// 'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml',
},
name: 'graphql-starter',
title: 'GraphQL Service',
description:
'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n',
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
generation: 1,
},
spec: {
type: 'cookiecutter',
path: './template',
},
};
expect(() => parseLocationAnnotation(mockEntity)).toThrow(
expect.objectContaining({
name: 'InputError',
message: `No location annotation provided in entity: ${mockEntity.metadata.name}`,
}),
);
});
it('should throw an error when the protocol part is not set in the location annotation', () => {
const mockEntity: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
[LOCATION_ANNOTATION]:
':https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml',
},
name: 'graphql-starter',
title: 'GraphQL Service',
description:
'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n',
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
generation: 1,
},
spec: {
type: 'cookiecutter',
path: './template',
},
};
expect(() => parseLocationAnnotation(mockEntity)).toThrow(
expect.objectContaining({
name: 'InputError',
message: `Failure to parse either protocol or location for entity: ${mockEntity.metadata.name}`,
}),
);
});
it('should throw an error when the location part is not set in the location annotation', () => {
const mockEntity: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
[LOCATION_ANNOTATION]: 'github:',
},
name: 'graphql-starter',
title: 'GraphQL Service',
description:
'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n',
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
generation: 1,
},
spec: {
type: 'cookiecutter',
path: './template',
},
};
expect(() => parseLocationAnnotation(mockEntity)).toThrow(
expect.objectContaining({
name: 'InputError',
message: `Failure to parse either protocol or location for entity: ${mockEntity.metadata.name}`,
}),
);
});
it('should parse the location and protocol correctly for simple locations', () => {
const mockEntity: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
[LOCATION_ANNOTATION]: 'file:./path',
},
name: 'graphql-starter',
title: 'GraphQL Service',
description:
'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n',
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
generation: 1,
},
spec: {
type: 'cookiecutter',
path: './template',
},
};
expect(parseLocationAnnotation(mockEntity)).toEqual({
protocol: 'file',
location: './path',
});
});
it('should parse the location and protocol correctly for complex with unescaped locations', () => {
const mockEntity: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
[LOCATION_ANNOTATION]: 'github:https://lol.com/:something/shello',
},
name: 'graphql-starter',
title: 'GraphQL Service',
description:
'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n',
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
generation: 1,
},
spec: {
type: 'cookiecutter',
path: './template',
},
};
expect(parseLocationAnnotation(mockEntity)).toEqual({
protocol: 'github',
location: 'https://lol.com/:something/shello',
});
});
});
});
@@ -0,0 +1,56 @@
/*
* 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 {
TemplateEntityV1alpha1,
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import { InputError } from '@backstage/backend-common';
import { RemoteProtocol } from './types';
export type ParsedLocationAnnotation = {
protocol: RemoteProtocol;
location: string;
};
export const parseLocationAnnotation = (
entity: TemplateEntityV1alpha1,
): ParsedLocationAnnotation => {
const annotation = entity.metadata.annotations?.[LOCATION_ANNOTATION];
if (!annotation) {
throw new InputError(
`No location annotation provided in entity: ${entity.metadata.name}`,
);
}
// split on the first colon for the protocol and the rest after the first split
// is the location.
const [protocol, location] = annotation.split(/:(.+)/) as [
RemoteProtocol?,
string?,
];
if (!protocol || !location) {
throw new InputError(
`Failure to parse either protocol or location for entity: ${entity.metadata.name}`,
);
}
return {
protocol,
location,
};
};
@@ -0,0 +1,20 @@
/*
* 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 * from './preparers';
export * from './types';
export * from './helpers';
export * from './file';
export * from './github';
@@ -0,0 +1,88 @@
/*
* 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 { Preparers } from '.';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { FilePreparer } from './file';
describe('Preparers', () => {
const mockTemplate: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
'backstage.io/managed-by-location':
'file:/Users/blam/dev/spotify/backstage/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml',
},
name: 'react-ssr-template',
title: 'React SSR Template',
description:
'Next.js application skeleton for creating isomorphic web applications.',
uid: '7357f4c5-aa58-4a1e-9670-18931eef771f',
etag: 'YWUxZWQyY2EtZDkxMC00MDM0LWI0ODAtMDgwMWY0YzdlMWIw',
generation: 1,
},
spec: {
type: 'cookiecutter',
path: '.',
},
};
it('should throw an error when the preparer for the source location is not registered', () => {
const preparers = new Preparers();
expect(() => preparers.get(mockTemplate)).toThrow(
expect.objectContaining({
message: 'No preparer registered for type: "file"',
}),
);
});
it('should return the correct preparer when the source matches', () => {
const preparers = new Preparers();
const preparer = new FilePreparer();
preparers.register('file', preparer);
expect(preparers.get(mockTemplate)).toBe(preparer);
});
it('should throw an error if the metadata tag does not exist in the entity', () => {
const brokenTemplate: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {},
name: 'react-ssr-template',
title: 'React SSR Template',
description:
'Next.js application skeleton for creating isomorphic web applications.',
uid: '7357f4c5-aa58-4a1e-9670-18931eef771f',
etag: 'YWUxZWQyY2EtZDkxMC00MDM0LWI0ODAtMDgwMWY0YzdlMWIw',
generation: 1,
},
spec: {
type: 'cookiecutter',
path: '.',
},
};
const preparers = new Preparers();
expect(() => preparers.get(brokenTemplate)).toThrow(
expect.objectContaining({
message: expect.stringContaining('No location annotation provided'),
}),
);
});
});
@@ -0,0 +1,38 @@
/*
* 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 { PreparerBase, RemoteProtocol, PreparerBuilder } from './types';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { parseLocationAnnotation } from './helpers';
export class Preparers implements PreparerBuilder {
private preparerMap = new Map<RemoteProtocol, PreparerBase>();
register(protocol: RemoteProtocol, preparer: PreparerBase) {
this.preparerMap.set(protocol, preparer);
}
get(template: TemplateEntityV1alpha1): PreparerBase {
const { protocol } = parseLocationAnnotation(template);
const preparer = this.preparerMap.get(protocol);
if (!preparer) {
throw new Error(`No preparer registered for type: "${protocol}"`);
}
return preparer;
}
}
@@ -0,0 +1,32 @@
/*
* 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 type { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
export type PreparerBase = {
/**
* Given an Entity definition from the Service Catalog, go and prepare a directory
* with contents from the remote location in temporary storage and return the path
* @param template The template entity from the Service Catalog
*/
prepare(template: TemplateEntityV1alpha1): Promise<string>;
};
export type PreparerBuilder = {
register(protocol: RemoteProtocol, preparer: PreparerBase): void;
get(template: TemplateEntityV1alpha1): PreparerBase;
};
export type RemoteProtocol = 'file' | 'github';
@@ -1,84 +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 { DiskStorage } from './disk';
import * as path from 'path';
describe('Disk Storage', () => {
it('should load a simple template from a simple directory', async () => {
const testTemplateDir = path.resolve(
__dirname,
'../../../test/mock-simple-template-dir',
);
const templateInfo = require(`${testTemplateDir}/mock-template/template-info.json`);
const repository = new DiskStorage({ directory: testTemplateDir });
await repository.reindex();
const templates = await repository.list();
expect(templates).toHaveLength(1);
expect(templates[0].id).toBe(templateInfo.id);
expect(templates[0].name).toBe(templateInfo.name);
expect(templates[0].description).toBe(templateInfo.description);
expect(templates[0].ownerId).toBe(templateInfo.ownerId);
});
it('should successfully load multiple templates from the same folder', async () => {
const testTemplateDir = path.resolve(
__dirname,
'../../../test/mock-multiple-templates-dir',
);
const repository = new DiskStorage({ directory: testTemplateDir });
await repository.reindex();
const templates = await repository.list();
expect(templates).toHaveLength(2);
});
it('should return empty array when there are no templates', async () => {
const testTemplateDir = path.resolve(
__dirname,
'/some-folder-that-deffo-does-not-exist',
);
const repository = new DiskStorage({ directory: testTemplateDir });
await repository.reindex();
const templates = await repository.list();
expect(templates).toHaveLength(0);
});
it('should be able to handle templates with invalid json and ignore them from the returned array', async () => {
const testTemplateDir = path.resolve(
__dirname,
'../../../test/mock-failing-template-dir',
);
const repository = new DiskStorage({ directory: testTemplateDir });
await repository.reindex();
const templates = await repository.list();
expect(templates).toHaveLength(1);
});
});
@@ -1,99 +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 globby from 'globby';
import fs from 'fs-extra';
import { Template, StorageBase as Base } from '.';
import { Logger } from 'winston';
interface DiskIndexEntry {
contents: Template;
location: string;
}
export class DiskStorage implements Base {
private repository: Template[] = [];
private localIndex: DiskIndexEntry[] = [];
private repoDir: string;
private logger?: Logger;
constructor({
directory = `${__dirname}/../../../sample-templates`,
logger,
}: {
directory?: string;
logger?: Logger;
}) {
this.repoDir = directory;
this.logger = logger;
}
public async list(): Promise<Template[]> {
if (this.repository.length === 0) {
await this.reindex();
}
return this.repository;
}
public async reindex(): Promise<void> {
this.localIndex = await this.index();
this.repository = this.localIndex.map(({ contents }) => contents);
}
public async prepare(templateId: string): Promise<string> {
const template = this.localIndex.find(
({ contents }) => contents.id === templateId,
);
if (!template) {
throw new Error('Template no found');
}
const tempDir = await fs.promises.mkdtemp(templateId);
await fs.copy(template.location, tempDir);
return tempDir;
}
private async index(): Promise<DiskIndexEntry[]> {
const matches = await globby(`${this.repoDir}/**/template-info.json`);
const fileContents: Array<{
location: string;
contents: string;
}> = await Promise.all(
matches.map(async (location: string) => ({
location,
contents: await fs.readFile(location, 'utf-8'),
})),
);
const validFiles: DiskIndexEntry[] = [];
for (const file of fileContents) {
try {
const contents: Template = JSON.parse(file.contents);
validFiles.push({ location: file.location, contents });
} catch (ex) {
this.logger?.error('Failure parsing JSON for template', {
path: file.location,
});
}
}
return validFiles;
}
}
@@ -1,58 +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 { StorageBase, createStorage } from '.';
import * as winston from 'winston';
describe('Storage Interface Test', () => {
const mockStore = new (class MockStorage implements StorageBase {
list = jest.fn();
prepare = jest.fn();
reindex = jest.fn();
public reset = () => {
this.list.mockReset();
this.prepare.mockReset();
this.reindex.mockReset();
};
})();
const logger = winston.createLogger();
afterEach(() => mockStore.reset());
it('should call list of the set repo when calling list', async () => {
const store = createStorage({ store: mockStore, logger });
await store.list();
expect(mockStore.list).toHaveBeenCalled();
});
it('should reindex on the repo when calling reindex', async () => {
const store = createStorage({ store: mockStore, logger });
await store.reindex();
expect(mockStore.reindex).toHaveBeenCalled();
});
it('should call prepare with the correct id when calling prepare', async () => {
const store = createStorage({ store: mockStore, logger });
await store.prepare('testid');
expect(mockStore.prepare).toHaveBeenCalledWith('testid');
});
});
@@ -1,53 +0,0 @@
import { Logger } from 'winston';
/*
* 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 interface Template {
id: string;
name: string;
description: string;
ownerId: string;
}
export abstract class StorageBase {
// lists all templates available
abstract async list(): Promise<Template[]>;
// can be used to build an index of the available templates;
abstract async reindex(): Promise<void>;
// returns a directory to run the templaterin
abstract async prepare(id: string): Promise<string>;
}
export interface StorageConfig {
store?: StorageBase;
logger?: Logger;
}
class Storage implements StorageBase {
store?: StorageBase;
constructor({ store }: StorageConfig) {
this.store = store;
}
list = () => this.store!.list();
prepare = (id: string) => this.store!.prepare(id);
reindex = () => this.store!.reindex();
}
export const createStorage = (storageConfig: StorageConfig): StorageBase => {
return new Storage(storageConfig);
};
@@ -25,7 +25,7 @@ export class CookieCutter implements TemplaterBase {
...options.values,
};
await fs.writeJSON(options.directory, cookieInfo);
await fs.writeJSON(`${options.directory}/cookiecutter.json`, cookieInfo);
return '';
// run cookie cutter with new json
}
@@ -15,7 +15,7 @@
*/
export interface RequiredTemplateValues {
componentId: string;
component_id: string;
}
export interface TemplaterRunOptions {
@@ -17,10 +17,11 @@
import { Logger } from 'winston';
import Router from 'express-promise-router';
import express from 'express';
import { StorageBase, TemplaterBase } from '../scaffolder';
import { PreparerBuilder, TemplaterBase } from '../scaffolder';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
export interface RouterOptions {
storage: StorageBase;
preparers: PreparerBuilder;
templater: TemplaterBase;
logger: Logger;
}
@@ -29,22 +30,49 @@ export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const router = Router();
const { storage, templater, logger: parentLogger } = options;
const { preparers, templater, logger: parentLogger } = options;
const logger = parentLogger.child({ plugin: 'scaffolder' });
router
.get('/v1/templates', async (_, res) => {
const templates = await storage.list();
res.status(200).json(templates);
})
.post('/v1/jobs', async (_, res) => {
// TODO(blam): Actually make this function work
const mock = 'templateid';
res.status(201).json({ accepted: true });
router.post('/v1/jobs', async (_, res) => {
// TODO(blam): Create a unique job here and return the ID so that
// The end user can poll for updates on the current job
res.status(201).json({ accepted: true });
const path = await storage.prepare(mock);
await templater.run({ directory: path, values: { componentId: 'test' } });
});
// TODO(blam): Take this entity from the post body sent from the frontend
const mockEntity: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
'backstage.io/managed-by-location':
'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml',
},
name: 'graphql-starter',
title: 'GraphQL Service',
description:
'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n',
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
generation: 1,
},
spec: {
type: 'cookiecutter',
path: './template',
},
};
// Get the preparer for the mock entity
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);
// Run the templater on the mock directory with values from the post body
await templater.run({ directory: path, values: { component_id: 'test' } });
console.warn(path);
});
const app = express();
app.set('logger', logger);
@@ -1,57 +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 { Server } from 'http';
import { Logger } from 'winston';
import {
createStorage,
createTemplater,
DiskStorage,
CookieCutter,
} from '../scaffolder';
import { createServiceBuilder, useHotMemoize } from '@backstage/backend-common';
import { createRouter } from './router';
export interface ServerOptions {
port: number;
enableCors: boolean;
logger: Logger;
}
export async function startStandaloneServer(
options: ServerOptions,
): Promise<Server> {
const logger = options.logger.child({ service: 'scaffolder-backend' });
const store = useHotMemoize(module, () => new DiskStorage({ logger }));
const templater = new CookieCutter();
logger.debug('Creating application...');
const router = await createRouter({
storage: createStorage({ store, logger }),
templater: createTemplater({ templater }),
logger,
});
const service = createServiceBuilder(module)
.enableCors({ origin: 'http://localhost:3000' })
.addRouter('/catalog', router);
return await service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}
module.hot?.accept();
@@ -1,6 +0,0 @@
{
"id": "mock-template-2",
"name": "mockmannen",
"description": "mock template for building stuff in backstage",
"ownerId": "blam"
}
@@ -1,7 +0,0 @@
{
"id": "mock-template",
"name": "mockmannen",
"description": "mock template for building stuff in backstage",
"ownerId": "blam",
<heres some invalid hson>
}
@@ -1,6 +0,0 @@
{
"id": "mock-template-2",
"name": "mockmannen",
"description": "mock template for building stuff in backstage",
"ownerId": "blam"
}
@@ -1,6 +0,0 @@
{
"id": "mock-template",
"name": "mockmannen",
"description": "mock template for building stuff in backstage",
"ownerId": "blam"
}
@@ -1,6 +0,0 @@
{
"id": "mock-template",
"name": "mockmannen",
"description": "mock template for building stuff in backstage",
"ownerId": "blam"
}