-
Notifications
You must be signed in to change notification settings - Fork 3.9k
/
pipeline.ts
1160 lines (1014 loc) · 40.1 KB
/
pipeline.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
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 * 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 {
ArnFormat,
BootstraplessSynthesizer,
DefaultStackSynthesizer,
FeatureFlags,
IStackSynthesizer,
Lazy,
Names,
PhysicalName,
RemovalPolicy,
Resource,
Stack,
Stage as CdkStage,
Token,
} from '../../core';
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;
}
/**
* 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;
}
export interface StageOptions extends StageProps {
readonly placement?: StagePlacement;
}
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 true
*/
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;
}
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);
}
/**
* 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;
constructor(scope: Construct, id: string, props: PipelineProps = {}) {
super(scope, id, {
physicalName: props.pipelineName,
});
validateName('Pipeline', this.physicalName);
// only one of artifactBucket and crossRegionReplicationBuckets can be supplied
if (props.artifactBucket && props.crossRegionReplicationBuckets) {
throw new Error('Only one of artifactBucket and crossRegionReplicationBuckets can be specified!');
}
// @deprecated(v2): switch to default false
this.crossAccountKeys = props.crossAccountKeys ?? true;
this.enableKeyRotation = props.enableKeyRotation;
// Cross account keys must be set for key rotation to be enabled
if (this.enableKeyRotation && !this.crossAccountKeys) {
throw new Error("Setting 'enableKeyRotation' to true also requires 'crossAccountKeys' to be enabled");
}
this.reuseCrossRegionSupportStacks = props.reuseCrossRegionSupportStacks ?? true;
// 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.
this.role = props.role || new iam.Role(this, 'Role', {
assumedBy: new iam.ServicePrincipal('codepipeline.amazonaws.com'),
});
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,
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);
}
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
*/
public addStage(props: StageOptions): IStage {
// check for duplicate Stages and names
if (this._stages.find(s => s.stageName === props.stageName)) {
throw new Error(`Stage with duplicate name '${props.stageName}' added to the Pipeline`);
}
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.
*/
public addToRolePolicy(statement: iam.PolicyStatement) {
this.role.addToPrincipalPolicy(statement);
}
/**
* 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
*/
public stage(stageName: string): IStage {
for (const stage of this._stages) {
if (stage.stageName === stageName) {
return stage;
}
}
throw new Error(`Pipeline does not contain a stage named '${stageName}'. Available stages: ${this._stages.map(s => s.stageName).join(', ')}`);
}
/**
* 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(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(),
];
}
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 Error(`Source action '${action.actionProperties.actionName}' must be in the same region as the pipeline`);
}
// 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 Error("You need to specify an explicit account when using CodePipeline's cross-region support");
}
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)) {
// generate a Role for this specific Action
actionRole = new iam.Role(actionScope, 'CodePipelineActionRole', {
assumedBy: new iam.AccountPrincipal(pipelineStack.account),
});
}
// 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 Error(
`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)',
);
}
}
// 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 Error("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}')`);
}
}
// 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;
}
// 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`, {
assumedBy: new iam.AccountPrincipal(pipelineStack.account),
roleName: PhysicalName.GENERATE_IF_NEEDED,
});
// 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 Error(`The 'account' property must be a concrete value (action: '${action.actionProperties.actionName}')`);
}
}
// 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 Error('Pipeline stack which uses cross-environment actions must have an explicitly set account');
}
// 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);
targetAccountStack = new Stack(app, stackId, {
stackName: `${pipelineStack.stackName}-support-${targetAccount}`,
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 Error('Error adding Stage to the Pipeline: ' +
'you can only provide at most one placement property, but ' +
`'${providedPlacementProps.join(', ')}' were given`);
}
if (placement.rightBefore !== undefined) {
const targetIndex = this.findStageIndex(placement.rightBefore);
if (targetIndex === -1) {
throw new Error('Error adding Stage to the Pipeline: ' +
`the requested Stage to add it before, '${placement.rightBefore.stageName}', was not found`);
}
return targetIndex;
}
if (placement.justAfter !== undefined) {
const targetIndex = this.findStageIndex(placement.justAfter);
if (targetIndex === -1) {
throw new Error('Error adding Stage to the Pipeline: ' +
`the requested Stage to add it after, '${placement.justAfter.stageName}', was not found`);
}
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;