diff --git a/packages/aws-cdk-lib/aws-codepipeline-actions/lib/codeconnections/source-action.ts b/packages/aws-cdk-lib/aws-codepipeline-actions/lib/codeconnections/source-action.ts new file mode 100644 index 0000000000000..1bf1efd831096 --- /dev/null +++ b/packages/aws-cdk-lib/aws-codepipeline-actions/lib/codeconnections/source-action.ts @@ -0,0 +1,165 @@ +import { Construct } from 'constructs'; +import * as codepipeline from '../../../aws-codepipeline'; +import * as iam from '../../../aws-iam'; +import { Action } from '../action'; +import { sourceArtifactBounds } from '../common'; + +/** + * The CodePipeline variables emitted by CodeStar source Action. + */ +export interface CodeConnectionsSourceVariables { + /** The name of the repository this action points to. */ + readonly fullRepositoryName: string; + /** The name of the branch this action tracks. */ + readonly branchName: string; + /** The date the currently last commit on the tracked branch was authored, in ISO-8601 format. */ + readonly authorDate: string; + /** The SHA1 hash of the currently last commit on the tracked branch. */ + readonly commitId: string; + /** The message of the currently last commit on the tracked branch. */ + readonly commitMessage: string; + /** The connection ARN this source uses. */ + readonly connectionArn: string; +} + +/** + * Construction properties for `CodeStarConnectionsSourceAction`. + */ +export interface CodeConnectionsSourceActionProps extends codepipeline.CommonAwsActionProps { + /** + * The output artifact that this action produces. + * Can be used as input for further pipeline actions. + */ + readonly output: codepipeline.Artifact; + + /** + * The ARN of the CodeStar Connection created in the AWS console + * that has permissions to access this GitHub or BitBucket repository. + * + * @example 'arn:aws:codestar-connections:us-east-1:123456789012:connection/12345678-abcd-12ab-34cdef5678gh' + * @see https://docs.aws.amazon.com/codepipeline/latest/userguide/connections-create.html + */ + readonly connectionArn: string; + + /** + * The owning user or organization of the repository. + * + * @example 'aws' + */ + readonly owner: string; + + /** + * The name of the repository. + * + * @example 'aws-cdk' + */ + readonly repo: string; + + /** + * The branch to build. + * + * @default 'master' + */ + readonly branch?: string; + + // long URL in @see + /** + * Whether the output should be the contents of the repository + * (which is the default), + * or a link that allows CodeBuild to clone the repository before building. + * + * **Note**: if this option is true, + * then only CodeBuild actions can use the resulting `output`. + * + * @default false + * @see https://docs.aws.amazon.com/codepipeline/latest/userguide/action-reference-CodestarConnectionSource.html#action-reference-CodestarConnectionSource-config + */ + readonly codeBuildCloneOutput?: boolean; + + /** + * Controls automatically starting your pipeline when a new commit + * is made on the configured repository and branch. If unspecified, + * the default value is true, and the field does not display by default. + * + * @default true + * @see https://docs.aws.amazon.com/codepipeline/latest/userguide/action-reference-CodestarConnectionSource.html + */ + readonly triggerOnPush?: boolean; +} + +/** + * A CodePipeline source action for the CodeStar Connections source, + * which allows connecting to GitHub and BitBucket. + */ +export class CodeConnectionsSourceAction extends Action { + /** + * The name of the property that holds the ARN of the CodeStar Connection + * inside of the CodePipeline Artifact's metadata. + * + * @internal + */ + public static readonly _CONNECTION_ARN_PROPERTY = 'CodeConnectionsArnProperty'; + + private readonly props: CodeConnectionsSourceActionProps; + + constructor(props: CodeConnectionsSourceActionProps) { + super({ + ...props, + category: codepipeline.ActionCategory.SOURCE, + owner: 'AWS', // because props also has a (different!) owner property! + provider: 'CodeConnections', + artifactBounds: sourceArtifactBounds(), + outputs: [props.output], + }); + + this.props = props; + } + + /** The variables emitted by this action. */ + public get variables(): CodeConnectionsSourceVariables { + return { + fullRepositoryName: this.variableExpression('FullRepositoryName'), + branchName: this.variableExpression('BranchName'), + authorDate: this.variableExpression('AuthorDate'), + commitId: this.variableExpression('CommitId'), + commitMessage: this.variableExpression('CommitMessage'), + connectionArn: this.variableExpression('ConnectionArn'), + }; + } + + protected bound(_scope: Construct, _stage: codepipeline.IStage, options: codepipeline.ActionBindOptions): codepipeline.ActionConfig { + // https://docs.aws.amazon.com/codepipeline/latest/userguide/security-iam.html#how-to-update-role-new-services + options.role.addToPrincipalPolicy(new iam.PolicyStatement({ + actions: [ + 'codeconnections:UseConnection', + ], + resources: [ + this.props.connectionArn, + ], + })); + + // the action needs to write the output to the pipeline bucket + options.bucket.grantReadWrite(options.role); + options.bucket.grantPutAcl(options.role); + + // if codeBuildCloneOutput is true, + // save the connectionArn in the Artifact instance + // to be read by the CodeBuildAction later + if (this.props.codeBuildCloneOutput === true) { + this.props.output.setMetadata(CodeConnectionsSourceAction._CONNECTION_ARN_PROPERTY, + this.props.connectionArn); + } + + return { + configuration: { + ConnectionArn: this.props.connectionArn, + FullRepositoryId: `${this.props.owner}/${this.props.repo}`, + BranchName: this.props.branch ?? 'master', + OutputArtifactFormat: this.props.codeBuildCloneOutput === true + ? 'CODEBUILD_CLONE_REF' + : undefined, + DetectChanges: this.props.triggerOnPush, + }, + }; + } +} diff --git a/packages/aws-cdk-lib/aws-codepipeline-actions/lib/index.ts b/packages/aws-cdk-lib/aws-codepipeline-actions/lib/index.ts index e82e34232e931..db2d8a6358d8a 100644 --- a/packages/aws-cdk-lib/aws-codepipeline-actions/lib/index.ts +++ b/packages/aws-cdk-lib/aws-codepipeline-actions/lib/index.ts @@ -1,6 +1,7 @@ export * from './alexa-ask/deploy-action'; export * from './bitbucket/source-action'; export * from './codestar-connections/source-action'; +export * from './codeconnections/source-action'; export * from './cloudformation'; export * from './codebuild/build-action'; export * from './codecommit/source-action'; diff --git a/packages/aws-cdk-lib/aws-codepipeline-actions/test/codepipeline-actions/codepipeline-source-actions.test.ts b/packages/aws-cdk-lib/aws-codepipeline-actions/test/codepipeline-actions/codepipeline-source-actions.test.ts new file mode 100644 index 0000000000000..b909ac6fc268e --- /dev/null +++ b/packages/aws-cdk-lib/aws-codepipeline-actions/test/codepipeline-actions/codepipeline-source-actions.test.ts @@ -0,0 +1,113 @@ +import { Stack } from 'aws-cdk-lib'; +import { Artifact } from 'aws-cdk-lib/aws-codepipeline'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import { CodeConnectionsSourceAction, CodeConnectionsSourceActionProps } from '../../lib'; + +describe('CodeConnectionsSourceAction', () => { + test('requires connectionArn', () => { + const stack = new Stack(); + + expect(() => { + new CodeConnectionsSourceAction({ + actionName: 'GitHub_Source', + owner: 'aws', + repo: 'aws-cdk', + output: new Artifact(), + } as CodeConnectionsSourceActionProps); // Type assertion to bypass type check for missing props + }).toThrow(/connectionArn is required/); + }); + + test('creates correct configuration', () => { + const stack = new Stack(); + const action = new CodeConnectionsSourceAction({ + actionName: 'GitHub_Source', + connectionArn: 'arn:aws:codestar-connections:us-east-1:123456789012:connection/12345678-abcd-12ab-34cdef5678gh', + owner: 'aws', + repo: 'aws-cdk', + output: new Artifact(), + }); + + expect(action).toBeDefined(); + const config = action.bind(stack, { + role: new iam.Role(stack, 'Role', { + assumedBy: new iam.ServicePrincipal('codepipeline.amazonaws.com'), + }), + bucket: new s3.Bucket(stack, 'PipelineBucket') + }); + + expect(config.configuration.ConnectionArn).toEqual('arn:aws:codestar-connections:us-east-1:123456789012:connection/12345678-abcd-12ab-34cdef5678gh'); + expect(config.configuration.FullRepositoryId).toEqual('aws/aws-cdk'); + expect(config.configuration.BranchName).toEqual('master'); + }); + + test('creates correct configuration with custom branch', () => { + const stack = new Stack(); + const action = new CodeConnectionsSourceAction({ + actionName: 'GitHub_Source', + connectionArn: 'arn:aws:codestar-connections:us-east-1:123456789012:connection/12345678-abcd-12ab-34cdef5678gh', + owner: 'aws', + repo: 'aws-cdk', + output: new Artifact(), + branch: 'develop', + }); + + expect(action).toBeDefined(); + const config = action.bind(stack, { + role: new iam.Role(stack, 'Role', { + assumedBy: new iam.ServicePrincipal('codepipeline.amazonaws.com'), + }), + bucket: new s3.Bucket(stack, 'PipelineBucket') + }); + + expect(config.configuration.BranchName).toEqual('develop'); + }); + + test('handles codeBuildCloneOutput option', () => { + const stack = new Stack(); + const outputArtifact = new Artifact(); + const action = new CodeConnectionsSourceAction({ + actionName: 'GitHub_Source', + connectionArn: 'arn:aws:codestar-connections:us-east-1:123456789012:connection/12345678-abcd-12ab-34cdef5678gh', + owner: 'aws', + repo: 'aws-cdk', + output: outputArtifact, + codeBuildCloneOutput: true, + }); + + expect(action).toBeDefined(); + const config = action.bind(stack, { + role: new iam.Role(stack, 'Role', { + assumedBy: new iam.ServicePrincipal('codepipeline.amazonaws.com'), + }), + bucket: new s3.Bucket(stack, 'PipelineBucket') + }); + + expect(config.configuration.OutputArtifactFormat).toEqual('CODEBUILD_CLONE_REF'); + + // Indirect way to verify that the metadata was set + const outputArtifactMetadata = (outputArtifact as any)._metadata; // Type assertion to access private member for test purposes + expect(outputArtifactMetadata[CodeConnectionsSourceAction._CONNECTION_ARN_PROPERTY]).toEqual('arn:aws:codestar-connections:us-east-1:123456789012:connection/12345678-abcd-12ab-34cdef5678gh'); + }); + + test('default triggerOnPush is true', () => { + const stack = new Stack(); + const action = new CodeConnectionsSourceAction({ + actionName: 'GitHub_Source', + connectionArn: 'arn:aws:codestar-connections:us-east-1:123456789012:connection/12345678-abcd-12ab-34cdef5678gh', + owner: 'aws', + repo: 'aws-cdk', + output: new Artifact(), + }); + + expect(action).toBeDefined(); + const config = action.bind(stack, { + role: new iam.Role(stack, 'Role', { + assumedBy: new iam.ServicePrincipal('codepipeline.amazonaws.com'), + }), + bucket: new s3.Bucket(stack, 'PipelineBucket') + }); + + expect(config.configuration.DetectChanges).toEqual(true); + }); +}); diff --git a/packages/aws-cdk-lib/aws-codepipeline-actions/test/codepipeline-connection/codepipelne-connections-source-action.test.ts b/packages/aws-cdk-lib/aws-codepipeline-actions/test/codepipeline-connection/codepipelne-connections-source-action.test.ts new file mode 100644 index 0000000000000..743e8e95e6558 --- /dev/null +++ b/packages/aws-cdk-lib/aws-codepipeline-actions/test/codepipeline-connection/codepipelne-connections-source-action.test.ts @@ -0,0 +1,114 @@ +import { Stack } from 'aws-cdk-lib'; +import { Artifact } from 'aws-cdk-lib/aws-codepipeline'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import { CodeConnectionsSourceAction, CodeConnectionsSourceActionProps } from '../../lib'; + +describe('CodeConnectionsSourceAction', () => { + + test('requires connectionArn', () => { + const stack = new Stack(); + + expect(() => { + new CodeConnectionsSourceAction({ + actionName: 'GitHub_Source', + owner: 'aws', + repo: 'aws-cdk', + output: new Artifact(), + } as CodeConnectionsSourceActionProps); + }).toThrow(/connectionArn is required/); + }); + + test('creates correct configuration', () => { + const stack = new Stack(); + const action = new CodeConnectionsSourceAction({ + actionName: 'GitHub_Source', + connectionArn: 'arn:aws:codestar-connections:us-east-1:123456789012:connection/12345678-abcd-12ab-34cdef5678gh', + owner: 'aws', + repo: 'aws-cdk', + output: new Artifact(), + }); + + expect(action).toBeDefined(); + const config = action.bind(stack, { + role: new iam.Role(stack, 'Role', { + assumedBy: new iam.ServicePrincipal('codepipeline.amazonaws.com'), + }), + bucket: new s3.Bucket(stack, 'PipelineBucket'), + }); + + expect(config.configuration.ConnectionArn).toEqual('arn:aws:codestar-connections:us-east-1:123456789012:connection/12345678-abcd-12ab-34cdef5678gh'); + expect(config.configuration.FullRepositoryId).toEqual('aws/aws-cdk'); + expect(config.configuration.BranchName).toEqual('master'); + }); + + test('creates correct configuration with custom branch', () => { + const stack = new Stack(); + const action = new CodeConnectionsSourceAction({ + actionName: 'GitHub_Source', + connectionArn: 'arn:aws:codestar-connections:us-east-1:123456789012:connection/12345678-abcd-12ab-34cdef5678gh', + owner: 'aws', + repo: 'aws-cdk', + output: new Artifact(), + branch: 'develop', + }); + + expect(action).toBeDefined(); + const config = action.bind(stack, { + role: new iam.Role(stack, 'Role', { + assumedBy: new iam.ServicePrincipal('codepipeline.amazonaws.com'), + }), + bucket: new s3.Bucket(stack, 'PipelineBucket'), + }); + + expect(config.configuration.BranchName).toEqual('develop'); + }); + + test('handles codeBuildCloneOutput option', () => { + const stack = new Stack(); + const outputArtifact = new Artifact(); + const action = new CodeConnectionsSourceAction({ + actionName: 'GitHub_Source', + connectionArn: 'arn:aws:codestar-connections:us-east-1:123456789012:connection/12345678-abcd-12ab-34cdef5678gh', + owner: 'aws', + repo: 'aws-cdk', + output: outputArtifact, + codeBuildCloneOutput: true, + }); + + expect(action).toBeDefined(); + const config = action.bind(stack, { + role: new iam.Role(stack, 'Role', { + assumedBy: new iam.ServicePrincipal('codepipeline.amazonaws.com'), + }), + bucket: new s3.Bucket(stack, 'PipelineBucket'), + }); + + expect(config.configuration.OutputArtifactFormat).toEqual('CODEBUILD_CLONE_REF'); + + const outputArtifactMetadata = (outputArtifact as any)._metadata; + expect(outputArtifactMetadata[CodeConnectionsSourceAction._CONNECTION_ARN_PROPERTY]).toEqual('arn:aws:codestar-connections:us-east-1:123456789012:connection/12345678-abcd-12ab-34cdef5678gh'); + }); + + test('default triggerOnPush is true', () => { + const stack = new Stack(); + const action = new CodeConnectionsSourceAction({ + actionName: 'GitHub_Source', + connectionArn: 'arn:aws:codestar-connections:us-east-1:123456789012:connection/12345678-abcd-12ab-34cdef5678gh', + owner: 'aws', + repo: 'aws-cdk', + output: new Artifact(), + }); + + expect(action).toBeDefined(); + const config = action.bind(stack, { + role: new iam.Role(stack, 'Role', { + assumedBy: new iam.ServicePrincipal('codepipeline.amazonaws.com'), + }), + bucket: new s3.Bucket(stack, 'PipelineBucket'), + }); + + expect(config.configuration.DetectChanges).toEqual(true); + }); + +}); diff --git a/packages/aws-cdk-lib/pipelines/lib/codepipeline/codepipeline-source.ts b/packages/aws-cdk-lib/pipelines/lib/codepipeline/codepipeline-source.ts index ee954e493e3fd..4b8cf98efa812 100644 --- a/packages/aws-cdk-lib/pipelines/lib/codepipeline/codepipeline-source.ts +++ b/packages/aws-cdk-lib/pipelines/lib/codepipeline/codepipeline-source.ts @@ -111,6 +111,19 @@ export abstract class CodePipelineSource extends Step implements ICodePipelineAc return new CodeStarConnectionSource(repoString, branch, props); } + /** + * Returns a CodeConnection source. + * + * @param repoString A string that encodes owner and repository separated by a slash (e.g. 'owner/repo'). The provided string must be resolvable at runtime. + * @param branch The branch to use. + * @param props The source properties, including the connection ARN. + * + * @see https://docs.aws.amazon.com/codepipeline/latest/userguide/connections-create.html + */ + public static codeConnection(repoString: string, branch: string, props: CodeConnectionsSourceOptions): CodePipelineSource { + return new CodeConnectionsSource(repoString, branch, props); + } + /** * Returns a CodeCommit source. * @@ -469,6 +482,102 @@ class CodeStarConnectionSource extends CodePipelineSource { } } +/** + * Configuration options for CodeConnection source + */ +export interface CodeConnectionsSourceOptions { + /** + * The ARN of the CodeConnection created in the AWS console + * that has permissions to access this GitHub or BitBucket repository. + * + * @example 'arn:aws:codeconnection:us-east-1:123456789012:connection/12345678-abcd-12ab-34cdef5678gh' + * @see https://docs.aws.amazon.com/codepipeline/latest/userguide/connections-create.html + */ + readonly connectionArn: string; + + /** + * If this is set, the next CodeBuild job clones the repository (instead of CodePipeline downloading the files). + * + * This provides access to repository history, and retains symlinks (symlinks would otherwise be + * removed by CodePipeline). + * + * **Note**: if this option is true, only CodeBuild jobs can use the output artifact. + * + * @default false + * @see https://docs.aws.amazon.com/codepipeline/latest/userguide/action-reference-CodeConnectionSource.html#action-reference-CodeConnectionSource-config + */ + readonly codeBuildCloneOutput?: boolean; + + /** + * Controls automatically starting your pipeline when a new commit + * is made on the configured repository and branch. If unspecified, + * the default value is true, and the field does not display by default. + * + * @default true + * @see https://docs.aws.amazon.com/codepipeline/latest/userguide/action-reference-CodeConnectionSource.html + */ + readonly triggerOnPush?: boolean; + + /** + * The action name used for this source in the CodePipeline + * + * @default - The repository string + */ + readonly actionName?: string; +} + +class CodeConnectionsSource extends CodePipelineSource { + private readonly owner: string; + private readonly repo: string; + + constructor(repoString: string, readonly branch: string, readonly props: CodeConnectionsSourceOptions) { + super(repoString); + + if (!this.isValidRepoString(repoString)) { + throw new Error(`CodeConnection repository name should be a resolved string like '/' or '///.../', got '${repoString}'`); + } + + const parts = repoString.split('/'); + + this.owner = parts[0]; + this.repo = parts.slice(1).join('/'); + this.configurePrimaryOutput(new FileSet('Source', this)); + } + + private isValidRepoString(repoString: string) { + if (Token.isUnresolved(repoString)) { + return false; + } + + const parts = repoString.split('/'); + + // minimum length is 2 (owner/repo) and + // maximum length is 22 (owner/parent group/twenty sub groups/repo). + // maximum length is based on limitation of GitLab, see https://docs.gitlab.com/ee/user/group/subgroups/ + if (parts.length < 2 || parts.length > 23) { + return false; + } + + // check if all element in parts is not empty + return parts.every(element => element !== ''); + } + + protected getAction(output: Artifact, actionName: string, runOrder: number, variablesNamespace?: string) { + return new cp_actions.CodeConnectionsSourceAction({ + output, + actionName: this.props.actionName ?? actionName, + runOrder, + connectionArn: this.props.connectionArn, + owner: this.owner, + repo: this.repo, + branch: this.branch, + codeBuildCloneOutput: this.props.codeBuildCloneOutput, + triggerOnPush: this.props.triggerOnPush, + variablesNamespace, + }); + } +} + /** * Configuration options for a CodeCommit source */