diff --git a/.changeset/red-readers-mix.md b/.changeset/red-readers-mix.md new file mode 100644 index 0000000000..1f1828cb4b --- /dev/null +++ b/.changeset/red-readers-mix.md @@ -0,0 +1,10 @@ +--- +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-scaffolder-backend': patch +--- + +Introduce scaffolder actions page which lists all available actions along with documentation about their input/output. + +Allow for actions to be extended with a description. + +The list actions page is by default available at `/create/actions`. diff --git a/docs/features/software-templates/builtin-actions.md b/docs/features/software-templates/builtin-actions.md index e6886b1f04..14dc7ef861 100644 --- a/docs/features/software-templates/builtin-actions.md +++ b/docs/features/software-templates/builtin-actions.md @@ -4,112 +4,13 @@ title: Builtin actions description: Documentation describing the built-in template actions. --- -# Built-in Actions +The scaffolder comes with several built-in actions for fetching content, +registering in the catalog and of course actions for creating and publishing a +git repository. -This is the list of built-in template actions +There are several repository providers supported out of the box such as GitHub, +Azure, GitLab and Bitbucket. -## `fetch:plain` - -Downloads content and places it in the workspacePath or optionally in a -subdirectory specified by the `targetPath` input option. - -input: - -- `url` (required) Relative path or absolute URL pointing to the directory tree - to fetch. -- `targetPath` Target path within the working directory to download the contents - to. - -output: nothing - -## `fetch:cookiecutter` - -Downloads template from `url` and templates with cookiecutter - -input: - -- `url` (required) Relative path or absolute URL pointing to the directory tree - to fetch. -- `targetPath` Target path within the working directory to download the contents - to. -- `values` Values to pass on to cookiecutter for templating. - -output: nothing - -## `catalog:register` - -Registers entity in the software catalog. - -input: - -- `catalogInfoUrl` (required) An absolute URL pointing to the catalog info file - location. - -output: - -- `repoContentsUrl` An absolute URL pointing to the root of a repository - directory tree. -- `catalogInfoPath` A relative path from the repo root pointing to the catalog - info file, defaults to /catalog-info.yaml - -## `publish:github` - -Initializes a git repository of contents in workspacePath and publishes to -GitHub. - -input: - -- `repoUrl` (required) Repository location -- `repoVisibility` (optional, default: 'private') Possible values: 'private', - 'public' or 'internal'. - -output: - -- `remoteUrl` A URL to the repository with the provider -- `repoContentsUrl` A URL to the root of the repository - -## `publish:gitlab` - -Initializes a git repository of contents in workspacePath and publishes to -GitLab. - -input: - -- `repoUrl` (required) Repository location -- `repoVisibility` (optional, default: 'private') Possible values: 'private', - 'public' or 'internal'. - -output: - -- `remoteUrl` A URL to the repository with the provider -- `repoContentsUrl` A URL to the root of the repository - -## `publish:bitbucket` - -Initializes a git repository of contents in workspacePath and publishes to -Bitbucket. - -input: - -- `repoUrl` (required) Repository location -- `repoVisibility` (optional, default: 'private') Possible values: 'private', - 'public'. - -output: - -- `remoteUrl` A URL to the repository with the provider -- `repoContentsUrl` A URL to the root of the repository - -## `publish:azure` - -Initializes a git repository of contents in workspacePath and publishes to -Azure. - -input: - -- `repoUrl` (required) Repository location - -output: - -- `remoteUrl` A URL to the repository with the provider -- `repoContentsUrl` A URL to the root of the repository +A list of all registered actions can be found under `/create/actions`. For local +development you should be able to reach them at +`http://localhost:3000/create/actions`. diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts b/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts index 2a841a564b..c9e163bfdd 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts @@ -38,4 +38,8 @@ export class TemplateActionRegistry { } return action; } + + list(): TemplateAction[] { + return [...this.actions.values()]; + } } diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts index 7cf1ac2120..b034b480e2 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts @@ -31,6 +31,8 @@ export function createCatalogRegisterAction(options: { | { repoContentsUrl: string; catalogInfoPath?: string } >({ id: 'catalog:register', + description: + 'Registers entities from a catalog descriptor file in the workspace into the software catalog.', schema: { input: { oneOf: [ diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.ts index 2a04ed301d..070aaa9679 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.ts @@ -38,6 +38,8 @@ export function createFetchCookiecutterAction(options: { values: JsonObject; }>({ id: 'fetch:cookiecutter', + description: + 'Downloads a template from the given URL into the workspace, and runs cookiecutter on it.', schema: { input: { type: 'object', diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts index 1eb1a6dc82..00c63535ca 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts @@ -28,6 +28,8 @@ export function createFetchPlainAction(options: { return createTemplateAction<{ url: string; targetPath?: string }>({ id: 'fetch:plain', + description: + "Downloads content and places it in the workspace, or optionally in a subdirectory specified by the 'targetPath' input option.", schema: { input: { type: 'object', diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts index 5eaba05ea0..db38960086 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts @@ -32,6 +32,8 @@ export function createPublishAzureAction(options: { description?: string; }>({ id: 'publish:azure', + description: + 'Initializes a git repository of the content in the workspace, and publishes it to Azure.', schema: { input: { type: 'object', diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts index 4c3cf30ecd..03881af35d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts @@ -167,6 +167,8 @@ export function createPublishBitbucketAction(options: { repoVisibility: 'private' | 'public'; }>({ id: 'publish:bitbucket', + description: + 'Initializes a git repository of the content in the workspace, and publishes it to Bitbucket.', schema: { input: { type: 'object', diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts index be0820d0b1..cb11e3d08a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts @@ -43,6 +43,8 @@ export function createPublishGithubAction(options: { repoVisibility: 'private' | 'internal' | 'public'; }>({ id: 'publish:github', + description: + 'Initializes a git repository of contents in workspace and publishes it to GitHub.', schema: { input: { type: 'object', diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts index 9122c828a0..eefdcdf11b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts @@ -31,6 +31,8 @@ export function createPublishGitlabAction(options: { repoVisibility: 'private' | 'internal' | 'public'; }>({ id: 'publish:gitlab', + description: + 'Initializes a git repository of the content in the workspace, and publishes it to GitLab.', schema: { input: { type: 'object', diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/types.ts b/plugins/scaffolder-backend/src/scaffolder/actions/types.ts index 20b7696d70..955cb72de9 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/types.ts @@ -44,6 +44,7 @@ export type ActionContext = { export type TemplateAction = { id: string; + description?: string; schema?: { input?: Schema; output?: Schema; diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index b7c2582c81..f01b4f8c95 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -261,6 +261,15 @@ describe('createRouter', () => { }); }); + describe('GET /v2/actions', () => { + it('lists available actions', async () => { + const response = await request(app).get('/v2/actions').send(); + expect(response.status).toEqual(200); + expect(response.body[0].id).toBeDefined(); + expect(response.body.length).toBeGreaterThan(8); + }); + }); + describe('POST /v2/tasks', () => { it('rejects template values which do not match the template schema definition', async () => { const response = await request(app) diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 3d47d5123b..81be03730b 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -347,6 +347,16 @@ export async function createRouter( } }, ) + .get('/v2/actions', async (_req, res) => { + const actionsList = actionRegistry.list().map(action => { + return { + id: action.id, + description: action.description, + schema: action.schema, + }; + }); + res.json(actionsList); + }) .post('/v2/tasks', async (req, res) => { const templateName: string = req.body.templateName; const values: TemplaterValues = req.body.values; diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index eef66a45ca..93fa32b164 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -43,6 +43,7 @@ "@rjsf/core": "^2.4.0", "@rjsf/material-ui": "^2.4.0", "classnames": "^2.2.6", + "json-schema": "^0.2.5", "git-url-parse": "^11.4.4", "humanize-duration": "^3.25.1", "luxon": "^1.25.0", diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 2be4caa81f..758cd555e3 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -25,7 +25,7 @@ import { } from '@backstage/core'; import { ScmIntegrations } from '@backstage/integration'; import ObservableImpl from 'zen-observable'; -import { ScaffolderTask, Status } from './types'; +import { ListActionsResponse, ScaffolderTask, Status } from './types'; export const scaffolderApiRef = createApiRef({ id: 'plugin.scaffolder.service', @@ -72,6 +72,9 @@ export interface ScaffolderApi { allowedHosts: string[]; }): Promise<{ type: string; title: string; host: string }[]>; + // Returns a list of all installed actions. + listActions(): Promise; + streamLogs({ taskId, after, @@ -227,4 +230,13 @@ export class ScaffolderClient implements ScaffolderApi { ); }); } + + /** + * @returns ListActionsResponse containing all registered actions. + */ + async listActions(): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); + const response = await fetch(`${baseUrl}/v2/actions`); + return await response.json(); + } } diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx new file mode 100644 index 0000000000..708d08359a --- /dev/null +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx @@ -0,0 +1,167 @@ +/* + * Copyright 2021 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 { ApiRegistry, ApiProvider } from '@backstage/core'; +import React from 'react'; +import { ScaffolderApi, scaffolderApiRef } from '../../api'; +import { ActionsPage } from './ActionsPage'; +import { rootRouteRef } from '../../routes'; +import { renderInTestApp } from '@backstage/test-utils'; + +const scaffolderApiMock: jest.Mocked = { + scaffold: jest.fn(), + getTemplateParameterSchema: jest.fn(), + getIntegrationsList: jest.fn(), + getTask: jest.fn(), + streamLogs: jest.fn(), + listActions: jest.fn(), +}; + +const apis = ApiRegistry.from([[scaffolderApiRef, scaffolderApiMock]]); + +describe('TemplatePage', () => { + beforeEach(() => jest.resetAllMocks()); + + it('renders action with input', async () => { + scaffolderApiMock.listActions.mockResolvedValue([ + { + id: 'test', + description: 'example description', + schema: { + input: { + type: 'object', + required: ['foobar'], + properties: { + foobar: { + title: 'Test title', + type: 'string', + }, + }, + }, + }, + }, + ]); + const rendered = await renderInTestApp( + + + , + { + mountedRoutes: { + '/create/actions': rootRouteRef, + }, + }, + ); + expect(rendered.queryByText('Test title')).toBeInTheDocument(); + expect(rendered.queryByText('example description')).toBeInTheDocument(); + expect(rendered.queryByText('foobar')).toBeInTheDocument(); + expect(rendered.queryByText('output')).not.toBeInTheDocument(); + }); + + it('renders action with input and output', async () => { + scaffolderApiMock.listActions.mockResolvedValue([ + { + id: 'test', + description: 'example description', + schema: { + input: { + type: 'object', + required: ['foobar'], + properties: { + foobar: { + title: 'Test title', + type: 'string', + }, + }, + }, + output: { + type: 'object', + properties: { + buzz: { + title: 'Test output', + type: 'string', + }, + }, + }, + }, + }, + ]); + const rendered = await renderInTestApp( + + + , + { + mountedRoutes: { + '/create/actions': rootRouteRef, + }, + }, + ); + expect(rendered.queryByText('Test title')).toBeInTheDocument(); + expect(rendered.queryByText('example description')).toBeInTheDocument(); + expect(rendered.queryByText('foobar')).toBeInTheDocument(); + expect(rendered.queryByText('Test output')).toBeInTheDocument(); + }); + + it('renders action with oneOf input', async () => { + scaffolderApiMock.listActions.mockResolvedValue([ + { + id: 'test', + description: 'example description', + schema: { + input: { + oneOf: [ + { + type: 'object', + required: ['foo'], + properties: { + foo: { + title: 'Foo title', + description: 'Foo description', + type: 'string', + }, + }, + }, + { + type: 'object', + required: ['bar'], + properties: { + bar: { + title: 'Bar title', + description: 'Bar description', + type: 'string', + }, + }, + }, + ], + }, + }, + }, + ]); + const rendered = await renderInTestApp( + + + , + { + mountedRoutes: { + '/create/actions': rootRouteRef, + }, + }, + ); + expect(rendered.queryByText('oneOf')).toBeInTheDocument(); + expect(rendered.queryByText('Foo title')).toBeInTheDocument(); + expect(rendered.queryByText('Foo description')).toBeInTheDocument(); + expect(rendered.queryByText('Bar title')).toBeInTheDocument(); + expect(rendered.queryByText('Bar description')).toBeInTheDocument(); + }); +}); diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx new file mode 100644 index 0000000000..a06ca50736 --- /dev/null +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx @@ -0,0 +1,192 @@ +/* + * Copyright 2021 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 React from 'react'; +import { useAsync } from 'react-use'; +import { + useApi, + Progress, + Content, + Header, + Page, + ErrorPage, +} from '@backstage/core'; +import { scaffolderApiRef } from '../../api'; +import { + Typography, + Paper, + Table, + TableBody, + Box, + TableCell, + TableContainer, + TableHead, + TableRow, + makeStyles, +} from '@material-ui/core'; +import { JSONSchema } from '@backstage/catalog-model'; +import { JSONSchema7Definition } from 'json-schema'; +import classNames from 'classnames'; + +const useStyles = makeStyles(theme => ({ + code: { + fontFamily: 'Menlo, monospace', + padding: theme.spacing(1), + backgroundColor: + theme.palette.type === 'dark' + ? theme.palette.grey[700] + : theme.palette.grey[300], + display: 'inline-block', + borderRadius: 5, + border: `1px solid ${theme.palette.grey[500]}`, + position: 'relative', + }, + + codeRequired: { + '&::after': { + position: 'absolute', + content: '"*"', + top: 0, + right: theme.spacing(0.5), + fontWeight: 'bolder', + color: theme.palette.error.light, + }, + }, +})); + +export const ActionsPage = () => { + const api = useApi(scaffolderApiRef); + const classes = useStyles(); + const { loading, value, error } = useAsync(async () => { + return api.listActions(); + }); + + if (loading) { + return ; + } + + if (error) { + return ( + + ); + } + + const formatRows = (input: JSONSchema) => { + const properties = input.properties; + if (!properties) { + return undefined; + } + + return Object.entries(properties).map(entry => { + const [key] = entry; + const props = (entry[1] as unknown) as JSONSchema; + const codeClassname = classNames(classes.code, { + [classes.codeRequired]: input.required?.includes(key), + }); + + return ( + + +
{key}
+
+ {props.title} + {props.description} + + {props.type} + +
+ ); + }); + }; + + const renderTable = (input: JSONSchema) => { + if (!input.properties) { + return undefined; + } + return ( + + + + + Name + Title + Description + Type + + + {formatRows(input)} +
+
+ ); + }; + + const renderTables = (name: string, input?: JSONSchema7Definition[]) => { + if (!input) { + return undefined; + } + + return ( + <> + {name} + {input.map((i, index) => ( +
{renderTable((i as unknown) as JSONSchema)}
+ ))} + + ); + }; + + const items = value?.map(action => { + if (action.id.startsWith('legacy:')) { + return undefined; + } + + const oneOf = renderTables('oneOf', action.schema?.input?.oneOf); + return ( + + + {action.id} + + {action.description} + {action.schema?.input && ( + + Input + {renderTable(action.schema.input)} + {oneOf} + + )} + {action.schema?.output && ( + + Output + {renderTable(action.schema.output)} + + )} + + ); + }); + + return ( + +
+ {items} + + ); +}; diff --git a/plugins/scaffolder/src/components/ActionsPage/index.ts b/plugins/scaffolder/src/components/ActionsPage/index.ts new file mode 100644 index 0000000000..64d548e37d --- /dev/null +++ b/plugins/scaffolder/src/components/ActionsPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 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 { ActionsPage } from './ActionsPage'; diff --git a/plugins/scaffolder/src/components/Router.tsx b/plugins/scaffolder/src/components/Router.tsx index 12d4ecce82..9a525c0be4 100644 --- a/plugins/scaffolder/src/components/Router.tsx +++ b/plugins/scaffolder/src/components/Router.tsx @@ -19,11 +19,13 @@ import { Routes, Route } from 'react-router'; import { ScaffolderPage } from './ScaffolderPage'; import { TemplatePage } from './TemplatePage'; import { TaskPage } from './TaskPage'; +import { ActionsPage } from './ActionsPage'; export const Router = () => ( } /> } /> } /> + } /> ); diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index 1b11b704ae..3cdf6c9738 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -39,6 +39,7 @@ const scaffolderApiMock: jest.Mocked = { getIntegrationsList: jest.fn(), getTask: jest.fn(), streamLogs: jest.fn(), + listActions: jest.fn(), }; const errorApiMock = { post: jest.fn(), error$: jest.fn() }; diff --git a/plugins/scaffolder/src/types.ts b/plugins/scaffolder/src/types.ts index 0499f4aca2..6a2c2eede4 100644 --- a/plugins/scaffolder/src/types.ts +++ b/plugins/scaffolder/src/types.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { JSONSchema } from '@backstage/catalog-model'; import { JsonValue } from '@backstage/config'; export type Status = 'open' | 'processing' | 'failed' | 'completed'; @@ -54,3 +55,12 @@ export type ScaffolderTask = { lastHeartbeatAt: string; createdAt: string; }; + +export type ListActionsResponse = Array<{ + id: string; + description?: string; + schema?: { + input?: JSONSchema; + output?: JSONSchema; + }; +}>;