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;
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');
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; } }
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, });
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}`, });
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));
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', });
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, });
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'), });
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));
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));
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'), });
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 }
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)