From 7b1142251cedaf2c3b6612bfa04d9d93f18eed61 Mon Sep 17 00:00:00 2001 From: "Stijn Brouwers (EISMEA)" Date: Sun, 24 Mar 2024 09:03:34 +0100 Subject: [PATCH 1/4] feat(integration): Add AWS CodeCommit URL Reader/Integration Signed-off-by: Stijn Brouwers (EISMEA) --- .changeset/ten-schools-leave.md | 6 + docs/features/techdocs/README.md | 1 + docs/integrations/aws-codecommit/locations.md | 43 ++ mkdocs.yml | 2 + packages/backend-common/package.json | 1 + .../reading/AwsCodeCommitUrlReader.test.ts | 542 ++++++++++++++++++ .../src/reading/AwsCodeCommitUrlReader.ts | 393 +++++++++++++ .../backend-common/src/reading/UrlReaders.ts | 2 + .../awsCodeCommit-mock-object.yaml | 1 + .../subFolder/awsCodeCommit-mock-object2.yaml | 1 + packages/integration/api-report.md | 46 ++ .../integration/src/ScmIntegrations.test.ts | 13 + packages/integration/src/ScmIntegrations.ts | 7 + .../AwsCodeCommitIntegration.test.ts | 109 ++++ .../awsCodeCommit/AwsCodeCommitIntegration.ts | 98 ++++ .../src/awsCodeCommit/config.test.ts | 93 +++ .../integration/src/awsCodeCommit/config.ts | 100 ++++ .../integration/src/awsCodeCommit/index.ts | 21 + packages/integration/src/index.ts | 1 + packages/integration/src/registry.ts | 2 + yarn.lock | 50 ++ 21 files changed, 1532 insertions(+) create mode 100644 .changeset/ten-schools-leave.md create mode 100644 docs/integrations/aws-codecommit/locations.md create mode 100644 packages/backend-common/src/reading/AwsCodeCommitUrlReader.test.ts create mode 100644 packages/backend-common/src/reading/AwsCodeCommitUrlReader.ts create mode 100644 packages/backend-common/src/reading/__fixtures__/awsCodeCommit/awsCodeCommit-mock-object.yaml create mode 100644 packages/backend-common/src/reading/__fixtures__/awsCodeCommit/subFolder/awsCodeCommit-mock-object2.yaml create mode 100644 packages/integration/src/awsCodeCommit/AwsCodeCommitIntegration.test.ts create mode 100644 packages/integration/src/awsCodeCommit/AwsCodeCommitIntegration.ts create mode 100644 packages/integration/src/awsCodeCommit/config.test.ts create mode 100644 packages/integration/src/awsCodeCommit/config.ts create mode 100644 packages/integration/src/awsCodeCommit/index.ts diff --git a/.changeset/ten-schools-leave.md b/.changeset/ten-schools-leave.md new file mode 100644 index 0000000000..0f4fa06e60 --- /dev/null +++ b/.changeset/ten-schools-leave.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-common': minor +'@backstage/integration': minor +--- + +Add AWS CodeCommit URL Reader/Integration diff --git a/docs/features/techdocs/README.md b/docs/features/techdocs/README.md index c6e1fd42ad..bee03ab45e 100644 --- a/docs/features/techdocs/README.md +++ b/docs/features/techdocs/README.md @@ -69,6 +69,7 @@ See [TechDocs Architecture](architecture.md) to get an overview of where the bel | GitLab | Yes ✅ | | GitLab Enterprise | Yes ✅ | | Gitea | Yes ✅ | +| AWS CodeCommit | Yes ✅ | ### File storage providers diff --git a/docs/integrations/aws-codecommit/locations.md b/docs/integrations/aws-codecommit/locations.md new file mode 100644 index 0000000000..05a13e5b21 --- /dev/null +++ b/docs/integrations/aws-codecommit/locations.md @@ -0,0 +1,43 @@ +--- +id: locations +sidebar_label: Locations +title: Amazon Web Services CodeCommit Locations +# prettier-ignore +description: Setting up an integration with Amazon Web Services CodeCommit +--- + +The AWS CodeCommit integration supports loading catalog entities from CodeCommit Repositories. +Entities can be added to +[static catalog configuration](../../features/software-catalog/configuration.md), +or registered with the +[catalog-import](https://github.com/backstage/backstage/tree/master/plugins/catalog-import) +plugin. + +## Configuration + +To use this integration, add configuration to your `app-config.yaml`: + +```yaml +integrations: + awsCodeCommit: + - accessKeyId: ${AWS_ACCESS_KEY_ID} + secretAccessKey: ${AWS_SECRET_ACCESS_KEY} +``` + +Then make sure the environment variables `AWS_ACCESS_KEY_ID` and +`AWS_SECRET_ACCESS_KEY` are set when you run Backstage. + +Users with multiple AWS accounts may want to use a role for CodeCommit that is +in a different AWS account. Using the `roleArn` parameter as seen below, you can +instruct the AWS CodeCommit reader to assume a role before accessing CodeCommit: + +```yaml +integrations: + awsCodeCommit: + - accessKeyId: ${AWS_ACCESS_KEY_ID} + secretAccessKey: ${AWS_SECRET_ACCESS_KEY} + roleArn: 'arn:aws:iam::xxxxxxxxxxxx:role/example-role' + externalId: 'some-id' # optional +``` + +When no entries are added, the AWS CodeCommit reader will add a default entry that uses the [standard credentials provider chain](https://docs.aws.amazon.com/AWSJavaSDK/latest/javadoc/com/amazonaws/auth/DefaultAWSCredentialsProviderChain.html). diff --git a/mkdocs.yml b/mkdocs.yml index d565ba9ba3..7ba6fdfefa 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -95,6 +95,8 @@ nav: - AWS S3: - Locations: 'integrations/aws-s3/locations.md' - Discovery: 'integrations/aws-s3/discovery.md' + - AWS CodeCommit: + - Locations: 'integrations/aws-codecommit/locations.md' - Azure: - Locations: 'integrations/azure/locations.md' - Discovery: 'integrations/azure/discovery.md' diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 51ee5ed304..cf74d4de20 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -51,6 +51,7 @@ }, "dependencies": { "@aws-sdk/abort-controller": "^3.347.0", + "@aws-sdk/client-codecommit": "^3.350.0", "@aws-sdk/client-s3": "^3.350.0", "@aws-sdk/credential-providers": "^3.350.0", "@aws-sdk/types": "^3.347.0", diff --git a/packages/backend-common/src/reading/AwsCodeCommitUrlReader.test.ts b/packages/backend-common/src/reading/AwsCodeCommitUrlReader.test.ts new file mode 100644 index 0000000000..44d785d9c4 --- /dev/null +++ b/packages/backend-common/src/reading/AwsCodeCommitUrlReader.test.ts @@ -0,0 +1,542 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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 { ConfigReader } from '@backstage/config'; +import { JsonObject } from '@backstage/types'; +import { getVoidLogger } from '../logging'; +import { DefaultReadTreeResponseFactory } from './tree'; +import { AwsCodeCommitUrlReader, parseUrl } from './AwsCodeCommitUrlReader'; +import { UrlReaderPredicateTuple } from './types'; +import path from 'path'; +import { NotModifiedError } from '@backstage/errors'; +import { mockClient } from 'aws-sdk-client-mock'; +import { + CodeCommitClient, + GetFileCommand, + GetFolderCommand, +} from '@aws-sdk/client-codecommit'; +import fs from 'fs'; +import { DefaultAwsCredentialsManager } from '@backstage/integration-aws-node'; +import { + AwsCodeCommitIntegration, + readAwsCodeCommitIntegrationConfig, +} from '@backstage/integration'; + +const AMAZON_AWS_CODECOMMIT_HOST = 'console.aws.amazon.com'; + +const treeResponseFactory = DefaultReadTreeResponseFactory.create({ + config: new ConfigReader({}), +}); + +describe('parseUrl', () => { + it('supports all formats (requireGitPath = true)', () => { + expect( + parseUrl( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-repo/browse/--/catalog-info.yaml?region=eu-west-1', + true, + ), + ).toEqual({ + path: 'catalog-info.yaml', + repositoryName: 'my-repo', + region: 'eu-west-1', + }); + expect( + parseUrl( + 'https://us-east-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-repo2/browse/--/test1/test2/catalog-info.yaml', + true, + ), + ).toEqual({ + path: 'test1/test2/catalog-info.yaml', + repositoryName: 'my-repo2', + region: 'us-east-1', + }); + expect( + parseUrl( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-test-repo/browse/refs/heads/develop/--/some-path/catalog-info.yaml', + true, + ), + ).toEqual({ + path: 'some-path/catalog-info.yaml', + repositoryName: 'my-test-repo', + region: 'eu-west-1', + commitSpecifier: 'refs/heads/develop', + }); + expect( + parseUrl( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-test-repo/browse/refs/tags/v1.0/--/some-path/catalog-info.yaml', + true, + ), + ).toEqual({ + path: 'some-path/catalog-info.yaml', + repositoryName: 'my-test-repo', + region: 'eu-west-1', + commitSpecifier: 'refs/tags/v1.0', + }); + expect( + parseUrl( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-test-repo/browse/c1234/--/some-path/catalog-info.yaml', + true, + ), + ).toEqual({ + path: 'some-path/catalog-info.yaml', + repositoryName: 'my-test-repo', + region: 'eu-west-1', + commitSpecifier: 'c1234', + }); + }); + it('throw correct error when not providing full url to file (requireGitPath = true)', () => { + expect(() => + parseUrl( + `https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-repo`, + true, + ), + ).toThrow('Please provide full path to yaml file from CodeCommit'); + }); + it('supports all formats (requireGitPath = false)', () => { + expect( + parseUrl( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-test-techdocs', + ), + ).toEqual({ + path: '/', + repositoryName: 'my-test-techdocs', + region: 'eu-west-1', + }); + expect( + parseUrl( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-test-techdocs/', + ), + ).toEqual({ + path: '/', + repositoryName: 'my-test-techdocs', + region: 'eu-west-1', + }); + expect( + parseUrl( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-test-techdocs/browse?region=eu-west-1', + ), + ).toEqual({ + path: '/', + repositoryName: 'my-test-techdocs', + region: 'eu-west-1', + }); + expect( + parseUrl( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-test-techdocs/browse/refs/heads/develop', + ), + ).toEqual({ + path: '/', + repositoryName: 'my-test-techdocs', + region: 'eu-west-1', + commitSpecifier: 'refs/heads/develop', + }); + expect( + parseUrl( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-test-techdocs/browse/refs/tags/v1.0', + ), + ).toEqual({ + path: '/', + repositoryName: 'my-test-techdocs', + region: 'eu-west-1', + commitSpecifier: 'refs/tags/v1.0', + }); + expect( + parseUrl( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-test-techdocs/browse/c1234', + ), + ).toEqual({ + path: '/', + repositoryName: 'my-test-techdocs', + region: 'eu-west-1', + commitSpecifier: 'c1234', + }); + expect( + parseUrl( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-test-techdocs/browse/c1234/', + ), + ).toEqual({ + path: '/', + repositoryName: 'my-test-techdocs', + region: 'eu-west-1', + commitSpecifier: 'c1234', + }); + expect( + parseUrl( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-test-techdocs/browse/c1234/--/folder/', + ), + ).toEqual({ + path: 'folder/', + repositoryName: 'my-test-techdocs', + region: 'eu-west-1', + commitSpecifier: 'c1234', + }); + }); + + it('throw correct error when providing edit url', () => { + expect(() => + parseUrl( + `https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-repo/files/edit/refs/heads/develop/--/test/catalog-info.yaml`, + ), + ).toThrow( + 'Please provide the view url to yaml file from CodeCommit, not the edit url', + ); + }); +}); + +describe('AwsCodeCommitUrlReader', () => { + const codeCommitClient = mockClient(CodeCommitClient); + + const createReader = (config: JsonObject): UrlReaderPredicateTuple[] => { + return AwsCodeCommitUrlReader.factory({ + config: new ConfigReader(config), + logger: getVoidLogger(), + treeResponseFactory, + }); + }; + + it('creates a sample reader without the awsCodeCommit field', () => { + const entries = createReader({ + integrations: {}, + }); + + expect(entries).toHaveLength(1); + }); + + it('creates a reader with credentials correctly configured', () => { + const awsCodeCommitIntegrations = []; + awsCodeCommitIntegrations.push({ + host: AMAZON_AWS_CODECOMMIT_HOST, + accessKeyId: 'fakekey', + secretAccessKey: 'fakekey', + }); + + const entries = createReader({ + integrations: { + awsCodeCommit: awsCodeCommitIntegrations, + }, + }); + + expect(entries).toHaveLength(1); + }); + + it('creates a reader with default credentials provider', () => { + const awsCodeCommitIntegrations = []; + awsCodeCommitIntegrations.push({ + host: AMAZON_AWS_CODECOMMIT_HOST, + }); + + const entries = createReader({ + integrations: { + awsCodeCommit: awsCodeCommitIntegrations, + }, + }); + + expect(entries).toHaveLength(1); + }); + + describe('predicates', () => { + const readers = createReader({ + integrations: { + awsCodeCommit: [{}], + }, + }); + const predicate = readers[0].predicate; + + it('returns true for the correct aws CodeCommit storage host', () => { + expect( + predicate( + new URL( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-repo', + ), + ), + ).toBe(true); + }); + + it('returns true for a url with the full path and the correct host', () => { + expect( + predicate( + new URL( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-repo/browse/--/catalog-info.yaml?region=eu-west-1', + ), + ), + ).toBe(true); + }); + + it('returns false for an incorrect host', () => { + expect(predicate(new URL('https://amazon.com'))).toBe(false); + }); + + it('returns false for a completely different host', () => { + expect(predicate(new URL('https://storage.cloud.google.com'))).toBe( + false, + ); + }); + + it('returns false for a path that starts with the wrong format', () => { + expect( + predicate( + new URL( + 'https://s3.console.aws.amazon.com/s3/object/bucket?&bucketType=general&prefix=catalog-info.yaml', + ), + ), + ).toBe(false); + }); + }); + + describe('read', () => { + const [{ reader }] = createReader({ + integrations: { + awsCodeCommit: [ + { + host: 'amazonaws.com', + accessKeyId: 'fake-access-key', + secretAccessKey: 'fake-secret-key', + }, + ], + }, + }); + + beforeEach(() => { + codeCommitClient.reset(); + + codeCommitClient.on(GetFileCommand).resolves({ + fileContent: fs.readFileSync( + path.resolve( + __dirname, + '__fixtures__/awsCodeCommit/awsCodeCommit-mock-object.yaml', + ), + ), + }); + }); + + it('returns contents of a file in a repository', async () => { + const { buffer } = await reader.readUrl( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-repo/browse/--/catalog-info.yaml', + ); + const response = await buffer(); + expect(response.toString().trim()).toBe('site_name: Test'); + }); + + it('rejects unknown targets', async () => { + const url = + 'https://eu-west-1.console.aws.NOTamazon.com/codesuite/codecommit/repositories/my-repo/browse/--/catalog-info.yaml'; + const expectedSnapshot = `[Error: Could not retrieve file from CodeCommit; caused by Error: Invalid AWS CodeCommit URL (unexpected host format): ${url}]`; + await expect(reader.readUrl(url)).rejects.toMatchInlineSnapshot( + expectedSnapshot, + ); + }); + }); + + describe('readUrl', () => { + const [{ reader }] = createReader({ + integrations: { + awsCodeCommit: [ + { + host: AMAZON_AWS_CODECOMMIT_HOST, + accessKeyId: 'fake-access-key', + secretAccessKey: 'fake-secret-key', + }, + ], + }, + }); + + beforeEach(() => { + codeCommitClient.reset(); + + codeCommitClient.on(GetFileCommand).resolves({ + fileContent: fs.readFileSync( + path.resolve( + __dirname, + '__fixtures__/awsCodeCommit/awsCodeCommit-mock-object.yaml', + ), + ), + commitId: `123abc`, + }); + }); + + it('returns contents of a file in a repository via buffer', async () => { + const { buffer, etag } = await reader.readUrl( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-repo/browse/--/catalog-info.yaml', + ); + expect(etag).toBe('123abc'); + const response = await buffer(); + expect(response.toString().trim()).toBe('site_name: Test'); + }); + + it('returns contents of a file in a repository via stream', async () => { + const { buffer, etag } = await reader.readUrl( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-repo/browse/--/catalog-info.yaml', + ); + expect(etag).toBe('123abc'); + const response = await buffer(); + expect(response.toString().trim()).toBe('site_name: Test'); + }); + + it('rejects unknown targets', async () => { + const url = + 'https://eu-west-1.console.aws.NOTamazon.com/codesuite/codecommit/repositories/my-repo/browse/--/catalog-info.yaml'; + const expectedSnapshot = `[Error: Could not retrieve file from CodeCommit; caused by Error: Invalid AWS CodeCommit URL (unexpected host format): ${url}]`; + await expect(reader.readUrl!(url)).rejects.toMatchInlineSnapshot( + expectedSnapshot, + ); + }); + }); + + describe('readUrl with etag', () => { + const [{ reader }] = createReader({ + integrations: { + awsCodeCommit: [ + { + host: AMAZON_AWS_CODECOMMIT_HOST, + accessKeyId: 'fake-access-key', + secretAccessKey: 'fake-secret-key', + }, + ], + }, + }); + + beforeEach(() => { + codeCommitClient.reset(); + + codeCommitClient.on(GetFileCommand).resolves({ + fileContent: fs.readFileSync( + path.resolve( + __dirname, + '__fixtures__/awsCodeCommit/awsCodeCommit-mock-object.yaml', + ), + ), + commitId: `123abc`, + blobId: '999', + filePath: 'catalog.yaml', + fileMode: 'EXECUTABLE', + fileSize: 123, + }); + }); + + it('Throw NotModifiedError on matching eTag', async () => { + await expect( + reader.readUrl!( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-repo/browse/--/catalog-info.yaml', + { + etag: '123abc', + }, + ), + ).rejects.toThrow(NotModifiedError); + }); + }); + + describe('readTree', () => { + let awsCodeCommitUrlReader: AwsCodeCommitUrlReader; + + beforeEach(() => { + codeCommitClient.reset(); + + codeCommitClient + .on(GetFolderCommand, { + folderPath: `/`, + repositoryName: `my-test-techdocs`, + commitSpecifier: undefined, + }) + .resolves({ + files: [ + { + absolutePath: `awsCodeCommit-mock-object.yaml`, + relativePath: `awsCodeCommit-mock-object.yaml`, + }, + ], + subFolders: [ + { + absolutePath: `subFolder`, + relativePath: `subFolder`, + }, + ], + }); + codeCommitClient + .on(GetFolderCommand, { + folderPath: `subFolder`, + repositoryName: `my-test-techdocs`, + commitSpecifier: undefined, + }) + .resolves({ + files: [ + { + absolutePath: `subFolder/awsCodeCommit-mock-object2.yaml`, + relativePath: `subFolder/awsCodeCommit-mock-object2.yaml`, + }, + ], + }); + + codeCommitClient + .on(GetFileCommand, { + filePath: `awsCodeCommit-mock-object.yaml`, + commitSpecifier: undefined, + repositoryName: `my-test-techdocs`, + }) + .resolves({ + fileContent: fs.readFileSync( + path.resolve( + __dirname, + '__fixtures__/awsCodeCommit/awsCodeCommit-mock-object.yaml', + ), + ), + }); + codeCommitClient + .on(GetFileCommand, { + filePath: `subFolder/awsCodeCommit-mock-object2.yaml`, + commitSpecifier: undefined, + repositoryName: `my-test-techdocs`, + }) + .resolves({ + fileContent: fs.readFileSync( + path.resolve( + __dirname, + '__fixtures__/awsCodeCommit/subFolder/awsCodeCommit-mock-object2.yaml', + ), + ), + }); + + const config = new ConfigReader({ + host: AMAZON_AWS_CODECOMMIT_HOST, + accessKeyId: 'fake-access-key', + secretAccessKey: 'fake-secret-key', + }); + + const credsManager = DefaultAwsCredentialsManager.fromConfig(config); + + awsCodeCommitUrlReader = new AwsCodeCommitUrlReader( + credsManager, + new AwsCodeCommitIntegration( + readAwsCodeCommitIntegrationConfig(config), + ), + { treeResponseFactory }, + ); + }); + + it('returns contents of a file in a repository', async () => { + const response = await awsCodeCommitUrlReader.readTree( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-test-techdocs', + ); + + const files = await response.files(); + const bodyRootFile = await files[0].content(); + const bodySubfolderFile = await files[1].content(); + + expect(files.length).toEqual(2); + expect(bodyRootFile.toString().trim()).toBe('site_name: Test'); + expect(bodySubfolderFile.toString().trim()).toBe('site_name: Test2'); + }); + }); +}); diff --git a/packages/backend-common/src/reading/AwsCodeCommitUrlReader.ts b/packages/backend-common/src/reading/AwsCodeCommitUrlReader.ts new file mode 100644 index 0000000000..fda8e4c3a0 --- /dev/null +++ b/packages/backend-common/src/reading/AwsCodeCommitUrlReader.ts @@ -0,0 +1,393 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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 { + ReaderFactory, + ReadTreeOptions, + ReadTreeResponse, + ReadTreeResponseFactory, + ReadUrlOptions, + ReadUrlResponse, + SearchResponse, + UrlReader, +} from './types'; +import { + AwsCredentialsManager, + DefaultAwsCredentialsManager, +} from '@backstage/integration-aws-node'; +import { + AwsCodeCommitIntegration, + ScmIntegrations, +} from '@backstage/integration'; +import { ForwardedError, NotModifiedError } from '@backstage/errors'; +import { fromTemporaryCredentials } from '@aws-sdk/credential-providers'; +import { + CodeCommitClient, + GetFileCommand, + GetFileCommandInput, + GetFileCommandOutput, + GetFolderCommand, +} from '@aws-sdk/client-codecommit'; +import { AwsCredentialIdentityProvider } from '@aws-sdk/types'; +import { Readable } from 'stream'; +import { ReadUrlResponseFactory } from './ReadUrlResponseFactory'; +import { relative } from 'path/posix'; +import { AbortController } from '@aws-sdk/abort-controller'; + +export function parseUrl( + url: string, + requireGitPath: boolean = false, +): { + path: string; + repositoryName: string; + region: string; + commitSpecifier?: string; +} { + const parsedUrl = new URL(url); + + if (parsedUrl.pathname.includes('/files/edit/')) { + throw new Error( + 'Please provide the view url to yaml file from CodeCommit, not the edit url', + ); + } + if (requireGitPath && !parsedUrl.pathname.includes('/browse/')) { + throw new Error('Please provide full path to yaml file from CodeCommit'); + } + + const hostMatch = parsedUrl.host.match( + /^([^\.]+)\.console\.aws\.amazon\.com$/, + ); + if (!hostMatch) { + throw new Error( + `Invalid AWS CodeCommit URL (unexpected host format): ${url}`, + ); + } + const [, region] = hostMatch; + + const pathMatch = parsedUrl.pathname.match( + /^\/codesuite\/codecommit\/repositories\/([^\/]+)\/browse\/((.*)\/)?--\/(.*)$/, + ); + + if (!pathMatch) { + if (!requireGitPath) { + const pathname = parsedUrl.pathname + .split('/--/')[0] + .replace('/codesuite/codecommit/repositories/', ''); + const [repositoryName, commitSpecifier] = pathname.split('/browse'); + + return { + region, + repositoryName: repositoryName.replace(/^\/|\/$/g, ''), + path: '/', + commitSpecifier: + commitSpecifier === '' + ? undefined + : commitSpecifier?.replace(/^\/|\/$/g, ''), + }; + } + throw new Error( + `Invalid AWS CodeCommit URL (unexpected path format): ${url}`, + ); + } + const [, repositoryName, , commitSpecifier, path] = pathMatch; + + return { + region, + repositoryName, + path, + // the commitSpecifier is passed to AWS SDK which does not allow empty strings so replace empty string with undefined + commitSpecifier: commitSpecifier === '' ? undefined : commitSpecifier, + }; +} + +/** + * Implements a {@link @backstage/backend-plugin-api#UrlReaderService} for AWS CodeCommit. + * + * @public + */ +export class AwsCodeCommitUrlReader implements UrlReader { + static factory: ReaderFactory = ({ config, treeResponseFactory }) => { + const integrations = ScmIntegrations.fromConfig(config); + const credsManager = DefaultAwsCredentialsManager.fromConfig(config); + + return integrations.awsCodeCommit.list().map(integration => { + const reader = new AwsCodeCommitUrlReader(credsManager, integration, { + treeResponseFactory, + }); + const predicate = (url: URL) => { + return ( + url.host.endsWith(integration.config.host) && + url.pathname.startsWith('/codesuite/codecommit') + ); + }; + + return { reader, predicate }; + }); + }; + + constructor( + private readonly credsManager: AwsCredentialsManager, + private readonly integration: AwsCodeCommitIntegration, + private readonly deps: { + treeResponseFactory: ReadTreeResponseFactory; + }, + ) {} + + /** + * If accessKeyId and secretAccessKey are missing, the standard credentials provider chain will be used: + * https://docs.aws.amazon.com/AWSJavaSDK/latest/javadoc/com/amazonaws/auth/DefaultAWSCredentialsProviderChain.html + */ + private static buildStaticCredentials( + accessKeyId: string, + secretAccessKey: string, + ): AwsCredentialIdentityProvider { + return async () => { + return { + accessKeyId, + secretAccessKey, + }; + }; + } + + private static async buildCredentials( + credsManager: AwsCredentialsManager, + region: string, + integration?: AwsCodeCommitIntegration, + ): Promise { + // Fall back to the default credential chain if neither account ID + // nor explicit credentials are provided + if (!integration) { + return (await credsManager.getCredentialProvider()).sdkCredentialProvider; + } + + const accessKeyId = integration.config.accessKeyId; + const secretAccessKey = integration.config.secretAccessKey; + let explicitCredentials: AwsCredentialIdentityProvider; + if (accessKeyId && secretAccessKey) { + explicitCredentials = AwsCodeCommitUrlReader.buildStaticCredentials( + accessKeyId, + secretAccessKey, + ); + } else { + explicitCredentials = (await credsManager.getCredentialProvider()) + .sdkCredentialProvider; + } + + const roleArn = integration.config.roleArn; + if (roleArn) { + return fromTemporaryCredentials({ + masterCredentials: explicitCredentials, + params: { + RoleSessionName: 'backstage-aws-code-commit-url-reader', + RoleArn: roleArn, + ExternalId: integration.config.externalId, + }, + clientConfig: { region }, + }); + } + + return explicitCredentials; + } + + private async buildCodeCommitClient( + credsManager: AwsCredentialsManager, + region: string, + integration: AwsCodeCommitIntegration, + ): Promise { + const credentials = await AwsCodeCommitUrlReader.buildCredentials( + credsManager, + region, + integration, + ); + + const codeCommit = new CodeCommitClient({ + region: region, + credentials: credentials, + }); + return codeCommit; + } + + async readUrl( + url: string, + options?: ReadUrlOptions, + ): Promise { + // etag and lastModifiedAfter are not supported by the CodeCommit API + try { + const { path, repositoryName, region, commitSpecifier } = parseUrl( + url, + true, + ); + const codeCommitClient = await this.buildCodeCommitClient( + this.credsManager, + region, + this.integration, + ); + const abortController = new AbortController(); + + const input: GetFileCommandInput = { + repositoryName: repositoryName, + commitSpecifier: commitSpecifier, + filePath: path, + }; + + options?.signal?.addEventListener('abort', () => abortController.abort()); + const getObjectCommand = new GetFileCommand(input); + const response: GetFileCommandOutput = await codeCommitClient.send( + getObjectCommand, + { + abortSignal: abortController.signal, + }, + ); + + if (options?.etag && options.etag === response.commitId) { + throw new NotModifiedError(); + } + + return ReadUrlResponseFactory.fromReadable( + Readable.from([response?.fileContent] || []), + { + etag: response.commitId, + }, + ); + } catch (e) { + if (e.$metadata && e.$metadata.httpStatusCode === 304) { + throw new NotModifiedError(); + } + if (e.name && e.name === 'NotModifiedError') { + throw new NotModifiedError(); + } + + throw new ForwardedError('Could not retrieve file from CodeCommit', e); + } + } + + async readTreePath( + codeCommitClient: CodeCommitClient, + abortSignal: any, + path: string, + repositoryName: string, + commitSpecifier?: string, + etag?: string, + ): Promise { + const getFolderCommand = new GetFolderCommand({ + folderPath: path, + repositoryName: repositoryName, + commitSpecifier: commitSpecifier, + }); + const response = await codeCommitClient.send(getFolderCommand, { + abortSignal: abortSignal, + }); + + if (etag && etag === response.commitId) { + throw new NotModifiedError(); + } + + const output: string[] = []; + if (response.files) { + response.files.forEach(file => { + if (file.absolutePath) { + output.push(file.absolutePath); + } + }); + } + if (!response.subFolders) { + return output; + } + + for (const subFolder of response.subFolders) { + if (subFolder.absolutePath) { + output.push( + ...(await this.readTreePath( + codeCommitClient, + abortSignal, + subFolder.absolutePath, + repositoryName, + commitSpecifier, + etag, + )), + ); + } + } + return output; + } + + async readTree( + url: string, + options?: ReadTreeOptions, + ): Promise { + // url: https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/test-stijn-delete-techdocs/browse?region=eu-west-1 + try { + const { path, repositoryName, region, commitSpecifier } = parseUrl(url); + const codeCommitClient = await this.buildCodeCommitClient( + this.credsManager, + region, + this.integration, + ); + + const abortController = new AbortController(); + options?.signal?.addEventListener('abort', () => abortController.abort()); + + const allFiles: string[] = await this.readTreePath( + codeCommitClient, + abortController.signal, + path, + repositoryName, + commitSpecifier, + options?.etag, + ); + const responses = []; + + for (let i = 0; i < allFiles.length; i++) { + const getFileCommand = new GetFileCommand({ + repositoryName: repositoryName, + filePath: String(allFiles[i]), + commitSpecifier: commitSpecifier, + }); + const response = await codeCommitClient.send(getFileCommand); + const objectData = await Readable.from([response?.fileContent] || []); + + responses.push({ + data: objectData, + path: relative( + path.startsWith('/') ? path : `/${path}`, + allFiles[i].startsWith('/') ? allFiles[i] : `/${allFiles[i]}`, + ), + }); + } + + return await this.deps.treeResponseFactory.fromReadableArray(responses); + } catch (e) { + if (e.name && e.name === 'NotModifiedError') { + throw new NotModifiedError(); + } + throw new ForwardedError( + 'Could not retrieve file tree from CodeCommit', + e, + ); + } + } + + async search(): Promise { + throw new Error('AwsCodeCommitReader does not implement search'); + } + + toString() { + const secretAccessKey = this.integration.config.secretAccessKey; + return `awsCodeCommit{host=${this.integration.config.host},authed=${Boolean( + secretAccessKey, + )}}`; + } +} diff --git a/packages/backend-common/src/reading/UrlReaders.ts b/packages/backend-common/src/reading/UrlReaders.ts index 3c1063d675..e987eb9186 100644 --- a/packages/backend-common/src/reading/UrlReaders.ts +++ b/packages/backend-common/src/reading/UrlReaders.ts @@ -30,6 +30,7 @@ import { FetchUrlReader } from './FetchUrlReader'; import { GoogleGcsUrlReader } from './GoogleGcsUrlReader'; import { AwsS3UrlReader } from './AwsS3UrlReader'; import { GiteaUrlReader } from './GiteaUrlReader'; +import { AwsCodeCommitUrlReader } from './AwsCodeCommitUrlReader'; /** * Creation options for {@link @backstage/backend-plugin-api#UrlReaderService}. @@ -94,6 +95,7 @@ export class UrlReaders { GitlabUrlReader.factory, GoogleGcsUrlReader.factory, AwsS3UrlReader.factory, + AwsCodeCommitUrlReader.factory, FetchUrlReader.factory, ]), }); diff --git a/packages/backend-common/src/reading/__fixtures__/awsCodeCommit/awsCodeCommit-mock-object.yaml b/packages/backend-common/src/reading/__fixtures__/awsCodeCommit/awsCodeCommit-mock-object.yaml new file mode 100644 index 0000000000..21213863bc --- /dev/null +++ b/packages/backend-common/src/reading/__fixtures__/awsCodeCommit/awsCodeCommit-mock-object.yaml @@ -0,0 +1 @@ +site_name: Test \ No newline at end of file diff --git a/packages/backend-common/src/reading/__fixtures__/awsCodeCommit/subFolder/awsCodeCommit-mock-object2.yaml b/packages/backend-common/src/reading/__fixtures__/awsCodeCommit/subFolder/awsCodeCommit-mock-object2.yaml new file mode 100644 index 0000000000..00ceaa8fbc --- /dev/null +++ b/packages/backend-common/src/reading/__fixtures__/awsCodeCommit/subFolder/awsCodeCommit-mock-object2.yaml @@ -0,0 +1 @@ +site_name: Test2 \ No newline at end of file diff --git a/packages/integration/api-report.md b/packages/integration/api-report.md index 07b4f7d012..6b691dff80 100644 --- a/packages/integration/api-report.md +++ b/packages/integration/api-report.md @@ -7,6 +7,36 @@ import { Config } from '@backstage/config'; import { ConsumedResponse } from '@backstage/errors'; import { RestEndpointMethodTypes } from '@octokit/rest'; +// @public +export class AwsCodeCommitIntegration implements ScmIntegration { + constructor(integrationConfig: AwsCodeCommitIntegrationConfig); + // (undocumented) + get config(): AwsCodeCommitIntegrationConfig; + // (undocumented) + static factory: ScmIntegrationsFactory; + // (undocumented) + resolveEditUrl(url: string): string; + // (undocumented) + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number | undefined; + }): string; + // (undocumented) + get title(): string; + // (undocumented) + get type(): string; +} + +// @public +export type AwsCodeCommitIntegrationConfig = { + host: string; + accessKeyId?: string; + secretAccessKey?: string; + roleArn?: string; + externalId?: string; +}; + // @public export class AwsS3Integration implements ScmIntegration { constructor(integrationConfig: AwsS3IntegrationConfig); @@ -646,6 +676,8 @@ export type GoogleGcsIntegrationConfig = { // @public export interface IntegrationsByType { + // (undocumented) + awsCodeCommit: ScmIntegrationsGroup; // (undocumented) awsS3: ScmIntegrationsGroup; // (undocumented) @@ -703,6 +735,16 @@ export interface RateLimitInfo { isRateLimited: boolean; } +// @public +export function readAwsCodeCommitIntegrationConfig( + config: Config, +): AwsCodeCommitIntegrationConfig; + +// @public +export function readAwsCodeCommitIntegrationConfigs( + configs: Config[], +): AwsCodeCommitIntegrationConfig[]; + // @public export function readAwsS3IntegrationConfig( config: Config, @@ -828,6 +870,8 @@ export interface ScmIntegration { export interface ScmIntegrationRegistry extends ScmIntegrationsGroup { // (undocumented) + awsCodeCommit: ScmIntegrationsGroup; + // (undocumented) awsS3: ScmIntegrationsGroup; // (undocumented) azure: ScmIntegrationsGroup; @@ -857,6 +901,8 @@ export interface ScmIntegrationRegistry export class ScmIntegrations implements ScmIntegrationRegistry { constructor(integrationsByType: IntegrationsByType); // (undocumented) + get awsCodeCommit(): ScmIntegrationsGroup; + // (undocumented) get awsS3(): ScmIntegrationsGroup; // (undocumented) get azure(): ScmIntegrationsGroup; diff --git a/packages/integration/src/ScmIntegrations.test.ts b/packages/integration/src/ScmIntegrations.test.ts index 4c64b2f0de..acf602e38d 100644 --- a/packages/integration/src/ScmIntegrations.test.ts +++ b/packages/integration/src/ScmIntegrations.test.ts @@ -36,12 +36,18 @@ import { GitLabIntegration } from './gitlab/GitLabIntegration'; import { basicIntegrations } from './helpers'; import { ScmIntegrations } from './ScmIntegrations'; import { GiteaIntegration, GiteaIntegrationConfig } from './gitea'; +import { AwsCodeCommitIntegration } from './awsCodeCommit/AwsCodeCommitIntegration'; +import { AwsCodeCommitIntegrationConfig } from './awsCodeCommit'; describe('ScmIntegrations', () => { const awsS3 = new AwsS3Integration({ host: 'awss3.local', } as AwsS3IntegrationConfig); + const awsCodeCommit = new AwsCodeCommitIntegration({ + host: 'awscodecommit.local', + } as AwsCodeCommitIntegrationConfig); + const azure = new AzureIntegration({ host: 'azure.local', } as AzureIntegrationConfig); @@ -76,6 +82,7 @@ describe('ScmIntegrations', () => { const i = new ScmIntegrations({ awsS3: basicIntegrations([awsS3], item => item.config.host), + awsCodeCommit: basicIntegrations([awsCodeCommit], item => item.config.host), azure: basicIntegrations([azure], item => item.config.host), bitbucket: basicIntegrations([bitbucket], item => item.config.host), bitbucketCloud: basicIntegrations([bitbucketCloud], item => item.title), @@ -91,6 +98,9 @@ describe('ScmIntegrations', () => { it('can get the specifics', () => { expect(i.awsS3.byUrl('https://awss3.local')).toBe(awsS3); + expect(i.awsCodeCommit.byUrl('https://awscodecommit.local')).toBe( + awsCodeCommit, + ); expect(i.azure.byUrl('https://azure.local')).toBe(azure); expect(i.bitbucket.byUrl('https://bitbucket.local')).toBe(bitbucket); expect(i.bitbucketCloud.byUrl('https://bitbucket.org')).toBe( @@ -109,6 +119,7 @@ describe('ScmIntegrations', () => { expect(i.list()).toEqual( expect.arrayContaining([ awsS3, + awsCodeCommit, azure, bitbucket, bitbucketCloud, @@ -123,6 +134,7 @@ describe('ScmIntegrations', () => { it('can select by url and host', () => { expect(i.byUrl('https://awss3.local')).toBe(awsS3); + expect(i.byUrl('https://awscodecommit.local')).toBe(awsCodeCommit); expect(i.byUrl('https://azure.local')).toBe(azure); expect(i.byUrl('https://bitbucket.local')).toBe(bitbucket); expect(i.byUrl('https://bitbucket.org')).toBe(bitbucketCloud); @@ -133,6 +145,7 @@ describe('ScmIntegrations', () => { expect(i.byUrl('https://gitea.local')).toBe(gitea); expect(i.byHost('awss3.local')).toBe(awsS3); + expect(i.byHost('awscodecommit.local')).toBe(awsCodeCommit); expect(i.byHost('azure.local')).toBe(azure); expect(i.byHost('bitbucket.local')).toBe(bitbucket); expect(i.byHost('bitbucket.org')).toBe(bitbucketCloud); diff --git a/packages/integration/src/ScmIntegrations.ts b/packages/integration/src/ScmIntegrations.ts index 24c28ce25e..6a1faaa75b 100644 --- a/packages/integration/src/ScmIntegrations.ts +++ b/packages/integration/src/ScmIntegrations.ts @@ -16,6 +16,7 @@ import { Config } from '@backstage/config'; import { AwsS3Integration } from './awsS3/AwsS3Integration'; +import { AwsCodeCommitIntegration } from './awsCodeCommit/AwsCodeCommitIntegration'; import { AzureIntegration } from './azure/AzureIntegration'; import { BitbucketCloudIntegration } from './bitbucketCloud/BitbucketCloudIntegration'; import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; @@ -35,6 +36,7 @@ import { GiteaIntegration } from './gitea'; */ export interface IntegrationsByType { awsS3: ScmIntegrationsGroup; + awsCodeCommit: ScmIntegrationsGroup; azure: ScmIntegrationsGroup; /** * @deprecated in favor of `bitbucketCloud` and `bitbucketServer` @@ -59,6 +61,7 @@ export class ScmIntegrations implements ScmIntegrationRegistry { static fromConfig(config: Config): ScmIntegrations { return new ScmIntegrations({ awsS3: AwsS3Integration.factory({ config }), + awsCodeCommit: AwsCodeCommitIntegration.factory({ config }), azure: AzureIntegration.factory({ config }), bitbucket: BitbucketIntegration.factory({ config }), bitbucketCloud: BitbucketCloudIntegration.factory({ config }), @@ -78,6 +81,10 @@ export class ScmIntegrations implements ScmIntegrationRegistry { return this.byType.awsS3; } + get awsCodeCommit(): ScmIntegrationsGroup { + return this.byType.awsCodeCommit; + } + get azure(): ScmIntegrationsGroup { return this.byType.azure; } diff --git a/packages/integration/src/awsCodeCommit/AwsCodeCommitIntegration.test.ts b/packages/integration/src/awsCodeCommit/AwsCodeCommitIntegration.test.ts new file mode 100644 index 0000000000..14b3c6c73b --- /dev/null +++ b/packages/integration/src/awsCodeCommit/AwsCodeCommitIntegration.test.ts @@ -0,0 +1,109 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { ConfigReader } from '@backstage/config'; +import { AwsCodeCommitIntegration } from './AwsCodeCommitIntegration'; + +const AMAZON_AWS_CODECOMMIT_HOST = 'console.aws.amazon.com'; + +describe('AwsCodeCommitIntegration', () => { + it('has a working factory with correct data when entry provided', () => { + const integrations = AwsCodeCommitIntegration.factory({ + config: new ConfigReader({ + integrations: { + awsCodeCommit: [ + { + accessKeyId: 'access key', + secretAccessKey: ' secret key ', + roleArn: `role arn`, + externalId: `external id`, + }, + ], + }, + }), + }); + expect(integrations.list().length).toBe(1); // including default + expect(integrations.list()[0].config.host).toBe(AMAZON_AWS_CODECOMMIT_HOST); + expect(integrations.list()[0].config.accessKeyId).toBe('access key'); + expect(integrations.list()[0].config.secretAccessKey).toBe('secret key'); + expect(integrations.list()[0].config.roleArn).toBe(`role arn`); + expect(integrations.list()[0].config.externalId).toBe(`external id`); + }); + it('has a working factory with default values when no data provided', () => { + const integrations = AwsCodeCommitIntegration.factory({ + config: new ConfigReader({ + integrations: {}, + }), + }); + expect(integrations.list().length).toBe(1); // including default + expect(integrations.list()[0].config.host).toBe(AMAZON_AWS_CODECOMMIT_HOST); + expect(integrations.list()[0].config.accessKeyId).toBeUndefined(); + expect(integrations.list()[0].config.secretAccessKey).toBeUndefined(); + expect(integrations.list()[0].config.roleArn).toBeUndefined(); + expect(integrations.list()[0].config.externalId).toBeUndefined(); + }); + + it('returns the basics', () => { + const integration = new AwsCodeCommitIntegration({ host: 'a.com' } as any); + expect(integration.type).toBe('awsCodeCommit'); + expect(integration.title).toBe('a.com'); + }); + + describe('resolveUrl', () => { + it('works for valid urls', () => { + const integration = new AwsCodeCommitIntegration({ + host: 'amazonaws.com', + } as any); + + expect( + integration.resolveUrl({ + url: 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-repo/browse/refs/heads/main/--/catalog-info.yaml', + base: 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-repo/browse/refs/heads/main/--/catalog-info.yaml', + }), + ).toBe( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-repo/browse/refs/heads/main/--/catalog-info.yaml', + ); + }); + }); + + it('resolve edit URL - with refs', () => { + const integration = new AwsCodeCommitIntegration({ + host: 'console.aws.amazon.com', + } as any); + + expect( + integration.resolveEditUrl( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-repo/browse/refs/heads/main/--/catalog-info.yaml?region=eu-west-1', + ), + ).toBe( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-repo/files/edit/refs/heads/main/--/catalog-info.yaml?region=eu-west-1', + ); + }); + + it('resolve edit URL - without refs', () => { + const integration = new AwsCodeCommitIntegration({ + host: 'console.aws.amazon.com', + } as any); + + expect( + integration.resolveEditUrl( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-repo/browse/--/catalog-info.yaml?region=eu-west-1', + ), + ).toBe( + 'https://eu-west-1.console.aws.amazon.com/codesuite/codecommit/repositories/my-repo/files/edit/--/catalog-info.yaml?region=eu-west-1', + ); + }); +}); diff --git a/packages/integration/src/awsCodeCommit/AwsCodeCommitIntegration.ts b/packages/integration/src/awsCodeCommit/AwsCodeCommitIntegration.ts new file mode 100644 index 0000000000..e5b0e4b76b --- /dev/null +++ b/packages/integration/src/awsCodeCommit/AwsCodeCommitIntegration.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { basicIntegrations, defaultScmResolveUrl } from '../helpers'; +import { ScmIntegration, ScmIntegrationsFactory } from '../types'; +import { + AwsCodeCommitIntegrationConfig, + readAwsCodeCommitIntegrationConfigs, +} from './config'; + +/** + * Integrates with AWS CodeCommit. + * + * @public + */ +export class AwsCodeCommitIntegration implements ScmIntegration { + static factory: ScmIntegrationsFactory = ({ + config, + }) => { + const configs = readAwsCodeCommitIntegrationConfigs( + config.getOptionalConfigArray('integrations.awsCodeCommit') ?? [], + ); + return basicIntegrations( + configs.map(c => new AwsCodeCommitIntegration(c)), + i => i.config.host, + ); + }; + + get type(): string { + return 'awsCodeCommit'; + } + + get config(): AwsCodeCommitIntegrationConfig { + return this.integrationConfig; + } + + get title(): string { + return this.integrationConfig.host; + } + + constructor( + private readonly integrationConfig: AwsCodeCommitIntegrationConfig, + ) {} + + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number | undefined; + }): string { + const resolved = defaultScmResolveUrl(options); + return resolved; + } + resolveEditUrl(url: string): string { + const parsedUrl = new URL(url); + const pathMatch = parsedUrl.pathname.match( + /^\/codesuite\/codecommit\/repositories\/([^\/]+)\//, + ); + if (!pathMatch) { + throw new Error(``); + } + const [, repositoryName] = pathMatch; + return replaceCodeCommitUrlType(url, repositoryName, 'edit'); + } +} + +/** + * Takes a CodeCommit URL and replaces the type part (blob, tree etc). + * + * @param url - The original URL + * @param type - The desired type, e.g. 'blob', 'edit' + * @public + */ +export function replaceCodeCommitUrlType( + url: string, + repositoryName: string, + type: 'browse' | 'edit', +): string { + const newString = type === 'edit' ? `files/edit` : type; + return url.replace( + new RegExp( + `\/codesuite\/codecommit\/repositories\/${repositoryName}\/(browse|files\/edit)\/`, + ), + `/codesuite/codecommit/repositories/${repositoryName}/${newString}/`, + ); +} diff --git a/packages/integration/src/awsCodeCommit/config.test.ts b/packages/integration/src/awsCodeCommit/config.test.ts new file mode 100644 index 0000000000..217485b45e --- /dev/null +++ b/packages/integration/src/awsCodeCommit/config.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { Config, ConfigReader } from '@backstage/config'; +import { + AwsCodeCommitIntegrationConfig, + readAwsCodeCommitIntegrationConfig, + readAwsCodeCommitIntegrationConfigs, +} from './config'; + +const AMAZON_AWS_CODECOMMIT_HOST = 'console.aws.amazon.com'; + +describe('readAwsCodeCommitIntegrationConfig', () => { + function buildConfig(data: Partial): Config { + return new ConfigReader(data); + } + + it('reads all values (default)', () => { + const output = readAwsCodeCommitIntegrationConfig(buildConfig({})); + expect(output).toEqual({ + host: AMAZON_AWS_CODECOMMIT_HOST, + }); + }); + + it('reads all values (custom entry)', () => { + const output = readAwsCodeCommitIntegrationConfig( + buildConfig({ + accessKeyId: 'fake-key', + secretAccessKey: 'fake-secret-key', + externalId: 'fake-external-id', + roleArn: 'fake-role-arn', + }), + ); + expect(output).toEqual({ + host: AMAZON_AWS_CODECOMMIT_HOST, + accessKeyId: 'fake-key', + secretAccessKey: 'fake-secret-key', + externalId: 'fake-external-id', + roleArn: 'fake-role-arn', + }); + }); +}); + +describe('readAwsCodeCommitIntegrationConfigs', () => { + function buildConfig( + data: Partial[], + ): Config[] { + return data.map(item => new ConfigReader(item)); + } + + it('reads all values', () => { + const output = readAwsCodeCommitIntegrationConfigs( + buildConfig([ + { + host: AMAZON_AWS_CODECOMMIT_HOST, + accessKeyId: 'key', + secretAccessKey: 'secret', + externalId: 'external-id', + roleArn: 'role-arn', + }, + ]), + ); + expect(output).toContainEqual({ + host: AMAZON_AWS_CODECOMMIT_HOST, + accessKeyId: 'key', + secretAccessKey: 'secret', + externalId: 'external-id', + roleArn: 'role-arn', + }); + }); + + it('adds a default entry when missing', () => { + const output = readAwsCodeCommitIntegrationConfigs(buildConfig([])); + expect(output).toEqual([ + { + host: AMAZON_AWS_CODECOMMIT_HOST, + }, + ]); + }); +}); diff --git a/packages/integration/src/awsCodeCommit/config.ts b/packages/integration/src/awsCodeCommit/config.ts new file mode 100644 index 0000000000..0ebd62519f --- /dev/null +++ b/packages/integration/src/awsCodeCommit/config.ts @@ -0,0 +1,100 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { Config } from '@backstage/config'; + +const AMAZON_AWS_CODECOMMIT_HOST = 'console.aws.amazon.com'; + +/** + * The configuration parameters for a single AWS CodeCommit provider. + * + * @public + */ +export type AwsCodeCommitIntegrationConfig = { + /** + * Host, git host derived from region + */ + host: string; + + /** + * (Optional) User access key id + */ + accessKeyId?: string; + + /** + * (Optional) User secret access key + */ + secretAccessKey?: string; + + /** + * (Optional) ARN of role to be assumed + */ + roleArn?: string; + + /** + * (Optional) External ID to use when assuming role + */ + externalId?: string; +}; + +/** + * Reads a single Aws CodeCommit integration config. + * + * @param config - The config object of a single integration + * @public + */ + +export function readAwsCodeCommitIntegrationConfig( + config: Config, +): AwsCodeCommitIntegrationConfig { + const accessKeyId = config.getOptionalString('accessKeyId'); + const secretAccessKey = config.getOptionalString('secretAccessKey')?.trim(); + const roleArn = config.getOptionalString('roleArn'); + const externalId = config.getOptionalString('externalId'); + const host = AMAZON_AWS_CODECOMMIT_HOST; + + return { + host, + accessKeyId, + secretAccessKey, + roleArn, + externalId, + }; +} + +/** + * Reads a set of AWS CodeCommit integration configs, and inserts some defaults for + * public Amazon AWS if not specified. + * + * @param configs - The config objects of the integrations + * @public + */ +export function readAwsCodeCommitIntegrationConfigs( + configs: Config[], +): AwsCodeCommitIntegrationConfig[] { + // First read all the explicit integrations + const result = configs.map(readAwsCodeCommitIntegrationConfig); + + // If no explicit console.aws.amazon.com integration was added, put one in the list as + // a convenience + if (!result.some(c => c.host === AMAZON_AWS_CODECOMMIT_HOST)) { + result.push({ + host: AMAZON_AWS_CODECOMMIT_HOST, + }); + } + + return result; +} diff --git a/packages/integration/src/awsCodeCommit/index.ts b/packages/integration/src/awsCodeCommit/index.ts new file mode 100644 index 0000000000..304abb2c91 --- /dev/null +++ b/packages/integration/src/awsCodeCommit/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { AwsCodeCommitIntegration } from './AwsCodeCommitIntegration'; +export { + readAwsCodeCommitIntegrationConfig, + readAwsCodeCommitIntegrationConfigs, +} from './config'; +export type { AwsCodeCommitIntegrationConfig } from './config'; diff --git a/packages/integration/src/index.ts b/packages/integration/src/index.ts index 700d7dc67c..b20477ee6a 100644 --- a/packages/integration/src/index.ts +++ b/packages/integration/src/index.ts @@ -21,6 +21,7 @@ */ export * from './awsS3'; +export * from './awsCodeCommit'; export * from './azure'; export * from './bitbucket'; export * from './bitbucketCloud'; diff --git a/packages/integration/src/registry.ts b/packages/integration/src/registry.ts index 7058ca1b5c..00706acc1c 100644 --- a/packages/integration/src/registry.ts +++ b/packages/integration/src/registry.ts @@ -16,6 +16,7 @@ import { ScmIntegration, ScmIntegrationsGroup } from './types'; import { AwsS3Integration } from './awsS3/AwsS3Integration'; +import { AwsCodeCommitIntegration } from './awsCodeCommit'; import { AzureIntegration } from './azure/AzureIntegration'; import { BitbucketCloudIntegration } from './bitbucketCloud/BitbucketCloudIntegration'; import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; @@ -33,6 +34,7 @@ import { GiteaIntegration } from './gitea/GiteaIntegration'; export interface ScmIntegrationRegistry extends ScmIntegrationsGroup { awsS3: ScmIntegrationsGroup; + awsCodeCommit: ScmIntegrationsGroup; azure: ScmIntegrationsGroup; /** * @deprecated in favor of `bitbucketCloud` and `bitbucketServer` diff --git a/yarn.lock b/yarn.lock index 54c9aa617f..ccdbdfa6a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -363,6 +363,55 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-codecommit@npm:^3.350.0": + version: 3.540.0 + resolution: "@aws-sdk/client-codecommit@npm:3.540.0" + dependencies: + "@aws-crypto/sha256-browser": 3.0.0 + "@aws-crypto/sha256-js": 3.0.0 + "@aws-sdk/client-sts": 3.540.0 + "@aws-sdk/core": 3.535.0 + "@aws-sdk/credential-provider-node": 3.540.0 + "@aws-sdk/middleware-host-header": 3.535.0 + "@aws-sdk/middleware-logger": 3.535.0 + "@aws-sdk/middleware-recursion-detection": 3.535.0 + "@aws-sdk/middleware-user-agent": 3.540.0 + "@aws-sdk/region-config-resolver": 3.535.0 + "@aws-sdk/types": 3.535.0 + "@aws-sdk/util-endpoints": 3.540.0 + "@aws-sdk/util-user-agent-browser": 3.535.0 + "@aws-sdk/util-user-agent-node": 3.535.0 + "@smithy/config-resolver": ^2.2.0 + "@smithy/core": ^1.4.0 + "@smithy/fetch-http-handler": ^2.5.0 + "@smithy/hash-node": ^2.2.0 + "@smithy/invalid-dependency": ^2.2.0 + "@smithy/middleware-content-length": ^2.2.0 + "@smithy/middleware-endpoint": ^2.5.0 + "@smithy/middleware-retry": ^2.2.0 + "@smithy/middleware-serde": ^2.3.0 + "@smithy/middleware-stack": ^2.2.0 + "@smithy/node-config-provider": ^2.3.0 + "@smithy/node-http-handler": ^2.5.0 + "@smithy/protocol-http": ^3.3.0 + "@smithy/smithy-client": ^2.5.0 + "@smithy/types": ^2.12.0 + "@smithy/url-parser": ^2.2.0 + "@smithy/util-base64": ^2.3.0 + "@smithy/util-body-length-browser": ^2.2.0 + "@smithy/util-body-length-node": ^2.3.0 + "@smithy/util-defaults-mode-browser": ^2.2.0 + "@smithy/util-defaults-mode-node": ^2.3.0 + "@smithy/util-endpoints": ^1.2.0 + "@smithy/util-middleware": ^2.2.0 + "@smithy/util-retry": ^2.2.0 + "@smithy/util-utf8": ^2.3.0 + tslib: ^2.6.2 + uuid: ^9.0.1 + checksum: 4ccc931eb2ce93fb7640d2006c38cfe59d9d89a8cb04a9e59d3a6a4e62f1bc7e67a737612fe79423c2da45605fa4cd02ec60087a11135fe6210681f7b28138d7 + languageName: node + linkType: hard + "@aws-sdk/client-cognito-identity@npm:3.540.0": version: 3.540.0 resolution: "@aws-sdk/client-cognito-identity@npm:3.540.0" @@ -3271,6 +3320,7 @@ __metadata: resolution: "@backstage/backend-common@workspace:packages/backend-common" dependencies: "@aws-sdk/abort-controller": ^3.347.0 + "@aws-sdk/client-codecommit": ^3.350.0 "@aws-sdk/client-s3": ^3.350.0 "@aws-sdk/credential-providers": ^3.350.0 "@aws-sdk/types": ^3.347.0 From 5144e65094feacfb715e508cb403cfe83aefcef7 Mon Sep 17 00:00:00 2001 From: "Stijn Brouwers (EISMEA)" Date: Sun, 24 Mar 2024 09:32:36 +0100 Subject: [PATCH 2/4] bugfix(backend-common): Run prettier Signed-off-by: Stijn Brouwers (EISMEA) --- .../__fixtures__/awsCodeCommit/awsCodeCommit-mock-object.yaml | 2 +- .../awsCodeCommit/subFolder/awsCodeCommit-mock-object2.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend-common/src/reading/__fixtures__/awsCodeCommit/awsCodeCommit-mock-object.yaml b/packages/backend-common/src/reading/__fixtures__/awsCodeCommit/awsCodeCommit-mock-object.yaml index 21213863bc..7470c0e8a3 100644 --- a/packages/backend-common/src/reading/__fixtures__/awsCodeCommit/awsCodeCommit-mock-object.yaml +++ b/packages/backend-common/src/reading/__fixtures__/awsCodeCommit/awsCodeCommit-mock-object.yaml @@ -1 +1 @@ -site_name: Test \ No newline at end of file +site_name: Test diff --git a/packages/backend-common/src/reading/__fixtures__/awsCodeCommit/subFolder/awsCodeCommit-mock-object2.yaml b/packages/backend-common/src/reading/__fixtures__/awsCodeCommit/subFolder/awsCodeCommit-mock-object2.yaml index 00ceaa8fbc..b2c132961b 100644 --- a/packages/backend-common/src/reading/__fixtures__/awsCodeCommit/subFolder/awsCodeCommit-mock-object2.yaml +++ b/packages/backend-common/src/reading/__fixtures__/awsCodeCommit/subFolder/awsCodeCommit-mock-object2.yaml @@ -1 +1 @@ -site_name: Test2 \ No newline at end of file +site_name: Test2 From 17b52de3aee8dcd3a076298e776cb8ff340ccaf4 Mon Sep 17 00:00:00 2001 From: "Stijn Brouwers (EISMEA)" Date: Sun, 24 Mar 2024 09:35:40 +0100 Subject: [PATCH 3/4] bugfix(integration-react): Fix unit test Signed-off-by: Stijn Brouwers (EISMEA) --- packages/integration-react/src/api/ScmIntegrationsApi.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/integration-react/src/api/ScmIntegrationsApi.test.ts b/packages/integration-react/src/api/ScmIntegrationsApi.test.ts index fb53bbdb12..0a1f1b1b8f 100644 --- a/packages/integration-react/src/api/ScmIntegrationsApi.test.ts +++ b/packages/integration-react/src/api/ScmIntegrationsApi.test.ts @@ -26,6 +26,6 @@ describe('scmIntegrationsApiRef', () => { it('should be instantiated', () => { const i = ScmIntegrationsApi.fromConfig(new ConfigReader({})); - expect(i.list().length).toBe(6); // The default ones + expect(i.list().length).toBe(7); // The default ones }); }); From 7677fbd435d5b1e923af2a3d57d4add861692f58 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 1 Apr 2024 15:19:20 +0200 Subject: [PATCH 4/4] Update .changeset/ten-schools-leave.md Signed-off-by: Patrik Oldsberg --- .changeset/ten-schools-leave.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/ten-schools-leave.md b/.changeset/ten-schools-leave.md index 0f4fa06e60..39d19e9d02 100644 --- a/.changeset/ten-schools-leave.md +++ b/.changeset/ten-schools-leave.md @@ -1,5 +1,5 @@ --- -'@backstage/backend-common': minor +'@backstage/backend-common': patch '@backstage/integration': minor ---