From 7e8f2552f6974b6febfdcc96af42c92180087c9d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 24 Feb 2021 20:44:37 +0100 Subject: [PATCH] scaffolder-backend: split builtin actions into multiple modules Signed-off-by: Johan Haals --- .../src/scaffolder/actions/builtin.ts | 361 ------------------ .../actions/builtin/catalog/index.ts | 17 + .../actions/builtin/catalog/register.ts | 63 +++ .../actions/builtin/fetch/cookiecutter.ts | 83 ++++ .../actions/builtin/fetch/helpers.ts | 86 +++++ .../scaffolder/actions/builtin/fetch/index.ts | 18 + .../scaffolder/actions/builtin/fetch/plain.ts | 57 +++ .../src/scaffolder/actions/builtin/index.ts | 19 + .../actions/builtin/publish/github.ts | 152 ++++++++ .../actions/builtin/publish/index.ts | 17 + .../src/scaffolder/actions/index.ts | 7 +- 11 files changed, 513 insertions(+), 367 deletions(-) delete mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/index.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/helpers.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/index.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/index.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin.ts deleted file mode 100644 index b392157ac6..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin.ts +++ /dev/null @@ -1,361 +0,0 @@ -/* - * 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 fs from 'fs-extra'; -import { resolve as resolvePath, isAbsolute } from 'path'; -import Docker from 'dockerode'; -import { TemplaterBuilder, TemplaterValues } from '../stages/templater'; -import { TemplateAction } from './types'; -import { InputError, UrlReader } from '@backstage/backend-common'; -import { - GithubCredentialsProvider, - ScmIntegrations, -} from '@backstage/integration'; -import { JsonValue } from '@backstage/config'; -import { CatalogApi } from '@backstage/catalog-client'; -import { getEntityName } from '@backstage/catalog-model'; -import { Octokit } from '@octokit/rest'; -import { initRepoAndPush } from '../stages/publish/helpers'; - -async function fetchContents({ - urlReader, - integrations, - baseUrl, - fetchUrl = '.', - outputPath, -}: { - urlReader: UrlReader; - integrations: ScmIntegrations; - baseUrl?: string; - fetchUrl?: JsonValue; - outputPath: string; -}) { - if (typeof fetchUrl !== 'string') { - throw new InputError( - `Invalid url parameter, expected string, got ${typeof fetchUrl}`, - ); - } - - let fetchUrlIsAbsolute = false; - try { - // eslint-disable-next-line no-new - new URL(fetchUrl); - fetchUrlIsAbsolute = true; - } catch { - /* ignored */ - } - - // We handle both file locations and url ones - if (!fetchUrlIsAbsolute && baseUrl?.startsWith('file://')) { - const basePath = baseUrl.slice('file://'.length); - if (isAbsolute(fetchUrl)) { - throw new InputError( - `Fetch URL may not be absolute for file locations, ${fetchUrl}`, - ); - } - const srcDir = resolvePath(basePath, '..', fetchUrl); - await fs.copy(srcDir, outputPath); - } else { - let readUrl; - - if (fetchUrlIsAbsolute) { - readUrl = fetchUrl; - } else if (baseUrl) { - const integration = integrations.byUrl(baseUrl); - if (!integration) { - throw new InputError(`No integration found for location ${baseUrl}`); - } - - readUrl = integration.resolveUrl({ - url: fetchUrl, - base: baseUrl, - }); - } else { - throw new InputError( - `Failed to fetch, template location could not be determined and the fetch URL is relative, ${fetchUrl}`, - ); - } - - const res = await urlReader.readTree(readUrl); - await fs.ensureDir(outputPath); - await res.dir({ targetDir: outputPath }); - } -} - -export function createFetchPlainAction(options: { - urlReader: UrlReader; - integrations: ScmIntegrations; -}): TemplateAction { - const { urlReader, integrations } = options; - - return { - id: 'fetch:plain', - 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( - `Fetch action targetPath may not specify a path outside the working directory`, - ); - } - - await fetchContents({ - urlReader, - integrations, - baseUrl: ctx.baseUrl, - fetchUrl: ctx.parameters.url, - outputPath, - }); - }, - }; -} - -export function createFetchCookiecutterAction(options: { - dockerClient: Docker; - urlReader: UrlReader; - integrations: ScmIntegrations; - templaters: TemplaterBuilder; -}): TemplateAction { - const { dockerClient, urlReader, templaters, integrations } = options; - - return { - id: 'fetch:cookiecutter', - async handler(ctx) { - ctx.logger.info('Fetching and then templating using cookiecutter'); - const workDir = await ctx.createTemporaryDirectory(); - const templateDir = resolvePath(workDir, 'template'); - const templateContentsDir = resolvePath( - templateDir, - "{{cookiecutter and 'contents'}}", - ); - const resultDir = resolvePath(workDir, 'result'); - - await fetchContents({ - urlReader, - integrations, - baseUrl: ctx.baseUrl, - fetchUrl: ctx.parameters.url, - outputPath: templateContentsDir, - }); - - const cookiecutter = templaters.get('cookiecutter'); - if (!cookiecutter) { - throw new Error('No cookiecutter templater available'); - } - - // Will execute the template in ./template and put the result in ./result - await cookiecutter.run({ - workspacePath: workDir, - dockerClient, - logStream: ctx.logStream, - values: ctx.parameters.values as TemplaterValues, - }); - - // 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( - `Fetch action targetPath may not specify a path outside the working directory`, - ); - } - await fs.copy(resultDir, outputPath); - }, - }; -} - -export function createPublishGithubAction(options: { - integrations: ScmIntegrations; - repoVisibility: 'private' | 'internal' | 'public'; -}): TemplateAction { - const { integrations, repoVisibility } = options; - - const credentialsProviders = new Map( - integrations.github.list().map(integration => { - const provider = GithubCredentialsProvider.create(integration.config); - return [integration.config.host, provider]; - }), - ); - - return { - id: 'publish:github', - 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}`); - } 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 credentialsProvider = credentialsProviders.get(host); - const integrationConfig = integrations.github.byHost(host); - - if (!credentialsProvider || !integrationConfig) { - throw new InputError( - `No matching integration configuration for host ${host}, please check your Integrations config`, - ); - } - - const { token } = await credentialsProvider.getCredentials({ - url: `${host}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, - }); - - if (!token) { - throw new InputError( - `No token available for host: ${host}, with owner ${owner}, and repo ${repo}`, - ); - } - - const client = new Octokit({ - auth: token, - baseUrl: integrationConfig.config.apiBaseUrl, - }); - - const user = await client.users.getByUsername({ - username: owner, - }); - - const repoCreationPromise = - user.data.type === 'Organization' - ? client.repos.createInOrg({ - name: repo, - org: owner, - private: repoVisibility !== 'public', - visibility: repoVisibility, - description: description as string, - }) - : client.repos.createForAuthenticatedUser({ - name: repo, - private: repoVisibility === 'private', - description: description as string, - }); - - const { data } = await repoCreationPromise; - const accessString = access as string; - if (accessString?.startsWith(`${owner}/`)) { - const [, team] = accessString.split('/'); - await client.teams.addOrUpdateRepoPermissionsInOrg({ - org: owner, - team_slug: team, - owner, - repo, - permission: 'admin', - }); - // no need to add accessString if it's the person who own's the personal account - } else if (accessString && accessString !== owner) { - await client.repos.addCollaborator({ - owner, - repo, - username: accessString, - permission: 'admin', - }); - } - - const remoteUrl = data.clone_url; - const repoContentsUrl = `${data?.html_url}/blob/master`; - - await initRepoAndPush({ - dir: ctx.workspacePath, - remoteUrl, - auth: { - username: 'x-access-token', - password: token, - }, - logger: ctx.logger, - }); - - ctx.output('remoteUrl', remoteUrl); - ctx.output('repoContentsUrl', repoContentsUrl); - }, - }; -} - -export function createCatalogRegisterAction(options: { - catalogClient: CatalogApi; - integrations: ScmIntegrations; -}): TemplateAction { - const { catalogClient, integrations } = options; - - return { - id: 'catalog:register', - async handler(ctx) { - const { - repoContentsUrl, - catalogInfoPath = '/catalog-info.yaml', - } = ctx.parameters; - - let { catalogInfoUrl } = ctx.parameters; - if (!catalogInfoUrl) { - const integration = integrations.byUrl(repoContentsUrl as string); - if (!integration) { - throw new InputError('No integration found for host'); - } - - catalogInfoUrl = integration.resolveUrl({ - base: repoContentsUrl as string, - url: catalogInfoPath as string, - }); - } - - ctx.logger.info(`Registering ${catalogInfoUrl} in the catalog`); - - const result = await catalogClient.addLocation({ - type: 'url', - target: catalogInfoUrl as string, - }); - if (result.entities.length >= 1) { - const { kind, name, namespace } = getEntityName(result.entities[0]); - ctx.output('entityRef', `${kind}:${namespace}/${name}`); - ctx.output('catalogInfoUrl', catalogInfoUrl); - } - }, - }; -} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/index.ts new file mode 100644 index 0000000000..2e25b5592f --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/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 { createCatalogRegisterAction } from './register'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts new file mode 100644 index 0000000000..4d19f1189f --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts @@ -0,0 +1,63 @@ +/* + * 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 { CatalogApi } from '@backstage/catalog-client'; +import { getEntityName } from '@backstage/catalog-model'; +import { TemplateAction } from '../../types'; + +export function createCatalogRegisterAction(options: { + catalogClient: CatalogApi; + integrations: ScmIntegrations; +}): TemplateAction { + const { catalogClient, integrations } = options; + + return { + id: 'catalog:register', + async handler(ctx) { + const { + repoContentsUrl, + catalogInfoPath = '/catalog-info.yaml', + } = ctx.parameters; + + let { catalogInfoUrl } = ctx.parameters; + if (!catalogInfoUrl) { + const integration = integrations.byUrl(repoContentsUrl as string); + if (!integration) { + throw new InputError('No integration found for host'); + } + + catalogInfoUrl = integration.resolveUrl({ + base: repoContentsUrl as string, + url: catalogInfoPath as string, + }); + } + + ctx.logger.info(`Registering ${catalogInfoUrl} in the catalog`); + + const result = await catalogClient.addLocation({ + type: 'url', + target: catalogInfoUrl as string, + }); + if (result.entities.length >= 1) { + const { kind, name, namespace } = getEntityName(result.entities[0]); + ctx.output('entityRef', `${kind}:${namespace}/${name}`); + ctx.output('catalogInfoUrl', catalogInfoUrl); + } + }, + }; +} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.ts new file mode 100644 index 0000000000..281a6e75d2 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.ts @@ -0,0 +1,83 @@ +/* + * 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 fs from 'fs-extra'; +import { resolve as resolvePath } from 'path'; +import Docker from 'dockerode'; +import { InputError, UrlReader } from '@backstage/backend-common'; +import { ScmIntegrations } from '@backstage/integration'; +import { TemplaterBuilder, TemplaterValues } from '../../../stages/templater'; +import { TemplateAction } from '../../types'; +import { fetchContents } from './helpers'; + +export function createFetchCookiecutterAction(options: { + dockerClient: Docker; + urlReader: UrlReader; + integrations: ScmIntegrations; + templaters: TemplaterBuilder; +}): TemplateAction { + const { dockerClient, urlReader, templaters, integrations } = options; + + return { + id: 'fetch:cookiecutter', + async handler(ctx) { + ctx.logger.info('Fetching and then templating using cookiecutter'); + const workDir = await ctx.createTemporaryDirectory(); + const templateDir = resolvePath(workDir, 'template'); + const templateContentsDir = resolvePath( + templateDir, + "{{cookiecutter and 'contents'}}", + ); + const resultDir = resolvePath(workDir, 'result'); + + await fetchContents({ + urlReader, + integrations, + baseUrl: ctx.baseUrl, + fetchUrl: ctx.parameters.url, + outputPath: templateContentsDir, + }); + + const cookiecutter = templaters.get('cookiecutter'); + if (!cookiecutter) { + throw new Error('No cookiecutter templater available'); + } + + // Will execute the template in ./template and put the result in ./result + await cookiecutter.run({ + workspacePath: workDir, + dockerClient, + logStream: ctx.logStream, + values: ctx.parameters.values as TemplaterValues, + }); + + // 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( + `Fetch action targetPath may not specify a path outside the working directory`, + ); + } + await fs.copy(resultDir, outputPath); + }, + }; +} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/helpers.ts new file mode 100644 index 0000000000..a739e53e1f --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/helpers.ts @@ -0,0 +1,86 @@ +/* + * 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 fs from 'fs-extra'; +import { resolve as resolvePath, isAbsolute } from 'path'; +import { InputError, UrlReader } from '@backstage/backend-common'; +import { ScmIntegrations } from '@backstage/integration'; +import { JsonValue } from '@backstage/config'; + +export async function fetchContents({ + urlReader, + integrations, + baseUrl, + fetchUrl = '.', + outputPath, +}: { + urlReader: UrlReader; + integrations: ScmIntegrations; + baseUrl?: string; + fetchUrl?: JsonValue; + outputPath: string; +}) { + if (typeof fetchUrl !== 'string') { + throw new InputError( + `Invalid url parameter, expected string, got ${typeof fetchUrl}`, + ); + } + + let fetchUrlIsAbsolute = false; + try { + // eslint-disable-next-line no-new + new URL(fetchUrl); + fetchUrlIsAbsolute = true; + } catch { + /* ignored */ + } + + // We handle both file locations and url ones + if (!fetchUrlIsAbsolute && baseUrl?.startsWith('file://')) { + const basePath = baseUrl.slice('file://'.length); + if (isAbsolute(fetchUrl)) { + throw new InputError( + `Fetch URL may not be absolute for file locations, ${fetchUrl}`, + ); + } + const srcDir = resolvePath(basePath, '..', fetchUrl); + await fs.copy(srcDir, outputPath); + } else { + let readUrl; + + if (fetchUrlIsAbsolute) { + readUrl = fetchUrl; + } else if (baseUrl) { + const integration = integrations.byUrl(baseUrl); + if (!integration) { + throw new InputError(`No integration found for location ${baseUrl}`); + } + + readUrl = integration.resolveUrl({ + url: fetchUrl, + base: baseUrl, + }); + } else { + throw new InputError( + `Failed to fetch, template location could not be determined and the fetch URL is relative, ${fetchUrl}`, + ); + } + + const res = await urlReader.readTree(readUrl); + await fs.ensureDir(outputPath); + await res.dir({ targetDir: outputPath }); + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/index.ts new file mode 100644 index 0000000000..0a7239b6b7 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/index.ts @@ -0,0 +1,18 @@ +/* + * 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 { createFetchPlainAction } from './plain'; +export { createFetchCookiecutterAction } from './cookiecutter'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts new file mode 100644 index 0000000000..371bc35e9a --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts @@ -0,0 +1,57 @@ +/* + * 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 { 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'; + +export function createFetchPlainAction(options: { + urlReader: UrlReader; + integrations: ScmIntegrations; +}): TemplateAction { + const { urlReader, integrations } = options; + + return { + id: 'fetch:plain', + 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( + `Fetch action targetPath may not specify a path outside the working directory`, + ); + } + + await fetchContents({ + urlReader, + integrations, + baseUrl: ctx.baseUrl, + fetchUrl: ctx.parameters.url, + outputPath, + }); + }, + }; +} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/index.ts new file mode 100644 index 0000000000..c85c6e33ce --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/index.ts @@ -0,0 +1,19 @@ +/* + * 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 * from './catalog'; +export * from './fetch'; +export * from './publish'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts new file mode 100644 index 0000000000..26f2775c91 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts @@ -0,0 +1,152 @@ +/* + * 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 { + GithubCredentialsProvider, + ScmIntegrations, +} from '@backstage/integration'; +import { Octokit } from '@octokit/rest'; +import { TemplateAction } from '../../types'; +import { initRepoAndPush } from '../../../stages/publish/helpers'; + +export function createPublishGithubAction(options: { + integrations: ScmIntegrations; + repoVisibility: 'private' | 'internal' | 'public'; +}): TemplateAction { + const { integrations, repoVisibility } = options; + + const credentialsProviders = new Map( + integrations.github.list().map(integration => { + const provider = GithubCredentialsProvider.create(integration.config); + return [integration.config.host, provider]; + }), + ); + + return { + id: 'publish:github', + 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}`); + } 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 credentialsProvider = credentialsProviders.get(host); + const integrationConfig = integrations.github.byHost(host); + + if (!credentialsProvider || !integrationConfig) { + throw new InputError( + `No matching integration configuration for host ${host}, please check your Integrations config`, + ); + } + + const { token } = await credentialsProvider.getCredentials({ + url: `${host}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, + }); + + if (!token) { + throw new InputError( + `No token available for host: ${host}, with owner ${owner}, and repo ${repo}`, + ); + } + + const client = new Octokit({ + auth: token, + baseUrl: integrationConfig.config.apiBaseUrl, + }); + + const user = await client.users.getByUsername({ + username: owner, + }); + + const repoCreationPromise = + user.data.type === 'Organization' + ? client.repos.createInOrg({ + name: repo, + org: owner, + private: repoVisibility !== 'public', + visibility: repoVisibility, + description: description as string, + }) + : client.repos.createForAuthenticatedUser({ + name: repo, + private: repoVisibility === 'private', + description: description as string, + }); + + const { data } = await repoCreationPromise; + const accessString = access as string; + if (accessString?.startsWith(`${owner}/`)) { + const [, team] = accessString.split('/'); + await client.teams.addOrUpdateRepoPermissionsInOrg({ + org: owner, + team_slug: team, + owner, + repo, + permission: 'admin', + }); + // no need to add accessString if it's the person who own's the personal account + } else if (accessString && accessString !== owner) { + await client.repos.addCollaborator({ + owner, + repo, + username: accessString, + permission: 'admin', + }); + } + + const remoteUrl = data.clone_url; + const repoContentsUrl = `${data?.html_url}/blob/master`; + + await initRepoAndPush({ + dir: ctx.workspacePath, + remoteUrl, + auth: { + username: 'x-access-token', + password: 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 new file mode 100644 index 0000000000..b1ecb91f9a --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/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 { createPublishGithubAction } from './github'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/index.ts index 3da406d45a..0f7931e74b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/index.ts @@ -14,11 +14,6 @@ * limitations under the License. */ -export { - createCatalogRegisterAction, - createFetchCookiecutterAction, - createFetchPlainAction, - createPublishGithubAction, -} from './builtin'; +export * from './builtin'; export { TemplateActionRegistry } from './TemplateActionRegistry'; export type { ActionContext, TemplateAction } from './types';