Wrap actions in createTemplateAction

Rename paramers to input

Co-authored-by: Ben Lambert <ben@blam.sh>
Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com>
Co-authored-by: Fredrik Adelöw <freben@users.noreply.github.com>
Signed-off-by: Johan Haals <johan.haals@gmail.com>
This commit is contained in:
Johan Haals
2021-02-25 13:55:37 +01:00
parent 3a54420fa1
commit 807cf164af
15 changed files with 198 additions and 154 deletions
@@ -14,15 +14,13 @@
* limitations under the License.
*/
import { ParameterBase, TemplateAction } from './types';
import { InputBase, TemplateAction } from './types';
import { ConflictError, NotFoundError } from '@backstage/backend-common';
export class TemplateActionRegistry {
private readonly actions = new Map<string, TemplateAction<any>>();
register<Parameters extends ParameterBase>(
action: TemplateAction<Parameters>,
) {
register<Parameters extends InputBase>(action: TemplateAction<Parameters>) {
if (this.actions.has(action.id)) {
throw new ConflictError(
`Template action with ID '${action.id}' has already been registered`,
@@ -18,72 +18,76 @@ import { InputError } from '@backstage/backend-common';
import { ScmIntegrations } from '@backstage/integration';
import { CatalogApi } from '@backstage/catalog-client';
import { getEntityName } from '@backstage/catalog-model';
import { TemplateAction } from '../../types';
import { createTemplateAction } from '../../createTemplateAction';
export function createCatalogRegisterAction(options: {
catalogClient: CatalogApi;
integrations: ScmIntegrations;
}): TemplateAction<
| { catalogInfoUrl: string }
| { repoContentsUrl: string; catalogInfoPath?: string }
> {
}) {
const { catalogClient, integrations } = options;
return {
return createTemplateAction<
| { catalogInfoUrl: string }
| { repoContentsUrl: string; catalogInfoPath?: string }
>({
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',
schema: {
input: {
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',
{
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 { parameters } = ctx;
const { input } = ctx;
let catalogInfoUrl;
if ('catalogInfoUrl' in parameters) {
catalogInfoUrl = parameters.catalogInfoUrl;
if ('catalogInfoUrl' in input) {
catalogInfoUrl = input.catalogInfoUrl;
} else {
const {
repoContentsUrl,
catalogInfoPath = '/catalog-info.yaml',
} = parameters;
const integration = integrations.byUrl(repoContentsUrl as string);
} = input;
const integration = integrations.byUrl(repoContentsUrl);
if (!integration) {
throw new InputError('No integration found for host');
throw new InputError(
`No integration found for host ${repoContentsUrl}`,
);
}
catalogInfoUrl = integration.resolveUrl({
base: repoContentsUrl as string,
url: catalogInfoPath as string,
base: repoContentsUrl,
url: catalogInfoPath,
});
}
@@ -91,13 +95,15 @@ export function createCatalogRegisterAction(options: {
const result = await catalogClient.addLocation({
type: 'url',
target: catalogInfoUrl as string,
target: catalogInfoUrl,
});
if (result.entities.length >= 1) {
const { kind, name, namespace } = getEntityName(result.entities[0]);
ctx.output('entityRef', `${kind}:${namespace}/${name}`);
ctx.output('catalogInfoUrl', catalogInfoUrl);
if (catalogInfoUrl) {
ctx.output('catalogInfoUrl', catalogInfoUrl);
}
}
},
};
});
}
@@ -21,18 +21,22 @@ 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';
import { createTemplateAction } from '../../createTemplateAction';
export function createFetchCookiecutterAction(options: {
dockerClient: Docker;
reader: UrlReader;
integrations: ScmIntegrations;
templaters: TemplaterBuilder;
}): TemplateAction<{ url: string; targetPath?: string; values: JsonObject }> {
}) {
const { dockerClient, reader, templaters, integrations } = options;
return {
return createTemplateAction<{
url: string;
targetPath?: string;
values: JsonObject;
}>({
id: 'fetch:cookiecutter',
schema: {
input: {
@@ -73,7 +77,7 @@ export function createFetchCookiecutterAction(options: {
reader,
integrations,
baseUrl: ctx.baseUrl,
fetchUrl: ctx.parameters.url,
fetchUrl: ctx.input.url,
outputPath: templateContentsDir,
});
@@ -87,11 +91,11 @@ export function createFetchCookiecutterAction(options: {
workspacePath: workDir,
dockerClient,
logStream: ctx.logStream,
values: ctx.parameters.values as TemplaterValues,
values: ctx.input.values as TemplaterValues,
});
// Finally move the template result into the task workspace
const targetPath = ctx.parameters.targetPath ?? './';
const targetPath = ctx.input.targetPath ?? './';
const outputPath = resolvePath(ctx.workspacePath, targetPath);
if (!outputPath.startsWith(ctx.workspacePath)) {
throw new InputError(
@@ -100,5 +104,5 @@ export function createFetchCookiecutterAction(options: {
}
await fs.copy(resultDir, outputPath);
},
};
});
}
@@ -17,16 +17,16 @@
import { resolve as resolvePath } from 'path';
import { InputError, UrlReader } from '@backstage/backend-common';
import { ScmIntegrations } from '@backstage/integration';
import { TemplateAction } from '../../types';
import { fetchContents } from './helpers';
import { createTemplateAction } from '../../createTemplateAction';
export function createFetchPlainAction(options: {
reader: UrlReader;
integrations: ScmIntegrations;
}): TemplateAction<{ url: string; targetPath?: string }> {
}) {
const { reader, integrations } = options;
return {
return createTemplateAction<{ url: string; targetPath?: string }>({
id: 'fetch:plain',
schema: {
input: {
@@ -52,7 +52,7 @@ export function createFetchPlainAction(options: {
ctx.logger.info('Fetching plain content from remote URL');
// Finally move the template result into the task workspace
const targetPath = ctx.parameters.targetPath ?? './';
const targetPath = ctx.input.targetPath ?? './';
const outputPath = resolvePath(ctx.workspacePath, targetPath);
if (!outputPath.startsWith(ctx.workspacePath)) {
throw new InputError(
@@ -64,9 +64,9 @@ export function createFetchPlainAction(options: {
reader,
integrations,
baseUrl: ctx.baseUrl,
fetchUrl: ctx.parameters.url,
fetchUrl: ctx.input.url,
outputPath,
});
},
};
});
}
@@ -16,21 +16,21 @@
import { InputError } from '@backstage/backend-common';
import { ScmIntegrations } from '@backstage/integration';
import { TemplateAction } from '../../types';
import { initRepoAndPush } from '../../../stages/publish/helpers';
import { GitRepositoryCreateOptions } from 'azure-devops-node-api/interfaces/GitInterfaces';
import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api';
import { parseRepoUrl } from './util';
import { createTemplateAction } from '../../createTemplateAction';
export function createPublishAzureAction(options: {
integrations: ScmIntegrations;
}): TemplateAction<{
repoUrl: string;
description?: string;
}> {
}) {
const { integrations } = options;
return {
return createTemplateAction<{
repoUrl: string;
description?: string;
}>({
id: 'publish:azure',
schema: {
input: {
@@ -63,12 +63,12 @@ export function createPublishAzureAction(options: {
},
async handler(ctx) {
const { owner, repo, host, organization } = parseRepoUrl(
ctx.parameters.repoUrl,
ctx.input.repoUrl,
);
if (!organization) {
throw new InputError(
`No Organization was included in the repo URL to create ${ctx.parameters.repoUrl}`,
`No Organization was included in the repo URL to create ${ctx.input.repoUrl}`,
);
}
@@ -122,5 +122,5 @@ export function createPublishAzureAction(options: {
ctx.output('remoteUrl', remoteUrl);
ctx.output('repoContentsUrl', repoContentsUrl);
},
};
});
}
@@ -19,10 +19,10 @@ import {
BitbucketIntegrationConfig,
ScmIntegrations,
} from '@backstage/integration';
import { TemplateAction } from '../../types';
import { initRepoAndPush } from '../../../stages/publish/helpers';
import { parseRepoUrl } from './util';
import fetch from 'cross-fetch';
import { createTemplateAction } from '../../createTemplateAction';
const createBitbucketCloudRepository = async (opts: {
owner: string;
@@ -33,8 +33,6 @@ const createBitbucketCloudRepository = async (opts: {
}) => {
const { owner, repo, description, repoVisibility, authorization } = opts;
let response: Response;
const options: RequestInit = {
method: 'POST',
body: JSON.stringify({
@@ -47,6 +45,8 @@ const createBitbucketCloudRepository = async (opts: {
'Content-Type': 'application/json',
},
};
let response: Response;
try {
response = await fetch(
`https://api.bitbucket.org/2.0/repositories/${owner}/${repo}`,
@@ -55,20 +55,26 @@ const createBitbucketCloudRepository = async (opts: {
} catch (e) {
throw new Error(`Unable to create repository, ${e}`);
}
if (response.status === 200) {
const r = await response.json();
let remoteUrl = '';
for (const link of r.links.clone) {
if (link.name === 'https') {
remoteUrl = link.href;
}
}
// TODO use the urlReader to get the default branch
const repoContentsUrl = `${r.links.html.href}/src/master`;
return { remoteUrl, repoContentsUrl };
if (response.status !== 200) {
throw new Error(
`Unable to create repository, ${response.status} ${
response.statusText
}, ${await response.text()}`,
);
}
throw new Error(`Not a valid response code ${await response.text()}`);
const r = await response.json();
let remoteUrl = '';
for (const link of r.links.clone) {
if (link.name === 'https') {
remoteUrl = link.href;
}
}
// TODO use the urlReader to get the default branch
const repoContentsUrl = `${r.links.html.href}/src/master`;
return { remoteUrl, repoContentsUrl };
};
const createBitbucketServerRepository = async (opts: {
@@ -110,18 +116,25 @@ const createBitbucketServerRepository = async (opts: {
} catch (e) {
throw new Error(`Unable to create repository, ${e}`);
}
if (response.status === 201) {
const r = await response.json();
let remoteUrl = '';
for (const link of r.links.clone) {
if (link.name === 'http') {
remoteUrl = link.href;
}
}
const repoContentsUrl = `${r.links.self[0].href}`;
return { remoteUrl, repoContentsUrl };
if (response.status !== 201) {
throw new Error(
`Unable to create repository, ${response.status} ${
response.statusText
}, ${await response.text()}`,
);
}
throw new Error(`Not a valid response code ${await response.text()}`);
const r = await response.json();
let remoteUrl = '';
for (const link of r.links.clone) {
if (link.name === 'http') {
remoteUrl = link.href;
}
}
const repoContentsUrl = `${r.links.self[0].href}`;
return { remoteUrl, repoContentsUrl };
};
const getAuthorizationHeader = (config: BitbucketIntegrationConfig) => {
@@ -145,14 +158,14 @@ const getAuthorizationHeader = (config: BitbucketIntegrationConfig) => {
export function createPublishBitbucketAction(options: {
integrations: ScmIntegrations;
}): TemplateAction<{
repoUrl: string;
description: string;
repoVisibility: 'private' | 'public';
}> {
}) {
const { integrations } = options;
return {
return createTemplateAction<{
repoUrl: string;
description: string;
repoVisibility: 'private' | 'public';
}>({
id: 'publish:bitbucket',
schema: {
input: {
@@ -189,11 +202,7 @@ export function createPublishBitbucketAction(options: {
},
},
async handler(ctx) {
const {
repoUrl,
description,
repoVisibility = 'private',
} = ctx.parameters;
const { repoUrl, description, repoVisibility = 'private' } = ctx.input;
const { owner, repo, host } = parseRepoUrl(repoUrl);
@@ -238,5 +247,5 @@ export function createPublishBitbucketAction(options: {
ctx.output('remoteUrl', remoteUrl);
ctx.output('repoContentsUrl', repoContentsUrl);
},
};
});
}
@@ -20,18 +20,13 @@ import {
ScmIntegrations,
} from '@backstage/integration';
import { Octokit } from '@octokit/rest';
import { TemplateAction } from '../../types';
import { initRepoAndPush } from '../../../stages/publish/helpers';
import { parseRepoUrl } from './util';
import { createTemplateAction } from '../../createTemplateAction';
export function createPublishGithubAction(options: {
integrations: ScmIntegrations;
}): TemplateAction<{
repoUrl: string;
description?: string;
access?: string;
repoVisibility: 'private' | 'internal' | 'public';
}> {
}) {
const { integrations } = options;
const credentialsProviders = new Map(
@@ -41,7 +36,12 @@ export function createPublishGithubAction(options: {
}),
);
return {
return createTemplateAction<{
repoUrl: string;
description?: string;
access?: string;
repoVisibility: 'private' | 'internal' | 'public';
}>({
id: 'publish:github',
schema: {
input: {
@@ -82,7 +82,7 @@ export function createPublishGithubAction(options: {
},
},
async handler(ctx) {
const { repoUrl, description, access, repoVisibility } = ctx.parameters;
const { repoUrl, description, access, repoVisibility } = ctx.input;
const { owner, repo, host } = parseRepoUrl(repoUrl);
@@ -91,7 +91,7 @@ export function createPublishGithubAction(options: {
if (!credentialsProvider || !integrationConfig) {
throw new InputError(
`No matching integration configuration for host ${host}, please check your Integrations config`,
`No matching integration configuration for host ${host}, please check your integrations config`,
);
}
@@ -165,5 +165,5 @@ export function createPublishGithubAction(options: {
ctx.output('remoteUrl', remoteUrl);
ctx.output('repoContentsUrl', repoContentsUrl);
},
};
});
}
@@ -16,20 +16,20 @@
import { InputError } from '@backstage/backend-common';
import { ScmIntegrations } from '@backstage/integration';
import { TemplateAction } from '../../types';
import { Gitlab } from '@gitbeaker/node';
import { initRepoAndPush } from '../../../stages/publish/helpers';
import { parseRepoUrl } from './util';
import { createTemplateAction } from '../../createTemplateAction';
export function createPublishGitlabAction(options: {
integrations: ScmIntegrations;
}): TemplateAction<{
repoUrl: string;
repoVisibility: 'private' | 'internal' | 'public';
}> {
}) {
const { integrations } = options;
return {
return createTemplateAction<{
repoUrl: string;
repoVisibility: 'private' | 'internal' | 'public';
}>({
id: 'publish:gitlab',
schema: {
input: {
@@ -62,7 +62,7 @@ export function createPublishGitlabAction(options: {
},
},
async handler(ctx) {
const { repoUrl, repoVisibility = 'private' } = ctx.parameters;
const { repoUrl, repoVisibility = 'private' } = ctx.input;
const { owner, repo, host } = parseRepoUrl(repoUrl);
@@ -118,5 +118,5 @@ export function createPublishGitlabAction(options: {
ctx.output('remoteUrl', remoteUrl);
ctx.output('repoContentsUrl', repoContentsUrl);
},
};
});
}
@@ -0,0 +1,28 @@
/*
* 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.
*/
// function createTemplateAction<Parameters extends ParameterBase>(
// options: TemplateAction<Parameters>,
// ): TemplateAction<any>;
import { InputBase, TemplateAction } from './types';
export const createTemplateAction = <Input extends InputBase>(
templateAction: TemplateAction<Input>,
): TemplateAction<any> => {
// TODO(blam): Can add some more validation here to validate the action later on
return templateAction;
};
@@ -16,4 +16,5 @@
export * from './builtin';
export { TemplateActionRegistry } from './TemplateActionRegistry';
export { createTemplateAction } from './createTemplateAction';
export type { ActionContext, TemplateAction } from './types';
@@ -21,9 +21,9 @@ import { Schema } from 'jsonschema';
type PartialJsonObject = Partial<JsonObject>;
type PartialJsonValue = PartialJsonObject | JsonValue | undefined;
export type ParameterBase = Partial<{ [name: string]: PartialJsonValue }>;
export type InputBase = Partial<{ [name: string]: PartialJsonValue }>;
export type ActionContext<Parameters extends ParameterBase> = {
export type ActionContext<Input extends InputBase> = {
/**
* Base URL for the location of the task spec, typically the url of the source entity file.
*/
@@ -33,7 +33,7 @@ export type ActionContext<Parameters extends ParameterBase> = {
logStream: Writable;
workspacePath: string;
parameters: Parameters;
input: Input;
output(name: string, value: JsonValue): void;
/**
@@ -42,11 +42,11 @@ export type ActionContext<Parameters extends ParameterBase> = {
createTemporaryDirectory(): Promise<string>;
};
export type TemplateAction<Parameters extends ParameterBase> = {
export type TemplateAction<Input extends InputBase> = {
id: string;
schema?: {
input?: Schema;
output?: Schema;
};
handler: (ctx: ActionContext<Parameters>) => Promise<void>;
handler: (ctx: ActionContext<Input>) => Promise<void>;
};
@@ -37,7 +37,7 @@ export function registerLegacyActions(
id: 'legacy:prepare',
async handler(ctx) {
ctx.logger.info('Preparing the skeleton');
const { protocol, url } = ctx.parameters;
const { protocol, url } = ctx.input;
const preparer =
protocol === 'file' ? new FilePreparer() : preparers.get(url as string);
@@ -53,12 +53,12 @@ export function registerLegacyActions(
id: 'legacy:template',
async handler(ctx) {
ctx.logger.info('Running the templater');
const templater = templaters.get(ctx.parameters.templater as string);
const templater = templaters.get(ctx.input.templater as string);
await templater.run({
workspacePath: ctx.workspacePath,
dockerClient,
logStream: ctx.logStream,
values: ctx.parameters.values as TemplaterValues,
values: ctx.input.values as TemplaterValues,
});
},
});
@@ -66,7 +66,7 @@ export function registerLegacyActions(
registry.register({
id: 'legacy:publish',
async handler(ctx) {
const { values } = ctx.parameters;
const { values } = ctx.input;
if (
typeof values !== 'object' ||
values === null ||
@@ -98,7 +98,7 @@ export class TaskWorker {
throw new Error(`Action '${step.action}' does not exist`);
}
const parameters = JSON.parse(
const input = JSON.parse(
JSON.stringify(step.input),
(_key, value) => {
if (typeof value === 'string') {
@@ -114,15 +114,13 @@ export class TaskWorker {
);
if (action.schema?.input) {
const validateResult = validateJsonSchema(
parameters,
action.schema,
{ propertyName: 'parameters' },
);
const validateResult = validateJsonSchema(input, action.schema, {
propertyName: 'input',
});
if (!validateResult.valid) {
const errors = validateResult.errors.join(', ');
throw new InputError(
`Invalid parameters passed to action ${action.id}, ${errors}`,
`Invalid input passed to action ${action.id}, ${errors}`,
);
}
}
@@ -136,7 +134,7 @@ export class TaskWorker {
baseUrl: task.spec.baseUrl,
logger: taskLogger,
logStream: stream,
parameters,
input,
workspacePath,
async createTemporaryDirectory() {
const tmpDir = await fs.mkdtemp(
@@ -54,7 +54,7 @@ export function templateEntityToSpec(
id: 'prepare',
name: 'Prepare',
action: 'legacy:prepare',
parameters: {
input: {
protocol,
url,
},
@@ -64,7 +64,7 @@ export function templateEntityToSpec(
id: 'template',
name: 'Template',
action: 'legacy:template',
parameters: {
input: {
templater,
values,
},
@@ -74,7 +74,7 @@ export function templateEntityToSpec(
id: 'publish',
name: 'Publish',
action: 'legacy:publish',
parameters: {
input: {
values,
},
});
@@ -83,7 +83,7 @@ export function templateEntityToSpec(
id: 'register',
name: 'Register',
action: 'catalog:register',
parameters: {
input: {
catalogInfoUrl: '{{ steps.publish.output.catalogInfoUrl }}',
},
});
@@ -118,7 +118,7 @@ describe('createRouter - working directory', () => {
dockerClient: new Docker(),
database: createDatabase(),
catalogClient: createCatalogClient([template]),
urlReader: mockUrlReader,
reader: mockUrlReader,
}),
).rejects.toThrow('access error');
});
@@ -133,7 +133,7 @@ describe('createRouter - working directory', () => {
dockerClient: new Docker(),
database: createDatabase(),
catalogClient: createCatalogClient([template]),
urlReader: mockUrlReader,
reader: mockUrlReader,
});
const app = express().use(router);
@@ -163,7 +163,7 @@ describe('createRouter - working directory', () => {
dockerClient: new Docker(),
database: createDatabase(),
catalogClient: createCatalogClient([template]),
urlReader: mockUrlReader,
reader: mockUrlReader,
});
const app = express().use(router);
@@ -237,7 +237,7 @@ describe('createRouter', () => {
dockerClient: new Docker(),
database: createDatabase(),
catalogClient: createCatalogClient([template]),
urlReader: mockUrlReader,
reader: mockUrlReader,
});
app = express().use(router);
});