packages/aws-cdk-lib/aws-codepipeline/lib/pipeline.ts (855 lines of code) (raw):

import { Construct } from 'constructs'; import { ActionCategory, IAction, IPipeline, IStage, PipelineNotificationEvents, PipelineNotifyOnOptions, } from './action'; import { CfnPipeline } from './codepipeline.generated'; import { CrossRegionSupportConstruct, CrossRegionSupportStack } from './private/cross-region-support-stack'; import { FullActionDescriptor } from './private/full-action-descriptor'; import { RichAction } from './private/rich-action'; import { Stage } from './private/stage'; import { validateName, validateNamespaceName, validateSourceAction } from './private/validation'; import { Rule } from './rule'; import { Trigger, TriggerProps } from './trigger'; import { Variable } from './variable'; import * as notifications from '../../aws-codestarnotifications'; import * as events from '../../aws-events'; import * as iam from '../../aws-iam'; import * as kms from '../../aws-kms'; import * as s3 from '../../aws-s3'; import { Annotations, ArnFormat, BootstraplessSynthesizer, DefaultStackSynthesizer, FeatureFlags, IStackSynthesizer, Lazy, Names, PhysicalName, RemovalPolicy, Resource, Stack, Stage as CdkStage, Token, ValidationError, } from '../../core'; import { addConstructMetadata, MethodMetadata } from '../../core/lib/metadata-resource'; import * as cxapi from '../../cx-api'; /** * Allows you to control where to place a new Stage when it's added to the Pipeline. * Note that you can provide only one of the below properties - * specifying more than one will result in a validation error. * * @see #rightBefore * @see #justAfter */ export interface StagePlacement { /** * Inserts the new Stage as a parent of the given Stage * (changing its current parent Stage, if it had one). */ readonly rightBefore?: IStage; /** * Inserts the new Stage as a child of the given Stage * (changing its current child Stage, if it had one). */ readonly justAfter?: IStage; } /** * The condition for the stage. * * A condition is made up of the rules and the result for the condition. */ export interface Condition { /** * The rules that make up the condition. * * @default - No rules are applied */ readonly rules?: Rule[]; /** * The action to be done when the condition is met. * * @default - No result action is taken */ readonly result?: Result; } /** * The conditions for making checks for the stage. */ export interface Conditions { /** * The conditions that are configured as entry conditions, making check to succeed the stage, or fail the stage. * * @default - No conditions are configured */ readonly conditions?: Condition[]; } /** * The configuration that specifies the result, such as rollback, to occur upon stage failure. */ export interface FailureConditions extends Conditions { /** * The specified result for when the failure conditions are met, such as rolling back the stage. * * @default FAIL */ readonly result?: Result; /** * The method that you want to configure for automatic stage retry on stage failure. * * @default ALL_ACTIONS */ readonly retryMode?: RetryMode; } /** * Construction properties of a Pipeline Stage. */ export interface StageProps { /** * The physical, human-readable name to assign to this Pipeline Stage. */ readonly stageName: string; /** * The list of Actions to create this Stage with. * You can always add more Actions later by calling `IStage#addAction`. */ readonly actions?: IAction[]; /** * Whether to enable transition to this stage. * * @default true */ readonly transitionToEnabled?: boolean; /** * The reason for disabling transition to this stage. Only applicable * if `transitionToEnabled` is set to `false`. * * @default 'Transition disabled' */ readonly transitionDisabledReason?: string; /** * The method to use when a stage allows entry. * * @default - No conditions are applied before stage entry */ readonly beforeEntry?: Conditions; /** * The method to use when a stage has not completed successfully. * * @default - No failure conditions are applied */ readonly onFailure?: FailureConditions; /** * The method to use when a stage has succeeded. * * @default - No success conditions are applied */ readonly onSuccess?: Conditions; } export interface StageOptions extends StageProps { readonly placement?: StagePlacement; } /** * The action to be done when the condition is met. */ export enum Result { /** * Rollback */ ROLLBACK = 'ROLLBACK', /** * Failure */ FAIL = 'FAIL', /** * Retry */ RETRY = 'RETRY', /** * Skip */ SKIP = 'SKIP', } /** * The method that you want to configure for automatic stage retry on stage failure. * You can specify to retry only failed action in the stage or all actions in the stage. */ export enum RetryMode { /** * Retry all actions under this stage */ ALL_ACTIONS = 'ALL_ACTIONS', /** * Only retry failed actions */ FAILED_ACTIONS = 'FAILED_ACTIONS', } /** * Pipeline types. */ export enum PipelineType { /** * V1 type */ V1 = 'V1', /** * V2 type */ V2 = 'V2', } /** * Execution mode. */ export enum ExecutionMode { /** * QUEUED mode. * * Executions are processed one by one in the order that they are queued. * * This requires pipeline type V2. */ QUEUED = 'QUEUED', /** * SUPERSEDED mode. * * A more recent execution can overtake an older one. * * This is the default. */ SUPERSEDED = 'SUPERSEDED', /** * PARALLEL mode. * * In PARALLEL mode, executions run simultaneously and independently of one * another. Executions don't wait for other runs to complete before starting * or finishing. * * This requires pipeline type V2. */ PARALLEL = 'PARALLEL', } export interface PipelineProps { /** * The S3 bucket used by this Pipeline to store artifacts. * * @default - A new S3 bucket will be created. */ readonly artifactBucket?: s3.IBucket; /** * The IAM role to be assumed by this Pipeline. * * @default a new IAM role will be created. */ readonly role?: iam.IRole; /** * Indicates whether to rerun the AWS CodePipeline pipeline after you update it. * * @default false */ readonly restartExecutionOnUpdate?: boolean; /** * Name of the pipeline. * * @default - AWS CloudFormation generates an ID and uses that for the pipeline name. */ readonly pipelineName?: string; /** * A map of region to S3 bucket name used for cross-region CodePipeline. * For every Action that you specify targeting a different region than the Pipeline itself, * if you don't provide an explicit Bucket for that region using this property, * the construct will automatically create a Stack containing an S3 Bucket in that region. * * @default - None. */ readonly crossRegionReplicationBuckets?: { [region: string]: s3.IBucket }; /** * The list of Stages, in order, * to create this Pipeline with. * You can always add more Stages later by calling `Pipeline#addStage`. * * @default - None. */ readonly stages?: StageProps[]; /** * Create KMS keys for cross-account deployments. * * This controls whether the pipeline is enabled for cross-account deployments. * * By default cross-account deployments are enabled, but this feature requires * that KMS Customer Master Keys are created which have a cost of $1/month. * * If you do not need cross-account deployments, you can set this to `false` to * not create those keys and save on that cost (the artifact bucket will be * encrypted with an AWS-managed key). However, cross-account deployments will * no longer be possible. * * @default false - false if the feature flag `CODEPIPELINE_CROSS_ACCOUNT_KEYS_DEFAULT_VALUE_TO_FALSE` * is true, true otherwise */ readonly crossAccountKeys?: boolean; /** * Enable KMS key rotation for the generated KMS keys. * * By default KMS key rotation is disabled, but will add an additional $1/month * for each year the key exists when enabled. * * @default - false (key rotation is disabled) */ readonly enableKeyRotation?: boolean; /** * Reuse the same cross region support stack for all pipelines in the App. * * @default - true (Use the same support stack for all pipelines in App) */ readonly reuseCrossRegionSupportStacks?: boolean; /** * Type of the pipeline. * * @default - PipelineType.V2 if the feature flag `CODEPIPELINE_DEFAULT_PIPELINE_TYPE_TO_V2` * is true, PipelineType.V1 otherwise * * @see https://docs.aws.amazon.com/codepipeline/latest/userguide/pipeline-types-planning.html */ readonly pipelineType?: PipelineType; /** * A list that defines the pipeline variables for a pipeline resource. * * `variables` can only be used when `pipelineType` is set to `PipelineType.V2`. * You can always add more variables later by calling `Pipeline#addVariable`. * * @default - No variables */ readonly variables?: Variable[]; /** * The trigger configuration specifying a type of event, such as Git tags, that * starts the pipeline. * * When a trigger configuration is specified, default change detection for repository * and branch commits is disabled. * * `triggers` can only be used when `pipelineType` is set to `PipelineType.V2`. * You can always add more triggers later by calling `Pipeline#addTrigger`. * * @default - No triggers */ readonly triggers?: TriggerProps[]; /** * The method that the pipeline will use to handle multiple executions. * * @default - ExecutionMode.SUPERSEDED */ readonly executionMode?: ExecutionMode; /** * Use pipeline service role for actions if no action role configured * * @default - false */ readonly usePipelineRoleForActions?: boolean; } abstract class PipelineBase extends Resource implements IPipeline { public abstract readonly pipelineName: string; public abstract readonly pipelineArn: string; /** * Defines an event rule triggered by this CodePipeline. * * @param id Identifier for this event handler. * @param options Additional options to pass to the event rule. */ public onEvent(id: string, options: events.OnEventOptions = {}): events.Rule { const rule = new events.Rule(this, id, options); rule.addTarget(options.target); rule.addEventPattern({ source: ['aws.codepipeline'], resources: [this.pipelineArn], }); return rule; } /** * Defines an event rule triggered by the "CodePipeline Pipeline Execution * State Change" event emitted from this pipeline. * * @param id Identifier for this event handler. * @param options Additional options to pass to the event rule. */ public onStateChange(id: string, options: events.OnEventOptions = {}): events.Rule { const rule = this.onEvent(id, options); rule.addEventPattern({ detailType: ['CodePipeline Pipeline Execution State Change'], }); return rule; } public bindAsNotificationRuleSource(_scope: Construct): notifications.NotificationRuleSourceConfig { return { sourceArn: this.pipelineArn, }; } public notifyOn( id: string, target: notifications.INotificationRuleTarget, options: PipelineNotifyOnOptions, ): notifications.INotificationRule { return new notifications.NotificationRule(this, id, { ...options, source: this, targets: [target], }); } public notifyOnExecutionStateChange( id: string, target: notifications.INotificationRuleTarget, options?: notifications.NotificationRuleOptions, ): notifications.INotificationRule { return this.notifyOn(id, target, { ...options, events: [ PipelineNotificationEvents.PIPELINE_EXECUTION_FAILED, PipelineNotificationEvents.PIPELINE_EXECUTION_CANCELED, PipelineNotificationEvents.PIPELINE_EXECUTION_STARTED, PipelineNotificationEvents.PIPELINE_EXECUTION_RESUMED, PipelineNotificationEvents.PIPELINE_EXECUTION_SUCCEEDED, PipelineNotificationEvents.PIPELINE_EXECUTION_SUPERSEDED, ], }); } public notifyOnAnyStageStateChange( id: string, target: notifications.INotificationRuleTarget, options?: notifications.NotificationRuleOptions, ): notifications.INotificationRule { return this.notifyOn(id, target, { ...options, events: [ PipelineNotificationEvents.STAGE_EXECUTION_CANCELED, PipelineNotificationEvents.STAGE_EXECUTION_FAILED, PipelineNotificationEvents.STAGE_EXECUTION_RESUMED, PipelineNotificationEvents.STAGE_EXECUTION_STARTED, PipelineNotificationEvents.STAGE_EXECUTION_SUCCEEDED, ], }); } public notifyOnAnyActionStateChange( id: string, target: notifications.INotificationRuleTarget, options?: notifications.NotificationRuleOptions, ): notifications.INotificationRule { return this.notifyOn(id, target, { ...options, events: [ PipelineNotificationEvents.ACTION_EXECUTION_CANCELED, PipelineNotificationEvents.ACTION_EXECUTION_FAILED, PipelineNotificationEvents.ACTION_EXECUTION_STARTED, PipelineNotificationEvents.ACTION_EXECUTION_SUCCEEDED, ], }); } public notifyOnAnyManualApprovalStateChange( id: string, target: notifications.INotificationRuleTarget, options?: notifications.NotificationRuleOptions, ): notifications.INotificationRule { return this.notifyOn(id, target, { ...options, events: [ PipelineNotificationEvents.MANUAL_APPROVAL_FAILED, PipelineNotificationEvents.MANUAL_APPROVAL_NEEDED, PipelineNotificationEvents.MANUAL_APPROVAL_SUCCEEDED, ], }); } } /** * An AWS CodePipeline pipeline with its associated IAM role and S3 bucket. * * @example * // create a pipeline * import * as codecommit from 'aws-cdk-lib/aws-codecommit'; * * const pipeline = new codepipeline.Pipeline(this, 'Pipeline'); * * // add a stage * const sourceStage = pipeline.addStage({ stageName: 'Source' }); * * // add a source action to the stage * declare const repo: codecommit.Repository; * declare const sourceArtifact: codepipeline.Artifact; * sourceStage.addAction(new codepipeline_actions.CodeCommitSourceAction({ * actionName: 'Source', * output: sourceArtifact, * repository: repo, * })); * * // ... add more stages */ export class Pipeline extends PipelineBase { /** * Import a pipeline into this app. * * @param scope the scope into which to import this pipeline * @param id the logical ID of the returned pipeline construct * @param pipelineArn The ARN of the pipeline (e.g. `arn:aws:codepipeline:us-east-1:123456789012:MyDemoPipeline`) */ public static fromPipelineArn(scope: Construct, id: string, pipelineArn: string): IPipeline { class Import extends PipelineBase { public readonly pipelineName = Stack.of(scope).splitArn(pipelineArn, ArnFormat.SLASH_RESOURCE_NAME).resource; public readonly pipelineArn = pipelineArn; } return new Import(scope, id, { environmentFromArn: pipelineArn, }); } /** * The IAM role AWS CodePipeline will use to perform actions or assume roles for actions with * a more specific IAM role. */ public readonly role: iam.IRole; /** * ARN of this pipeline */ public readonly pipelineArn: string; /** * The name of the pipeline */ public readonly pipelineName: string; /** * The version of the pipeline * * @attribute */ public readonly pipelineVersion: string; /** * Bucket used to store output artifacts */ public readonly artifactBucket: s3.IBucket; private readonly _stages = new Array<Stage>(); private readonly crossRegionBucketsPassed: boolean; private readonly _crossRegionSupport: { [region: string]: CrossRegionSupport } = {}; private readonly _crossAccountSupport: { [account: string]: Stack } = {}; private readonly crossAccountKeys: boolean; private readonly enableKeyRotation?: boolean; private readonly reuseCrossRegionSupportStacks: boolean; private readonly codePipeline: CfnPipeline; private readonly pipelineType: PipelineType; private readonly usePipelineRoleForActions: boolean; private readonly variables = new Array<Variable>(); private readonly triggers = new Array<Trigger>(); constructor(scope: Construct, id: string, props: PipelineProps = {}) { super(scope, id, { physicalName: props.pipelineName, }); // Enhanced CDK Analytics Telemetry addConstructMetadata(this, props); validateName(this, 'Pipeline', this.physicalName); // only one of artifactBucket and crossRegionReplicationBuckets can be supplied if (props.artifactBucket && props.crossRegionReplicationBuckets) { throw new ValidationError('Only one of artifactBucket and crossRegionReplicationBuckets can be specified!', this); } // The feature flag is set to true by default for new projects, otherwise false. this.crossAccountKeys = props.crossAccountKeys ?? (FeatureFlags.of(this).isEnabled(cxapi.CODEPIPELINE_CROSS_ACCOUNT_KEYS_DEFAULT_VALUE_TO_FALSE) ? false : true); this.enableKeyRotation = props.enableKeyRotation; // Cross account keys must be set for key rotation to be enabled if (this.enableKeyRotation && !this.crossAccountKeys) { throw new ValidationError("Setting 'enableKeyRotation' to true also requires 'crossAccountKeys' to be enabled", this); } this.reuseCrossRegionSupportStacks = props.reuseCrossRegionSupportStacks ?? true; this.usePipelineRoleForActions = props.usePipelineRoleForActions ?? false; // If a bucket has been provided, use it - otherwise, create a bucket. let propsBucket = this.getArtifactBucketFromProps(props); if (!propsBucket) { let encryptionKey; if (this.crossAccountKeys) { encryptionKey = new kms.Key(this, 'ArtifactsBucketEncryptionKey', { // remove the key - there is a grace period of a few days before it's gone for good, // that should be enough for any emergency access to the bucket artifacts removalPolicy: RemovalPolicy.DESTROY, enableKeyRotation: this.enableKeyRotation, }); // add an alias to make finding the key in the console easier new kms.Alias(this, 'ArtifactsBucketEncryptionKeyAlias', { aliasName: this.generateNameForDefaultBucketKeyAlias(), targetKey: encryptionKey, removalPolicy: RemovalPolicy.DESTROY, // destroy the alias along with the key }); } propsBucket = new s3.Bucket(this, 'ArtifactsBucket', { bucketName: PhysicalName.GENERATE_IF_NEEDED, encryptionKey, encryption: encryptionKey ? s3.BucketEncryption.KMS : s3.BucketEncryption.KMS_MANAGED, enforceSSL: true, blockPublicAccess: new s3.BlockPublicAccess(s3.BlockPublicAccess.BLOCK_ALL), removalPolicy: RemovalPolicy.RETAIN, }); } this.artifactBucket = propsBucket; // If a role has been provided, use it - otherwise, create a role. const isRemoveRootPrincipal = FeatureFlags.of(this).isEnabled(cxapi.PIPELINE_REDUCE_CROSS_ACCOUNT_ACTION_ROLE_TRUST_SCOPE); this.role = props.role || new iam.Role(this, 'Role', { assumedBy: new iam.ServicePrincipal('codepipeline.amazonaws.com'), roleName: isRemoveRootPrincipal ? PhysicalName.GENERATE_IF_NEEDED : undefined, }); const isDefaultV2 = FeatureFlags.of(this).isEnabled(cxapi.CODEPIPELINE_DEFAULT_PIPELINE_TYPE_TO_V2); if (!isDefaultV2 && props.pipelineType === undefined) { Annotations.of(this).addWarningV2('@aws-cdk/aws-codepipeline:unspecifiedPipelineType', 'V1 pipeline type is implicitly selected when `pipelineType` is not set. If you want to use V2 type, set `PipelineType.V2`.'); } this.pipelineType = props.pipelineType ?? (isDefaultV2 ? PipelineType.V2 : PipelineType.V1); if ( props.executionMode && [ExecutionMode.QUEUED, ExecutionMode.PARALLEL].includes(props.executionMode) && this.pipelineType !== PipelineType.V2 ) { throw new ValidationError(`${props.executionMode} execution mode can only be used with V2 pipelines, \`PipelineType.V2\` must be specified for \`pipelineType\``, this); } this.codePipeline = new CfnPipeline(this, 'Resource', { artifactStore: Lazy.any({ produce: () => this.renderArtifactStoreProperty() }), artifactStores: Lazy.any({ produce: () => this.renderArtifactStoresProperty() }), stages: Lazy.any({ produce: () => this.renderStages() }), disableInboundStageTransitions: Lazy.any({ produce: () => this.renderDisabledTransitions() }, { omitEmptyArray: true }), roleArn: this.role.roleArn, restartExecutionOnUpdate: props && props.restartExecutionOnUpdate, pipelineType: props.pipelineType ?? (isDefaultV2 ? PipelineType.V2 : undefined), variables: Lazy.any({ produce: () => this.renderVariables() }, { omitEmptyArray: true }), triggers: Lazy.any({ produce: () => this.renderTriggers() }, { omitEmptyArray: true }), executionMode: props.executionMode, name: this.physicalName, }); // this will produce a DependsOn for both the role and the policy resources. this.codePipeline.node.addDependency(this.role); this.artifactBucket.grantReadWrite(this.role); this.pipelineName = this.getResourceNameAttribute(this.codePipeline.ref); this.pipelineVersion = this.codePipeline.attrVersion; this.crossRegionBucketsPassed = !!props.crossRegionReplicationBuckets; for (const [region, replicationBucket] of Object.entries(props.crossRegionReplicationBuckets || {})) { this._crossRegionSupport[region] = { replicationBucket, stack: Stack.of(replicationBucket), }; } // Does not expose a Fn::GetAtt for the ARN so we'll have to make it ourselves this.pipelineArn = Stack.of(this).formatArn({ service: 'codepipeline', resource: this.pipelineName, }); for (const stage of props.stages || []) { this.addStage(stage); } for (const variable of props.variables || []) { this.addVariable(variable); } for (const trigger of props.triggers || []) { this.addTrigger(trigger); } this.node.addValidation({ validate: () => this.validatePipeline() }); } /** * Creates a new Stage, and adds it to this Pipeline. * * @param props the creation properties of the new Stage * @returns the newly created Stage */ @MethodMetadata() public addStage(props: StageOptions): IStage { // check for duplicate Stages and names if (this._stages.find(s => s.stageName === props.stageName)) { throw new ValidationError(`Stage with duplicate name '${props.stageName}' added to the Pipeline`, this); } const stage = new Stage(props, this); const index = props.placement ? this.calculateInsertIndexFromPlacement(props.placement) : this.stageCount; this._stages.splice(index, 0, stage); return stage; } /** * Adds a statement to the pipeline role. */ @MethodMetadata() public addToRolePolicy(statement: iam.PolicyStatement) { this.role.addToPrincipalPolicy(statement); } /** * Adds a new Variable to this Pipeline. * * @param variable Variable instance to add to this Pipeline * @returns the newly created variable */ @MethodMetadata() public addVariable(variable: Variable): Variable { // check for duplicate variables and names if (this.variables.find(v => v.variableName === variable.variableName)) { throw new ValidationError(`Variable with duplicate name '${variable.variableName}' added to the Pipeline`, this); } this.variables.push(variable); return variable; } /** * Adds a new Trigger to this Pipeline. * * @param props Trigger property to add to this Pipeline * @returns the newly created trigger */ @MethodMetadata() public addTrigger(props: TriggerProps): Trigger { const trigger = new Trigger(props); const actionName = props.gitConfiguration?.sourceAction.actionProperties.actionName; // check for duplicate source actions for triggers if (actionName !== undefined && this.triggers.find(t => t.sourceAction?.actionProperties.actionName === actionName)) { throw new ValidationError(`Trigger with duplicate source action '${actionName}' added to the Pipeline`, this); } this.triggers.push(trigger); return trigger; } /** * Get the number of Stages in this Pipeline. */ public get stageCount(): number { return this._stages.length; } /** * Returns the stages that comprise the pipeline. * * **Note**: the returned array is a defensive copy, * so adding elements to it has no effect. * Instead, use the `addStage` method if you want to add more stages * to the pipeline. */ public get stages(): IStage[] { return this._stages.slice(); } /** * Access one of the pipeline's stages by stage name */ @MethodMetadata() public stage(stageName: string): IStage { for (const stage of this._stages) { if (stage.stageName === stageName) { return stage; } } throw new ValidationError(`Pipeline does not contain a stage named '${stageName}'. Available stages: ${this._stages.map(s => s.stageName).join(', ')}`, this); } /** * Returns all of the `CrossRegionSupportStack`s that were generated automatically * when dealing with Actions that reside in a different region than the Pipeline itself. * */ public get crossRegionSupport(): { [region: string]: CrossRegionSupport } { const ret: { [region: string]: CrossRegionSupport } = {}; Object.keys(this._crossRegionSupport).forEach((key) => { ret[key] = this._crossRegionSupport[key]; }); return ret; } /** @internal */ public _attachActionToPipeline(stage: Stage, action: IAction, actionScope: Construct): FullActionDescriptor { const richAction = new RichAction(action, this); // handle cross-region actions here const crossRegionInfo = this.ensureReplicationResourcesExistFor(richAction); // get the role for the given action, handling if it's cross-account const actionRole = this.getRoleForAction(stage, richAction, actionScope); // // CodePipeline Variables validateNamespaceName(this, richAction.actionProperties.variablesNamespace); // bind the Action (type h4x) const actionConfig = richAction.bind(actionScope, stage, { role: actionRole ? actionRole : this.role, bucket: crossRegionInfo.artifactBucket, }); return new FullActionDescriptor({ // must be 'action', not 'richAction', // as those are returned by the IStage.actions property, // and it's important customers of Pipeline get the same instance // back as they added to the pipeline action, actionConfig, actionRole, actionRegion: crossRegionInfo.region, }); } /** * Validate the pipeline structure * * Validation happens according to the rules documented at * * https://docs.aws.amazon.com/codepipeline/latest/userguide/reference-pipeline-structure.html#pipeline-requirements */ private validatePipeline(): string[] { return [ ...this.validateSourceActionLocations(), ...this.validateHasStages(), ...this.validateStages(), ...this.validateArtifacts(), ...this.validateVariables(), ...this.validateTriggers(), ]; } private ensureReplicationResourcesExistFor(action: RichAction): CrossRegionInfo { if (!action.isCrossRegion) { return { artifactBucket: this.artifactBucket, }; } // The action has a specific region, // require the pipeline to have a known region as well. this.requireRegion(); // source actions have to be in the same region as the pipeline if (action.actionProperties.category === ActionCategory.SOURCE) { throw new ValidationError(`Source action '${action.actionProperties.actionName}' must be in the same region as the pipeline`, this); } // check whether we already have a bucket in that region, // either passed from the outside or previously created const crossRegionSupport = this.obtainCrossRegionSupportFor(action); // the stack containing the replication bucket must be deployed before the pipeline Stack.of(this).addDependency(crossRegionSupport.stack); // The Pipeline role must be able to replicate to that bucket crossRegionSupport.replicationBucket.grantReadWrite(this.role); return { artifactBucket: crossRegionSupport.replicationBucket, region: action.effectiveRegion, }; } /** * Get or create the cross-region support construct for the given action */ private obtainCrossRegionSupportFor(action: RichAction) { // this method is never called for non cross-region actions const actionRegion = action.effectiveRegion!; let crossRegionSupport = this._crossRegionSupport[actionRegion]; if (!crossRegionSupport) { // we need to create scaffolding resources for this region const otherStack = action.resourceStack; crossRegionSupport = this.createSupportResourcesForRegion(otherStack, actionRegion); this._crossRegionSupport[actionRegion] = crossRegionSupport; } return crossRegionSupport; } private createSupportResourcesForRegion(otherStack: Stack | undefined, actionRegion: string): CrossRegionSupport { // if we have a stack from the resource passed - use that! if (otherStack) { // check if the stack doesn't have this magic construct already const id = `CrossRegionReplicationSupport-d823f1d8-a990-4e5c-be18-4ac698532e65-${actionRegion}`; let crossRegionSupportConstruct = otherStack.node.tryFindChild(id) as CrossRegionSupportConstruct; if (!crossRegionSupportConstruct) { crossRegionSupportConstruct = new CrossRegionSupportConstruct(otherStack, id, { createKmsKey: this.crossAccountKeys, enableKeyRotation: this.enableKeyRotation, }); } return { replicationBucket: crossRegionSupportConstruct.replicationBucket, stack: otherStack, }; } // otherwise - create a stack with the resources needed for replication across regions const pipelineStack = Stack.of(this); const pipelineAccount = pipelineStack.account; if (Token.isUnresolved(pipelineAccount)) { throw new ValidationError("You need to specify an explicit account when using CodePipeline's cross-region support", this); } const app = this.supportScope(); const supportStackId = `cross-region-stack-${this.reuseCrossRegionSupportStacks ? pipelineAccount : pipelineStack.stackName}:${actionRegion}`; let supportStack = app.node.tryFindChild(supportStackId) as CrossRegionSupportStack; if (!supportStack) { supportStack = new CrossRegionSupportStack(app, supportStackId, { pipelineStackName: pipelineStack.stackName, region: actionRegion, account: pipelineAccount, synthesizer: this.getCrossRegionSupportSynthesizer(), createKmsKey: this.crossAccountKeys, enableKeyRotation: this.enableKeyRotation, }); } return { stack: supportStack, replicationBucket: supportStack.replicationBucket, }; } private getCrossRegionSupportSynthesizer(): IStackSynthesizer | undefined { if (this.stack.synthesizer instanceof DefaultStackSynthesizer) { // if we have the new synthesizer, // we need a bootstrapless copy of it, // because we don't want to require bootstrapping the environment // of the pipeline account in this replication region return new BootstraplessSynthesizer({ deployRoleArn: this.stack.synthesizer.deployRoleArn, cloudFormationExecutionRoleArn: this.stack.synthesizer.cloudFormationExecutionRoleArn, }); } else { // any other synthesizer: just return undefined // (ie., use the default based on the context settings) return undefined; } } private generateNameForDefaultBucketKeyAlias(): string { const prefix = 'alias/codepipeline-'; const maxAliasLength = 256; const maxResourceNameLength = maxAliasLength - prefix.length; // Names.uniqueId() may have naming collisions when the IDs of resources are similar // and/or when they are too long and sliced. We do not want to update this and // automatically change the name of every KMS key already generated so we are putting // this under a feature flag. const uniqueId = FeatureFlags.of(this).isEnabled(cxapi.CODEPIPELINE_CROSS_ACCOUNT_KEY_ALIAS_STACK_SAFE_RESOURCE_NAME) ? Names.uniqueResourceName(this, { separator: '-', maxLength: maxResourceNameLength, allowedSpecialCharacters: '/_-', }) : Names.uniqueId(this).slice(-maxResourceNameLength); return prefix + uniqueId.toLowerCase(); } /** * Gets the role used for this action, * including handling the case when the action is supposed to be cross-account. * * @param stage the stage the action belongs to * @param action the action to return/create a role for * @param actionScope the scope, unique to the action, to create new resources in */ private getRoleForAction(stage: Stage, action: RichAction, actionScope: Construct): iam.IRole | undefined { const pipelineStack = Stack.of(this); let actionRole = this.getRoleFromActionPropsOrGenerateIfCrossAccount(stage, action); if (!actionRole && this.isAwsOwned(action)) { if (this.usePipelineRoleForActions) { return undefined; } // generate a Role for this specific Action const isRemoveRootPrincipal = FeatureFlags.of(this).isEnabled(cxapi.PIPELINE_REDUCE_STAGE_ROLE_TRUST_SCOPE); const roleProps = isRemoveRootPrincipal ? { assumedBy: new iam.ArnPrincipal(this.role.roleArn), // Allow only the pipeline execution role } : { assumedBy: new iam.AccountPrincipal(pipelineStack.account), }; actionRole = new iam.Role(actionScope, 'CodePipelineActionRole', roleProps); } // the pipeline role needs assumeRole permissions to the action role const grant = actionRole?.grantAssumeRole(this.role); grant?.applyBefore(this.codePipeline); return actionRole; } private getRoleFromActionPropsOrGenerateIfCrossAccount(stage: Stage, action: RichAction): iam.IRole | undefined { const pipelineStack = Stack.of(this); // if we have a cross-account action, the pipeline's bucket must have a KMS key // (otherwise we can't configure cross-account trust policies) if (action.isCrossAccount) { const artifactBucket = this.ensureReplicationResourcesExistFor(action).artifactBucket; if (!artifactBucket.encryptionKey) { throw new ValidationError( `Artifact Bucket must have a KMS Key to add cross-account action '${action.actionProperties.actionName}' ` + `(pipeline account: '${renderEnvDimension(this.env.account)}', action account: '${renderEnvDimension(action.effectiveAccount)}'). ` + 'Create Pipeline with \'crossAccountKeys: true\' (or pass an existing Bucket with a key)', this, ); } } // if a Role has been passed explicitly, always use it // (even if the backing resource is from a different account - // this is how the user can override our default support logic) if (action.actionProperties.role) { if (this.isAwsOwned(action)) { // the role has to be deployed before the pipeline // (our magical cross-stack dependencies will not work, // because the role might be from a different environment), // but _only_ if it's a new Role - // an imported Role should not add the dependency if (iam.Role.isRole(action.actionProperties.role)) { const roleStack = Stack.of(action.actionProperties.role); pipelineStack.addDependency(roleStack); } return action.actionProperties.role; } else { // ...except if the Action is not owned by 'AWS', // as that would be rejected by CodePipeline at deploy time throw new ValidationError( "Specifying a Role is not supported for actions with an owner different than 'AWS' - " + `got '${action.actionProperties.owner}' (Action: '${action.actionProperties.actionName}' in Stage: '${stage.stageName}')`, this, ); } } // if we don't have a Role passed, // and the action is cross-account, // generate a Role in that other account stack const otherAccountStack = this.getOtherStackIfActionIsCrossAccount(action); if (!otherAccountStack) { return undefined; } const isRemoveRootPrincipal = FeatureFlags.of(this).isEnabled(cxapi.PIPELINE_REDUCE_CROSS_ACCOUNT_ACTION_ROLE_TRUST_SCOPE); const basePrincipal = new iam.AccountPrincipal(pipelineStack.account); const roleProps = { roleName: PhysicalName.GENERATE_IF_NEEDED, assumedBy: isRemoveRootPrincipal ? basePrincipal.withConditions( { ArnEquals: { 'aws:PrincipalArn': this.role.roleArn, }, }, ) : basePrincipal, }; // generate a role in the other stack, that the Pipeline will assume for executing this action const ret = new iam.Role(otherAccountStack, `${Names.uniqueId(this)}-${stage.stageName}-${action.actionProperties.actionName}-ActionRole`, roleProps); // the other stack with the role has to be deployed before the pipeline stack // (CodePipeline verifies you can assume the action Role on creation) pipelineStack.addDependency(otherAccountStack); return ret; } /** * Returns the Stack this Action belongs to if this is a cross-account Action. * If this Action is not cross-account (i.e., it lives in the same account as the Pipeline), * it returns undefined. * * @param action the Action to return the Stack for */ private getOtherStackIfActionIsCrossAccount(action: IAction): Stack | undefined { const targetAccount = action.actionProperties.resource ? action.actionProperties.resource.env.account : action.actionProperties.account; if (targetAccount === undefined) { // if the account of the Action is not specified, // then it defaults to the same account the pipeline itself is in return undefined; } // check whether the action's account is a static string if (Token.isUnresolved(targetAccount)) { if (Token.isUnresolved(this.env.account)) { // the pipeline is also env-agnostic, so that's fine return undefined; } else { throw new ValidationError(`The 'account' property must be a concrete value (action: '${action.actionProperties.actionName}')`, this); } } // At this point, we know that the action's account is a static string. // In this case, the pipeline's account must also be a static string. if (Token.isUnresolved(this.env.account)) { throw new ValidationError('Pipeline stack which uses cross-environment actions must have an explicitly set account', this); } // at this point, we know that both the Pipeline's account, // and the action-backing resource's account are static strings // if they are identical - nothing to do (the action is not cross-account) if (this.env.account === targetAccount) { return undefined; } // at this point, we know that the action is certainly cross-account, // so we need to return a Stack in its account to create the helper Role in const candidateActionResourceStack = action.actionProperties.resource ? Stack.of(action.actionProperties.resource) : undefined; if (candidateActionResourceStack?.account === targetAccount) { // we always use the "latest" action-backing resource's Stack for this account, // even if a different one was used earlier this._crossAccountSupport[targetAccount] = candidateActionResourceStack; return candidateActionResourceStack; } let targetAccountStack: Stack | undefined = this._crossAccountSupport[targetAccount]; if (!targetAccountStack) { const stackId = `cross-account-support-stack-${targetAccount}`; const app = this.supportScope(); targetAccountStack = app.node.tryFindChild(stackId) as Stack; if (!targetAccountStack) { const actionRegion = action.actionProperties.resource ? action.actionProperties.resource.env.region : action.actionProperties.region; const pipelineStack = Stack.of(this); // If the token is unresolved, we let Stack construct to generate the stack name for us. const stackName = Token.isUnresolved(pipelineStack.stackName) ? undefined : `${pipelineStack.stackName}-support-${targetAccount}`; targetAccountStack = new Stack(app, stackId, { stackName: stackName, env: { account: targetAccount, region: actionRegion ?? pipelineStack.region, }, }); } this._crossAccountSupport[targetAccount] = targetAccountStack; } return targetAccountStack; } private isAwsOwned(action: IAction) { const owner = action.actionProperties.owner; return !owner || owner === 'AWS'; } private getArtifactBucketFromProps(props: PipelineProps): s3.IBucket | undefined { if (props.artifactBucket) { return props.artifactBucket; } if (props.crossRegionReplicationBuckets) { const pipelineRegion = this.requireRegion(); return props.crossRegionReplicationBuckets[pipelineRegion]; } return undefined; } private calculateInsertIndexFromPlacement(placement: StagePlacement): number { // check if at most one placement property was provided const providedPlacementProps = ['rightBefore', 'justAfter', 'atIndex'] .filter((prop) => (placement as any)[prop] !== undefined); if (providedPlacementProps.length > 1) { throw new ValidationError( 'Error adding Stage to the Pipeline: ' + 'you can only provide at most one placement property, but ' + `'${providedPlacementProps.join(', ')}' were given`, this, ); } if (placement.rightBefore !== undefined) { const targetIndex = this.findStageIndex(placement.rightBefore); if (targetIndex === -1) { throw new ValidationError( 'Error adding Stage to the Pipeline: ' + `the requested Stage to add it before, '${placement.rightBefore.stageName}', was not found`, this, ); } return targetIndex; } if (placement.justAfter !== undefined) { const targetIndex = this.findStageIndex(placement.justAfter); if (targetIndex === -1) { throw new ValidationError( 'Error adding Stage to the Pipeline: ' + `the requested Stage to add it after, '${placement.justAfter.stageName}', was not found`, this, ); } return targetIndex + 1; } return this.stageCount; } private findStageIndex(targetStage: IStage) { return this._stages.findIndex(stage => stage === targetStage); } private validateSourceActionLocations(): string[] { const errors = new Array<string>(); let firstStage = true; for (const stage of this._stages) { const onlySourceActionsPermitted = firstStage; for (const action of stage.actionDescriptors) { errors.push(...validateSourceAction(onlySourceActionsPermitted, action.category, action.actionName, stage.stageName)); } firstStage = false; } return errors; } private validateHasStages(): string[] { if (this.stageCount < 2) { return ['Pipeline must have at least two stages']; } return []; } private validateStages(): string[] { const ret = new Array<string>(); for (const stage of this._stages) { ret.push(...stage.validate()); } return ret; } private validateArtifacts(): string[] { const ret = new Array<string>(); const producers: Record<string, PipelineLocation> = {}; const firstConsumers: Record<string, PipelineLocation> = {}; for (const [stageIndex, stage] of enumerate(this._stages)) { // For every output artifact, get the producer for (const action of stage.actionDescriptors) { const actionLoc = new PipelineLocation(stageIndex, stage, action); for (const outputArtifact of action.outputs) { // output Artifacts always have a name set const name = outputArtifact.artifactName!; if (producers[name]) { ret.push(`Both Actions '${producers[name].actionName}' and '${action.actionName}' are producting Artifact '${name}'. Every artifact can only be produced once.`); continue; } producers[name] = actionLoc; } // For every input artifact, get the first consumer for (const inputArtifact of action.inputs) { const name = inputArtifact.artifactName; if (!name) { ret.push(`Action '${action.actionName}' is using an unnamed input Artifact, which is not being produced in this pipeline`); continue; } firstConsumers[name] = firstConsumers[name] ? firstConsumers[name].first(actionLoc) : actionLoc; } } } // Now validate that every input artifact is produced before it's // being consumed. for (const [artifactName, consumerLoc] of Object.entries(firstConsumers)) { const producerLoc = producers[artifactName]; if (!producerLoc) { ret.push(`Action '${consumerLoc.actionName}' is using input Artifact '${artifactName}', which is not being produced in this pipeline`); continue; } if (consumerLoc.beforeOrEqual(producerLoc)) { ret.push(`${consumerLoc} is consuming input Artifact '${artifactName}' before it is being produced at ${producerLoc}`); } } return ret; } private validateVariables(): string[] { const errors: string[] = []; if (this.variables.length && this.pipelineType !== PipelineType.V2) { errors.push('Pipeline variables can only be used with V2 pipelines, `PipelineType.V2` must be specified for `pipelineType`'); } return errors; } private validateTriggers(): string[] { const errors: string[] = []; if (this.triggers.length && this.pipelineType !== PipelineType.V2) { errors.push('Triggers can only be used with V2 pipelines, `PipelineType.V2` must be specified for `pipelineType`'); } return errors; } private renderArtifactStoresProperty(): CfnPipeline.ArtifactStoreMapProperty[] | undefined { if (!this.crossRegion) { return undefined; } // add the Pipeline's artifact store const primaryRegion = this.requireRegion(); this._crossRegionSupport[primaryRegion] = { replicationBucket: this.artifactBucket, stack: Stack.of(this), }; return Object.entries(this._crossRegionSupport).map(([region, support]) => ({ region, artifactStore: this.renderArtifactStore(support.replicationBucket), })); } private renderArtifactStoreProperty(): CfnPipeline.ArtifactStoreProperty | undefined { if (this.crossRegion) { return undefined; } return this.renderPrimaryArtifactStore(); } private renderPrimaryArtifactStore(): CfnPipeline.ArtifactStoreProperty { return this.renderArtifactStore(this.artifactBucket); } private renderArtifactStore(bucket: s3.IBucket): CfnPipeline.ArtifactStoreProperty { let encryptionKey: CfnPipeline.EncryptionKeyProperty | undefined; const bucketKey = bucket.encryptionKey; if (bucketKey) { encryptionKey = { type: 'KMS', id: bucketKey.keyArn, }; } return { type: 'S3', location: bucket.bucketName, encryptionKey, }; } private get crossRegion(): boolean { if (this.crossRegionBucketsPassed) { return true; } return this._stages.some(stage => stage.actionDescriptors.some(action => action.region !== undefined)); } private renderStages(): CfnPipeline.StageDeclarationProperty[] { return this._stages.map(stage => stage.render()); } private renderDisabledTransitions(): CfnPipeline.StageTransitionProperty[] { return this._stages .filter(stage => !stage.transitionToEnabled) .map(stage => ({ reason: stage.transitionDisabledReason, stageName: stage.stageName, })); } private renderVariables(): CfnPipeline.VariableDeclarationProperty[] { return this.variables.map(variable => variable._render()); } private renderTriggers(): CfnPipeline.PipelineTriggerDeclarationProperty[] { return this.triggers.map(trigger => trigger._render()); } private requireRegion(): string { const region = this.env.region; if (Token.isUnresolved(region)) { throw new ValidationError('Pipeline stack which uses cross-environment actions must have an explicitly set region', this); } return region; } private supportScope(): CdkStage { const scope = CdkStage.of(this); if (!scope) { throw new ValidationError('Pipeline stack which uses cross-environment actions must be part of a CDK App or Stage', this); } return scope; } } /** * An interface representing resources generated in order to support * the cross-region capabilities of CodePipeline. * You get instances of this interface from the `Pipeline#crossRegionSupport` property. * */ export interface CrossRegionSupport { /** * The Stack that has been created to house the replication Bucket * required for this region. */ readonly stack: Stack; /** * The replication Bucket used by CodePipeline to operate in this region. * Belongs to `stack`. */ readonly replicationBucket: s3.IBucket; } interface CrossRegionInfo { readonly artifactBucket: s3.IBucket; readonly region?: string; } function enumerate<A>(xs: A[]): Array<[number, A]> { const ret = new Array<[number, A]>(); for (let i = 0; i < xs.length; i++) { ret.push([i, xs[i]]); } return ret; } class PipelineLocation { constructor(private readonly stageIndex: number, private readonly stage: IStage, private readonly action: FullActionDescriptor) { } public get stageName() { return this.stage.stageName; } public get actionName() { return this.action.actionName; } /** * Returns whether a is before or the same order as b */ public beforeOrEqual(rhs: PipelineLocation) { if (this.stageIndex !== rhs.stageIndex) { return rhs.stageIndex < rhs.stageIndex; } return this.action.runOrder <= rhs.action.runOrder; } /** * Returns the first location between this and the other one */ public first(rhs: PipelineLocation) { return this.beforeOrEqual(rhs) ? this : rhs; } public toString() { // runOrders are 1-based, so make the stageIndex also 1-based otherwise it's going to be confusing. return `Stage ${this.stageIndex + 1} Action ${this.action.runOrder} ('${this.stageName}'/'${this.actionName}')`; } } /** * Render an env dimension without showing the ugly stringified tokens */ function renderEnvDimension(s: string | undefined) { return Token.isUnresolved(s) ? '(current)' : s; }