scaffolder-backend: add action parameter type and schema with validation

Signed-off-by: Johan Haals <johan.haals@gmail.com>
This commit is contained in:
Patrik Oldsberg
2021-02-25 01:19:42 +01:00
committed by Johan Haals
parent 7e8f2552f6
commit 20ba3205f0
7 changed files with 154 additions and 42 deletions
@@ -14,13 +14,15 @@
* limitations under the License.
*/
import { TemplateAction } from './types';
import { ParameterBase, TemplateAction } from './types';
import { ConflictError, NotFoundError } from '@backstage/backend-common';
export class TemplateActionRegistry {
private readonly actions = new Map<string, TemplateAction>();
private readonly actions = new Map<string, TemplateAction<any>>();
register(action: TemplateAction) {
register<Parameters extends ParameterBase>(
action: TemplateAction<Parameters>,
) {
if (this.actions.has(action.id)) {
throw new ConflictError(
`Template action with ID '${action.id}' has already been registered`,
@@ -29,7 +31,7 @@ export class TemplateActionRegistry {
this.actions.set(action.id, action);
}
get(actionId: string): TemplateAction {
get(actionId: string): TemplateAction<any> {
const action = this.actions.get(actionId);
if (!action) {
throw new NotFoundError(
@@ -23,19 +23,59 @@ import { TemplateAction } from '../../types';
export function createCatalogRegisterAction(options: {
catalogClient: CatalogApi;
integrations: ScmIntegrations;
}): TemplateAction {
}): TemplateAction<
| { catalogInfoUrl: string }
| { repoContentsUrl: string; catalogInfoPath?: string }
> {
const { catalogClient, integrations } = options;
return {
id: 'catalog:register',
parameterSchema: {
oneOf: [
{
type: 'object',
required: ['catalogInfoUrl'],
properties: {
catalogInfoUrl: {
title: 'Catalog Info URL',
description:
'An absolute URL pointing to the catalog info file location',
type: 'string',
},
},
},
{
type: 'object',
required: ['repoContentsUrl'],
properties: {
repoContentsUrl: {
title: 'Repository Contents URL',
description:
'An absolute URL pointing to the root of a repository directory tree',
type: 'string',
},
catalogInfoPath: {
title: 'Fetch URL',
description:
'A relative path from the repo root pointing to the catalog info file, defaults to /catalog-info.yaml',
type: 'string',
},
},
},
],
},
async handler(ctx) {
const {
repoContentsUrl,
catalogInfoPath = '/catalog-info.yaml',
} = ctx.parameters;
const { parameters } = ctx;
let { catalogInfoUrl } = ctx.parameters;
if (!catalogInfoUrl) {
let catalogInfoUrl;
if ('catalogInfoUrl' in parameters) {
catalogInfoUrl = parameters.catalogInfoUrl;
} else {
const {
repoContentsUrl,
catalogInfoPath = '/catalog-info.yaml',
} = parameters;
const integration = integrations.byUrl(repoContentsUrl as string);
if (!integration) {
throw new InputError('No integration found for host');
@@ -19,6 +19,7 @@ import { resolve as resolvePath } from 'path';
import Docker from 'dockerode';
import { InputError, UrlReader } from '@backstage/backend-common';
import { ScmIntegrations } from '@backstage/integration';
import { JsonObject } from '@backstage/config';
import { TemplaterBuilder, TemplaterValues } from '../../../stages/templater';
import { TemplateAction } from '../../types';
import { fetchContents } from './helpers';
@@ -28,11 +29,34 @@ export function createFetchCookiecutterAction(options: {
urlReader: UrlReader;
integrations: ScmIntegrations;
templaters: TemplaterBuilder;
}): TemplateAction {
}): TemplateAction<{ url: string; targetPath?: string; values: JsonObject }> {
const { dockerClient, urlReader, templaters, integrations } = options;
return {
id: 'fetch:cookiecutter',
parameterSchema: {
type: 'object',
required: ['url'],
properties: {
url: {
title: 'Fetch URL',
description:
'Relative path or absolute URL pointing to the directory tree to fetch',
type: 'string',
},
targetPath: {
title: 'Target Path',
description:
'Target path within the working directory to download the contents to.',
type: 'string',
},
values: {
title: 'Template Values',
description: 'Values to pass on to cookiecutter for templating',
type: 'object',
},
},
},
async handler(ctx) {
ctx.logger.info('Fetching and then templating using cookiecutter');
const workDir = await ctx.createTemporaryDirectory();
@@ -66,11 +90,6 @@ export function createFetchCookiecutterAction(options: {
// Finally move the template result into the task workspace
const targetPath = ctx.parameters.targetPath ?? './';
if (typeof targetPath !== 'string') {
throw new InputError(
`Fetch action targetPath is not a string, got ${targetPath}`,
);
}
const outputPath = resolvePath(ctx.workspacePath, targetPath);
if (!outputPath.startsWith(ctx.workspacePath)) {
throw new InputError(
@@ -23,21 +23,34 @@ import { fetchContents } from './helpers';
export function createFetchPlainAction(options: {
urlReader: UrlReader;
integrations: ScmIntegrations;
}): TemplateAction {
}): TemplateAction<{ url: string; targetPath?: string }> {
const { urlReader, integrations } = options;
return {
id: 'fetch:plain',
parameterSchema: {
type: 'object',
required: ['url'],
properties: {
url: {
title: 'Fetch URL',
description:
'Relative path or absolute URL pointing to the directory tree to fetch',
type: 'string',
},
targetPath: {
title: 'Target Path',
description:
'Target path within the working directory to download the contents to.',
type: 'string',
},
},
},
async handler(ctx) {
ctx.logger.info('Fetching plain content from remote URL');
// Finally move the template result into the task workspace
const targetPath = ctx.parameters.targetPath ?? './';
if (typeof targetPath !== 'string') {
throw new InputError(
`Fetch action targetPath is not a string, got ${targetPath}`,
);
}
const outputPath = resolvePath(ctx.workspacePath, targetPath);
if (!outputPath.startsWith(ctx.workspacePath)) {
throw new InputError(
@@ -26,7 +26,11 @@ import { initRepoAndPush } from '../../../stages/publish/helpers';
export function createPublishGithubAction(options: {
integrations: ScmIntegrations;
repoVisibility: 'private' | 'internal' | 'public';
}): TemplateAction {
}): TemplateAction<{
repoUrl: string;
description?: string;
access?: string;
}> {
const { integrations, repoVisibility } = options;
const credentialsProviders = new Map(
@@ -38,14 +42,27 @@ export function createPublishGithubAction(options: {
return {
id: 'publish:github',
parameterSchema: {
type: 'object',
required: ['repoUrl'],
properties: {
repoUrl: {
title: 'Repository Location',
type: 'string',
},
description: {
title: 'Repository Description',
type: 'string',
},
access: {
title: 'Additional Repository Access',
type: 'string',
},
},
},
async handler(ctx) {
const { repoUrl, description, access } = ctx.parameters;
if (typeof repoUrl !== 'string') {
throw new Error(
`Invalid repo URL passed to publish:github, got ${typeof repoUrl}`,
);
}
let parsed;
try {
parsed = new URL(`https://${repoUrl}`);
@@ -103,18 +120,17 @@ export function createPublishGithubAction(options: {
org: owner,
private: repoVisibility !== 'public',
visibility: repoVisibility,
description: description as string,
description: description,
})
: client.repos.createForAuthenticatedUser({
name: repo,
private: repoVisibility === 'private',
description: description as string,
description: description,
});
const { data } = await repoCreationPromise;
const accessString = access as string;
if (accessString?.startsWith(`${owner}/`)) {
const [, team] = accessString.split('/');
if (access?.startsWith(`${owner}/`)) {
const [, team] = access.split('/');
await client.teams.addOrUpdateRepoPermissionsInOrg({
org: owner,
team_slug: team,
@@ -122,12 +138,12 @@ export function createPublishGithubAction(options: {
repo,
permission: 'admin',
});
// no need to add accessString if it's the person who own's the personal account
} else if (accessString && accessString !== owner) {
// no need to add access if it's the person who own's the personal account
} else if (access && access !== owner) {
await client.repos.addCollaborator({
owner,
repo,
username: accessString,
username: access,
permission: 'admin',
});
}
@@ -16,9 +16,14 @@
import { Logger } from 'winston';
import { Writable } from 'stream';
import { JsonValue } from '@backstage/config';
import { JsonValue, JsonObject } from '@backstage/config';
import { Schema } from 'jsonschema';
export type ActionContext = {
type PartialJsonObject = Partial<JsonObject>;
type PartialJsonValue = PartialJsonObject | JsonValue | undefined;
export type ParameterBase = Partial<{ [name: string]: PartialJsonValue }>;
export type ActionContext<Parameters extends ParameterBase> = {
/**
* Base URL for the location of the task spec, typically the url of the source entity file.
*/
@@ -28,7 +33,7 @@ export type ActionContext = {
logStream: Writable;
workspacePath: string;
parameters: { [name: string]: JsonValue };
parameters: Parameters;
output(name: string, value: JsonValue): void;
/**
@@ -37,7 +42,8 @@ export type ActionContext = {
createTemporaryDirectory(): Promise<string>;
};
export type TemplateAction = {
export type TemplateAction<Parameters extends ParameterBase> = {
id: string;
handler: (ctx: ActionContext) => Promise<void>;
parameterSchema?: Schema;
handler: (ctx: ActionContext<Parameters>) => Promise<void>;
};
@@ -18,11 +18,13 @@ import { PassThrough } from 'stream';
import { Logger } from 'winston';
import * as winston from 'winston';
import { JsonValue, JsonObject } from '@backstage/config';
import { validate as validateJsonSchema } from 'jsonschema';
import { TaskBroker, Task } from './types';
import fs from 'fs-extra';
import path from 'path';
import { TemplateActionRegistry } from '../actions/TemplateActionRegistry';
import * as handlebars from 'handlebars';
import { InputError } from '@backstage/backend-common';
type Options = {
logger: Logger;
@@ -111,6 +113,20 @@ export class TaskWorker {
},
);
if (action.parameterSchema) {
const validateResult = validateJsonSchema(
parameters,
action.parameterSchema,
{ propertyName: 'parameters' },
);
if (!validateResult.valid) {
const errors = validateResult.errors.join(', ');
throw new InputError(
`Invalid parameters passed to action ${action.id}, ${errors}`,
);
}
}
const stepOutputs: { [name: string]: JsonValue } = {};
// Keep track of all tmp dirs that are created by the action so we can remove them after