Merge pull request #2881 from Marvin9/chore/use-catalog-api

chore: use catalog API to fetch entity in scaffolder-backend
This commit is contained in:
Patrik Oldsberg
2020-11-05 11:17:34 +01:00
committed by GitHub
11 changed files with 181 additions and 52 deletions
+16
View File
@@ -0,0 +1,16 @@
---
'@backstage/plugin-scaffolder': minor
'@backstage/plugin-scaffolder-backend': minor
---
`createRouter` of scaffolder backend will now require additional option as `entityClient` which could be generated by `CatalogEntityClient` in `plugin-scaffolder-backend` package. Here is example to generate `entityClient`.
```js
import { CatalogEntityClient } from '@backstage/plugin-scaffolder-backend';
import { SingleHostDiscovery } from '@backstage/backend-common';
const discovery = SingleHostDiscovery.fromConfig(config);
const entityClient = new CatalogEntityClient({ discovery });
```
- Scaffolder's API `/v1/jobs` will accept `templateName` instead of `template` Entity.
@@ -21,7 +21,9 @@ import {
Publishers,
CreateReactAppTemplater,
Templaters,
CatalogEntityClient,
} from '@backstage/plugin-scaffolder-backend';
import { SingleHostDiscovery } from '@backstage/backend-common';
import type { PluginEnvironment } from '../types';
import Docker from 'dockerode';
@@ -40,6 +42,9 @@ export default async function createPlugin({
const dockerClient = new Docker();
const discovery = SingleHostDiscovery.fromConfig(config);
const entityClient = new CatalogEntityClient({ discovery });
return await createRouter({
preparers,
templaters,
@@ -47,5 +52,6 @@ export default async function createPlugin({
logger,
config,
dockerClient,
entityClient,
});
}
@@ -11,7 +11,9 @@ import {
CreateReactAppTemplater,
Templaters,
RepoVisibilityOptions,
CatalogEntityClient,
} from '@backstage/plugin-scaffolder-backend';
import { SingleHostDiscovery } from '@backstage/backend-common';
import { Octokit } from '@octokit/rest';
import { Gitlab } from '@gitbeaker/node';
import type { PluginEnvironment } from '../types';
@@ -97,6 +99,10 @@ export default async function createPlugin({
}
const dockerClient = new Docker();
const discovery = SingleHostDiscovery.fromConfig(config);
const entityClient = new CatalogEntityClient({ discovery });
return await createRouter({
preparers,
templaters,
@@ -104,5 +110,6 @@ export default async function createPlugin({
logger,
config,
dockerClient,
entityClient,
});
}
+2 -1
View File
@@ -44,7 +44,8 @@
"nodegit": "0.27.0",
"uuid": "^8.2.0",
"winston": "^3.2.1",
"yaml": "^1.10.0"
"yaml": "^1.10.0",
"cross-fetch": "^3.0.6"
},
"devDependencies": {
"@backstage/cli": "^0.2.0",
+1
View File
@@ -16,3 +16,4 @@
export * from './scaffolder';
export * from './service/router';
export * from './lib/catalog';
@@ -0,0 +1,72 @@
/*
* 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 fetch from 'cross-fetch';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import {
ConflictError,
NotFoundError,
PluginEndpointDiscovery,
} from '@backstage/backend-common';
/**
* A catalog client tailored for reading out entity data from the catalog.
*/
export class CatalogEntityClient {
private readonly discovery: PluginEndpointDiscovery;
constructor(options: { discovery: PluginEndpointDiscovery }) {
this.discovery = options.discovery;
}
/**
* Looks up a single template using a template name.
*
* Throws a NotFoundError or ConflictError if 0 or multiple templates are found.
*/
async findTemplate(templateName: string): Promise<TemplateEntityV1alpha1> {
const conditions = [
'kind=template',
`metadata.name=${encodeURIComponent(templateName)}`,
];
const baseUrl = await this.discovery.getBaseUrl('catalog');
const response = await fetch(
`${baseUrl}/entities?filter=${conditions.join(',')}`,
);
if (!response.ok) {
const text = await response.text();
throw new Error(
`Request failed with ${response.status} ${response.statusText}, ${text}`,
);
}
const templates: TemplateEntityV1alpha1[] = await response.json();
if (templates.length !== 1) {
if (templates.length > 1) {
throw new ConflictError(
'Templates lookup resulted in multiple matches',
);
} else {
throw new NotFoundError('Template not found');
}
}
return templates[0];
}
}
@@ -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.
*/
export { CatalogEntityClient } from './CatalogEntityClient';
@@ -35,6 +35,10 @@ import Docker from 'dockerode';
jest.mock('dockerode');
const generateEntityClient: any = (template: any) => ({
findTemplate: () => Promise.resolve(template),
});
describe('createRouter - working directory', () => {
const mockPrepare = jest.fn();
const mockPreparers = new Preparers();
@@ -74,6 +78,8 @@ describe('createRouter - working directory', () => {
},
};
const mockedEntityClient = generateEntityClient(template);
it('should throw an error when working directory does not exist or is not writable', async () => {
mockAccess.mockImplementation(() => {
throw new Error('access error');
@@ -87,6 +93,7 @@ describe('createRouter - working directory', () => {
publishers: new Publishers(),
config: ConfigReader.fromConfigs([workDirConfig('/path')]),
dockerClient: new Docker(),
entityClient: mockedEntityClient,
}),
).rejects.toThrow('access error');
});
@@ -99,11 +106,12 @@ describe('createRouter - working directory', () => {
publishers: new Publishers(),
config: ConfigReader.fromConfigs([workDirConfig('/path')]),
dockerClient: new Docker(),
entityClient: mockedEntityClient,
});
const app = express().use(router);
await request(app).post('/v1/jobs').send({
template,
templateName: '',
values: {},
});
@@ -121,11 +129,12 @@ describe('createRouter - working directory', () => {
publishers: new Publishers(),
config: ConfigReader.fromConfigs([]),
dockerClient: new Docker(),
entityClient: mockedEntityClient,
});
const app = express().use(router);
await request(app).post('/v1/jobs').send({
template,
templateName: '',
values: {},
});
@@ -137,6 +146,43 @@ describe('createRouter - working directory', () => {
describe('createRouter', () => {
let app: express.Express;
const template = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
description: 'Create a new CRA website project',
name: 'create-react-app-template',
tags: ['experimental', 'react', 'cra'],
title: 'Create React App Template',
},
spec: {
owner: 'web@example.com',
path: '.',
schema: {
properties: {
component_id: {
description: 'Unique name of the component',
title: 'Name',
type: 'string',
},
description: {
description: 'Description of the component',
title: 'Description',
type: 'string',
},
use_typescript: {
default: true,
description: 'Include typescript',
title: 'Use Typescript',
type: 'boolean',
},
},
required: ['component_id', 'use_typescript'],
},
templater: 'cra',
type: 'website',
},
};
beforeAll(async () => {
const router = await createRouter({
@@ -146,6 +192,7 @@ describe('createRouter', () => {
publishers: new Publishers(),
config: ConfigReader.fromConfigs([]),
dockerClient: new Docker(),
entityClient: generateEntityClient(template),
});
app = express().use(router);
});
@@ -155,47 +202,9 @@ describe('createRouter', () => {
});
describe('POST /v1/jobs', () => {
const template = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
description: 'Create a new CRA website project',
name: 'create-react-app-template',
tags: ['experimental', 'react', 'cra'],
title: 'Create React App Template',
},
spec: {
owner: 'web@example.com',
path: '.',
schema: {
properties: {
component_id: {
description: 'Unique name of the component',
title: 'Name',
type: 'string',
},
description: {
description: 'Description of the component',
title: 'Description',
type: 'string',
},
use_typescript: {
default: true,
description: 'Include typescript',
title: 'Use Typescript',
type: 'boolean',
},
},
required: ['component_id', 'use_typescript'],
},
templater: 'cra',
type: 'website',
},
};
it('rejects template values which do not match the template schema definition', async () => {
const response = await request(app).post('/v1/jobs').send({
template,
templateName: '',
values: {},
});
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { Config, JsonValue } from '@backstage/config';
import fs from 'fs-extra';
import Docker from 'dockerode';
@@ -29,6 +28,7 @@ import {
TemplaterBuilder,
PublisherBuilder,
} from '../scaffolder';
import { CatalogEntityClient } from '../lib/catalog';
import { validate, ValidatorResult } from 'jsonschema';
export interface RouterOptions {
@@ -39,6 +39,7 @@ export interface RouterOptions {
logger: Logger;
config: Config;
dockerClient: Docker;
entityClient: CatalogEntityClient;
}
export async function createRouter(
@@ -54,6 +55,7 @@ export async function createRouter(
logger: parentLogger,
config,
dockerClient,
entityClient,
} = options;
const logger = parentLogger.child({ plugin: 'scaffolder' });
@@ -104,14 +106,17 @@ export async function createRouter(
});
})
.post('/v1/jobs', async (req, res) => {
const template: TemplateEntityV1alpha1 = req.body.template;
const templateName: string = req.body.templateName;
const values: RequiredTemplateValues & Record<string, JsonValue> =
req.body.values;
const template = await entityClient.findTemplate(templateName);
const validationResult: ValidatorResult = validate(
values,
template.spec.schema,
);
if (!validationResult.valid) {
res.status(400).json({ errors: validationResult.errors });
return;
+3 -7
View File
@@ -15,7 +15,6 @@
*/
import { createApiRef, DiscoveryApi } from '@backstage/core';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
export const scaffolderApiRef = createApiRef<ScaffolderApi>({
id: 'plugin.scaffolder.service',
@@ -33,20 +32,17 @@ export class ScaffolderApi {
* Executes the scaffolding of a component, given a template and its
* parameter values.
*
* @param template Template entity for the scaffolder to use. New project is going to be created out of this template.
* @param templateName Template name for the scaffolder to use. New project is going to be created out of this template.
* @param values Parameters for the template, e.g. name, description
*/
async scaffold(
template: TemplateEntityV1alpha1,
values: Record<string, any>,
) {
async scaffold(templateName: string, values: Record<string, any>) {
const url = `${await this.discoveryApi.getBaseUrl('scaffolder')}/v1/jobs`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ template, values: { ...values } }),
body: JSON.stringify({ templateName, values: { ...values } }),
});
if (response.status !== 201) {
@@ -96,7 +96,7 @@ export const TemplatePage = () => {
const handleCreate = async () => {
try {
const job = await scaffolderApi.scaffold(template!, formState);
const job = await scaffolderApi.scaffold(templateName, formState);
setJobId(job);
} catch (e) {
errorApi.post(e);