DEV Community

Kenta Goto for AWS Heroes

Posted on

AWS CDK: Resources and Causes of Replacement in Non-Stage to Stage Migration

What is a Stage

AWS CDK has a concept called Stage. This is like an abstraction of stacks, and using Stages makes it easier to manage multiple stacks together.

For specific examples, I have covered them in the following article:

How to Use AWS CDK Stage and When to Choose Static vs Dynamic Stack Creation


Resource Replacement in Stages

In CDK, a resource is replaced when its logical ID is changed.

Additionally, migrating non-Stage resources to a Stage changes the Construct path.

However, since logical IDs are generated based on the construct path below the stack, logical IDs basically do not change even when migrating to a Stage. In other words, resource replacement does not occur in most cases.


Resources That Get Replaced When Migrating to Stage and Their Causes

However, some resources do get replaced when migrating from non-Stage to Stage.

There are three causes for replacement of these resources:

  • Logical ID changes
  • Physical name changes
  • Replacement property changes

Fundamentally, the information that changes when migrating non-Stage resources to a Stage is information based on the Construct path.

For example, this.node.path and this.node.addr. Similarly, Names.nodeUniqueId and Names.uniqueId, which generate unique IDs using that information, are also affected.

Let's look at specific resource examples.

* Note: The resource examples introduced below are only some of the resources that get replaced. This is not a comprehensive list. Please be aware that there are other resources that may get replaced.

SecurityGroup

First, with SecurityGroup, replacement occurs due to a change in a Replacement property called GroupDescription in CloudFormation.

When you explicitly specify a value for description in SecurityGroupProps, replacement does not occur. However, when unspecified, the Construct path this.node.path is used. When migrating to a Stage, the path changes, resulting in a change to the GroupDescription value.

The CDK internal code that causes this is as follows:

packages/aws-cdk-lib/aws-ec2/lib/security-group.ts

const groupDescription = props.description || this.node.path; 
Enter fullscreen mode Exit fullscreen mode

SecurityGroup Ingress/Egress Rules

Replacement also occurs with SecurityGroup Ingress/Egress rules.

Specifically, replacement occurs with addIngressRule (SecurityGroupIngress) and with addEgressRule (SecurityGroupEgress) when allowAllOutbound is false.

This is caused by logical ID changes.

const sg1 = new SecurityGroup(this, 'SecurityGroup1', { vpc: vpc, allowAllOutbound: true, }); const sg2 = new SecurityGroup(this, 'SecurityGroup2', { vpc: vpc, allowAllOutbound: false, // Rules won't be added with addEgressRule when true }); sg2.addIngressRule(sg1, Port.tcp(80), 'Allow traffic from sg1 to sg2 on port 80'); sg2.addEgressRule(sg1, Port.tcp(80), 'Allow traffic from sg2 to sg1 on port 80'); 
Enter fullscreen mode Exit fullscreen mode

The CDK internal code that causes this is as follows:

this.uniqueId and peer.uniqueId internally use a method called Names.nodeUniqueId(this.node), which outputs a unique ID based on the construct path.

Since this value is ultimately passed to the resource's Construct ID and used as the logical ID, when migrating to a Stage, this result changes, and replacement occurs due to the logical ID change.

packages/aws-cdk-lib/aws-ec2/lib/security-group.ts

 protected determineRuleScope( peer: IPeer, connection: Port, fromTo: 'from' | 'to', remoteRule?: boolean): RuleScope { if (remoteRule && SecurityGroupBase.isSecurityGroup(peer) && differentStacks(this, peer)) { // Reversed const reversedFromTo = fromTo === 'from' ? 'to' : 'from'; return { scope: peer, id: `${this.uniqueId}:${connection} ${reversedFromTo}` }; } else { // Regular (do old ID escaping to in order to not disturb existing deployments) return { scope: this, id: `${fromTo} ${this.renderPeer(peer)}:${connection}`.replace('/', '_') }; } } private renderPeer(peer: IPeer) { if (Token.isUnresolved(peer.uniqueId)) { // Need to return a unique value each time a peer // is an unresolved token, else the duplicate skipper // in `sg.addXxxRule` can detect unique rules as duplicates return this.peerAsTokenCount++ ? `'{IndirectPeer${this.peerAsTokenCount}}'` : '{IndirectPeer}'; } else { return peer.uniqueId; } } 
Enter fullscreen mode Exit fullscreen mode

StepFunctions LogGroup in CustomResource Provider

In the CustomResource Provider construct, when isCompleteHandler is specified, a StepFunctions StateMachine is created internally.

const isCompleteHandler = new Function(this, 'MyFunction', { runtime: Runtime.NODEJS_22_X, handler: 'index.handler', code: Code.fromAsset(path.join(__dirname, 'lambda')), }); new Provider(this, 'Provider', { onEventHandler, isCompleteHandler, }); 
Enter fullscreen mode Exit fullscreen mode

In the LogGroup of this StateMachine, this.node.addr is used for the log group name (logGroupName), which is the physical name.

Also, this.node.addr is a hash value generated based on the construct path.

When migrating to a Stage, this value changes, causing the physical name to change and replacement to occur.

The CDK internal code that causes this is as follows:

packages/aws-cdk-lib/custom-resources/lib/provider-framework/waiter-state-machine.ts

const logGroup = logOptions?.destination ?? new LogGroup(this, 'LogGroup', { // Log group name should start with `/aws/vendedlogs/` to not exceed Cloudwatch Logs Resource Policy // size limit. // https://docs.aws.amazon.com/step-functions/latest/dg/bp-cwl.html // // By using the auto-generated name of the Lambda created in the `Provider` that calls this // `WaiterStateMachine` construct, even if the `Provider` (or its parent) is deleted and then // created again, the log group name will not duplicate previously created one with removal // policy `RETAIN`. This is because that the Lambda will be re-created again with auto-generated name. // The `node.addr` is also used to prevent duplicate names no matter how many times this construct // is created in the stack. It will not duplicate if called on other stacks. logGroupName: `/aws/vendedlogs/states/waiter-state-machine-${this.isCompleteHandler.functionName}-${this.node.addr}`, }); 
Enter fullscreen mode Exit fullscreen mode

Lambda Event Source

When using SqsEventSource or SnsEventSource with the Lambda Function's addEventSource method, replacement also occurs because the logical ID changes during Stage migration. Other event sources like S3EventSource, DynamoEventSource, and KinesisEventSource behave similarly.

const func = new Function(this, 'MyFunction', { runtime: Runtime.NODEJS_22_X, handler: 'index.handler', code: Code.fromAsset(path.join(__dirname, 'lambda')), }); func.addEventSource(new SqsEventSource(queue)); func.addEventSource(new SnsEventSource(topic)); 
Enter fullscreen mode Exit fullscreen mode

However, when using the addEventSourceMapping method or EventSourceMapping Construct, replacement does not occur.

func.addEventSourceMapping('MyEventSourceMapping', { eventSourceArn: 'arn:aws:sqs:us-east-1:123456789012:MyQueue', }); new EventSourceMapping(this, id, { target: func, eventSourceArn: 'arn:aws:sqs:us-east-1:123456789012:MyQueue', }); 
Enter fullscreen mode Exit fullscreen mode

The corresponding CDK internal code is as follows:

In SqsEventSource, Names.nodeUniqueId is used, which is passed to the EventSourceMapping's Construct ID and used for logical ID generation. When migrating to a Stage, this value changes, and replacement occurs due to the logical ID change.

packages/aws-cdk-lib/aws-lambda-event-sources/lib/sqs.ts

 public bind(target: lambda.IFunction) { const eventSourceMapping = target.addEventSourceMapping(`SqsEventSource:${Names.nodeUniqueId(this.queue.node)}`, { batchSize: this.props.batchSize, maxBatchingWindow: this.props.maxBatchingWindow, maxConcurrency: this.props.maxConcurrency, reportBatchItemFailures: this.props.reportBatchItemFailures, enabled: this.props.enabled, eventSourceArn: this.queue.queueArn, filters: this.props.filters, filterEncryption: this.props.filterEncryption, metricsConfig: this.props.metricsConfig, }); 
Enter fullscreen mode Exit fullscreen mode

Also, while the above SqsEventSource changes the logical ID of AWS::Lambda::EventSourceMapping, SnsEventSource changes the logical ID of AWS::Lambda::Permission.

This is due to the implementation of the LambdaSubscription class that is called internally, rather than the EventSource itself. Here too, Names.nodeUniqueId is used.

packages/aws-cdk-lib/aws-sns-subscriptions/lib/lambda.ts

 public bind(topic: sns.ITopic): sns.TopicSubscriptionConfig { // Create subscription under *consuming* construct to make sure it ends up // in the correct stack in cases of cross-stack subscriptions. if (!Construct.isConstruct(this.fn)) { throw new ValidationError('The supplied lambda Function object must be an instance of Construct', topic); } this.fn.addPermission(`AllowInvoke:${Names.nodeUniqueId(topic.node)}`, { sourceArn: topic.topicArn, principal: new iam.ServicePrincipal('sns.amazonaws.com'), }); 
Enter fullscreen mode Exit fullscreen mode

SNS Topic Lambda Subscription

As mentioned above, when using LambdaSubscription (aws-cdk-lib/aws-sns-subscriptions) with the SNS Topic's addSubscription method, replacement also occurs during Stage migration.

const topic = new Topic(this, 'MyTopic'); const func = new Function(this, 'MyFunction', { runtime: Runtime.NODEJS_22_X, handler: 'index.handler', code: Code.fromAsset(path.join(__dirname, 'lambda')), }); topic.addSubscription(new LambdaSubscription(func)); 
Enter fullscreen mode Exit fullscreen mode

S3 Bucket Lambda Notification (Destination)

When using LambdaDestination (aws-cdk-lib/aws-s3-notifications) with the S3 Bucket's addEventNotification method, replacement of AWS::Lambda::Permission similar to the above cases also occurs during Stage migration.

const bucket = new Bucket(this, 'MyBucket'); const func = new Function(this, 'MyFunction', { runtime: Runtime.NODEJS_22_X, handler: 'index.handler', code: Code.fromAsset(path.join(__dirname, 'lambda')), }); bucket.addEventNotification(EventType.OBJECT_CREATED, new LambdaDestination(func)); 
Enter fullscreen mode Exit fullscreen mode

fromGeneratedSecret in RDS Credentials

When using fromGeneratedSecret for credentials in RDS/Aurora DatabaseCluster etc., replacement also occurs because the logical ID changes during Stage migration. Here, the logical ID of the SecretsManager Secret is changed.

Also, when using methods like fromPassword, fromUsername, or fromSecret, replacement does not occur.

new DatabaseCluster(this, 'Cluster', { engine, vpc, writer, credentials: Credentials.fromGeneratedSecret('clusteradmin'), }); 
Enter fullscreen mode Exit fullscreen mode

The corresponding CDK internal code is as follows:

Specifically, within the internally called DatabaseSecret, the logical ID is overridden (overrideLogicalId) with a value using Names.uniqueId.

packages/aws-cdk-lib/aws-rds/lib/database-secret.ts

if (props.replaceOnPasswordCriteriaChanges) { const hash = md5hash( JSON.stringify({ // Use here the options that influence the password generation. // If at some point we add other password customization options // they should be added here below (e.g. `passwordLength`). excludeCharacters, }), ); const logicalId = `${Names.uniqueId(this)}${hash}`; const secret = this.node.defaultChild as secretsmanager.CfnSecret; secret.overrideLogicalId(logicalId.slice(-255)); // Take last 255 chars } 
Enter fullscreen mode Exit fullscreen mode

Summary

Basically, logical IDs do not change when migrating to a Stage, so replacement does not occur. However, since some resources may experience replacement, it's good to verify with snapshot tests and similar methods.

Top comments (0)