From 8c1e321684237e8dc7922cef3c5ace8ec1b7855b Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 25 Feb 2021 09:13:40 +0100 Subject: [PATCH] feat: porting over the other existing publishers to the new action format Signed-off-by: Johan Haals --- .../actions/builtin/publish/azure.ts | 94 ++++++++ .../actions/builtin/publish/bitbucket.ts | 217 ++++++++++++++++++ .../actions/builtin/publish/github.ts | 23 +- .../actions/builtin/publish/gitlab.ts | 102 ++++++++ .../actions/builtin/publish/index.ts | 3 + .../actions/builtin/publish/util.ts | 27 +++ 6 files changed, 445 insertions(+), 21 deletions(-) create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts new file mode 100644 index 0000000000..0728e753b2 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/azure.ts @@ -0,0 +1,94 @@ +/* + * 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 { 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'; + +export function createPublishAzureAction(options: { + integrations: ScmIntegrations; +}): TemplateAction<{ + repoUrl: string; + description?: string; +}> { + const { integrations } = options; + + return { + id: 'publish:azure', + parameterSchema: { + type: 'object', + required: ['repoUrl'], + properties: { + repoUrl: { + title: 'Repository Location', + type: 'string', + }, + description: { + title: 'Repository Description', + type: 'string', + }, + }, + }, + async handler(ctx) { + const { owner, repo, host } = parseRepoUrl(ctx.parameters.repoUrl); + const integrationConfig = integrations.azure.byHost(host); + + if (!integrationConfig) { + throw new InputError( + `No matching integration configuration for host ${host}, please check your Integrations config`, + ); + } + if (!integrationConfig.config.token) { + throw new InputError(`No token provided for Azure Integration ${host}`); + } + const authHandler = getPersonalAccessTokenHandler( + integrationConfig.config.token, + ); + + const webApi = new WebApi(`https://${host}/${owner}`, authHandler); + const client = await webApi.getGitApi(); + const createOptions: GitRepositoryCreateOptions = { name: repo }; + const { remoteUrl } = await client.createRepository(createOptions, owner); + + if (!remoteUrl) { + throw new InputError( + 'No remote URL returned from create repository for Azure', + ); + } + + // blam: Repo contents is serialized into the path, + // so it's just the base path I think + const repoContentsUrl = remoteUrl; + + await initRepoAndPush({ + dir: ctx.workspacePath, + remoteUrl, + auth: { + username: 'notempty', + password: integrationConfig.config.token, + }, + logger: ctx.logger, + }); + + ctx.output('remoteUrl', remoteUrl); + ctx.output('repoContentsUrl', repoContentsUrl); + }, + }; +} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts new file mode 100644 index 0000000000..d530fb90bc --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucket.ts @@ -0,0 +1,217 @@ +/* + * 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 { InputError } from '@backstage/backend-common'; +import { + BitbucketIntegrationConfig, + ScmIntegrations, +} from '@backstage/integration'; +import { TemplateAction } from '../../types'; +import { initRepoAndPush } from '../../../stages/publish/helpers'; +import { parseRepoUrl } from './util'; + +const createBitbucketCloudRepository = async (opts: { + owner: string; + repo: string; + description: string; + repoVisibility: 'private' | 'public'; + authorization: string; +}) => { + const { owner, repo, description, repoVisibility, authorization } = opts; + + let response: Response; + + const options: RequestInit = { + method: 'POST', + body: JSON.stringify({ + scm: 'git', + description: description, + is_private: repoVisibility === 'private', + }), + headers: { + Authorization: authorization, + 'Content-Type': 'application/json', + }, + }; + try { + response = await fetch( + `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}`, + options, + ); + } 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 }; + } + throw new Error(`Not a valid response code ${await response.text()}`); +}; + +const createBitbucketServerRepository = async (opts: { + host: string; + owner: string; + repo: string; + description: string; + repoVisibility: 'private' | 'public'; + authorization: string; +}) => { + const { + host, + owner, + repo, + description, + authorization, + repoVisibility, + } = opts; + + let response: Response; + const options: RequestInit = { + method: 'POST', + body: JSON.stringify({ + name: repo, + description: description, + is_private: repoVisibility === 'private', + }), + headers: { + Authorization: authorization, + 'Content-Type': 'application/json', + }, + }; + + try { + response = await fetch( + `https://${host}/rest/api/1.0/projects/${owner}/repos`, + options, + ); + } 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 }; + } + throw new Error(`Not a valid response code ${await response.text()}`); +}; + +const getAuthorizationHeader = (config: BitbucketIntegrationConfig) => { + if (config.username && config.appPassword) { + const buffer = Buffer.from( + `${config.username}:${config.appPassword}`, + 'utf8', + ); + + return `Basic ${buffer.toString('base64')}`; + } + + if (config.token) { + return `Bearer ${config.token}`; + } + + throw new Error( + `Authorization has not been provided for Bitbucket. Please add either username + appPassword or token to the Integrations config`, + ); +}; + +export function createPublishBitbucketAction(options: { + integrations: ScmIntegrations; + repoVisibility: 'private' | 'public'; +}): TemplateAction<{ + repoUrl: string; + description: string; +}> { + const { integrations, repoVisibility } = options; + + return { + id: 'publish:bitbucket', + parameterSchema: { + type: 'object', + required: ['repoUrl'], + properties: { + repoUrl: { + title: 'Repository Location', + type: 'string', + }, + description: { + title: 'Repository Description', + type: 'string', + }, + }, + }, + async handler(ctx) { + const { repoUrl, description } = ctx.parameters; + + const { owner, repo, host } = parseRepoUrl(repoUrl); + + const integrationConfig = integrations.bitbucket.byHost(host); + + if (!integrationConfig) { + throw new InputError( + `No matching integration configuration for host ${host}, please check your Integrations config`, + ); + } + + const authorization = getAuthorizationHeader(integrationConfig.config); + + const createMethod = + host === 'bitbucket.org' + ? createBitbucketCloudRepository + : createBitbucketServerRepository; + + const { remoteUrl, repoContentsUrl } = await createMethod({ + authorization, + host, + owner, + repo, + repoVisibility, + description, + }); + + await initRepoAndPush({ + dir: ctx.workspacePath, + remoteUrl, + auth: { + username: integrationConfig.config.username + ? integrationConfig.config.username + : 'x-token-auth', + password: integrationConfig.config.appPassword + ? integrationConfig.config.appPassword + : integrationConfig.config.token ?? '', + }, + logger: ctx.logger, + }); + + ctx.output('remoteUrl', remoteUrl); + ctx.output('repoContentsUrl', repoContentsUrl); + }, + }; +} 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 822854cadb..f4a7b38c50 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts @@ -22,6 +22,7 @@ import { import { Octokit } from '@octokit/rest'; import { TemplateAction } from '../../types'; import { initRepoAndPush } from '../../../stages/publish/helpers'; +import { parseRepoUrl } from './util'; export function createPublishGithubAction(options: { integrations: ScmIntegrations; @@ -63,27 +64,7 @@ export function createPublishGithubAction(options: { async handler(ctx) { const { repoUrl, description, access } = ctx.parameters; - let parsed; - try { - parsed = new URL(`https://${repoUrl}`); - } catch (error) { - throw new InputError( - `Invalid repo URL passed to publish:github, got ${repoUrl}, ${error}`, - ); - } - const host = parsed.host; - const owner = parsed.searchParams.get('owner'); - if (!owner) { - throw new InputError( - `Invalid repo URL passed to publish:github, missing owner`, - ); - } - const repo = parsed.searchParams.get('repo'); - if (!repo) { - throw new InputError( - `Invalid repo URL passed to publish:github, missing repo`, - ); - } + const { owner, repo, host } = parseRepoUrl(repoUrl); const credentialsProvider = credentialsProviders.get(host); const integrationConfig = integrations.github.byHost(host); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts new file mode 100644 index 0000000000..f5af2885aa --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlab.ts @@ -0,0 +1,102 @@ +/* + * 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 { 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'; + +export function createPublishGitlabAction(options: { + integrations: ScmIntegrations; + repoVisibility: 'private' | 'internal' | 'public'; +}): TemplateAction<{ + repoUrl: string; +}> { + const { integrations, repoVisibility } = options; + + return { + id: 'publish:gitlab', + parameterSchema: { + type: 'object', + required: ['repoUrl'], + properties: { + repoUrl: { + title: 'Repository Location', + type: 'string', + }, + }, + }, + async handler(ctx) { + const { repoUrl } = ctx.parameters; + + const { owner, repo, host } = parseRepoUrl(repoUrl); + + const integrationConfig = integrations.gitlab.byHost(host); + + if (!integrationConfig) { + throw new InputError( + `No matching integration configuration for host ${host}, please check your Integrations config`, + ); + } + + if (!integrationConfig.config.token) { + throw new InputError( + `No token provided for GitLab Integration ${host}`, + ); + } + + const client = new Gitlab({ + host: integrationConfig.config.baseUrl, + token: integrationConfig.config.token, + }); + + let { id: targetNamespace } = (await client.Namespaces.show(owner)) as { + id: number; + }; + + if (!targetNamespace) { + const { id } = (await client.Users.current()) as { + id: number; + }; + targetNamespace = id; + } + + const { http_url_to_repo } = await client.Projects.create({ + namespace_id: targetNamespace, + name: repo, + visibility: repoVisibility, + }); + + const remoteUrl = (http_url_to_repo as string).replace(/\.git$/, ''); + const repoContentsUrl = `${remoteUrl}/-/blob/master`; + + await initRepoAndPush({ + dir: ctx.workspacePath, + remoteUrl, + auth: { + username: 'oauth2', + password: integrationConfig.config.token, + }, + logger: ctx.logger, + }); + + ctx.output('remoteUrl', remoteUrl); + ctx.output('repoContentsUrl', repoContentsUrl); + }, + }; +} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts index b1ecb91f9a..70a7909144 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts @@ -15,3 +15,6 @@ */ export { createPublishGithubAction } from './github'; +export { createPublishAzureAction } from './azure'; +export { createPublishGitlabAction } from './gitlab'; +export { createPublishBitbucketAction } from './bitbucket'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts new file mode 100644 index 0000000000..2fbda2b0b7 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts @@ -0,0 +1,27 @@ +import { InputError } from '@backstage/backend-common'; +export const parseRepoUrl = (repoUrl: string) => { + let parsed; + try { + parsed = new URL(`https://${repoUrl}`); + } catch (error) { + throw new InputError( + `Invalid repo URL passed to publisher, got ${repoUrl}, ${error}`, + ); + } + const host = parsed.host; + const owner = parsed.searchParams.get('owner'); + + if (!owner) { + throw new InputError( + `Invalid repo URL passed to publisher: ${repoUrl}, missing owner`, + ); + } + const repo = parsed.searchParams.get('repo'); + if (!repo) { + throw new InputError( + `Invalid repo URL passed to publisher: ${repoUrl}, missing repo`, + ); + } + + return { host, owner, repo }; +};