diff --git a/.changeset/giant-hairs-collect.md b/.changeset/giant-hairs-collect.md new file mode 100644 index 0000000000..01d2790354 --- /dev/null +++ b/.changeset/giant-hairs-collect.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-sonarqube': patch +--- + +Add support for the security hotspots that are provided by SonarQube and SonarCloud. diff --git a/.changeset/olive-zebras-itch.md b/.changeset/olive-zebras-itch.md new file mode 100644 index 0000000000..6a12e35c87 --- /dev/null +++ b/.changeset/olive-zebras-itch.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-kubernetes': patch +--- + +Show Kubernetes Service manifests. +Show Kubernetes Ingress manifests. diff --git a/.changeset/rotten-lobsters-grin.md b/.changeset/rotten-lobsters-grin.md new file mode 100644 index 0000000000..019ec78733 --- /dev/null +++ b/.changeset/rotten-lobsters-grin.md @@ -0,0 +1,52 @@ +--- +'@backstage/create-app': minor +'@backstage/integration': minor +'@backstage/plugin-scaffolder': minor +'@backstage/plugin-scaffolder-backend': minor +--- + +- Deprecating the `scaffolder.${provider}.token` auth duplication and favoring `integrations.${provider}` instead. If you receive deprecation warnings your config should change like the following: + +```yaml +scaffolder: + github: + token: + $env: GITHUB_TOKEN + visibility: public +``` + +To something that looks like this: + +```yaml +integration: + github: + - host: github.com + token: + $env: GITHUB_TOKEN +scaffolder: + github: + visibility: public +``` + +You can also configure multiple different hosts under the `integration` config like the following: + +```yaml +integration: + github: + - host: github.com + token: + $env: GITHUB_TOKEN + - host: ghe.mycompany.com + token: + $env: GITHUB_ENTERPRISE_TOKEN +``` + +This of course is the case for all the providers respectively. + +- Adding support for cross provider scaffolding, you can now create repositories in for example Bitbucket using a template residing in GitHub. + +- Fix GitLab scaffolding so that it returns a `catalogInfoUrl` which automatically imports the project into the catalog. + +- The `Store Path` field on the `scaffolder` frontend has now changed so that you require the full URL to the desired destination repository. + +`backstage/new-repository` would become `https://github.com/backstage/new-repository` if provider was GitHub for example. diff --git a/.changeset/tasty-dolls-dance.md b/.changeset/tasty-dolls-dance.md new file mode 100644 index 0000000000..3627e918b4 --- /dev/null +++ b/.changeset/tasty-dolls-dance.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +Ensured that versions bumps of packages used in the app template trigger a release of this package when needed. diff --git a/.changeset/techdocs-healthy-kiwis-mix.md b/.changeset/techdocs-healthy-kiwis-mix.md new file mode 100644 index 0000000000..9415b43bca --- /dev/null +++ b/.changeset/techdocs-healthy-kiwis-mix.md @@ -0,0 +1,9 @@ +--- +'@backstage/techdocs-common': patch +'@backstage/plugin-techdocs': patch +'@backstage/plugin-techdocs-backend': patch +--- + +Create type for TechDocsMetadata (#3716) + +This change introduces a new type (TechDocsMetadata) in packages/techdocs-common. This type is then introduced in the endpoint response in techdocs-backend and in the api interface in techdocs (frontend). diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 03f84f4417..df1edbd842 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -93,6 +93,7 @@ Henneke Heroku horizontalpodautoscalers Hostname +hotspots html http https diff --git a/app-config.yaml b/app-config.yaml index 71c3310713..02afb8ac41 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -372,3 +372,8 @@ homepage: timezone: 'Asia/Tokyo' pagerduty: eventsBaseUrl: 'https://events.pagerduty.com/v2' + +kafka: + clientId: backstage + brokers: + - localhost:9092 diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index 5d36d508a5..4e2257a46c 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -34,6 +34,7 @@ export default async function createPlugin({ const cookiecutterTemplater = new CookieCutter(); const craTemplater = new CreateReactAppTemplater(); const templaters = new Templaters(); + templaters.register('cookiecutter', cookiecutterTemplater); templaters.register('cra', craTemplater); diff --git a/packages/core/package.json b/packages/core/package.json index d86ea3b240..94e8662679 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,7 +46,7 @@ "d3-zoom": "^2.0.0", "dagre": "^0.8.5", "qs": "^6.9.4", - "immer": "^7.0.9", + "immer": "^8.0.1", "lodash": "^4.17.15", "material-table": "^1.69.1", "prop-types": "^15.7.2", diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 0c703bcdac..245df9212e 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -37,6 +37,13 @@ "recursive-readdir": "^2.2.2" }, "devDependencies": { + "@types/fs-extra": "^9.0.1", + "@types/inquirer": "^7.3.1", + "@types/react-dev-utils": "^9.0.4", + "@types/recursive-readdir": "^2.2.0", + "ts-node": "^8.6.2" + }, + "peerDependencies": { "@backstage/backend-common": "^0.4.3", "@backstage/catalog-model": "^0.6.1", "@backstage/cli": "^0.4.6", @@ -62,12 +69,7 @@ "@backstage/plugin-techdocs-backend": "^0.5.3", "@backstage/plugin-user-settings": "^0.2.3", "@backstage/test-utils": "^0.1.6", - "@backstage/theme": "^0.2.2", - "@types/fs-extra": "^9.0.1", - "@types/inquirer": "^7.3.1", - "@types/react-dev-utils": "^9.0.4", - "@types/recursive-readdir": "^2.2.0", - "ts-node": "^8.6.2" + "@backstage/theme": "^0.2.2" }, "nodemonConfig": { "watch": "./src", diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts index 196d48ec78..c8bd3e5012 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts @@ -18,6 +18,7 @@ export default async function createPlugin({ const cookiecutterTemplater = new CookieCutter(); const craTemplater = new CreateReactAppTemplater(); const templaters = new Templaters(); + templaters.register('cookiecutter', cookiecutterTemplater); templaters.register('cra', craTemplater); diff --git a/packages/e2e-test/package.json b/packages/e2e-test/package.json index ea1dcc8ea2..4327d7161c 100644 --- a/packages/e2e-test/package.json +++ b/packages/e2e-test/package.json @@ -15,7 +15,7 @@ "license": "Apache-2.0", "main": "src/index.ts", "scripts": { - "start": "node .", + "start": "nodemon --", "lint": "backstage-cli lint", "test": "backstage-cli test", "test:e2e": "yarn start" @@ -36,5 +36,10 @@ "tree-kill": "^1.2.2", "ts-node": "^8.6.2", "zombie": "^6.1.4" + }, + "nodemonConfig": { + "watch": "./src", + "exec": "bin/e2e-test", + "ext": "ts" } } diff --git a/packages/e2e-test/src/commands/run.ts b/packages/e2e-test/src/commands/run.ts index be00b99e54..2ddcc333d2 100644 --- a/packages/e2e-test/src/commands/run.ts +++ b/packages/e2e-test/src/commands/run.ts @@ -80,10 +80,22 @@ async function buildDistWorkspace(workspaceName: string, rootDir: string) { // We grab the needed dependencies from the create app template const createAppDeps = new Set(); + + function appendDeps(pkg: any) { + Array() + .concat( + Object.keys(pkg.dependencies ?? {}), + Object.keys(pkg.devDependencies ?? {}), + Object.keys(pkg.peerDependencies ?? {}), + ) + .filter(name => name.startsWith('@backstage/')) + .forEach(dep => createAppDeps.add(dep)); + } + for (const pkgJsonPath of templatePackagePaths) { const path = paths.resolveOwnRoot(pkgJsonPath); const pkgTemplate = await fs.readFile(path, 'utf8'); - const { dependencies = {}, devDependencies = {} } = JSON.parse( + const pkg = JSON.parse( handlebars.compile(pkgTemplate)( { privatePackage: true, @@ -102,13 +114,12 @@ async function buildDistWorkspace(workspaceName: string, rootDir: string) { }, ), ); - - Array() - .concat(Object.keys(dependencies), Object.keys(devDependencies)) - .filter(name => name.startsWith('@backstage/')) - .forEach(dep => createAppDeps.add(dep)); + appendDeps(pkg); } + // eslint-disable-next-line import/no-extraneous-dependencies + appendDeps(require('@backstage/create-app/package.json')); + print(`Preparing workspace`); await runPlain([ 'yarn', diff --git a/packages/integration/src/github/GithubCredentialsProvider.ts b/packages/integration/src/github/GithubCredentialsProvider.ts index 843ef629ab..dfb809ee5f 100644 --- a/packages/integration/src/github/GithubCredentialsProvider.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import gitUrlParse from 'git-url-parse'; +import parseGitUrl from 'git-url-parse'; import { GithubAppConfig, GitHubIntegrationConfig } from './config'; import { createAppAuth } from '@octokit/auth-app'; import { Octokit, RestEndpointMethodTypes } from '@octokit/rest'; @@ -221,7 +221,7 @@ export class GithubCredentialsProvider { * const { token, headers } = await getCredentials({url: 'github.com/backstage/foobar'}) */ async getCredentials(opts: { url: string }): Promise { - const parsed = gitUrlParse(opts.url); + const parsed = parseGitUrl(opts.url); const owner = parsed.owner || parsed.name; const repo = parsed.owner ? parsed.name : undefined; diff --git a/packages/integration/src/gitlab/config.test.ts b/packages/integration/src/gitlab/config.test.ts index 31643d74a6..303b4bd270 100644 --- a/packages/integration/src/gitlab/config.test.ts +++ b/packages/integration/src/gitlab/config.test.ts @@ -31,11 +31,14 @@ describe('readGitLabIntegrationConfig', () => { buildConfig({ host: 'a.com', token: 't', + baseUrl: 'https://baseurl.for.me/gitlab', }), ); + expect(output).toEqual({ host: 'a.com', token: 't', + baseUrl: 'https://baseurl.for.me/gitlab', }); }); @@ -44,6 +47,7 @@ describe('readGitLabIntegrationConfig', () => { expect(output).toEqual({ host: 'gitlab.com', apiBaseUrl: 'https://gitlab.com/api/v4', + baseUrl: 'https://gitlab.com', }); }); @@ -54,6 +58,7 @@ describe('readGitLabIntegrationConfig', () => { expect(output).toEqual({ host: 'gitlab.com', + baseUrl: 'https://gitlab.com', apiBaseUrl: 'https://gitlab.com/api/v4', }); }); @@ -89,6 +94,7 @@ describe('readGitLabIntegrationConfigs', () => { expect(output).toContainEqual({ host: 'a.com', token: 't', + baseUrl: 'https://a.com', }); }); diff --git a/packages/integration/src/gitlab/config.ts b/packages/integration/src/gitlab/config.ts index fd52a436b6..47269b4c32 100644 --- a/packages/integration/src/gitlab/config.ts +++ b/packages/integration/src/gitlab/config.ts @@ -46,6 +46,14 @@ export type GitLabIntegrationConfig = { * If no token is specified, anonymous access is used. */ token?: string; + + /** + * The baseUrl of this provider, e.g "https://gitlab.com", + * which is passed into the gitlab client. + * + * If no baseUrl is provided, it will default to https://${host} + */ + baseUrl?: string; }; /** @@ -59,6 +67,7 @@ export function readGitLabIntegrationConfig( const host = config.getOptionalString('host') ?? GITLAB_HOST; let apiBaseUrl = config.getOptionalString('apiBaseUrl'); const token = config.getOptionalString('token'); + const baseUrl = config.getOptionalString('baseUrl') ?? `https://${host}`; if (!isValidHost(host)) { throw new Error( @@ -71,7 +80,8 @@ export function readGitLabIntegrationConfig( } else if (host === GITLAB_HOST) { apiBaseUrl = GITLAB_API_BASE_URL; } - return { host, token, apiBaseUrl }; + + return { host, token, apiBaseUrl, baseUrl }; } /** diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index a069326375..6536ce8c70 100644 --- a/packages/techdocs-common/package.json +++ b/packages/techdocs-common/package.json @@ -50,6 +50,7 @@ "fs-extra": "^9.0.1", "git-url-parse": "^11.4.3", "js-yaml": "^4.0.0", + "json5": "^2.1.3", "mime-types": "^2.1.27", "mock-fs": "^4.13.0", "recursive-readdir": "^2.2.2", diff --git a/packages/techdocs-common/src/stages/publish/awsS3.test.ts b/packages/techdocs-common/src/stages/publish/awsS3.test.ts index c157a7c205..3902aab21a 100755 --- a/packages/techdocs-common/src/stages/publish/awsS3.test.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.test.ts @@ -18,7 +18,7 @@ import path from 'path'; import * as winston from 'winston'; import { ConfigReader } from '@backstage/config'; import { AwsS3Publish } from './awsS3'; -import { PublisherBase } from './types'; +import { PublisherBase, TechDocsMetadata } from './types'; import type { Entity, EntityName } from '@backstage/catalog-model'; const createMockEntity = (annotations = {}): Entity => { @@ -159,13 +159,39 @@ describe('AwsS3Publish', () => { mockFs({ [entityRootDir]: { - 'techdocs_metadata.json': 'file-content', + 'techdocs_metadata.json': + '{"site_name": "backstage", "site_description": "site_content"}', }, }); - expect(await publisher.fetchTechDocsMetadata(entityNameMock)).toBe( - 'file-content', - ); + const expectedMetadata: TechDocsMetadata = { + site_name: 'backstage', + site_description: 'site_content', + }; + expect( + await publisher.fetchTechDocsMetadata(entityNameMock), + ).toStrictEqual(expectedMetadata); + mockFs.restore(); + }); + + it('should return tech docs metadata when json encoded with single quotes', async () => { + const entityNameMock = createMockEntityName(); + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'techdocs_metadata.json': `{'site_name': 'backstage', 'site_description': 'site_content'}`, + }, + }); + + const expectedMetadata: TechDocsMetadata = { + site_name: 'backstage', + site_description: 'site_content', + }; + expect( + await publisher.fetchTechDocsMetadata(entityNameMock), + ).toStrictEqual(expectedMetadata); mockFs.restore(); }); diff --git a/packages/techdocs-common/src/stages/publish/awsS3.ts b/packages/techdocs-common/src/stages/publish/awsS3.ts index 3f7a0e3d81..b9e9fe12e6 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -20,9 +20,10 @@ import { Logger } from 'winston'; import { Entity, EntityName } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { getHeadersForFileExtension, getFileTreeRecursively } from './helpers'; -import { PublisherBase, PublishRequest } from './types'; +import { PublisherBase, PublishRequest, TechDocsMetadata } from './types'; import fs from 'fs-extra'; import { Readable } from 'stream'; +import JSON5 from 'json5'; const streamToBuffer = (stream: Readable): Promise => { return new Promise((resolve, reject) => { @@ -32,7 +33,7 @@ const streamToBuffer = (stream: Readable): Promise => { stream.on('error', reject); stream.on('end', () => resolve(Buffer.concat(chunks))); } catch (e) { - throw new Error(`Unable to parse the response data, ${e.message}`); + throw new Error(`Unable to parse the response data ${e.message}`); } }); }; @@ -162,9 +163,11 @@ export class AwsS3Publish implements PublisherBase { } } - async fetchTechDocsMetadata(entityName: EntityName): Promise { + async fetchTechDocsMetadata( + entityName: EntityName, + ): Promise { try { - return await new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; this.storageClient @@ -182,8 +185,11 @@ export class AwsS3Publish implements PublisherBase { `Unable to parse the techdocs metadata file ${entityRootDir}/techdocs_metadata.json.`, ); } + const techdocsMetadata = JSON5.parse( + techdocsMetadataJson.toString('utf-8'), + ); - resolve(techdocsMetadataJson.toString('utf-8')); + resolve(techdocsMetadata); }) .catch(err => { this.logger.error(err.message); diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.ts b/packages/techdocs-common/src/stages/publish/googleStorage.ts index 9c28de4130..82a9620e8f 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.ts @@ -24,7 +24,8 @@ import { Logger } from 'winston'; import { Entity, EntityName } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { getHeadersForFileExtension, getFileTreeRecursively } from './helpers'; -import { PublisherBase, PublishRequest } from './types'; +import { PublisherBase, PublishRequest, TechDocsMetadata } from './types'; +import JSON5 from 'json5'; export class GoogleGCSPublish implements PublisherBase { static async fromConfig( @@ -132,7 +133,7 @@ export class GoogleGCSPublish implements PublisherBase { }); } - fetchTechDocsMetadata(entityName: EntityName): Promise { + fetchTechDocsMetadata(entityName: EntityName): Promise { return new Promise((resolve, reject) => { const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; @@ -152,7 +153,7 @@ export class GoogleGCSPublish implements PublisherBase { const techdocsMetadataJson = Buffer.concat( fileStreamChunks, ).toString(); - resolve(techdocsMetadataJson); + resolve(JSON5.parse(techdocsMetadataJson)); }); }); } diff --git a/packages/techdocs-common/src/stages/publish/index.ts b/packages/techdocs-common/src/stages/publish/index.ts index 494efe5ac1..dbf4ff732a 100644 --- a/packages/techdocs-common/src/stages/publish/index.ts +++ b/packages/techdocs-common/src/stages/publish/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ export { Publisher } from './publish'; -export type { PublisherBase, PublisherType } from './types'; +export type { PublisherBase, PublisherType, TechDocsMetadata } from './types'; diff --git a/packages/techdocs-common/src/stages/publish/local.ts b/packages/techdocs-common/src/stages/publish/local.ts index de670cfba5..d07472e17d 100644 --- a/packages/techdocs-common/src/stages/publish/local.ts +++ b/packages/techdocs-common/src/stages/publish/local.ts @@ -25,7 +25,12 @@ import { PluginEndpointDiscovery, } from '@backstage/backend-common'; import { Config } from '@backstage/config'; -import { PublisherBase, PublishRequest, PublishResponse } from './types'; +import { + PublisherBase, + PublishRequest, + PublishResponse, + TechDocsMetadata, +} from './types'; // TODO: Use a more persistent storage than node_modules or /tmp directory. // Make it configurable with techdocs.publisher.local.publishDirectory @@ -102,7 +107,7 @@ export class LocalPublish implements PublisherBase { }); } - fetchTechDocsMetadata(entityName: EntityName): Promise { + fetchTechDocsMetadata(entityName: EntityName): Promise { return new Promise((resolve, reject) => { this.discovery.getBaseUrl('techdocs').then(techdocsApiUrl => { const storageUrl = new URL( @@ -116,7 +121,7 @@ export class LocalPublish implements PublisherBase { .then(response => response .json() - .then(techdocsMetadataJson => resolve(techdocsMetadataJson)) + .then(techdocsMetadata => resolve(techdocsMetadata)) .catch(err => { reject( `Unable to parse metadata JSON for ${entityRootDir}. Error: ${err}`, diff --git a/packages/techdocs-common/src/stages/publish/types.ts b/packages/techdocs-common/src/stages/publish/types.ts index 24a9d05adf..db6a075d43 100644 --- a/packages/techdocs-common/src/stages/publish/types.ts +++ b/packages/techdocs-common/src/stages/publish/types.ts @@ -32,6 +32,14 @@ export type PublishResponse = { remoteUrl?: string; } | void; +/** + * Type to hold metadata found in techdocs_metadata.json and associated with each site + */ +export type TechDocsMetadata = { + site_name: string; + site_description: string; +}; + /** * Base class for a TechDocs publisher (e.g. Local, Google GCS Bucket, AWS S3, etc.) * The publisher handles publishing of the generated static files after the prepare and generate steps of TechDocs. @@ -50,7 +58,7 @@ export interface PublisherBase { * Retrieve TechDocs Metadata about a site e.g. name, contributors, last updated, etc. * This API uses the techdocs_metadata.json file that co-exists along with the generated docs. */ - fetchTechDocsMetadata(entityName: EntityName): Promise; + fetchTechDocsMetadata(entityName: EntityName): Promise; /** * Route middleware to serve static documentation files for an entity. diff --git a/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.test.tsx b/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.test.tsx new file mode 100644 index 0000000000..cf37eb3e79 --- /dev/null +++ b/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.test.tsx @@ -0,0 +1,38 @@ +/* + * 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 React from 'react'; +import { render } from '@testing-library/react'; +import * as ingresses from './__fixtures__/2-ingresses.json'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { IngressDrawer } from './IngressDrawer'; + +describe('IngressDrawer', () => { + it('should render ingress drawer', async () => { + const { getByText, getAllByText } = render( + wrapInTestApp( + , + ), + ); + + expect(getAllByText('awesome-service')).toHaveLength(2); + expect(getByText('YAML')).toBeInTheDocument(); + expect(getByText('Rules')).toBeInTheDocument(); + expect(getByText('Host: api.awesome-host.io')).toBeInTheDocument(); + expect(getAllByText('Service Port: 80')).toHaveLength(2); + expect(getAllByText('Service Name: awesome-service')).toHaveLength(2); + }); +}); diff --git a/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx b/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx new file mode 100644 index 0000000000..9e63f22fda --- /dev/null +++ b/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx @@ -0,0 +1,58 @@ +/* + * 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 React from 'react'; +import { ExtensionsV1beta1Ingress } from '@kubernetes/client-node'; +import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer'; +import { Typography, Grid } from '@material-ui/core'; + +export const IngressDrawer = ({ + ingress, + expanded, +}: { + ingress: ExtensionsV1beta1Ingress; + expanded?: boolean; +}) => { + return ( + { + return ingress.spec || {}; + }} + > + + + + {ingress.metadata?.name ?? 'unknown object'} + + + + + Ingress + + + + + ); +}; diff --git a/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.test.tsx b/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.test.tsx new file mode 100644 index 0000000000..580561095e --- /dev/null +++ b/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.test.tsx @@ -0,0 +1,34 @@ +/* + * 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 React from 'react'; +import { render } from '@testing-library/react'; +import * as oneIngressFixture from './__fixtures__/2-ingresses.json'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { IngressesAccordions } from './IngressesAccordions'; + +describe('IngressesAccordions', () => { + it('should render 1 ingress', async () => { + const { getByText } = render( + wrapInTestApp( + , + ), + ); + + expect(getByText('awesome-service')).toBeInTheDocument(); + expect(getByText('Ingress')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.tsx b/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.tsx new file mode 100644 index 0000000000..a49f966ffd --- /dev/null +++ b/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.tsx @@ -0,0 +1,101 @@ +/* + * 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 { GroupedResponses } from '../../types/types'; +import React from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Divider, + Grid, +} from '@material-ui/core'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { ExtensionsV1beta1Ingress } from '@kubernetes/client-node'; +import { StructuredMetadataTable } from '@backstage/core'; +import { IngressDrawer } from './IngressDrawer'; + +type IngressesAccordionsProps = { + deploymentResources: GroupedResponses; +}; + +export const IngressesAccordions = ({ + deploymentResources, +}: IngressesAccordionsProps) => { + return ( + + {deploymentResources.ingresses.map((ingress, i) => ( + + + + ))} + + ); +}; + +type IngressAccordionProps = { + ingress: ExtensionsV1beta1Ingress; +}; + +const IngressAccordion = ({ ingress }: IngressAccordionProps) => { + return ( + + }> + + + + + + + ); +}; + +type IngressSummaryProps = { + ingress: ExtensionsV1beta1Ingress; +}; + +const IngressSummary = ({ ingress }: IngressSummaryProps) => { + return ( + + + + + + + + + + ); +}; + +type IngressCardProps = { + ingress: ExtensionsV1beta1Ingress; +}; + +const IngressCard = ({ ingress }: IngressCardProps) => { + return ( + + ); +}; diff --git a/plugins/kubernetes/src/components/IngressesAccordions/__fixtures__/2-ingresses.json b/plugins/kubernetes/src/components/IngressesAccordions/__fixtures__/2-ingresses.json new file mode 100644 index 0000000000..136c70697d --- /dev/null +++ b/plugins/kubernetes/src/components/IngressesAccordions/__fixtures__/2-ingresses.json @@ -0,0 +1,57 @@ +{ + "ingresses": [ + { + "metadata": { + "annotations": { + "artifact.spinnaker.io/location": "default", + "artifact.spinnaker.io/name": "awesome-service", + "artifact.spinnaker.io/type": "kubernetes/ingress", + "kubernetes.io/ingress.class": "traefik", + "kubernetes.io/ingress.global-static-ip-name": "traefik-tcp-lb", + "moniker.spinnaker.io/application": "awesome-service", + "moniker.spinnaker.io/cluster": "ingress awesome-service" + }, + "creationTimestamp": "2018-11-16T14:00:13.000Z", + "generation": 11, + "labels": { + "app": "awesome-service", + "app.kubernetes.io/managed-by": "spinnaker", + "app.kubernetes.io/name": "awesome-service" + }, + "name": "awesome-service", + "namespace": "default", + "resourceVersion": "564824116", + "selfLink": "/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/awesome-service", + "uid": "f072e0b4-e9a7-11e8-af65-42010a9c0022" + }, + "spec": { + "rules": [ + { + "host": "api.awesome-host.io", + "http": { + "paths": [ + { + "backend": { + "serviceName": "awesome-service", + "servicePort": 80 + }, + "path": "/v1/awesome-service" + }, + { + "backend": { + "serviceName": "awesome-service", + "servicePort": 80 + }, + "path": "/v1/awesome-services" + } + ] + } + } + ] + }, + "status": { + "loadBalancer": {} + } + } + ] +} diff --git a/plugins/kubernetes/src/components/IngressesAccordions/index.ts b/plugins/kubernetes/src/components/IngressesAccordions/index.ts new file mode 100644 index 0000000000..a71f53fac7 --- /dev/null +++ b/plugins/kubernetes/src/components/IngressesAccordions/index.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ +export { IngressesAccordions } from './IngressesAccordions'; diff --git a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx index 39e73782f1..092ada0724 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx @@ -47,6 +47,8 @@ import { DeploymentsAccordions } from '../DeploymentsAccordions'; import { ErrorReporting } from '../ErrorReporting'; import { groupResponses } from '../../utils/response'; import { DetectedError, detectErrors } from '../../error-detection'; +import { IngressesAccordions } from '../IngressesAccordions'; +import { ServicesAccordions } from '../ServicesAccordions'; type KubernetesContentProps = { entity: Entity; children?: React.ReactNode }; @@ -187,10 +189,22 @@ const Cluster = ({ clusterObjects, detectedErrors }: ClusterProps) => { /> - + + + + + + + + + + + + + diff --git a/plugins/kubernetes/src/components/ServicesAccordions/ServiceDrawer.test.tsx b/plugins/kubernetes/src/components/ServicesAccordions/ServiceDrawer.test.tsx new file mode 100644 index 0000000000..d9082ff632 --- /dev/null +++ b/plugins/kubernetes/src/components/ServicesAccordions/ServiceDrawer.test.tsx @@ -0,0 +1,39 @@ +/* + * 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 React from 'react'; +import { render } from '@testing-library/react'; +import * as services from './__fixtures__/2-services.json'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { ServiceDrawer } from './ServiceDrawer'; + +describe('ServiceDrawer', () => { + it('should render deployment drawer', async () => { + const { getByText, getAllByText } = render( + wrapInTestApp( + , + ), + ); + + expect(getAllByText('awesome-service-grpc')).toHaveLength(2); + expect(getAllByText('Service')).toHaveLength(2); + expect(getByText('YAML')).toBeInTheDocument(); + expect(getByText('Cluster IP')).toBeInTheDocument(); + expect(getByText('Ports')).toBeInTheDocument(); + expect(getByText('Target Port: 1997')).toBeInTheDocument(); + expect(getByText('App: awesome-service')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/ServicesAccordions/ServiceDrawer.tsx b/plugins/kubernetes/src/components/ServicesAccordions/ServiceDrawer.tsx new file mode 100644 index 0000000000..d48f17813a --- /dev/null +++ b/plugins/kubernetes/src/components/ServicesAccordions/ServiceDrawer.tsx @@ -0,0 +1,58 @@ +/* + * 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 React from 'react'; +import { V1Service } from '@kubernetes/client-node'; +import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer'; +import { Typography, Grid } from '@material-ui/core'; + +export const ServiceDrawer = ({ + service, + expanded, +}: { + service: V1Service; + expanded?: boolean; +}) => { + return ( + { + return service.spec || {}; + }} + > + + + + {service.metadata?.name ?? 'unknown object'} + + + + + Service + + + + + ); +}; diff --git a/plugins/kubernetes/src/components/ServicesAccordions/ServicesAccordions.test.tsx b/plugins/kubernetes/src/components/ServicesAccordions/ServicesAccordions.test.tsx new file mode 100644 index 0000000000..46172b4150 --- /dev/null +++ b/plugins/kubernetes/src/components/ServicesAccordions/ServicesAccordions.test.tsx @@ -0,0 +1,37 @@ +/* + * 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 React from 'react'; +import { render } from '@testing-library/react'; +import * as twoDeployFixture from './__fixtures__/2-services.json'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { ServicesAccordions } from './ServicesAccordions'; + +describe('ServicesAccordions', () => { + it('should render 2 services', async () => { + const { getByText } = render( + wrapInTestApp( + , + ), + ); + + expect(getByText('awesome-service-grpc')).toBeInTheDocument(); + expect(getByText('Type: ClusterIP')).toBeInTheDocument(); + + expect(getByText('awesome-service-pg')).toBeInTheDocument(); + expect(getByText('Type: ExternalName')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/ServicesAccordions/ServicesAccordions.tsx b/plugins/kubernetes/src/components/ServicesAccordions/ServicesAccordions.tsx new file mode 100644 index 0000000000..6484f2211d --- /dev/null +++ b/plugins/kubernetes/src/components/ServicesAccordions/ServicesAccordions.tsx @@ -0,0 +1,123 @@ +/* + * 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 { GroupedResponses } from '../../types/types'; +import React from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Divider, + Grid, + Typography, +} from '@material-ui/core'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { V1Service } from '@kubernetes/client-node'; +import { StructuredMetadataTable } from '@backstage/core'; +import { ServiceDrawer } from './ServiceDrawer'; + +type ServicesAccordionsProps = { + deploymentResources: GroupedResponses; +}; + +export const ServicesAccordions = ({ + deploymentResources, +}: ServicesAccordionsProps) => { + return ( + + {deploymentResources.services.map((service, i) => ( + + + + ))} + + ); +}; + +type ServiceAccordionProps = { + service: V1Service; +}; + +const ServiceAccordion = ({ service }: ServiceAccordionProps) => { + return ( + + }> + + + + + + + ); +}; + +type ServiceSummaryProps = { + service: V1Service; +}; + +const ServiceSummary = ({ service }: ServiceSummaryProps) => { + return ( + + + + + + + + + + + + Type: {service.spec?.type ?? '?'} + + + + ); +}; + +type ServiceCardProps = { + service: V1Service; +}; + +const ServiceCard = ({ service }: ServiceCardProps) => { + const metadata: any = {}; + + if (service.status?.loadBalancer?.ingress?.length ?? -1 > 0) { + metadata.loadbalancer = service.status?.loadBalancer; + } + + if (service.spec?.type === 'ClusterIP') { + metadata.clusterIP = service.spec.clusterIP; + } + if (service.spec?.type === 'ExternalName') { + metadata.externalName = service.spec.externalName; + } + + return ( + + ); +}; diff --git a/plugins/kubernetes/src/components/ServicesAccordions/__fixtures__/2-services.json b/plugins/kubernetes/src/components/ServicesAccordions/__fixtures__/2-services.json new file mode 100644 index 0000000000..f982ebf164 --- /dev/null +++ b/plugins/kubernetes/src/components/ServicesAccordions/__fixtures__/2-services.json @@ -0,0 +1,83 @@ +{ + "services": [ + { + "metadata": { + "annotations": { + "artifact.spinnaker.io/location": "default", + "artifact.spinnaker.io/name": "awesome-service-grpc", + "artifact.spinnaker.io/type": "kubernetes/service", + "moniker.spinnaker.io/application": "awesome-service", + "moniker.spinnaker.io/cluster": "service awesome-service-grpc" + }, + "creationTimestamp": "2021-01-04T16:35:04.000Z", + "labels": { + "app": "awesome-service", + "app.kubernetes.io/managed-by": "spinnaker", + "app.kubernetes.io/name": "awesome-service" + }, + "name": "awesome-service-grpc", + "namespace": "default", + "resourceVersion": "548901649", + "selfLink": "/api/v1/namespaces/default/services/awesome-service-grpc", + "uid": "461cdcd7-8c61-4125-91f9-e03d745f2f2c" + }, + "spec": { + "clusterIP": "None", + "ports": [ + { + "name": "grpc", + "port": 1997, + "protocol": "TCP", + "targetPort": 1997 + } + ], + "selector": { + "app": "awesome-service" + }, + "sessionAffinity": "None", + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } + }, + { + "metadata": { + "annotations": { + "artifact.spinnaker.io/location": "default", + "artifact.spinnaker.io/name": "awesome-service-pg", + "artifact.spinnaker.io/type": "kubernetes/service", + "moniker.spinnaker.io/application": "awesome-service", + "moniker.spinnaker.io/cluster": "service awesome-service-pg" + }, + "creationTimestamp": "2021-01-04T16:35:02.000Z", + "labels": { + "app": "awesome-service", + "app.kubernetes.io/managed-by": "spinnaker", + "app.kubernetes.io/name": "awesome-service" + }, + "name": "awesome-service-pg", + "namespace": "default", + "resourceVersion": "548901625", + "selfLink": "/api/v1/namespaces/default/services/awesome-service-pg", + "uid": "7d7ff8f2-6caa-4888-ae55-b6d41833ab92" + }, + "spec": { + "externalName": "10.244.0.5", + "ports": [ + { + "name": "pg", + "port": 5432, + "protocol": "TCP", + "targetPort": 5432 + } + ], + "sessionAffinity": "None", + "type": "ExternalName" + }, + "status": { + "loadBalancer": {} + } + } + ] +} diff --git a/plugins/kubernetes/src/components/ServicesAccordions/index.ts b/plugins/kubernetes/src/components/ServicesAccordions/index.ts new file mode 100644 index 0000000000..a3c392c743 --- /dev/null +++ b/plugins/kubernetes/src/components/ServicesAccordions/index.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ +export { ServicesAccordions } from './ServicesAccordions'; diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts index 25f9b23e59..4ea0b4371b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts @@ -120,7 +120,7 @@ export class JobProcessor implements Processor { // Log to the current stage the error that occurred and fail the stage. stage.status = 'FAILED'; logger.error(`Stage failed with error: ${error.message}`); - + logger.debug(error.stack); // Throw the error so the job can be failed too. throw error; } finally { diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts index 09bd6b7885..be6d215e5b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts @@ -13,15 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - makeDeprecatedLocationTypeDetector, - parseLocationAnnotation, -} from './helpers'; +import { parseLocationAnnotation } from './helpers'; import { TemplateEntityV1alpha1, LOCATION_ANNOTATION, } from '@backstage/catalog-model'; -import { ConfigReader } from '@backstage/config'; describe('Helpers', () => { describe('parseLocationAnnotation', () => { @@ -30,10 +26,7 @@ describe('Helpers', () => { apiVersion: 'backstage.io/v1alpha1', kind: 'Template', metadata: { - annotations: { - // [LOCATION_ANNOTATION]: - // 'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml', - }, + annotations: {}, name: 'graphql-starter', title: 'GraphQL Service', description: @@ -257,29 +250,4 @@ describe('Helpers', () => { }); }); }); - - describe('makeDeprecatedLocationTypeDetector', () => { - it('detects deprecated location types', () => { - const detector = makeDeprecatedLocationTypeDetector( - new ConfigReader({ - integrations: { - github: [{ host: 'derp.com' }, { host: 'foo.com' }], - gitlab: [{ host: 'derp.org' }, { host: 'foo.org' }], - azure: [{ host: 'derp.net' }, { host: 'foo.net' }], - }, - }), - ); - - expect(detector('http://lol:wut@derp.com/wat')).toBe('github'); - expect(detector('https://foo.com/wat')).toBe('github'); - expect(detector('http://derp.org:80/wat')).toBe('gitlab'); - expect(detector('https://foo.org/wat')).toBe('gitlab'); - expect(detector('http://not.derp.net')).toBe(undefined); - expect(detector('http://derp.net')).toBe('azure/api'); - expect(detector('http://derp.net:8080/wat')).toBe('azure/api'); - expect(detector('http://github.com')).toBe('github'); - expect(detector('http://gitlab.com')).toBe('gitlab'); - expect(detector('http://dev.azure.com')).toBe('azure/api'); - }); - }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts index 21b63609e8..f8d8229298 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts @@ -17,12 +17,10 @@ import { TemplateEntityV1alpha1, LOCATION_ANNOTATION, } from '@backstage/catalog-model'; -import { Config } from '@backstage/config'; import { InputError } from '@backstage/backend-common'; -import { RemoteProtocol } from './types'; export type ParsedLocationAnnotation = { - protocol: RemoteProtocol; + protocol: 'file' | 'url'; location: string; }; @@ -40,7 +38,7 @@ export const parseLocationAnnotation = ( // split on the first colon for the protocol and the rest after the first split // is the location. const [protocol, location] = annotation.split(/:(.+)/) as [ - RemoteProtocol?, + ('file' | 'url')?, string?, ]; @@ -55,41 +53,3 @@ export const parseLocationAnnotation = ( location, }; }; - -export type DeprecatedLocationTypeDetector = ( - url: string, -) => string | undefined; - -// The reason for the existence of this is to help in migration to using mostly locations -// of type 'url'. This allows us to detect the deprecated location type based on the host, -// which we in turn can use to select out preparer or publisher. -// -// TODO(Rugvip): This should be removed in the future once we fully migrate to using -// integrations configuration for the scaffolder. -export function makeDeprecatedLocationTypeDetector( - config: Config, -): DeprecatedLocationTypeDetector { - const hostMap = new Map(); - - // These are installed by default by the integrations - hostMap.set('github.com', 'github'); - hostMap.set('gitlab.com', 'gitlab'); - hostMap.set('dev.azure.com', 'azure/api'); - - config.getOptionalConfigArray('integrations.github')?.forEach(sub => { - hostMap.set(sub.getString('host'), 'github'); - }); - config.getOptionalConfigArray('integrations.gitlab')?.forEach(sub => { - hostMap.set(sub.getString('host'), 'gitlab'); - }); - 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'); - }); - 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/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts index 39efd1c62a..8dd72c35e9 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts @@ -26,13 +26,14 @@ import { LOCATION_ANNOTATION, } from '@backstage/catalog-model'; import { getVoidLogger, Git } from '@backstage/backend-common'; -import { ConfigReader } from '@backstage/config'; describe('AzurePreparer', () => { const mockGitClient = { clone: jest.fn(), }; + const logger = getVoidLogger(); + jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any); let mockEntity: TemplateEntityV1alpha1; @@ -44,7 +45,7 @@ describe('AzurePreparer', () => { metadata: { annotations: { [LOCATION_ANNOTATION]: - 'azure/api:https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml', + 'url:https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml', }, name: 'graphql-starter', title: 'GraphQL Service', @@ -78,30 +79,33 @@ describe('AzurePreparer', () => { }; }); - it('initializes git client with the correct arguments if an access token is provided for a repository', async () => { - const preparer = new AzurePreparer( - new ConfigReader({ - scaffolder: { - azure: { - api: { - token: 'fake-token', - }, - }, - }, - }), - ); - const logger = getVoidLogger(); + const preparer = AzurePreparer.fromConfig({ + host: 'dev.azure.com', + token: 'fake-azure-token', + }); + + // TODO(blam): Here's a test that will fail when the deprecation is complete + it('calls the clone command with deprecated token', async () => { await preparer.prepare(mockEntity, { logger }); expect(Git.fromAuth).toHaveBeenCalledWith({ - username: 'notempty', - password: 'fake-token', logger, + password: 'fake-azure-token', + username: 'notempty', }); }); - it('calls the clone command with the correct arguments for a repository', async () => { - const preparer = new AzurePreparer(new ConfigReader({})); + it('calls the clone command with token from integrations config', async () => { + await preparer.prepare(mockEntity, { logger }); + + expect(Git.fromAuth).toHaveBeenCalledWith({ + logger, + password: 'fake-azure-token', + username: 'notempty', + }); + }); + + it('calls the clone command with the correct arguments for a repository', async () => { await preparer.prepare(mockEntity, { logger: getVoidLogger() }); expect(mockGitClient.clone).toHaveBeenCalledWith({ @@ -112,7 +116,6 @@ describe('AzurePreparer', () => { }); it('calls the clone command with the correct arguments for a repository when no path is provided', async () => { - const preparer = new AzurePreparer(new ConfigReader({})); delete mockEntity.spec.path; await preparer.prepare(mockEntity, { logger: getVoidLogger() }); @@ -125,7 +128,6 @@ describe('AzurePreparer', () => { }); it('return the temp directory with the path to the folder if it is specified', async () => { - const preparer = new AzurePreparer(new ConfigReader({})); mockEntity.spec.path = './template/test/1/2/3'; const response = await preparer.prepare(mockEntity, { @@ -138,12 +140,11 @@ describe('AzurePreparer', () => { }); it('return the working directory with the path to the folder if it is specified', async () => { - const preparer = new AzurePreparer(new ConfigReader({})); mockEntity.spec.path = './template/test/1/2/3'; const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), workingDirectory: '/workDir', + logger: getVoidLogger(), }); expect(response.split('\\').join('/')).toMatch( diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts index 51763c6291..8b204fa799 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts @@ -18,32 +18,26 @@ import fs from 'fs-extra'; import path from 'path'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { parseLocationAnnotation } from '../helpers'; -import { InputError, Git } from '@backstage/backend-common'; +import { Git } from '@backstage/backend-common'; import { PreparerBase, PreparerOptions } from './types'; import parseGitUrl from 'git-url-parse'; -import { Config } from '@backstage/config'; +import { AzureIntegrationConfig } from '@backstage/integration'; export class AzurePreparer implements PreparerBase { - private readonly privateToken: string; - - constructor(config: Config) { - this.privateToken = - config.getOptionalString('scaffolder.azure.api.token') ?? ''; + static fromConfig(config: AzureIntegrationConfig) { + return new AzurePreparer({ token: config.token }); } + constructor(private readonly config: { token?: string }) {} + async prepare( template: TemplateEntityV1alpha1, opts: PreparerOptions, ): Promise { - const { protocol, location } = parseLocationAnnotation(template); - const workingDirectory = opts?.workingDirectory ?? os.tmpdir(); - const { logger } = opts; + const { location } = parseLocationAnnotation(template); + const workingDirectory = opts.workingDirectory ?? os.tmpdir(); + const logger = opts.logger; - if (!['azure/api', 'url'].includes(protocol)) { - throw new InputError( - `Wrong location protocol: ${protocol}, should be 'url'`, - ); - } const templateId = template.metadata.name; const parsedGitLocation = parseGitUrl(location); @@ -59,9 +53,9 @@ export class AzurePreparer implements PreparerBase { // Username can be anything but the empty string according to: // https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page#use-a-pat - const git = this.privateToken + const git = this.config.token ? Git.fromAuth({ - password: this.privateToken, + password: this.config.token, username: 'notempty', logger, }) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts index 809d18c808..ff74fffdd7 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts @@ -26,10 +26,10 @@ import { LOCATION_ANNOTATION, } from '@backstage/catalog-model'; import { getVoidLogger, Git } from '@backstage/backend-common'; -import { ConfigReader } from '@backstage/config'; describe('BitbucketPreparer', () => { let mockEntity: TemplateEntityV1alpha1; + const logger = getVoidLogger(); const mockGitClient = { clone: jest.fn(), }; @@ -78,8 +78,13 @@ describe('BitbucketPreparer', () => { }; }); + const preparer = BitbucketPreparer.fromConfig({ + host: 'bitbucket.org', + username: 'fake-user', + appPassword: 'fake-password', + }); + it('calls the clone command with the correct arguments for a repository', async () => { - const preparer = new BitbucketPreparer(new ConfigReader({})); await preparer.prepare(mockEntity, { logger: getVoidLogger() }); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://bitbucket.org/backstage-project/backstage-repo', @@ -88,28 +93,21 @@ describe('BitbucketPreparer', () => { }); it('calls the clone command with the correct arguments if an app password is provided for a repository', async () => { - const preparer = new BitbucketPreparer( - new ConfigReader({ - integrations: { - bitbucket: [ - { - host: 'bitbucket.org', - username: 'fake-user', - appPassword: 'fake-password', - }, - ], - }, - }), - ); - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); - expect(mockGitClient.clone).toHaveBeenCalledWith({ - url: 'https://bitbucket.org/backstage-project/backstage-repo', - dir: expect.any(String), + const preparer = BitbucketPreparer.fromConfig({ + host: 'bitbucket.org', + username: 'fake-user', + appPassword: 'fake-password', + }); + await preparer.prepare(mockEntity, { logger }); + + expect(Git.fromAuth).toHaveBeenCalledWith({ + logger, + username: 'fake-user', + password: 'fake-password', }); }); it('calls the clone command with the correct arguments for a repository when no path is provided', async () => { - const preparer = new BitbucketPreparer(new ConfigReader({})); delete mockEntity.spec.path; await preparer.prepare(mockEntity, { logger: getVoidLogger() }); expect(mockGitClient.clone).toHaveBeenCalledWith({ @@ -119,7 +117,6 @@ describe('BitbucketPreparer', () => { }); it('return the temp directory with the path to the folder if it is specified', async () => { - const preparer = new BitbucketPreparer(new ConfigReader({})); mockEntity.spec.path = './template/test/1/2/3'; const response = await preparer.prepare(mockEntity, { logger: getVoidLogger(), @@ -130,12 +127,26 @@ describe('BitbucketPreparer', () => { ); }); + it('calls the clone command with with token for auth method', async () => { + const preparer = BitbucketPreparer.fromConfig({ + host: 'bitbucket.org', + token: 'fake-token', + }); + + await preparer.prepare(mockEntity, { logger }); + + expect(Git.fromAuth).toHaveBeenCalledWith({ + logger, + username: 'x-token-auth', + password: 'fake-token', + }); + }); + it('return the working directory with the path to the folder if it is specified', async () => { - const preparer = new BitbucketPreparer(new ConfigReader({})); mockEntity.spec.path = './template/test/1/2/3'; const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), workingDirectory: '/workDir', + logger: getVoidLogger(), }); expect(response.split('\\').join('/')).toMatch( diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts index 278b481da2..5020a5f658 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts @@ -18,35 +18,35 @@ import fs from 'fs-extra'; import path from 'path'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { parseLocationAnnotation } from '../helpers'; -import { InputError, Git } from '@backstage/backend-common'; +import { Git } from '@backstage/backend-common'; import { PreparerBase, PreparerOptions } from './types'; +import { BitbucketIntegrationConfig } from '@backstage/integration'; import parseGitUrl from 'git-url-parse'; -import { Config } from '@backstage/config'; export class BitbucketPreparer implements PreparerBase { - private readonly privateToken: string; - private readonly username: string; - - constructor(config: Config) { - this.username = - config.getOptionalString('scaffolder.bitbucket.api.username') ?? ''; - this.privateToken = - config.getOptionalString('scaffolder.bitbucket.api.token') ?? ''; + static fromConfig(config: BitbucketIntegrationConfig) { + return new BitbucketPreparer({ + username: config.username, + token: config.token, + appPassword: config.appPassword, + }); } + constructor( + private readonly config: { + username?: string; + token?: string; + appPassword?: string; + }, + ) {} + async prepare( template: TemplateEntityV1alpha1, opts: PreparerOptions, ): Promise { - const { protocol, location } = parseLocationAnnotation(template); - const workingDirectory = opts?.workingDirectory ?? os.tmpdir(); - const { logger } = opts; - - if (!['bitbucket', 'url'].includes(protocol)) { - throw new InputError( - `Wrong location protocol: ${protocol}, should be 'url'`, - ); - } + const { location } = parseLocationAnnotation(template); + const workingDirectory = opts.workingDirectory ?? os.tmpdir(); + const logger = opts.logger; const templateId = template.metadata.name; const repo = parseGitUrl(location); @@ -63,10 +63,10 @@ export class BitbucketPreparer implements PreparerBase { const checkoutLocation = path.resolve(tempDir, templateDirectory); - const git = this.privateToken + const auth = this.getAuth(); + const git = auth ? Git.fromAuth({ - username: this.username, - password: this.privateToken, + ...auth, logger, }) : Git.fromAuth({ logger }); @@ -78,4 +78,21 @@ export class BitbucketPreparer implements PreparerBase { return checkoutLocation; } + + private getAuth(): { username: string; password: string } | undefined { + const { username, token, appPassword } = this.config; + + if (username && appPassword) { + return { username: username, password: appPassword }; + } + + if (token) { + return { + username: 'x-token-auth', + password: token! || appPassword!, + }; + } + + return undefined; + } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts index aa040bfe58..b43545f5fc 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts @@ -32,6 +32,7 @@ describe('GitHubPreparer', () => { const mockGitClient = { clone: jest.fn(), }; + const logger = getVoidLogger(); jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any); @@ -76,9 +77,13 @@ describe('GitHubPreparer', () => { }, }; }); - it('calls the clone command with the correct arguments for a repository', async () => { - const preparer = new GithubPreparer(); + const preparer = GithubPreparer.fromConfig({ + host: 'github.com', + token: 'fake-token', + }); + + it('calls the clone command with the correct arguments for a repository', async () => { await preparer.prepare(mockEntity, { logger: getVoidLogger() }); expect(mockGitClient.clone).toHaveBeenCalledWith({ @@ -86,8 +91,8 @@ describe('GitHubPreparer', () => { dir: expect.any(String), }); }); + it('calls the clone command with the correct arguments for a repository when no path is provided', async () => { - const preparer = new GithubPreparer(); delete mockEntity.spec.path; await preparer.prepare(mockEntity, { logger: getVoidLogger() }); @@ -99,23 +104,29 @@ describe('GitHubPreparer', () => { }); it('return the temp directory with the path to the folder if it is specified', async () => { - const preparer = new GithubPreparer(); + const preparer = GithubPreparer.fromConfig({ + host: 'github.com', + token: 'fake-token', + }); mockEntity.spec.path = './template/test/1/2/3'; const response = await preparer.prepare(mockEntity, { logger: getVoidLogger(), }); - expect(response.split('\\').join('/')).toMatch( /\/template\/test\/1\/2\/3$/, ); }); it('return the working directory with the path to the folder if it is specified', async () => { - const preparer = new GithubPreparer(); + const preparer = GithubPreparer.fromConfig({ + host: 'github.com', + token: 'fake-token', + }); + mockEntity.spec.path = './template/test/1/2/3'; const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), workingDirectory: '/workDir', + logger: getVoidLogger(), }); expect(response.split('\\').join('/')).toMatch( @@ -123,15 +134,12 @@ describe('GitHubPreparer', () => { ); }); - it('calls the clone command with the token when provided', async () => { - const preparer = new GithubPreparer({ token: 'abc' }); - const logger = getVoidLogger(); - + it('calls the clone command with token', async () => { await preparer.prepare(mockEntity, { logger }); expect(Git.fromAuth).toHaveBeenCalledWith({ logger, - username: 'abc', + username: 'fake-token', password: 'x-oauth-basic', }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts index c157263894..cfeff454e7 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts @@ -18,30 +18,26 @@ import fs from 'fs-extra'; import path from 'path'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { parseLocationAnnotation } from '../helpers'; -import { InputError, Git } from '@backstage/backend-common'; +import { Git } from '@backstage/backend-common'; import { PreparerBase, PreparerOptions } from './types'; import parseGitUrl from 'git-url-parse'; +import { GitHubIntegrationConfig } from '@backstage/integration'; export class GithubPreparer implements PreparerBase { - token?: string; - - constructor(params: { token?: string } = {}) { - this.token = params.token; + static fromConfig(config: GitHubIntegrationConfig) { + return new GithubPreparer({ token: config.token }); } + constructor(private readonly config: { token?: string }) {} + async prepare( template: TemplateEntityV1alpha1, opts: PreparerOptions, ): Promise { - const { protocol, location } = parseLocationAnnotation(template); - const workingDirectory = opts?.workingDirectory ?? os.tmpdir(); - const { logger } = opts; + const { location } = parseLocationAnnotation(template); + const workingDirectory = opts.workingDirectory ?? os.tmpdir(); + const logger = opts.logger; - if (!['github', 'url'].includes(protocol)) { - throw new InputError( - `Wrong location protocol: ${protocol}, should be 'url'`, - ); - } const templateId = template.metadata.name; const parsedGitLocation = parseGitUrl(location); @@ -57,9 +53,9 @@ export class GithubPreparer implements PreparerBase { const checkoutLocation = path.resolve(tempDir, templateDirectory); - const git = this.token + const git = this.config.token ? Git.fromAuth({ - username: this.token, + username: this.config.token, password: 'x-oauth-basic', logger, }) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts index 8dad349a13..19cbf793e4 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts @@ -24,15 +24,15 @@ import { TemplateEntityV1alpha1, LOCATION_ANNOTATION, } from '@backstage/catalog-model'; -import { ConfigReader } from '@backstage/config'; import { getVoidLogger, Git } from '@backstage/backend-common'; -const mockEntityWithProtocol = (protocol: string): TemplateEntityV1alpha1 => ({ +const mockTemplate = (): TemplateEntityV1alpha1 => ({ apiVersion: 'backstage.io/v1alpha1', kind: 'Template', metadata: { annotations: { - [LOCATION_ANNOTATION]: `${protocol}:https://gitlab.com/benjdlambert/backstage-graphql-template/-/blob/master/template.yaml`, + [LOCATION_ANNOTATION]: + 'url:https://gitlab.com/benjdlambert/backstage-graphql-template/-/blob/master/template.yaml', }, name: 'graphql-starter', title: 'GraphQL Service', @@ -70,108 +70,72 @@ describe('GitLabPreparer', () => { const mockGitClient = { clone: jest.fn(), }; + const logger = getVoidLogger(); jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any); beforeEach(() => { jest.clearAllMocks(); }); + const preparer = GitlabPreparer.fromConfig({ + host: 'gitlab.com', + token: 'fake-token', + }); + it(`calls the clone command with the correct arguments for a repository`, async () => { + mockEntity = mockTemplate(); - ['gitlab', 'gitlab/api'].forEach(protocol => { - it(`calls the clone command with the correct arguments for a repository using the ${protocol} protocol`, async () => { - const preparer = new GitlabPreparer(new ConfigReader({})); - mockEntity = mockEntityWithProtocol(protocol); + await preparer.prepare(mockEntity, { logger: getVoidLogger() }); - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); - - expect(mockGitClient.clone).toHaveBeenCalledWith({ - url: 'https://gitlab.com/benjdlambert/backstage-graphql-template', - dir: expect.any(String), - }); - }); - - it(`calls the clone command with the correct arguments if an access token is provided in integrations for a repository using the ${protocol} protocol`, async () => { - const preparer = new GitlabPreparer( - new ConfigReader({ - integrations: { - gitlab: [ - { - host: 'gitlab.com', - token: 'fake-token', - }, - ], - }, - }), - ); - mockEntity = mockEntityWithProtocol(protocol); - const logger = getVoidLogger(); - - await preparer.prepare(mockEntity, { logger }); - - expect(Git.fromAuth).toHaveBeenCalledWith({ - logger, - username: 'oauth2', - password: 'fake-token', - }); - }); - - it(`calls the clone command with the correct arguments if an access token is provided in scaffolder for a repository using the ${protocol} protocol`, async () => { - const preparer = new GitlabPreparer( - new ConfigReader({ - scaffolder: { - gitlab: { api: { token: 'fake-token' } }, - }, - }), - ); - mockEntity = mockEntityWithProtocol(protocol); - const logger = getVoidLogger(); - - await preparer.prepare(mockEntity, { logger }); - - expect(Git.fromAuth).toHaveBeenCalledWith({ - logger, - username: 'oauth2', - password: 'fake-token', - }); - }); - - it(`calls the clone command with the correct arguments for a repository when no path is provided using the ${protocol} protocol`, async () => { - const preparer = new GitlabPreparer(new ConfigReader({})); - mockEntity = mockEntityWithProtocol(protocol); - delete mockEntity.spec.path; - - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); - - expect(mockGitClient.clone).toHaveBeenCalledWith({ - url: 'https://gitlab.com/benjdlambert/backstage-graphql-template', - dir: expect.any(String), - }); - }); - - it(`return the temp directory with the path to the folder if it is specified using the ${protocol} protocol`, async () => { - const preparer = new GitlabPreparer(new ConfigReader({})); - mockEntity = mockEntityWithProtocol(protocol); - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), - }); - - expect(response.split('\\').join('/')).toMatch( - /\/template\/test\/1\/2\/3$/, - ); - }); - - it('return the working directory with the path to the folder if it is specified', async () => { - const preparer = new GitlabPreparer(new ConfigReader({})); - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), - workingDirectory: '/workDir', - }); - - expect(response.split('\\').join('/')).toMatch( - /\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/, - ); + expect(mockGitClient.clone).toHaveBeenCalledWith({ + url: 'https://gitlab.com/benjdlambert/backstage-graphql-template', + dir: expect.any(String), }); }); + + it(`calls the clone command with the correct arguments if an access token is provided in integrations for a repository`, async () => { + mockEntity = mockTemplate(); + + await preparer.prepare(mockEntity, { logger }); + + expect(Git.fromAuth).toHaveBeenCalledWith({ + logger, + username: 'oauth2', + password: 'fake-token', + }); + }); + + it(`calls the clone command with the correct arguments for a repository when no path is provided`, async () => { + mockEntity = mockTemplate(); + delete mockEntity.spec.path; + + await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + + expect(mockGitClient.clone).toHaveBeenCalledWith({ + url: 'https://gitlab.com/benjdlambert/backstage-graphql-template', + dir: expect.any(String), + }); + }); + + it(`return the temp directory with the path to the folder if it is specified`, async () => { + mockEntity = mockTemplate(); + mockEntity.spec.path = './template/test/1/2/3'; + const response = await preparer.prepare(mockEntity, { + logger: getVoidLogger(), + }); + expect(response.split('\\').join('/')).toMatch( + /\/template\/test\/1\/2\/3$/, + ); + }); + + it('return the working directory with the path to the folder if it is specified', async () => { + mockEntity.spec.path = './template/test/1/2/3'; + const response = await preparer.prepare(mockEntity, { + workingDirectory: '/workDir', + logger: getVoidLogger(), + }); + + expect(response.split('\\').join('/')).toMatch( + /\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/, + ); + }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts index 9b0d468431..1fca2ff0f9 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts @@ -13,13 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { InputError, Git } from '@backstage/backend-common'; +import { Git } from '@backstage/backend-common'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { Config } from '@backstage/config'; -import { - GitLabIntegrationConfig, - readGitLabIntegrationConfigs, -} from '@backstage/integration'; +import { GitLabIntegrationConfig } from '@backstage/integration'; import fs from 'fs-extra'; import parseGitUrl from 'git-url-parse'; import os from 'os'; @@ -28,31 +24,20 @@ import { parseLocationAnnotation } from '../helpers'; import { PreparerBase, PreparerOptions } from './types'; export class GitlabPreparer implements PreparerBase { - private readonly integrations: GitLabIntegrationConfig[]; - private readonly scaffolderToken: string | undefined; - - constructor(config: Config) { - this.integrations = readGitLabIntegrationConfigs( - config.getOptionalConfigArray('integrations.gitlab') ?? [], - ); - this.scaffolderToken = config.getOptionalString( - 'scaffolder.gitlab.api.token', - ); + static fromConfig(config: GitLabIntegrationConfig) { + return new GitlabPreparer({ token: config.token }); } + constructor(private readonly config: { token?: string }) {} + async prepare( template: TemplateEntityV1alpha1, opts: PreparerOptions, ): Promise { - const { protocol, location } = parseLocationAnnotation(template); - const { logger } = opts; - const workingDirectory = opts?.workingDirectory ?? os.tmpdir(); + const { location } = parseLocationAnnotation(template); + const logger = opts.logger; + const workingDirectory = opts.workingDirectory ?? os.tmpdir(); - if (!['gitlab', 'gitlab/api', 'url'].includes(protocol)) { - throw new InputError( - `Wrong location protocol: ${protocol}, should be 'url'`, - ); - } const templateId = template.metadata.name; const parsedGitLocation = parseGitUrl(location); @@ -66,10 +51,9 @@ export class GitlabPreparer implements PreparerBase { template.spec.path ?? '.', ); - const token = this.getToken(parsedGitLocation.resource); - const git = token + const git = this.config.token ? Git.fromAuth({ - password: token, + password: this.config.token, username: 'oauth2', logger, }) @@ -82,11 +66,4 @@ export class GitlabPreparer implements PreparerBase { return path.resolve(tempDir, templateDirectory); } - - private getToken(host: string): string | undefined { - return ( - this.scaffolderToken || - this.integrations.find(c => c.host === host)?.token - ); - } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.test.ts index 7c2d7e0a47..8dd85690cb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.test.ts @@ -14,109 +14,36 @@ * limitations under the License. */ import { Preparers } from '.'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { FilePreparer } from './file'; +import { GithubPreparer } from './github'; describe('Preparers', () => { - const mockTemplate: TemplateEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - 'backstage.io/managed-by-location': - 'file:/Users/bingo/backstage/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml', - }, - name: 'react-ssr-template', - title: 'React SSR Template', - description: - 'Next.js application skeleton for creating isomorphic web applications.', - uid: '7357f4c5-aa58-4a1e-9670-18931eef771f', - etag: 'YWUxZWQyY2EtZDkxMC00MDM0LWI0ODAtMDgwMWY0YzdlMWIw', - generation: 1, - }, - spec: { - templater: 'cookiecutter', - path: '.', - type: 'website', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, - }; - it('should throw an error when the preparer for the source location is not registered', () => { + it('should return the correct preparer based on the hostname', async () => { + const preparer = await GithubPreparer.fromConfig({ + host: 'github.com', + apiBaseUrl: 'lols', + token: 'something else yo', + }); + const preparers = new Preparers(); + preparers.register('github.com', preparer); - expect(() => preparers.get(mockTemplate)).toThrow( - expect.objectContaining({ - message: 'No preparer registered for type: "file"', - }), - ); - }); - it('should return the correct preparer when the source matches', () => { - const preparers = new Preparers(); - const preparer = new FilePreparer(); - - preparers.register('file', preparer); - - expect(preparers.get(mockTemplate)).toBe(preparer); + expect( + preparers.get('https://github.com/please/find/me/something/from/github'), + ).toBe(preparer); }); - it('should throw an error if the metadata tag does not exist in the entity', () => { - const brokenTemplate: TemplateEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: {}, - name: 'react-ssr-template', - title: 'React SSR Template', - description: - 'Next.js application skeleton for creating isomorphic web applications.', - uid: '7357f4c5-aa58-4a1e-9670-18931eef771f', - etag: 'YWUxZWQyY2EtZDkxMC00MDM0LWI0ODAtMDgwMWY0YzdlMWIw', - generation: 1, - }, - spec: { - type: 'website', - templater: 'cookiecutter', - path: '.', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, - }; + it('should throw an error if there is nothing that will match the url provided', async () => { + const preparer = await GithubPreparer.fromConfig({ + host: 'github.com', + apiBaseUrl: 'lols', + token: 'something else yo', + }); const preparers = new Preparers(); + preparers.register('github.com', preparer); - expect(() => preparers.get(brokenTemplate)).toThrow( - expect.objectContaining({ - message: expect.stringContaining('No location annotation provided'), - }), + expect(() => preparers.get('https://404.com')).toThrow( + `Unable to find a preparer for URL: https://404.com. Please make sure to register this host under an integration in app-config`, ); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts index 906fc0f9fd..a0b477b66a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts @@ -15,95 +15,65 @@ */ import { Config } from '@backstage/config'; -import { Logger } from 'winston'; import { PreparerBase, PreparerBuilder } from './types'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { - DeprecatedLocationTypeDetector, - makeDeprecatedLocationTypeDetector, - parseLocationAnnotation, -} from '../helpers'; -import { RemoteProtocol } from '../types'; -import { FilePreparer } from './file'; +import { Logger } from 'winston'; + import { GitlabPreparer } from './gitlab'; import { AzurePreparer } from './azure'; import { GithubPreparer } from './github'; import { BitbucketPreparer } from './bitbucket'; +import { ScmIntegrations } from '@backstage/integration'; export class Preparers implements PreparerBuilder { - private preparerMap = new Map(); + private preparerMap = new Map(); - constructor(private readonly typeDetector?: DeprecatedLocationTypeDetector) {} - - register(protocol: RemoteProtocol, preparer: PreparerBase) { - this.preparerMap.set(protocol, preparer); + register(host: string, preparer: PreparerBase) { + this.preparerMap.set(host, preparer); } - get(template: TemplateEntityV1alpha1): PreparerBase { - const { protocol, location } = parseLocationAnnotation(template); - - const preparer = this.preparerMap.get(protocol); - + get(url: string): PreparerBase { + const preparer = this.preparerMap.get(new URL(url).host); if (!preparer) { - if ((protocol as string) === 'url') { - const type = this.typeDetector?.(location); - const detected = type && this.preparerMap.get(type as RemoteProtocol); - if (detected) { - return detected; - } - if (type) { - throw new Error( - `No preparer configuration available for type '${type}' with url "${location}". ` + - "Make sure you've added appropriate configuration in the 'scaffolder' configuration section", - ); - } else { - throw new Error( - `Failed to detect preparer type. Unable to determine integration type for location "${location}". ` + - "Please add appropriate configuration to the 'integrations' configuration section", - ); - } - } - throw new Error(`No preparer registered for type: "${protocol}"`); + throw new Error( + `Unable to find a preparer for URL: ${url}. Please make sure to register this host under an integration in app-config`, + ); } - return preparer; } static async fromConfig( config: Config, - { logger }: { logger: Logger }, + // eslint-disable-next-line + _: { logger: Logger }, ): Promise { - const typeDetector = makeDeprecatedLocationTypeDetector(config); + const preparers = new Preparers(); + const scm = ScmIntegrations.fromConfig(config); + for (const integration of scm.azure.list()) { + preparers.register( + integration.config.host, + AzurePreparer.fromConfig(integration.config), + ); + } - const preparers = new Preparers(typeDetector); + for (const integration of scm.github.list()) { + preparers.register( + integration.config.host, + GithubPreparer.fromConfig(integration.config), + ); + } - const filePreparer = new FilePreparer(); - const gitlabPreparer = new GitlabPreparer(config); - const azurePreparer = new AzurePreparer(config); - const bitbucketPreparer = new BitbucketPreparer(config); + for (const integration of scm.gitlab.list()) { + preparers.register( + integration.config.host, + GitlabPreparer.fromConfig(integration.config), + ); + } - preparers.register('file', filePreparer); - preparers.register('gitlab', gitlabPreparer); - preparers.register('gitlab/api', gitlabPreparer); - preparers.register('azure/api', azurePreparer); - preparers.register('bitbucket', bitbucketPreparer); - - const githubConfig = config.getOptionalConfig('scaffolder.github'); - if (githubConfig) { - try { - const githubToken = githubConfig.getString('token'); - const githubPreparer = new GithubPreparer({ token: githubToken }); - - preparers.register('github', githubPreparer); - } catch (e) { - if (process.env.NODE_ENV !== 'development') { - throw new Error( - `Failed to initialize github scaffolding provider, ${e.message}`, - ); - } - - logger.warn(`Skipping github scaffolding provider, ${e.message}`); - } + for (const integration of scm.bitbucket.list()) { + preparers.register( + integration.config.host, + BitbucketPreparer.fromConfig(integration.config), + ); } return preparers; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts index 4936461c5c..17ffea5557 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts @@ -15,14 +15,13 @@ */ import type { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { Logger } from 'winston'; -import { RemoteProtocol } from '../types'; export type PreparerOptions = { - logger: Logger; workingDirectory?: string; + logger: Logger; }; -export type PreparerBase = { +export interface PreparerBase { /** * Given an Entity definition from the Service Catalog, go and prepare a directory * with contents from the remote location in temporary storage and return the path @@ -30,11 +29,11 @@ export type PreparerBase = { */ prepare( template: TemplateEntityV1alpha1, - opts: PreparerOptions, + opts?: PreparerOptions, ): Promise; -}; +} export type PreparerBuilder = { - register(protocol: RemoteProtocol, preparer: PreparerBase): void; - get(template: TemplateEntityV1alpha1): PreparerBase; + register(host: string, preparer: PreparerBase): void; + get(url: string): PreparerBase; }; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts index f1e0cd6175..0251b3d983 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts @@ -15,32 +15,40 @@ */ jest.mock('./helpers'); +jest.mock('azure-devops-node-api', () => ({ + WebApi: jest.fn(), + getPersonalAccessTokenHandler: jest.fn(), +})); + import { AzurePublisher } from './azure'; -import { GitApi } from 'azure-devops-node-api/GitApi'; +import { WebApi } from 'azure-devops-node-api'; import * as helpers from './helpers'; import { getVoidLogger } from '@backstage/backend-common'; -const { mockGitApi } = require('azure-devops-node-api/GitApi') as { - mockGitApi: { - createRepository: jest.MockedFunction; - }; -}; - describe('Azure Publisher', () => { - const publisher = new AzurePublisher(new GitApi('', []), 'fake-token'); const logger = getVoidLogger(); - beforeEach(() => { - jest.clearAllMocks(); - }); - describe('publish: createRemoteInAzure', () => { it('should use azure-devops-node-api to create a repo in the given project', async () => { - mockGitApi.createRepository.mockResolvedValue({ + const mockGitClient = { + createRepository: jest.fn(), + }; + const mockGitApi = { + getGitApi: jest.fn().mockReturnValue(mockGitClient), + }; + + ((WebApi as unknown) as jest.Mock).mockImplementation(() => mockGitApi); + + const publisher = await AzurePublisher.fromConfig({ + host: 'dev.azure.com', + token: 'fake-azure-token', + }); + + mockGitClient.createRepository.mockResolvedValue({ remoteUrl: 'https://dev.azure.com/organization/project/_git/repo', } as { remoteUrl: string }); - const result = await publisher.publish({ + const result = await publisher!.publish({ values: { storePath: 'project/repo', owner: 'bob', @@ -54,7 +62,7 @@ describe('Azure Publisher', () => { catalogInfoUrl: 'https://dev.azure.com/organization/project/_git/repo?path=%2Fcatalog-info.yaml', }); - expect(mockGitApi.createRepository).toHaveBeenCalledWith( + expect(mockGitClient.createRepository).toHaveBeenCalledWith( { name: 'repo', }, @@ -63,7 +71,7 @@ describe('Azure Publisher', () => { expect(helpers.initRepoAndPush).toHaveBeenCalledWith({ dir: '/tmp/test', remoteUrl: 'https://dev.azure.com/organization/project/_git/repo', - auth: { username: 'notempty', password: 'fake-token' }, + auth: { username: 'notempty', password: 'fake-azure-token' }, logger, }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts index ffe6845f01..a27eba8a14 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts @@ -15,27 +15,38 @@ */ import { PublisherBase, PublisherOptions, PublisherResult } from './types'; -import { GitApi } from 'azure-devops-node-api/GitApi'; +import { IGitApi } from 'azure-devops-node-api/GitApi'; import { GitRepositoryCreateOptions } from 'azure-devops-node-api/interfaces/GitInterfaces'; -import { JsonValue } from '@backstage/config'; -import { RequiredTemplateValues } from '../templater'; import { initRepoAndPush } from './helpers'; +import { AzureIntegrationConfig } from '@backstage/integration'; +import parseGitUrl from 'git-url-parse'; +import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api'; export class AzurePublisher implements PublisherBase { - private readonly client: GitApi; - private readonly token: string; - - constructor(client: GitApi, token: string) { - this.client = client; - this.token = token; + static async fromConfig(config: AzureIntegrationConfig) { + if (!config.token) { + return undefined; + } + const authHandler = getPersonalAccessTokenHandler(config.token); + const webApi = new WebApi(config.host, authHandler); + const azureClient = await webApi.getGitApi(); + return new AzurePublisher({ token: config.token, client: azureClient }); } + constructor(private readonly config: { token: string; client: IGitApi }) {} + async publish({ values, directory, logger, }: PublisherOptions): Promise { - const remoteUrl = await this.createRemote(values); + const { owner, name } = parseGitUrl(values.storePath); + + const remoteUrl = await this.createRemote({ + project: owner, + name, + }); + const catalogInfoUrl = `${remoteUrl}?path=%2Fcatalog-info.yaml`; await initRepoAndPush({ @@ -43,7 +54,7 @@ export class AzurePublisher implements PublisherBase { remoteUrl, auth: { username: 'notempty', - password: this.token, + password: this.config.token, }, logger, }); @@ -51,13 +62,13 @@ export class AzurePublisher implements PublisherBase { return { remoteUrl, catalogInfoUrl }; } - private async createRemote( - values: RequiredTemplateValues & Record, - ) { - const [project, name] = values.storePath.split('/'); - + private async createRemote(opts: { name: string; project: string }) { + const { name, project } = opts; const createOptions: GitRepositoryCreateOptions = { name }; - const repo = await this.client.createRepository(createOptions, project); + const repo = await this.config.client.createRepository( + createOptions, + project, + ); return repo.remoteUrl || ''; } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts index 3b084f1bf9..f75eea939a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts @@ -58,15 +58,15 @@ describe('Bitbucket Publisher', () => { ), ); - const publisher = new BitbucketPublisher( - 'https://bitbucket.org', - 'fake-user', - 'fake-token', - ); + const publisher = await BitbucketPublisher.fromConfig({ + host: 'bitbucket.org', + username: 'fake-user', + appPassword: 'fake-token', + }); const result = await publisher.publish({ values: { - storePath: 'project/repo', + storePath: 'https://bitbucket.org/project/repo', owner: 'bob', }, directory: '/tmp/test', @@ -87,6 +87,7 @@ describe('Bitbucket Publisher', () => { }); }); }); + describe('publish: createRemoteInBitbucketServer', () => { it('should create repo in bitbucket server', async () => { server.use( @@ -116,15 +117,14 @@ describe('Bitbucket Publisher', () => { ), ); - const publisher = new BitbucketPublisher( - 'https://bitbucket.mycompany.com', - 'fake-user', - 'fake-token', - ); + const publisher = await BitbucketPublisher.fromConfig({ + host: 'bitbucket.mycompany.com', + token: 'fake-token', + }); const result = await publisher.publish({ values: { - storePath: 'project/repo', + storePath: 'https://bitbucket.mycompany.com/project/repo', owner: 'bob', }, directory: '/tmp/test', @@ -140,7 +140,7 @@ describe('Bitbucket Publisher', () => { expect(initRepoAndPush).toHaveBeenCalledWith({ dir: '/tmp/test', remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo', - auth: { username: 'fake-user', password: 'fake-token' }, + auth: { username: 'x-token-auth', password: 'fake-token' }, logger: logger, }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts index ad83ad54e8..a0b867507d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts @@ -16,62 +16,90 @@ import { PublisherBase, PublisherOptions, PublisherResult } from './types'; import { initRepoAndPush } from './helpers'; -import { RequiredTemplateValues } from '../templater'; -import { JsonValue } from '../../../../../../packages/config/src'; import fetch from 'cross-fetch'; +import { BitbucketIntegrationConfig } from '@backstage/integration'; +import parseGitUrl from 'git-url-parse'; +// TODO(blam): We should probably start to use a bitbucket client here that we can change +// the baseURL to point at on-prem or public bitbucket versions like we do for +// github and ghe. There's to much logic and not enough types here for us to say that this way is better than using +// a supported bitbucket client if one exists. 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; + static async fromConfig(config: BitbucketIntegrationConfig) { + return new BitbucketPublisher({ + host: config.host, + token: config.token, + appPassword: config.appPassword, + username: config.username, + }); } + constructor( + private readonly config: { + host: string; + token?: string; + appPassword?: string; + username?: string; + }, + ) {} + async publish({ values, directory, logger, }: PublisherOptions): Promise { - const result = await this.createRemote(values); + const { owner: project, name } = parseGitUrl(values.storePath); + + const description = values.description as string; + const result = await this.createRemote({ + project, + name, + description, + }); await initRepoAndPush({ dir: directory, remoteUrl: result.remoteUrl, auth: { - username: this.username, - password: this.token, + username: this.config.username ? this.config.username : 'x-token-auth', + password: this.config.appPassword + ? this.config.appPassword + : this.config.token ?? '', }, logger, }); return result; } - private async createRemote( - values: RequiredTemplateValues & Record, - ): Promise { - if (this.host === 'https://bitbucket.org') { - return this.createBitbucketCloudRepository(values); + private async createRemote(opts: { + project: string; + name: string; + description: string; + }): Promise { + if (this.config.host === 'bitbucket.org') { + return this.createBitbucketCloudRepository(opts); } - return this.createBitbucketServerRepository(values); + return this.createBitbucketServerRepository(opts); } - private async createBitbucketCloudRepository( - values: RequiredTemplateValues & Record, - ): Promise { - const [project, name] = values.storePath.split('/'); + private async createBitbucketCloudRepository(opts: { + project: string; + name: string; + description: string; + }): Promise { + const { project, name, description } = opts; let response: Response; - const buffer = Buffer.from(`${this.username}:${this.token}`, 'utf8'); + const buffer = Buffer.from( + `${this.config.username}:${this.config.appPassword}`, + 'utf8', + ); const options: RequestInit = { method: 'POST', body: JSON.stringify({ scm: 'git', - description: values.description, + description: description, }), headers: { Authorization: `Basic ${buffer.toString('base64')}`, @@ -102,26 +130,28 @@ export class BitbucketPublisher implements PublisherBase { throw new Error(`Not a valid response code ${await response.text()}`); } - private async createBitbucketServerRepository( - values: RequiredTemplateValues & Record, - ): Promise { - const [project, name] = values.storePath.split('/'); + private async createBitbucketServerRepository(opts: { + project: string; + name: string; + description: string; + }): Promise { + const { project, name, description } = opts; let response: Response; const options: RequestInit = { method: 'POST', body: JSON.stringify({ name: name, - description: values.description, + description: description, }), headers: { - Authorization: `Bearer ${this.token}`, + Authorization: `Bearer ${this.config.token}`, 'Content-Type': 'application/json', }, }; try { response = await fetch( - `${this.host}/rest/api/1.0/projects/${project}/repos`, + `https://${this.config.host}/rest/api/1.0/projects/${project}/repos`, options, ); } catch (e) { diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts index 2487076605..9a788b960d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts @@ -37,14 +37,16 @@ describe('GitHub Publisher', () => { }); describe('with public repo visibility', () => { - const publisher = new GithubPublisher({ - client: new Octokit(), - token: 'abc', - repoVisibility: 'public', - }); - describe('publish: createRemoteInGithub', () => { it('should use octokit to create a repo in an organisation if the organisation property is set', async () => { + const publisher = await GithubPublisher.fromConfig( + { + token: 'fake-token', + host: 'github.com', + }, + { repoVisibility: 'public' }, + ); + mockGithubClient.repos.createInOrg.mockResolvedValue({ data: { clone_url: 'https://github.com/backstage/backstage.git', @@ -56,9 +58,9 @@ describe('GitHub Publisher', () => { }, } as RestEndpointMethodTypes['users']['getByUsername']['response']); - const result = await publisher.publish({ + const result = await publisher!.publish({ values: { - storePath: 'blam/test', + storePath: 'https://github.com/blam/test', owner: 'bob', access: 'blam/team', }, @@ -89,12 +91,20 @@ describe('GitHub Publisher', () => { expect(initRepoAndPush).toHaveBeenCalledWith({ dir: '/tmp/test', remoteUrl: 'https://github.com/backstage/backstage.git', - auth: { username: 'abc', password: 'x-oauth-basic' }, + auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, }); }); it('should use octokit to create a repo in the authed user if the organisation property is not set', async () => { + const publisher = await GithubPublisher.fromConfig( + { + token: 'fake-token', + host: 'github.com', + }, + { repoVisibility: 'public' }, + ); + mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ data: { clone_url: 'https://github.com/backstage/backstage.git', @@ -106,9 +116,9 @@ describe('GitHub Publisher', () => { }, } as RestEndpointMethodTypes['users']['getByUsername']['response']); - const result = await publisher.publish({ + const result = await publisher!.publish({ values: { - storePath: 'blam/test', + storePath: 'https://github.com/blam/test', owner: 'bob', access: 'blam', }, @@ -132,13 +142,21 @@ describe('GitHub Publisher', () => { expect(initRepoAndPush).toHaveBeenCalledWith({ dir: '/tmp/test', remoteUrl: 'https://github.com/backstage/backstage.git', - auth: { username: 'abc', password: 'x-oauth-basic' }, + auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, }); }); }); it('should invite other user in the authed user', async () => { + const publisher = await GithubPublisher.fromConfig( + { + token: 'fake-token', + host: 'github.com', + }, + { repoVisibility: 'public' }, + ); + mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ data: { clone_url: 'https://github.com/backstage/backstage.git', @@ -150,9 +168,9 @@ describe('GitHub Publisher', () => { }, } as RestEndpointMethodTypes['users']['getByUsername']['response']); - const result = await publisher.publish({ + const result = await publisher!.publish({ values: { - storePath: 'blam/test', + storePath: 'https://github.com/blam/test', owner: 'bob', access: 'bob', description: 'description', @@ -182,20 +200,22 @@ describe('GitHub Publisher', () => { expect(initRepoAndPush).toHaveBeenCalledWith({ dir: '/tmp/test', remoteUrl: 'https://github.com/backstage/backstage.git', - auth: { username: 'abc', password: 'x-oauth-basic' }, + auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, }); }); }); describe('with internal repo visibility', () => { - const publisher = new GithubPublisher({ - client: new Octokit(), - token: 'abc', - repoVisibility: 'internal', - }); - it('creates a private repository in the organization with visibility set to internal', async () => { + const publisher = await GithubPublisher.fromConfig( + { + token: 'fake-token', + host: 'github.com', + }, + { repoVisibility: 'internal' }, + ); + mockGithubClient.repos.createInOrg.mockResolvedValue({ data: { clone_url: 'https://github.com/backstage/backstage.git', @@ -207,10 +227,10 @@ describe('GitHub Publisher', () => { }, } as RestEndpointMethodTypes['users']['getByUsername']['response']); - const result = await publisher.publish({ + const result = await publisher!.publish({ values: { isOrg: true, - storePath: 'blam/test', + storePath: 'https://github.com/blam/test', owner: 'bob', }, directory: '/tmp/test', @@ -231,20 +251,22 @@ describe('GitHub Publisher', () => { expect(initRepoAndPush).toHaveBeenCalledWith({ dir: '/tmp/test', remoteUrl: 'https://github.com/backstage/backstage.git', - auth: { username: 'abc', password: 'x-oauth-basic' }, + auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, }); }); }); describe('private visibility in a user account', () => { - const publisher = new GithubPublisher({ - client: new Octokit(), - token: 'abc', - repoVisibility: 'private', - }); - it('creates a private repository', async () => { + const publisher = await GithubPublisher.fromConfig( + { + token: 'fake-token', + host: 'github.com', + }, + { repoVisibility: 'private' }, + ); + mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ data: { clone_url: 'https://github.com/backstage/backstage.git', @@ -256,9 +278,9 @@ describe('GitHub Publisher', () => { }, } as RestEndpointMethodTypes['users']['getByUsername']['response']); - const result = await publisher.publish({ + const result = await publisher!.publish({ values: { - storePath: 'blam/test', + storePath: 'https://github.com/blam/test', owner: 'bob', }, directory: '/tmp/test', @@ -279,7 +301,7 @@ describe('GitHub Publisher', () => { expect(initRepoAndPush).toHaveBeenCalledWith({ dir: '/tmp/test', remoteUrl: 'https://github.com/backstage/backstage.git', - auth: { username: 'abc', password: 'x-oauth-basic' }, + auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts index 39a588882f..31d94d5f53 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts @@ -15,46 +15,61 @@ */ import { PublisherBase, PublisherOptions, PublisherResult } from './types'; -import { Octokit } from '@octokit/rest'; import { initRepoAndPush } from './helpers'; -import { JsonValue } from '@backstage/config'; -import { RequiredTemplateValues } from '../templater'; +import { GitHubIntegrationConfig } from '@backstage/integration'; +import parseGitUrl from 'git-url-parse'; +import { Octokit } from '@octokit/rest'; export type RepoVisibilityOptions = 'private' | 'internal' | 'public'; - -interface GithubPublisherParams { - client: Octokit; - token: string; - repoVisibility: RepoVisibilityOptions; -} - export class GithubPublisher implements PublisherBase { - private client: Octokit; - private token: string; - private repoVisibility: RepoVisibilityOptions; + static async fromConfig( + config: GitHubIntegrationConfig, + { repoVisibility }: { repoVisibility: RepoVisibilityOptions }, + ) { + if (!config.token) { + return undefined; + } - constructor({ - client, - token, - repoVisibility = 'public', - }: GithubPublisherParams) { - this.client = client; - this.token = token; - this.repoVisibility = repoVisibility; + const githubClient = new Octokit({ + auth: config.token, + baseUrl: config.apiBaseUrl, + }); + + return new GithubPublisher({ + token: config.token, + client: githubClient, + repoVisibility, + }); } + constructor( + private readonly config: { + token: string; + client: Octokit; + repoVisibility: RepoVisibilityOptions; + }, + ) {} async publish({ values, directory, logger, }: PublisherOptions): Promise { - const remoteUrl = await this.createRemote(values); + const { owner, name } = parseGitUrl(values.storePath); + + const description = values.description as string; + const access = values.access as string; + const remoteUrl = await this.createRemote({ + description, + access, + name, + owner, + }); await initRepoAndPush({ dir: directory, remoteUrl, auth: { - username: this.token, + username: this.config.token, password: 'x-oauth-basic', }, logger, @@ -68,35 +83,38 @@ export class GithubPublisher implements PublisherBase { return { remoteUrl, catalogInfoUrl }; } - private async createRemote( - values: RequiredTemplateValues & Record, - ) { - const [owner, name] = values.storePath.split('/'); - const description = values.description as string; + private async createRemote(opts: { + access: string; + name: string; + owner: string; + description: string; + }) { + const { access, description, owner, name } = opts; - const user = await this.client.users.getByUsername({ username: owner }); + const user = await this.config.client.users.getByUsername({ + username: owner, + }); const repoCreationPromise = user.data.type === 'Organization' - ? this.client.repos.createInOrg({ + ? this.config.client.repos.createInOrg({ name, org: owner, - private: this.repoVisibility !== 'public', - visibility: this.repoVisibility, + private: this.config.repoVisibility !== 'public', + visibility: this.config.repoVisibility, description, }) - : this.client.repos.createForAuthenticatedUser({ + : this.config.client.repos.createForAuthenticatedUser({ name, - private: this.repoVisibility === 'private', + private: this.config.repoVisibility === 'private', description, }); const { data } = await repoCreationPromise; - const access = values.access as string; if (access?.startsWith(`${owner}/`)) { const [, team] = access.split('/'); - await this.client.teams.addOrUpdateRepoPermissionsInOrg({ + await this.config.client.teams.addOrUpdateRepoPermissionsInOrg({ org: owner, team_slug: team, owner, @@ -105,7 +123,7 @@ export class GithubPublisher implements PublisherBase { }); // no need to add access if it's the person who own's the personal account } else if (access && access !== owner) { - await this.client.repos.addCollaborator({ + await this.config.client.repos.addCollaborator({ owner, repo: name, username: access, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts index 4894d99291..11b32a6de5 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts @@ -14,33 +14,47 @@ * limitations under the License. */ -jest.mock('@gitbeaker/node'); +jest.mock('@gitbeaker/node', () => ({ + Gitlab: jest.fn(), +})); + jest.mock('./helpers'); import { GitlabPublisher } from './gitlab'; -import { Gitlab as GitlabAPI } from '@gitbeaker/core'; import { Gitlab } from '@gitbeaker/node'; import { initRepoAndPush } from './helpers'; import { getVoidLogger } from '@backstage/backend-common'; -const { mockGitlabClient } = require('@gitbeaker/node') as { - mockGitlabClient: { - Namespaces: jest.Mocked; - Projects: jest.Mocked; - Users: jest.Mocked; - }; -}; - describe('GitLab Publisher', () => { const logger = getVoidLogger(); - const publisher = new GitlabPublisher(new Gitlab({}), 'fake-token'); + const mockGitlabClient = { + Namespaces: { + show: jest.fn(), + }, + Projects: { + create: jest.fn(), + }, + Users: { + current: jest.fn(), + }, + }; beforeEach(() => { jest.clearAllMocks(); + + ((Gitlab as unknown) as jest.Mock).mockImplementation( + () => mockGitlabClient, + ); }); describe('publish: createRemoteInGitLab', () => { it('should use gitbeaker to create a repo in a namespace if the namespace property is set', async () => { + const publisher = await GitlabPublisher.fromConfig({ + host: 'gitlab.com', + token: 'fake-token', + baseUrl: 'https://gitlab.hosted.com', + }); + mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 42, } as { id: number }); @@ -48,17 +62,24 @@ describe('GitLab Publisher', () => { http_url_to_repo: 'mockclone', } as { http_url_to_repo: string }); - const result = await publisher.publish({ + const result = await publisher!.publish({ values: { isOrg: true, - storePath: 'bloum/blam/test', + storePath: 'https://gitlab.com/blam/test', owner: 'bob', }, directory: '/tmp/test', logger, }); - expect(result).toEqual({ remoteUrl: 'mockclone' }); + expect(Gitlab).toHaveBeenCalledWith({ + token: 'fake-token', + host: 'https://gitlab.hosted.com', + }); + expect(result).toEqual({ + remoteUrl: 'mockclone', + catalogInfoUrl: 'mockclone', + }); expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({ namespace_id: 42, name: 'test', @@ -72,6 +93,11 @@ describe('GitLab Publisher', () => { }); it('should use gitbeaker to create a repo in the authed user if the namespace property is not set', async () => { + const publisher = await GitlabPublisher.fromConfig({ + host: 'gitlab.com', + token: 'fake-token', + }); + mockGitlabClient.Namespaces.show.mockResolvedValue({}); mockGitlabClient.Users.current.mockResolvedValue({ id: 21, @@ -80,16 +106,19 @@ describe('GitLab Publisher', () => { http_url_to_repo: 'mockclone', } as { http_url_to_repo: string }); - const result = await publisher.publish({ + const result = await publisher!.publish({ values: { - storePath: 'bloum/blam/test', + storePath: 'https://gitlab.com/blam/test', owner: 'bob', }, directory: '/tmp/test', logger, }); - expect(result).toEqual({ remoteUrl: 'mockclone' }); + expect(result).toEqual({ + remoteUrl: 'mockclone', + catalogInfoUrl: 'mockclone', + }); expect(mockGitlabClient.Users.current).toHaveBeenCalled(); expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({ namespace_id: 21, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts index c933ffb48a..24149ab2f3 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts @@ -15,57 +15,74 @@ */ import { PublisherBase, PublisherOptions, PublisherResult } from './types'; -import { Gitlab } from '@gitbeaker/core'; -import { JsonValue } from '@backstage/config'; +import { Gitlab } from '@gitbeaker/node'; +import { Gitlab as GitlabClient } from '@gitbeaker/core'; import { initRepoAndPush } from './helpers'; -import { RequiredTemplateValues } from '../templater'; +import parseGitUrl from 'git-url-parse'; + +import { GitLabIntegrationConfig } from '@backstage/integration'; export class GitlabPublisher implements PublisherBase { - private readonly client: Gitlab; - private readonly token: string; + static async fromConfig(config: GitLabIntegrationConfig) { + if (!config.token) { + return undefined; + } - constructor(client: Gitlab, token: string) { - this.client = client; - this.token = token; + const client = new Gitlab({ host: config.baseUrl, token: config.token }); + return new GitlabPublisher({ token: config.token, client }); } + constructor( + private readonly config: { token: string; client: GitlabClient }, + ) {} + async publish({ values, directory, logger, }: PublisherOptions): Promise { - const remoteUrl = await this.createRemote(values); + const { owner, name } = parseGitUrl(values.storePath); + + const remoteUrl = await this.createRemote({ + owner, + name, + }); await initRepoAndPush({ dir: directory, remoteUrl, auth: { username: 'oauth2', - password: this.token, + password: this.config.token, }, logger, }); - return { remoteUrl }; + const catalogInfoUrl = remoteUrl.replace( + /\.git$/, + '/-/blob/master/catalog-info.yaml', + ); + return { remoteUrl, catalogInfoUrl }; } - private async createRemote( - values: RequiredTemplateValues & Record, - ) { - const pathElements = values.storePath.split('/'); - const name = pathElements[pathElements.length - 1]; - pathElements.pop(); - const owner = pathElements.join('/'); + private async createRemote(opts: { name: string; owner: string }) { + const { owner, name } = opts; - let targetNamespace = ((await this.client.Namespaces.show(owner)) as { + // TODO(blam): this needs cleaning up to be nicer. The amount of brackets is too damn high! + // Shouldn't have to cast things now + let targetNamespace = ((await this.config.client.Namespaces.show( + owner, + )) as { id: number; }).id; + if (!targetNamespace) { - targetNamespace = ((await this.client.Users.current()) as { id: number }) - .id; + targetNamespace = ((await this.config.client.Users.current()) as { + id: number; + }).id; } - const project = (await this.client.Projects.create({ + const project = (await this.config.client.Projects.create({ namespace_id: targetNamespace, name: name, })) as { http_url_to_repo: string }; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.test.ts index b8181b0134..87bcd1d003 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.test.ts @@ -14,120 +14,112 @@ * limitations under the License. */ import { Publishers } from './publishers'; -import { - LOCATION_ANNOTATION, - TemplateEntityV1alpha1, -} from '@backstage/catalog-model'; import { GithubPublisher } from './github'; -import { Octokit } from '@octokit/rest'; +import { getVoidLogger } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { AzurePublisher } from './azure'; +import { GitlabPublisher } from './gitlab'; +import { BitbucketPublisher } from './bitbucket'; jest.mock('@octokit/rest'); +jest.mock('azure-devops-node-api'); describe('Publishers', () => { - const mockTemplate: TemplateEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - [LOCATION_ANNOTATION]: - 'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml', - }, - name: 'graphql-starter', - title: 'GraphQL Service', - description: - 'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n', - uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', - etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', - generation: 1, - }, - spec: { - type: 'website', - templater: 'cookiecutter', - path: './template', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, - }; + const logger = getVoidLogger(); it('should throw an error when the publisher for the source location is not registered', () => { const publishers = new Publishers(); - expect(() => publishers.get(mockTemplate)).toThrow( + expect(() => publishers.get('https://github.com/org/repo')).toThrow( expect.objectContaining({ - message: 'No publisher registered for type: "github"', + message: + 'Unable to find a publisher for URL: https://github.com/org/repo. Please make sure to register this host under an integration in app-config', }), ); }); - it('should return the correct preparer when the source matches', () => { - const publishers = new Publishers(); - const publisher = new GithubPublisher({ - client: new Octokit(), - token: 'fake', - repoVisibility: 'public', - }); - publishers.register('github', publisher); - - expect(publishers.get(mockTemplate)).toBe(publisher); - }); - - it('should throw an error if the metadata tag does not exist in the entity', () => { - const brokenTemplate: TemplateEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: {}, - name: 'react-ssr-template', - title: 'React SSR Template', - description: - 'Next.js application skeleton for creating isomorphic web applications.', - uid: '7357f4c5-aa58-4a1e-9670-18931eef771f', - etag: 'YWUxZWQyY2EtZDkxMC00MDM0LWI0ODAtMDgwMWY0YzdlMWIw', - generation: 1, - }, - spec: { - type: 'website', - templater: 'cookiecutter', - path: '.', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, + it('should return the correct preparer when the source matches for github', async () => { + const publishers = await Publishers.fromConfig( + new ConfigReader({ + integrations: { + github: [{ host: 'github.com', token: 'blob' }], }, - }, - }; - - const publishers = new Publishers(); - - expect(() => publishers.get(brokenTemplate)).toThrow( - expect.objectContaining({ - message: expect.stringContaining('No location annotation provided'), }), + { + logger, + }, ); + + expect(publishers.get('https://github.com/org/repo')).toBeInstanceOf( + GithubPublisher, + ); + }); + + it('should return the correct preparer when the source matches for azure', async () => { + const publishers = await Publishers.fromConfig( + new ConfigReader({ + integrations: { + azure: [{ host: 'dev.azure.com', token: 'blob' }], + }, + }), + { + logger, + }, + ); + + expect( + publishers.get('https://dev.azure.com/org/project/_git/repo'), + ).toBeInstanceOf(AzurePublisher); + }); + + it('should return the correct preparer when the source matches for bitbucket', async () => { + const publishers = await Publishers.fromConfig( + new ConfigReader({ + integrations: { + bitbucket: [{ host: 'bitbucket.com', token: 'blob' }], + }, + }), + { + logger, + }, + ); + expect(publishers.get('https://bitbucket.org/owner/repo')).toBeInstanceOf( + BitbucketPublisher, + ); + }); + + it('should return the correct preparer when the source matches for gitlab', async () => { + const publishers = await Publishers.fromConfig( + new ConfigReader({ + integrations: { + gitlab: [{ host: 'gitlab.com', token: 'blob' }], + }, + }), + { + logger, + }, + ); + expect(publishers.get('https://gitlab.com/owner/repo')).toBeInstanceOf( + GitlabPublisher, + ); + }); + + it('should respect registrations for custom URLs for providers using the integrations config', async () => { + const publishers = await Publishers.fromConfig( + new ConfigReader({ + integrations: { + github: [ + { host: 'my.special.github.enterprise.thing', token: 'lolghe' }, + ], + }, + }), + { + logger, + }, + ); + + expect( + publishers.get('https://my.special.github.enterprise.thing/org/repo'), + ).toBeInstanceOf(GithubPublisher); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts index 75243aa610..79eb3b7708 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts @@ -14,184 +14,130 @@ * limitations under the License. */ -import { Logger } from 'winston'; -import { Octokit } from '@octokit/rest'; -import { Gitlab } from '@gitbeaker/node'; -import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api'; import { Config } from '@backstage/config'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { - DeprecatedLocationTypeDetector, - makeDeprecatedLocationTypeDetector, - parseLocationAnnotation, -} from '../helpers'; import { PublisherBase, PublisherBuilder } from './types'; -import { RemoteProtocol } from '../types'; import { GithubPublisher, RepoVisibilityOptions } from './github'; import { GitlabPublisher } from './gitlab'; import { AzurePublisher } from './azure'; import { BitbucketPublisher } from './bitbucket'; +import { Logger } from 'winston'; +import { ScmIntegrations } from '@backstage/integration'; export class Publishers implements PublisherBuilder { - private publisherMap = new Map(); + private publisherMap = new Map(); - constructor(private readonly typeDetector?: DeprecatedLocationTypeDetector) {} - - register(protocol: RemoteProtocol, publisher: PublisherBase) { - this.publisherMap.set(protocol, publisher); + register(host: string, preparer: PublisherBase | undefined) { + this.publisherMap.set(host, preparer); } - get(template: TemplateEntityV1alpha1): PublisherBase { - const { protocol, location } = parseLocationAnnotation(template); - const publisher = this.publisherMap.get(protocol); - - if (!publisher) { - if ((protocol as string) === 'url') { - const type = this.typeDetector?.(location); - const detected = type && this.publisherMap.get(type as RemoteProtocol); - if (detected) { - return detected; - } - if (type) { - throw new Error( - `No publisher configuration available for type '${type}' with url "${location}". ` + - "Make sure you've added appropriate configuration in the 'scaffolder' configuration section", - ); - } else { - throw new Error( - `Failed to detect publisher type. Unable to determine integration type for location "${location}". ` + - "Please add appropriate configuration to the 'integrations' configuration section", - ); - } - } - throw new Error(`No publisher registered for type: "${protocol}"`); + get(url: string): PublisherBase { + const preparer = this.publisherMap.get(new URL(url).host); + if (!preparer) { + throw new Error( + `Unable to find a publisher for URL: ${url}. Please make sure to register this host under an integration in app-config`, + ); } - - return publisher; + return preparer; } static async fromConfig( config: Config, { logger }: { logger: Logger }, ): Promise { - const typeDetector = makeDeprecatedLocationTypeDetector(config); - const publishers = new Publishers(typeDetector); + const publishers = new Publishers(); - const githubConfig = config.getOptionalConfig('scaffolder.github'); - if (githubConfig) { - try { - const repoVisibility = githubConfig.getString( - 'visibility', - ) as RepoVisibilityOptions; + const scm = ScmIntegrations.fromConfig(config); - const githubToken = githubConfig.getString('token'); - const githubHost = - githubConfig.getOptionalString('host') ?? 'https://api.github.com'; - const githubClient = new Octokit({ - auth: githubToken, - baseUrl: githubHost, - }); - const githubPublisher = new GithubPublisher({ - client: githubClient, - token: githubToken, - repoVisibility, - }); + const deprecationWarning = (name: string) => { + logger.warn( + `'Specifying credentials for ${name} in the Scaffolder configuration is deprecated. This will cause errors in a future release. Please migrate to using integrations config and specifying tokens under hostnames'`, + ); + }; - publishers.register('file', githubPublisher); - publishers.register('github', githubPublisher); - } catch (e) { - const providerName = 'github'; - if (process.env.NODE_ENV !== 'development') { - throw new Error( - `Failed to initialize ${providerName} scaffolding provider, ${e.message}`, - ); - } + for (const integration of scm.azure.list()) { + const publisher = await AzurePublisher.fromConfig(integration.config); + if (publisher) { + publishers.register(integration.config.host, publisher); + } else { + deprecationWarning('Azure'); - logger.warn( - `Skipping ${providerName} scaffolding provider, ${e.message}`, + publishers.register( + integration.config.host, + await AzurePublisher.fromConfig({ + token: config.getOptionalString('scaffolder.azure.token'), + host: integration.config.host, + }), ); } } - const gitLabConfig = config.getOptionalConfig('scaffolder.gitlab'); - if (gitLabConfig) { - try { - const gitLabToken = gitLabConfig.getConfig('api').getString('token'); - const gitLabClient = new Gitlab({ - host: gitLabConfig.getConfig('api').getOptionalString('baseUrl'), - token: gitLabToken, - }); - const gitLabPublisher = new GitlabPublisher(gitLabClient, gitLabToken); - publishers.register('gitlab', gitLabPublisher); - publishers.register('gitlab/api', gitLabPublisher); - } catch (e) { - const providerName = 'gitlab'; - if (process.env.NODE_ENV !== 'development') { - throw new Error( - `Failed to initialize ${providerName} scaffolding provider, ${e.message}`, - ); - } + for (const integration of scm.github.list()) { + const repoVisibility = (config.getOptionalString( + 'scaffolder.github.visibility', + ) ?? 'public') as RepoVisibilityOptions; - logger.warn( - `Skipping ${providerName} scaffolding provider, ${e.message}`, + const publisher = await GithubPublisher.fromConfig(integration.config, { + repoVisibility, + }); + if (publisher) { + publishers.register(integration.config.host, publisher); + } else { + deprecationWarning('GitHub'); + + publishers.register( + integration.config.host, + await GithubPublisher.fromConfig( + { + token: config.getOptionalString('scaffolder.github.token') ?? '', + host: integration.config.host, + }, + { repoVisibility }, + ), ); } } - const azureConfig = config.getOptionalConfig('scaffolder.azure'); - if (azureConfig) { - try { - const baseUrl = azureConfig.getString('baseUrl'); - const azureToken = azureConfig.getConfig('api').getString('token'); + for (const integration of scm.gitlab.list()) { + const publisher = await GitlabPublisher.fromConfig(integration.config); - const authHandler = getPersonalAccessTokenHandler(azureToken); - const webApi = new WebApi(baseUrl, authHandler); - const azureClient = await webApi.getGitApi(); + if (publisher) { + publishers.register(integration.config.host, publisher); + } else { + deprecationWarning('Gitlab'); - const azurePublisher = new AzurePublisher(azureClient, azureToken); - publishers.register('azure/api', azurePublisher); - } catch (e) { - const providerName = 'azure'; - 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}`, + publishers.register( + integration.config.host, + await GitlabPublisher.fromConfig({ + token: config.getOptionalString('scaffolder.gitlab.token') ?? '', + host: integration.config.host, + }), ); } } - 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'); + for (const integration of scm.bitbucket.list()) { + const publisher = await BitbucketPublisher.fromConfig(integration.config); - const bitbucketPublisher = new BitbucketPublisher( - baseUrl, - bitbucketUsername, - bitbucketToken, - ); - publishers.register('bitbucket', bitbucketPublisher); - } catch (e) { - const providerName = 'bitbucket'; - if (process.env.NODE_ENV !== 'development') { - throw new Error( - `Failed to initialize ${providerName} scaffolding provider, ${e.message}`, - ); - } + if (publisher) { + publishers.register(integration.config.host, publisher); + } else { + deprecationWarning('Bitbucket'); - logger.warn( - `Skipping ${providerName} scaffolding provider, ${e.message}`, + publishers.register( + integration.config.host, + await BitbucketPublisher.fromConfig({ + token: config.getOptionalString('scaffolder.bitbucket.token') ?? '', + username: + config.getOptionalString('scaffolder.bitbucket.username') ?? '', + appPassword: + config.getOptionalString('scaffolder.bitbucket.appPassword') ?? + '', + host: integration.config.host, + }), ); } } + return publishers; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts index bc6b63cc57..456e1c0ca0 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts @@ -13,10 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { RequiredTemplateValues } from '../templater'; import { JsonValue } from '@backstage/config'; -import { RemoteProtocol } from '../types'; import { Logger } from 'winston'; /** @@ -35,8 +33,8 @@ export type PublisherBase = { export type PublisherOptions = { values: RequiredTemplateValues & Record; - logger: Logger; directory: string; + logger: Logger; }; export type PublisherResult = { @@ -45,6 +43,6 @@ export type PublisherResult = { }; export type PublisherBuilder = { - register(protocol: RemoteProtocol, publisher: PublisherBase): void; - get(template: TemplateEntityV1alpha1): PublisherBase; + register(host: string, publisher: PublisherBase): void; + get(storePath: string): PublisherBase; }; diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 4d610299eb..785e099c3f 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -47,7 +47,7 @@ describe('createRouter - working directory', () => { const mockPreparer = { prepare: mockPrepare, }; - mockPreparers.register('azure/api', mockPreparer); + mockPreparers.register('dev.azure.com', mockPreparer); }); beforeEach(() => { @@ -65,7 +65,7 @@ describe('createRouter - working directory', () => { kind: 'Template', metadata: { annotations: { - 'backstage.io/managed-by-location': 'azure/api:dev.azure.com', + 'backstage.io/managed-by-location': 'url:https://dev.azure.com', }, }, spec: { diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index ffd4d6487c..8af32f6c0c 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -27,6 +27,8 @@ import { StageContext, TemplaterBuilder, PublisherBuilder, + parseLocationAnnotation, + FilePreparer, } from '../scaffolder'; import { CatalogEntityClient } from '../lib/catalog'; import { validate, ValidatorResult } from 'jsonschema'; @@ -129,7 +131,15 @@ export async function createRouter( { name: 'Prepare the skeleton', handler: async ctx => { - const preparer = preparers.get(ctx.entity); + const { protocol, location: pullPath } = parseLocationAnnotation( + ctx.entity, + ); + + const preparer = + protocol === 'file' + ? new FilePreparer() + : preparers.get(pullPath); + const skeletonDir = await preparer.prepare(ctx.entity, { logger: ctx.logger, workingDirectory, @@ -154,7 +164,7 @@ export async function createRouter( { name: 'Publish template', handler: async (ctx: StageContext<{ resultDir: string }>) => { - const publisher = publishers.get(ctx.entity); + const publisher = publishers.get(ctx.values.storePath); ctx.logger.info('Will now store the template'); const result = await publisher.publish({ values: ctx.values, @@ -167,9 +177,9 @@ export async function createRouter( ], }); - res.status(201).json({ id: job.id }); - jobProcessor.run(job); + + res.status(201).json({ id: job.id }); }); const app = express(); diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 655b7f324c..a6c59d2135 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -40,6 +40,7 @@ "@rjsf/core": "^2.4.0", "@rjsf/material-ui": "^2.4.0", "classnames": "^2.2.6", + "git-url-parse": "^11.4.3", "moment": "^2.26.0", "react": "^16.13.1", "react-dom": "^16.13.1", diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index fd4e4c3ded..60f5ad8dec 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -39,6 +39,7 @@ import { rootRoute } from '../../routes'; import { JobStatusModal } from '../JobStatusModal'; import { MultistepJsonForm } from '../MultistepJsonForm'; import { useJobPolling } from '../hooks/useJobPolling'; +import parseGitUrl from 'git-url-parse'; const useTemplate = ( templateName: string, @@ -63,10 +64,10 @@ const OWNER_REPO_SCHEMA = { description: 'Who is going to own this component', }, storePath: { - format: 'GitHub user or org / Repo name', type: 'string' as const, title: 'Store path', - description: 'GitHub store path in org/repo format', + description: + 'A full URL to the repository that should be created. e.g https://github.com/backstage/new-repo', }, access: { type: 'string' as const, @@ -75,11 +76,6 @@ const OWNER_REPO_SCHEMA = { }, }, }; - -const REPO_FORMAT = { - 'GitHub user or org / Repo name': /[^\/]*\/[^\/]*/, -}; - export const TemplatePage = () => { const errorApi = useApi(errorApiRef); const catalogApi = useApi(catalogApiRef); @@ -182,7 +178,34 @@ export const TemplatePage = () => { { label: 'Choose owner and repo', schema: OWNER_REPO_SCHEMA, - customFormats: REPO_FORMAT, + validate: (formData, errors) => { + const { storePath } = formData; + try { + const parsedUrl = parseGitUrl(storePath); + + if ( + !parsedUrl.resource || + !parsedUrl.owner || + !parsedUrl.name + ) { + if (parsedUrl.resource === 'dev.azure.com') { + errors.storePath.addError( + "The store path should be formatted like https://dev.azure.com/{org}/{project}/_git/{repo} for Azure URL's", + ); + } else { + errors.storePath.addError( + 'The store path should be a complete Git URL to the new repository location. For example: https://github.com/{owner}/{repo}', + ); + } + } + } catch (ex) { + errors.storePath.addError( + `Failed validation of the store pathn with message ${ex.message}`, + ); + } + + return errors; + }, }, ]} /> diff --git a/plugins/sonarqube/dev/index.tsx b/plugins/sonarqube/dev/index.tsx index 830d8aa575..5e8aedd577 100644 --- a/plugins/sonarqube/dev/index.tsx +++ b/plugins/sonarqube/dev/index.tsx @@ -59,6 +59,8 @@ createDevApp() projectUrl: `/#${componentKey}`, getIssuesUrl: i => `/#${componentKey}/issues/${i}`, getComponentMeasuresUrl: i => `/#${componentKey}/measures/${i}`, + getSecurityHotspotsUrl: () => + `#${componentKey}/security_hotspots`, } as FindingSummary; case 'failed': @@ -70,6 +72,7 @@ createDevApp() reliability_rating: '2.0', vulnerabilities: '18', security_rating: '3.0', + security_review_rating: '3.0', code_smells: '22', sqale_rating: '5.0', coverage: '15.7', @@ -78,6 +81,8 @@ createDevApp() projectUrl: `/#${componentKey}`, getIssuesUrl: i => `/#${componentKey}/issues/${i}`, getComponentMeasuresUrl: i => `/#${componentKey}/measures/${i}`, + getSecurityHotspotsUrl: () => + `#${componentKey}/security_hotspots`, } as FindingSummary; case 'passed': @@ -89,6 +94,8 @@ createDevApp() reliability_rating: '1.0', vulnerabilities: '0', security_rating: '1.0', + security_hotspots_reviewed: '100.0', + security_review_rating: '1.0', code_smells: '0', sqale_rating: '1.0', coverage: '100.0', @@ -97,6 +104,8 @@ createDevApp() projectUrl: `/#${componentKey}`, getIssuesUrl: i => `/#${componentKey}/issues/${i}`, getComponentMeasuresUrl: i => `/#${componentKey}/measures/${i}`, + getSecurityHotspotsUrl: () => + `#${componentKey}/security_hotspots`, } as FindingSummary; default: diff --git a/plugins/sonarqube/src/api/SonarQubeApi.ts b/plugins/sonarqube/src/api/SonarQubeApi.ts index 5734f9a3ca..0715d1fd02 100644 --- a/plugins/sonarqube/src/api/SonarQubeApi.ts +++ b/plugins/sonarqube/src/api/SonarQubeApi.ts @@ -30,6 +30,7 @@ export interface FindingSummary { projectUrl: string; getIssuesUrl: SonarUrlProcessorFunc; getComponentMeasuresUrl: SonarUrlProcessorFunc; + getSecurityHotspotsUrl: () => string; } export const sonarQubeApiRef = createApiRef({ diff --git a/plugins/sonarqube/src/api/SonarQubeClient.test.ts b/plugins/sonarqube/src/api/SonarQubeClient.test.ts index a3ae24de52..1b3b5c5709 100644 --- a/plugins/sonarqube/src/api/SonarQubeClient.test.ts +++ b/plugins/sonarqube/src/api/SonarQubeClient.test.ts @@ -46,7 +46,7 @@ describe('SonarQubeClient', () => { server.use( rest.get(`${mockBaseUrl}/sonarqube/measures/search`, (req, res, ctx) => { expect(req.url.searchParams.toString()).toBe( - 'projectKeys=our-service&metricKeys=alert_status%2Cbugs%2Creliability_rating%2Cvulnerabilities%2Csecurity_rating%2Ccode_smells%2Csqale_rating%2Ccoverage%2Cduplicated_lines_density', + 'projectKeys=our-service&metricKeys=alert_status%2Cbugs%2Creliability_rating%2Cvulnerabilities%2Csecurity_rating%2Csecurity_hotspots_reviewed%2Csecurity_review_rating%2Ccode_smells%2Csqale_rating%2Ccoverage%2Cduplicated_lines_density', ); return res( ctx.json({ @@ -81,6 +81,16 @@ describe('SonarQubeClient', () => { value: '1.0', component: 'our-service', }, + { + metric: 'security_hotspots_reviewed', + value: '100', + component: 'our-service', + }, + { + metric: 'security_review_rating', + value: '1.0', + component: 'our-service', + }, { metric: 'code_smells', value: '100', @@ -123,6 +133,8 @@ describe('SonarQubeClient', () => { reliability_rating: '3.0', vulnerabilities: '4', security_rating: '1.0', + security_hotspots_reviewed: '100', + security_review_rating: '1.0', code_smells: '100', sqale_rating: '2.0', coverage: '55.5', @@ -158,6 +170,8 @@ describe('SonarQubeClient', () => { reliability_rating: '3.0', vulnerabilities: '4', security_rating: '1.0', + security_hotspots_reviewed: '100', + security_review_rating: '1.0', code_smells: '100', sqale_rating: '2.0', coverage: '55.5', diff --git a/plugins/sonarqube/src/api/SonarQubeClient.ts b/plugins/sonarqube/src/api/SonarQubeClient.ts index 893f32b0a6..061919dd41 100644 --- a/plugins/sonarqube/src/api/SonarQubeClient.ts +++ b/plugins/sonarqube/src/api/SonarQubeClient.ts @@ -63,6 +63,8 @@ export class SonarQubeClient implements SonarQubeApi { reliability_rating: undefined, vulnerabilities: undefined, security_rating: undefined, + security_hotspots_reviewed: undefined, + security_review_rating: undefined, code_smells: undefined, sqale_rating: undefined, coverage: undefined, @@ -92,10 +94,12 @@ export class SonarQubeClient implements SonarQubeApi { `${ this.baseUrl }project/issues?id=${componentKey}&types=${identifier.toUpperCase()}&resolved=false`, - getComponentMeasuresUrl: (identifier: string) => + getComponentMeasuresUrl: identifier => `${ this.baseUrl }component_measures?id=${componentKey}&metric=${identifier.toLowerCase()}&resolved=false&view=list`, + getSecurityHotspotsUrl: () => + `${this.baseUrl}project/security_hotspots?id=${componentKey}`, }; } } diff --git a/plugins/sonarqube/src/api/types.ts b/plugins/sonarqube/src/api/types.ts index fc99b2d87d..1e0593126f 100644 --- a/plugins/sonarqube/src/api/types.ts +++ b/plugins/sonarqube/src/api/types.ts @@ -42,6 +42,10 @@ export type MetricKey = | 'code_smells' | 'sqale_rating' + // security hotspots + | 'security_hotspots_reviewed' + | 'security_review_rating' + // coverage | 'coverage' diff --git a/plugins/sonarqube/src/components/SonarQubeCard/RatingCard.tsx b/plugins/sonarqube/src/components/SonarQubeCard/RatingCard.tsx index 6a94f7e8f4..37e978bbe9 100644 --- a/plugins/sonarqube/src/components/SonarQubeCard/RatingCard.tsx +++ b/plugins/sonarqube/src/components/SonarQubeCard/RatingCard.tsx @@ -22,6 +22,7 @@ const useStyles = makeStyles(theme => { return { root: { margin: theme.spacing(1, 0), + minWidth: '140px', }, upper: { display: 'flex', diff --git a/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx b/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx index a68a022717..c5e295a22b 100644 --- a/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx +++ b/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx @@ -26,6 +26,7 @@ import { Chip, Grid } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import BugReport from '@material-ui/icons/BugReport'; import LockOpen from '@material-ui/icons/LockOpen'; +import Security from '@material-ui/icons/Security'; import SentimentVeryDissatisfied from '@material-ui/icons/SentimentVeryDissatisfied'; import React, { useMemo } from 'react'; import { useAsync } from 'react-use'; @@ -205,6 +206,25 @@ export const SonarQubeCard = ({ leftSlot={} rightSlot={} /> + {value.metrics.security_review_rating && ( + } + title="Hotspots Reviewed" + link={value.getSecurityHotspotsUrl()} + leftSlot={ + + } + rightSlot={ + + } + /> + )}
{ - res.send(techdocsMetadataJson); - }) - .catch(reason => { - res.status(500).send(`Unable to get Metadata. Reason: ${reason}`); - }); + try { + const techdocsMetadata = await publisher.fetchTechDocsMetadata( + entityName, + ); + + res.send(techdocsMetadata); + } catch (err) { + logger.error( + `Unable to get metadata for ${entityName.namespace}/${entityName.name} with error ${err}`, + ); + res + .status(500) + .send( + `Unable to get metadata for $${entityName.namespace}/${entityName.name}, reason: ${err}`, + ); + } }); router.get('/metadata/entity/:namespace/:kind/:name', async (req, res) => { diff --git a/plugins/techdocs/src/api.ts b/plugins/techdocs/src/api.ts index c8581ec5d3..3a950aeeb3 100644 --- a/plugins/techdocs/src/api.ts +++ b/plugins/techdocs/src/api.ts @@ -16,6 +16,7 @@ import { createApiRef } from '@backstage/core'; import { EntityName } from '@backstage/catalog-model'; +import { TechDocsMetadata } from './types'; export const techdocsStorageApiRef = createApiRef({ id: 'plugin.techdocs.storageservice', @@ -33,7 +34,7 @@ export interface TechDocsStorage { } export interface TechDocs { - getTechDocsMetadata(entityId: EntityName): Promise; + getTechDocsMetadata(entityId: EntityName): Promise; getEntityMetadata(entityId: EntityName): Promise; } diff --git a/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx index ac9d21d319..c994b52422 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx @@ -19,12 +19,13 @@ import { AsyncState } from 'react-use/lib/useAsync'; import CodeIcon from '@material-ui/icons/Code'; import { EntityName } from '@backstage/catalog-model'; import { Header, HeaderLabel, Link } from '@backstage/core'; +import { TechDocsMetadata } from '../../types'; type TechDocsPageHeaderProps = { entityId: EntityName; metadataRequest: { entity: AsyncState; - techdocs: AsyncState; + techdocs: AsyncState; }; }; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/types.ts b/plugins/techdocs/src/types.ts similarity index 79% rename from plugins/scaffolder-backend/src/scaffolder/stages/types.ts rename to plugins/techdocs/src/types.ts index 56111337fe..36cc4e187b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/types.ts +++ b/plugins/techdocs/src/types.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 Spotify AB + * 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. @@ -13,10 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export type RemoteProtocol = - | 'file' - | 'github' - | 'gitlab' - | 'gitlab/api' - | 'azure/api' - | 'bitbucket'; + +export type TechDocsMetadata = { + site_name: string; + site_description: string; +}; diff --git a/yarn.lock b/yarn.lock index ff91348489..514c9a4ff9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2453,7 +2453,7 @@ d3-shape "^2.0.0" d3-zoom "^2.0.0" dagre "^0.8.5" - immer "^7.0.9" + immer "^8.0.1" lodash "^4.17.15" material-table "^1.69.1" prop-types "^15.7.2" @@ -15280,10 +15280,10 @@ immer@1.10.0: resolved "https://registry.npmjs.org/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d" integrity sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg== -immer@^7.0.9: - version "7.0.9" - resolved "https://registry.npmjs.org/immer/-/immer-7.0.9.tgz#28e7552c21d39dd76feccd2b800b7bc86ee4a62e" - integrity sha512-Vs/gxoM4DqNAYR7pugIxi0Xc8XAun/uy7AQu4fLLqaTBHxjOP9pJ266Q9MWA/ly4z6rAFZbvViOtihxUZ7O28A== +immer@^8.0.1: + version "8.0.1" + resolved "https://registry.npmjs.org/immer/-/immer-8.0.1.tgz#9c73db683e2b3975c424fb0572af5889877ae656" + integrity sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA== immutable@>=3.8.2, immutable@^3.8.1, immutable@^3.8.2, immutable@^3.x.x: version "3.8.2" @@ -16957,7 +16957,7 @@ json3@^3.3.2: resolved "https://registry.npmjs.org/json3/-/json3-3.3.3.tgz#7fc10e375fc5ae42c4705a5cc0aa6f62be305b81" integrity sha512-c7/8mbUsKigAbLkD5B010BK4D9LZm7A1pNItkEwiUZRpIN66exu/e7YQWysGun+TRKaJp8MhemM+VkfWv42aCA== -json5@2.x, json5@^2.1.1, json5@^2.1.2: +json5@2.x, json5@^2.1.1, json5@^2.1.2, json5@^2.1.3: version "2.1.3" resolved "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43" integrity sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==