Merge pull request #1371 from spotify/mob/prepare-from-catalog
File Preparer for the Scaffolder using the Catalog Template Entity
This commit is contained in:
+6
-4
@@ -156,10 +156,12 @@ Integrators also configure closed source plugins locally from the monorepo.
|
||||
We chose GitHub because it is the tool that we are most familiar with, so that
|
||||
will naturally lead to integrations for GitHub being developed at an early
|
||||
stage. Hosting this project on GitHub does not exclude integrations with
|
||||
alternatives, such as [GitLab](https://github.com/spotify/backstage/issues?q=is%3Aissue+is%3Aopen+GitLab) or Bitbucket. We believe that in time there will be
|
||||
plugins that will provide functionality for these tools as well. Hopefully,
|
||||
contributed by the community! Also note, implementations of Backstage can be
|
||||
hosted wherever you feel suits your needs best.
|
||||
alternatives, such as
|
||||
[GitLab](https://github.com/spotify/backstage/issues?q=is%3Aissue+is%3Aopen+GitLab)
|
||||
or Bitbucket. We believe that in time there will be plugins that will provide
|
||||
functionality for these tools as well. Hopefully, contributed by the community!
|
||||
Also note, implementations of Backstage can be hosted wherever you feel suits
|
||||
your needs best.
|
||||
|
||||
### Who maintains Backstage?
|
||||
|
||||
|
||||
@@ -17,13 +17,17 @@
|
||||
import {
|
||||
CookieCutter,
|
||||
createRouter,
|
||||
DiskStorage,
|
||||
FilePreparer,
|
||||
Preparers,
|
||||
} from '@backstage/plugin-scaffolder-backend';
|
||||
import type { PluginEnvironment } from '../types';
|
||||
|
||||
export default async function createPlugin({ logger }: PluginEnvironment) {
|
||||
const storage = new DiskStorage({ logger });
|
||||
const templater = new CookieCutter();
|
||||
const filePreparer = new FilePreparer();
|
||||
const preparers = new Preparers();
|
||||
|
||||
return await createRouter({ storage, templater, logger });
|
||||
preparers.register('file', filePreparer);
|
||||
|
||||
return await createRouter({ preparers, templater, logger });
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface TemplateEntityV1alpha1 extends Entity {
|
||||
kind: typeof KIND;
|
||||
spec: {
|
||||
type: string;
|
||||
path?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,6 +40,7 @@ export class TemplateEntityV1alpha1Policy implements EntityPolicy {
|
||||
spec: yup
|
||||
.object({
|
||||
type: yup.string().required().min(1),
|
||||
path: yup.string(),
|
||||
})
|
||||
.required(),
|
||||
});
|
||||
|
||||
+1
-19
@@ -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
|
||||
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.1.1-alpha.9",
|
||||
"@backstage/catalog-model": "^0.1.1-alpha.9",
|
||||
"@types/express": "^4.17.6",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
@@ -38,7 +39,8 @@
|
||||
"@backstage/cli": "^0.1.1-alpha.9",
|
||||
"@types/fs-extra": "^9.0.1",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"supertest": "^4.0.2"
|
||||
"supertest": "^4.0.2",
|
||||
"yaml": "^1.10.0"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*.{js,d.ts}"
|
||||
|
||||
+1
-1
@@ -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,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,19 @@
|
||||
/*
|
||||
* 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';
|
||||
@@ -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';
|
||||
@@ -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,47 @@ 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': `file:${__dirname}/../../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: '.',
|
||||
},
|
||||
};
|
||||
|
||||
// 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();
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"id": "mock-template-2",
|
||||
"name": "mockmannen",
|
||||
"description": "mock template for building stuff in backstage",
|
||||
"ownerId": "blam"
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"id": "mock-template",
|
||||
"name": "mockmannen",
|
||||
"description": "mock template for building stuff in backstage",
|
||||
"ownerId": "blam",
|
||||
<heres some invalid hson>
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"id": "mock-template-2",
|
||||
"name": "mockmannen",
|
||||
"description": "mock template for building stuff in backstage",
|
||||
"ownerId": "blam"
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"id": "mock-template",
|
||||
"name": "mockmannen",
|
||||
"description": "mock template for building stuff in backstage",
|
||||
"ownerId": "blam"
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"id": "mock-template",
|
||||
"name": "mockmannen",
|
||||
"description": "mock template for building stuff in backstage",
|
||||
"ownerId": "blam"
|
||||
}
|
||||
Reference in New Issue
Block a user