From d9eba5b71511636c596bb77281f287401d0b38f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20A=CC=8Ahsberg?= Date: Thu, 10 Dec 2020 12:32:47 +0000 Subject: [PATCH] Add initial support for Bitbucket Server scaffolding --- .../src/scaffolder/stages/helpers.ts | 4 +- .../scaffolder/stages/prepare/bitbucket.ts | 85 ++++++++++++++ .../scaffolder/stages/prepare/preparers.ts | 3 + .../scaffolder/stages/publish/bitbucket.ts | 104 ++++++++++++++++++ .../scaffolder/stages/publish/publishers.ts | 29 +++++ .../src/scaffolder/stages/types.ts | 3 +- 6 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts index e54e796b3b..40aa0ab7b1 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts @@ -85,7 +85,9 @@ export function makeDeprecatedLocationTypeDetector( config.getOptionalConfigArray('integrations.azure')?.forEach(sub => { hostMap.set(sub.getString('host'), 'azure/api'); }); - + config.getOptionalConfigArray('integrations.bitbucket')?.forEach(sub => { + hostMap.set(sub.getString('host'), 'bitbucket/api'); + }); return (url: string): string | undefined => { const parsed = new URL(url); return hostMap.get(parsed.hostname); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts new file mode 100644 index 0000000000..587a742605 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts @@ -0,0 +1,85 @@ +/* + * 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 os from 'os'; +import fs from 'fs-extra'; +import path from 'path'; +import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { parseLocationAnnotation } from '../helpers'; +import { InputError } from '@backstage/backend-common'; +import { PreparerBase, PreparerOptions } from './types'; +import GitUriParser from 'git-url-parse'; +import { Clone, Cred } from 'nodegit'; +import { Config } from '@backstage/config'; + +export class BitbucketPreparer implements PreparerBase { + private readonly privateToken: string; + private readonly user: string; + + constructor(config: Config) { + this.user = + config.getOptionalString('scaffolder.bitbucket.api.username') ?? ''; + this.privateToken = + config.getOptionalString('scaffolder.bitbucket.api.token') ?? ''; + } + + async prepare( + template: TemplateEntityV1alpha1, + opts: PreparerOptions, + ): Promise { + const { protocol, location } = parseLocationAnnotation(template); + const workingDirectory = opts?.workingDirectory ?? os.tmpdir(); + + if (!['bitbucket/api', 'url'].includes(protocol)) { + throw new InputError( + `Wrong location protocol: ${protocol}, should be 'url'`, + ); + } + const templateId = template.metadata.name; + + const repo = GitUriParser(location); + let repositoryCheckoutUrl; + // could be refactor once https://github.com/IonicaBizau/git-url-parse/pull/117 has been published + if (repo.source === 'bitbucket.org') { + repositoryCheckoutUrl = `${repo.protocol}://${repo.resource}/${repo.owner}/${repo.name}`; + } else { + repositoryCheckoutUrl = `${repo.protocol}://${repo.resource}/scm/${repo.owner}/${repo.name}`; + } + + const tempDir = await fs.promises.mkdtemp( + path.join(workingDirectory, templateId), + ); + + const templateDirectory = path.join( + `${path.dirname(repo.filepath)}`, + template.spec.path ?? '.', + ); + + const options = this.privateToken + ? { + fetchOpts: { + callbacks: { + credentials: () => + Cred.userpassPlaintextNew(this.user, this.privateToken), + }, + }, + } + : {}; + + await Clone.clone(repositoryCheckoutUrl, tempDir, options); + + return path.resolve(tempDir, templateDirectory); + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts index 84be130b79..25e771ae12 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts @@ -28,6 +28,7 @@ import { FilePreparer } from './file'; import { GitlabPreparer } from './gitlab'; import { AzurePreparer } from './azure'; import { GithubPreparer } from './github'; +import { BitbucketPreparer } from './bitbucket'; export class Preparers implements PreparerBuilder { private preparerMap = new Map(); @@ -79,11 +80,13 @@ export class Preparers implements PreparerBuilder { const filePreparer = new FilePreparer(); const gitlabPreparer = new GitlabPreparer(config); const azurePreparer = new AzurePreparer(config); + const bitbucketPreparer = new BitbucketPreparer(config); preparers.register('file', filePreparer); preparers.register('gitlab', gitlabPreparer); preparers.register('gitlab/api', gitlabPreparer); preparers.register('azure/api', azurePreparer); + preparers.register('bitbucket/api', bitbucketPreparer); const githubConfig = config.getOptionalConfig('scaffolder.github'); if (githubConfig) { diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts new file mode 100644 index 0000000000..84e06440d8 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts @@ -0,0 +1,104 @@ +/* + * 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 { PublisherBase, PublisherOptions, PublisherResult } from './types'; +import { pushToRemoteUserPass } from './helpers'; +import { RequiredTemplateValues } from '../templater'; +import { JsonValue } from '../../../../../../packages/config/src'; +import fetch from 'cross-fetch'; + +export class BitbucketPublisher implements PublisherBase { + private readonly host: string; + private readonly username: string; + private readonly token: string; + + constructor(host: string, username: string, token: string) { + this.host = host; + this.username = username; + this.token = token; + } + + async publish({ + values, + directory, + }: PublisherOptions): Promise { + const result = await this.createRemote(values); + + await pushToRemoteUserPass( + directory, + result.remoteUrl, + this.username, + this.token, + ); + return result; + } + + private async createRemote( + values: RequiredTemplateValues & Record, + ): Promise { + const [project, name] = values.storePath.split('/'); + if (this.host === 'bitbucket.org') { + return this.createBitbucketCloudRepository(project, name); + } + return this.createBitbucketServerRepository(project, name); + } + + private async createBitbucketCloudRepository( + project: string, + name: string, + ): Promise { + throw new Error( + `Failed to create ${project}/${name} on bitbucket.org which is currently not supported`, + ); + } + + private async createBitbucketServerRepository( + project: string, + name: string, + ): Promise { + let response: Response; + const options: RequestInit = { + method: 'POST', + body: JSON.stringify({ + name: name, + }), + headers: { + Authorization: `Bearer ${this.token}`, + 'Content-Type': 'application/json', + }, + }; + try { + response = await fetch( + `${this.host}/rest/api/1.0/projects/${project}/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 catalogInfoUrl = `${r.links.self[0].href}/catalog-info.yaml`; + return { remoteUrl, catalogInfoUrl }; + } + throw new Error(`Not a valid response code ${await response.text()}`); + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts index 03a4e884ba..7496600356 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts @@ -30,6 +30,7 @@ import { RemoteProtocol } from '../types'; import { GithubPublisher, RepoVisibilityOptions } from './github'; import { GitlabPublisher } from './gitlab'; import { AzurePublisher } from './azure'; +import { BitbucketPublisher } from './bitbucket'; export class Publishers implements PublisherBuilder { private publisherMap = new Map(); @@ -163,6 +164,34 @@ export class Publishers implements PublisherBuilder { } } + const bitbucketConfig = config.getOptionalConfig( + 'scaffolder.bitbucket.api', + ); + if (bitbucketConfig) { + try { + const baseUrl = bitbucketConfig.getString('host'); + const bitbucketUsername = bitbucketConfig.getString('username'); + const bitbucketToken = bitbucketConfig.getString('token'); + + const bitbucketPublisher = new BitbucketPublisher( + baseUrl, + bitbucketUsername, + bitbucketToken, + ); + publishers.register('bitbucket/api', bitbucketPublisher); + } catch (e) { + const providerName = 'bitbucket'; + if (process.env.NODE_ENV !== 'development') { + throw new Error( + `Failed to initialize ${providerName} scaffolding provider, ${e.message}`, + ); + } + + logger.warn( + `Skipping ${providerName} scaffolding provider, ${e.message}`, + ); + } + } return publishers; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/types.ts index 938d906857..c1dc9fbcdb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/types.ts @@ -18,4 +18,5 @@ export type RemoteProtocol = | 'github' | 'gitlab' | 'gitlab/api' - | 'azure/api'; + | 'azure/api' + | 'bitbucket/api';