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:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user