Skip to content

Commit 4c7d9f4

Browse files
authored
fix(tags): excludeResourceTypes does not work for tags applied at stack level (under feature flag) (#31443)
Stacks are considered taggable, and so `Tags.of(this).add('key', 'value')` used to add tags to Stacks in scope. Usually this happens if `this` is an instance of `Stack`, which it commonly is in user code. Since `Tags.of(...)` walks the construct tree, it will add tags to the stack *and* to all the resources in the stack. Then, come deploy time, CloudFormation will also try and apply all the stack tags to the resources again. This is both unnecessary, as well as leads to loss of control: `excludeResourceTypes` appears to not work, since it will lead to resources not being tagged in the template (good) but then the resources will still be tagged by CloudFormation because the stack itself is tagged (bad). Also, if the tags applied this way contain intrinsics, they will contain nonsense because they are applied in a context where CloudFormation expressions don't work. ## In this change There is way to prevent Stacks from being tagged, by including `aws:cdk:stack` in the list of `excludeResourceTypes` (this is a fake resource type that Stack tags respect). Under a feature flag, `@aws-cdk/core:explicitStackTags`, this is now the default behavior. That resource type will be excluded by default, unless it is listed in the `includeResourceTypes` list. However, doing `includeResourceTypes` is still not desirable: stack tags should be applied directly on the `Stack` object if desired. This requires a user to make a conscious decision between resource-level and stack-level tagging: either apply tags to the stack, which will apply it to all resources but remove the ability to do `excludeResourceTypes`; or apply tags to (groups of) resources inside the template. Another benefit is that for tags applied at the stack level, this will resolve the following issue: #15947, as resources "becoming" taggable all of a sudden will not affect the template anymore. Closes #28017. Closes #33945. Closes #30055. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 748d03c commit 4c7d9f4

File tree

6 files changed

+216
-3
lines changed

6 files changed

+216
-3
lines changed

packages/aws-cdk-lib/core/lib/stack.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,15 @@ export interface StackProps {
121121
readonly stackName?: string;
122122

123123
/**
124-
* Stack tags that will be applied to all the taggable resources and the stack itself.
124+
* Tags that will be applied to the Stack
125+
*
126+
* These tags are applied to the CloudFormation Stack itself. They will not
127+
* appear in the CloudFormation template.
128+
*
129+
* However, at deployment time, CloudFormation will apply these tags to all
130+
* resources in the stack that support tagging. You will not be able to exempt
131+
* resources from tagging (using the `excludeResourceTypes` property of
132+
* `Tags.of(...).add()`) for tags applied in this way.
125133
*
126134
* @default {}
127135
*/
@@ -1609,6 +1617,24 @@ export class Stack extends Construct implements ITaggable {
16091617
pattern,
16101618
));
16111619
}
1620+
1621+
/**
1622+
* Configure a stack tag
1623+
*
1624+
* At deploy time, CloudFormation will automatically apply all stack tags to all resources in the stack.
1625+
*/
1626+
public addStackTag(tagName: string, tagValue: string) {
1627+
this.tags.setTag(tagName, tagValue);
1628+
}
1629+
1630+
/**
1631+
* Remove a stack tag
1632+
*
1633+
* At deploy time, CloudFormation will automatically apply all stack tags to all resources in the stack.
1634+
*/
1635+
public removeStackTag(tagName: string) {
1636+
this.tags.removeTag(tagName, 0);
1637+
}
16121638
}
16131639

16141640
function merge(template: any, fragment: any): void {

packages/aws-cdk-lib/core/lib/tag-aspect.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { Construct, IConstruct } from 'constructs';
22
import { Annotations } from './annotations';
33
import { IAspect, Aspects, AspectOptions } from './aspect';
44
import { UnscopedValidationError } from './errors';
5+
import { FeatureFlags } from './feature-flags';
6+
import * as cxapi from '../../cx-api';
57
import { mutatingAspectPrio32333 } from './private/aspect-prio';
68
import { ITaggable, ITaggableV2, TagManager } from './tag-manager';
79

@@ -22,6 +24,7 @@ export interface TagProps {
2224
* An empty array will allow this tag to be applied to all resources. A
2325
* non-empty array will apply this tag only if the Resource type is not in
2426
* this array.
27+
*
2528
* @default []
2629
*/
2730
readonly excludeResourceTypes?: string[];
@@ -155,12 +158,41 @@ export class Tags {
155158
return new Tags(scope);
156159
}
157160

158-
private constructor(private readonly scope: IConstruct) { }
161+
private readonly explicitStackTags: boolean;
162+
163+
private constructor(private readonly scope: IConstruct) {
164+
this.explicitStackTags = FeatureFlags.of(scope).isEnabled(cxapi.EXPLICIT_STACK_TAGS) ?? false;
165+
}
159166

160167
/**
161-
* add tags to the node of a construct and all its the taggable children
168+
* Add tags to the node of a construct and all its the taggable children
169+
*
170+
* ## Tagging and CloudFormation Stacks
171+
*
172+
* If the feature flag `@aws-cdk/core:explicitStackTags` is set to `true`
173+
* (recommended modern behavior), Stacks will not automatically be tagged.
174+
* Stack tags should be configured on Stacks directly (preferred), or
175+
* you must explicitly include the resource type `aws:cdk:stack` in the
176+
* `includeResourceTypes` array.
177+
*
178+
* If the feature flag is set to `false` (legacy behavior) then both Stacks
179+
* and resources in the indicated scope will both be tagged by default, which
180+
* leads to tags being applied twice (once in the template, and then once
181+
* again automatically by CloudFormation as part of the stack deployment).
182+
* That behavior leads to loss of control as `excludeResourceTypes` will
183+
* prevent tags from appearing in the template, but they will still be
184+
* applied to the Stack and hence CloudFormation will still apply them
185+
* to the resource.
162186
*/
163187
public add(key: string, value: string, props: TagProps = {}) {
188+
// Implicitly add `aws:cdk:stack` to the `excludeResourceTypes` array in modern behavior
189+
if (this.explicitStackTags && !props.includeResourceTypes?.includes('aws:cdk:stack')) {
190+
props = {
191+
...props,
192+
excludeResourceTypes: [...props.excludeResourceTypes ?? [], 'aws:cdk:stack'],
193+
};
194+
}
195+
164196
const tag = new Tag(key, value, props);
165197
const options: AspectOptions = { priority: mutatingAspectPrio32333(this.scope) };
166198
Aspects.of(this.scope).add(tag, options);

packages/aws-cdk-lib/core/test/stack.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ import {
1414
PERMISSIONS_BOUNDARY_CONTEXT_KEY,
1515
Aspects,
1616
Stage,
17+
TagManager,
18+
Resource,
19+
TagType,
20+
ITaggable,
21+
ITaggableV2,
1722
Token,
1823
} from '../lib';
1924
import { Intrinsic } from '../lib/private/intrinsic';
@@ -2162,6 +2167,89 @@ describe('stack', () => {
21622167
expect(asm.getStackArtifact(stack2.artifactId).tags).toEqual(expected);
21632168
});
21642169

2170+
describe.each([false, true])(`with ${cxapi.EXPLICIT_STACK_TAGS} set to %p`, (explicitStackTags) => {
2171+
let app: App;
2172+
2173+
beforeEach(() => {
2174+
app = new App({
2175+
stackTraces: false,
2176+
context: {
2177+
[cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT]: false,
2178+
[cxapi.EXPLICIT_STACK_TAGS]: explicitStackTags,
2179+
},
2180+
});
2181+
});
2182+
2183+
test('stack tags are both in stack metadata and stack artifact properties', () => {
2184+
// GIVEN
2185+
2186+
const stack = new Stack(app, 'stack1', {
2187+
tags: {
2188+
foo: 'bar',
2189+
},
2190+
});
2191+
2192+
// THEN
2193+
const asm = app.synth();
2194+
2195+
const stackArtifact = asm.getStackArtifact(stack.artifactId);
2196+
expect(stackArtifact.manifest.metadata).toEqual({
2197+
'/stack1': [
2198+
{
2199+
type: 'aws:cdk:stack-tags',
2200+
data: [{ key: 'foo', value: 'bar' }],
2201+
},
2202+
],
2203+
});
2204+
expect(stackArtifact.tags).toEqual({ foo: 'bar' });
2205+
});
2206+
2207+
test('stack tags do not appear in template', () => {
2208+
const stack = new Stack(app, 'stack1', {
2209+
tags: {
2210+
foo: 'bar',
2211+
},
2212+
});
2213+
new TaggableResource(stack, 'res');
2214+
2215+
// THEN
2216+
const asm = app.synth();
2217+
const stackArtifact = asm.getStackArtifact(stack.artifactId);
2218+
expect(stackArtifact.template.Resources.res).toEqual({
2219+
Type: 'AWS::Taggable::Resource',
2220+
Properties: {
2221+
R: 1,
2222+
},
2223+
});
2224+
});
2225+
2226+
test('tags added using Tags.of() are or are not applied to Stacks', () => {
2227+
const stack = new Stack(app, 'stack1');
2228+
Tags.of(stack).add('resourceTag', 'resourceValue');
2229+
2230+
// THEN
2231+
const asm = app.synth();
2232+
const stackArtifact = asm.getStackArtifact(stack.artifactId);
2233+
if (explicitStackTags) {
2234+
expect(stackArtifact.tags).toEqual({});
2235+
} else {
2236+
expect(stackArtifact.tags).toEqual({ resourceTag: 'resourceValue' });
2237+
}
2238+
});
2239+
2240+
test('tags added using Tags.of() are applied to Stacks if explicitly included', () => {
2241+
const stack = new Stack(app, 'stack1');
2242+
Tags.of(stack).add('resourceTag', 'resourceValue', {
2243+
includeResourceTypes: ['aws:cdk:stack'],
2244+
});
2245+
2246+
// THEN
2247+
const asm = app.synth();
2248+
const stackArtifact = asm.getStackArtifact(stack.artifactId);
2249+
expect(stackArtifact.tags).toEqual({ resourceTag: 'resourceValue' });
2250+
});
2251+
});
2252+
21652253
test('warning when stack tags contain tokens', () => {
21662254
// GIVEN
21672255
const app = new App({
@@ -2573,3 +2661,19 @@ class StackWithPostProcessor extends Stack {
25732661
return template;
25742662
}
25752663
}
2664+
2665+
class TaggableResource extends CfnResource implements ITaggableV2 {
2666+
public readonly cdkTagManager = new TagManager(TagType.KEY_VALUE, 'TaggableResource', {}, {
2667+
tagPropertyName: 'Tags',
2668+
});
2669+
2670+
constructor(scope: Construct, id: string) {
2671+
super(scope, id, {
2672+
type: 'AWS::Taggable::Resource',
2673+
properties: {
2674+
R: 1,
2675+
Tags: Lazy.any({ produce: () => this.cdkTagManager.renderTags() }),
2676+
},
2677+
});
2678+
}
2679+
}

packages/aws-cdk-lib/cx-api/FEATURE_FLAGS.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ Flags come in three types:
104104
| [@aws-cdk/aws-s3:publicAccessBlockedByDefault](#aws-cdkaws-s3publicaccessblockedbydefault) | When enabled, setting any combination of options for BlockPublicAccess will automatically set true for any options not defined. | 2.196.0 | fix |
105105
| [@aws-cdk/aws-lambda:useCdkManagedLogGroup](#aws-cdkaws-lambdausecdkmanagedloggroup) | When enabled, CDK creates and manages loggroup for the lambda function | 2.200.0 | new default |
106106
| [@aws-cdk/aws-kms:applyImportedAliasPermissionsToPrincipal](#aws-cdkaws-kmsapplyimportedaliaspermissionstoprincipal) | Enable grant methods on Aliases imported by name to use kms:ResourceAliases condition | 2.202.0 | fix |
107+
| [@aws-cdk/core:explicitStackTags](#aws-cdkcoreexplicitstacktags) | When enabled, stack tags need to be assigned explicitly on a Stack. | V2NEXT | new default |
107108

108109
<!-- END table -->
109110

@@ -167,6 +168,7 @@ The following json shows the current recommended set of flags, as `cdk init` wou
167168
"@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true,
168169
"@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false,
169170
"@aws-cdk/aws-s3:keepNotificationInImportedBucket": false,
171+
"@aws-cdk/core:explicitStackTags": true,
170172
"@aws-cdk/aws-ecs:enableImdsBlockingDeprecatedFeature": false,
171173
"@aws-cdk/aws-ecs:disableEcsImdsBlocking": true,
172174
"@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true,
@@ -2196,4 +2198,31 @@ When disabled, grant calls on imported aliases will be dropped (no-op) to mainta
21962198
**Compatibility with old behavior:** Remove calls to the grant* methods on the aliases referenced by name
21972199

21982200

2201+
### @aws-cdk/core:explicitStackTags
2202+
2203+
*When enabled, stack tags need to be assigned explicitly on a Stack.*
2204+
2205+
Flag type: New default behavior
2206+
2207+
Without this feature flag enabled, if tags are added to a Stack using
2208+
`Tags.of(scope).add(...)`, they will be added to both the stack and all resources
2209+
in the stack template.
2210+
2211+
That leads to the tags being applied twice: once in the template, and once
2212+
again automatically by CloudFormation, which will apply all stack tags to
2213+
all resources in the stack. This leads to loss of control, as the
2214+
`excludeResourceTypes` option of the Tags API will not have any effect.
2215+
2216+
With this flag enabled, tags added to a stack using `Tags.of(...)` are ignored,
2217+
and Stack tags must be configured explicitly on the Stack object.
2218+
2219+
2220+
| Since | Unset behaves like | Recommended value |
2221+
| ----- | ----- | ----- |
2222+
| (not in v1) | | |
2223+
| V2NEXT | `false` | `true` |
2224+
2225+
**Compatibility with old behavior:** Configure stack-level tags using `new Stack(..., { tags: { ... } })`.
2226+
2227+
21992228
<!-- END details -->

packages/aws-cdk-lib/cx-api/lib/features.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export const ECS_REMOVE_DEFAULT_DEPLOYMENT_ALARM = '@aws-cdk/aws-ecs:removeDefau
112112
export const LOG_API_RESPONSE_DATA_PROPERTY_TRUE_DEFAULT = '@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault';
113113
export const S3_KEEP_NOTIFICATION_IN_IMPORTED_BUCKET = '@aws-cdk/aws-s3:keepNotificationInImportedBucket';
114114
export const USE_NEW_S3URI_PARAMETERS_FOR_BEDROCK_INVOKE_MODEL_TASK = '@aws-cdk/aws-stepfunctions-tasks:useNewS3UriParametersForBedrockInvokeModelTask';
115+
export const EXPLICIT_STACK_TAGS = '@aws-cdk/core:explicitStackTags';
115116
export const REDUCE_EC2_FARGATE_CLOUDWATCH_PERMISSIONS = '@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions';
116117
export const DYNAMODB_TABLEV2_RESOURCE_POLICY_PER_REPLICA = '@aws-cdk/aws-dynamodb:resourcePolicyPerReplica';
117118
export const EC2_SUM_TIMEOUT_ENABLED = '@aws-cdk/aws-ec2:ec2SumTImeoutEnabled';
@@ -1177,6 +1178,26 @@ export const FLAGS: Record<string, FlagInfo> = {
11771178
},
11781179

11791180
//////////////////////////////////////////////////////////////////////
1181+
[EXPLICIT_STACK_TAGS]: {
1182+
type: FlagType.ApiDefault,
1183+
summary: 'When enabled, stack tags need to be assigned explicitly on a Stack.',
1184+
detailsMd: `
1185+
Without this feature flag enabled, if tags are added to a Stack using
1186+
\`Tags.of(scope).add(...)\`, they will be added to both the stack and all resources
1187+
in the stack template.
1188+
1189+
That leads to the tags being applied twice: once in the template, and once
1190+
again automatically by CloudFormation, which will apply all stack tags to
1191+
all resources in the stack. This leads to loss of control, as the
1192+
\`excludeResourceTypes\` option of the Tags API will not have any effect.
1193+
1194+
With this flag enabled, tags added to a stack using \`Tags.of(...)\` are ignored,
1195+
and Stack tags must be configured explicitly on the Stack object.
1196+
`,
1197+
introducedIn: { v2: 'V2NEXT' },
1198+
recommendedValue: true,
1199+
compatibilityWithOldBehaviorMd: 'Configure stack-level tags using `new Stack(..., { tags: { ... } })`.',
1200+
},
11801201
[Enable_IMDS_Blocking_Deprecated_Feature]: {
11811202
type: FlagType.Temporary,
11821203
summary: 'When set to true along with canContainersAccessInstanceRole=false in ECS cluster, new updated ' +

packages/aws-cdk-lib/recommended-feature-flags.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true,
5252
"@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false,
5353
"@aws-cdk/aws-s3:keepNotificationInImportedBucket": false,
54+
"@aws-cdk/core:explicitStackTags": true,
5455
"@aws-cdk/aws-ecs:enableImdsBlockingDeprecatedFeature": false,
5556
"@aws-cdk/aws-ecs:disableEcsImdsBlocking": true,
5657
"@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true,

0 commit comments

Comments
 (0)