Skip to content

Commit 334008f

Browse files
authored
chore(core): ITaggableV2 is safe to implement for L1s (#25610)
`ITaggable` defines a property: `tags: TagManager`. This definition conflicts with many L1 resources: they have `<cfnProperty>: <PropType>` properties on them, and if a property is named `tags` they conflict. The old behavior is to have `tags: TagManager` for a select set of resources we recognize, and `tags: CfnResource.TagProperty[]` for all other resources. There is occlusion whichever way we choose these properties. Introduce a new interface, `ITaggable2 { tagManager: TagManager }`. I'm going to state there is *very little chance* of upstream properties being called `TagManager`, and so we can have both the `tags: CnfResource.TagProperty[]` property as well as the `tagManager: TagManager` property. The plan is to generate one of the following L1 patterns: ```ts // Old class CfnSomething implements ITaggable { readonly tags: TagManager; // Backwards compatible tagsRaw: CfnSomething.TagProperty[]; // Also allow direct access } // New class CfnSomething implements ITaggable2 { readonly tags: CfnSomething.TagProperty[]; // Backwards compatible readonly cdkTagManager: TagManager; // Allow access to the TagManager } ``` ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 21fc1d1 commit 334008f

File tree

7 files changed

+209
-28
lines changed

7 files changed

+209
-28
lines changed

packages/aws-cdk-lib/core/lib/cfn-resource.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -482,9 +482,12 @@ export class CfnResource extends CfnRefElement {
482482

483483
protected get cfnProperties(): { [key: string]: any } {
484484
const props = this._cfnProperties || {};
485-
if (TagManager.isTaggable(this)) {
485+
const tagMgr = TagManager.of(this);
486+
if (tagMgr) {
486487
const tagsProp: { [key: string]: any } = {};
487-
tagsProp[this.tags.tagPropertyName] = this.tags.renderTags();
488+
// If this object has a TagManager, then render it out into the correct field. We assume there
489+
// is no shadow tags object, so we don't pass anything to renderTags().
490+
tagsProp[tagMgr.tagPropertyName] = tagMgr.renderTags();
488491
return deepMerge(props, tagsProp);
489492
}
490493
return props;

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

+25-6
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Construct, IConstruct } from 'constructs';
22
import { Annotations } from './annotations';
33
import { IAspect, Aspects } from './aspect';
4-
import { ITaggable, TagManager } from './tag-manager';
4+
import { ITaggable, ITaggableV2, TagManager } from './tag-manager';
55

66
/**
77
* Properties for a tag
@@ -72,12 +72,15 @@ abstract class TagBase implements IAspect {
7272
}
7373

7474
public visit(construct: IConstruct): void {
75-
if (TagManager.isTaggable(construct)) {
75+
if (TagManager.isTaggableV2(construct)) {
76+
this.applyTagV2(construct);
77+
} else if (TagManager.isTaggable(construct)) {
7678
this.applyTag(construct);
7779
}
7880
}
7981

8082
protected abstract applyTag(resource: ITaggable): void;
83+
protected abstract applyTagV2(resource: ITaggableV2): void;
8184
}
8285

8386
/**
@@ -121,8 +124,16 @@ export class Tag extends TagBase {
121124
}
122125

123126
protected applyTag(resource: ITaggable) {
124-
if (resource.tags.applyTagAspectHere(this.props.includeResourceTypes, this.props.excludeResourceTypes)) {
125-
resource.tags.setTag(
127+
this.applyManager(resource.tags);
128+
}
129+
130+
protected applyTagV2(resource: ITaggableV2) {
131+
this.applyManager(resource.cdkTagManager);
132+
}
133+
134+
private applyManager(mgr: TagManager) {
135+
if (mgr.applyTagAspectHere(this.props.includeResourceTypes, this.props.excludeResourceTypes)) {
136+
mgr.setTag(
126137
this.key,
127138
this.value,
128139
this.props.priority ?? this.defaultPriority,
@@ -173,8 +184,16 @@ export class RemoveTag extends TagBase {
173184
}
174185

175186
protected applyTag(resource: ITaggable): void {
176-
if (resource.tags.applyTagAspectHere(this.props.includeResourceTypes, this.props.excludeResourceTypes)) {
177-
resource.tags.removeTag(this.key, this.props.priority ?? this.defaultPriority);
187+
this.applyManager(resource.tags);
188+
}
189+
190+
protected applyTagV2(resource: ITaggableV2): void {
191+
this.applyManager(resource.cdkTagManager);
192+
}
193+
194+
private applyManager(mgr: TagManager) {
195+
if (mgr.applyTagAspectHere(this.props.includeResourceTypes, this.props.excludeResourceTypes)) {
196+
mgr.removeTag(this.key, this.props.priority ?? this.defaultPriority);
178197
}
179198
}
180199
}

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

+58-10
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,23 @@ export interface ITaggable {
238238
readonly tags: TagManager;
239239
}
240240

241+
/**
242+
* Modernized version of ITaggable
243+
*
244+
* `ITaggable` has a problem: for a number of L1 resources, we failed to generate
245+
* `tags: TagManager`, and generated `tags: CfnSomeResource.TagProperty[]` instead.
246+
*
247+
* To mark these resources as taggable, we need to put the `TagManager` in a new property
248+
* whose name is unlikely to conflict with any existing properties. Hence, a new interface
249+
* for that purpose. All future resources will implement `ITaggableV2`.
250+
*/
251+
export interface ITaggableV2 {
252+
/**
253+
* TagManager to set, remove and format tags
254+
*/
255+
readonly cdkTagManager: TagManager;
256+
}
257+
241258
/**
242259
* Options to configure TagManager behavior
243260
*/
@@ -283,7 +300,22 @@ export class TagManager {
283300
* Check whether the given construct is Taggable
284301
*/
285302
public static isTaggable(construct: any): construct is ITaggable {
286-
return (construct as any).tags !== undefined;
303+
const tags = (construct as any).tags;
304+
return tags && typeof tags === 'object' && tags.constructor.name === 'TagManager';
305+
}
306+
307+
/**
308+
* Check whether the given construct is ITaggableV2
309+
*/
310+
public static isTaggableV2(construct: any): construct is ITaggableV2 {
311+
return (construct as any).cdkTagManager !== undefined;
312+
}
313+
314+
/**
315+
* Return the TagManager associated with the given construct, if any
316+
*/
317+
public static of(construct: any): TagManager | undefined {
318+
return TagManager.isTaggableV2(construct) ? construct.cdkTagManager : TagManager.isTaggable(construct) ? construct.tags : undefined;
287319
}
288320

289321
/**
@@ -303,21 +335,19 @@ export class TagManager {
303335
public readonly renderedTags: IResolvable;
304336

305337
private readonly tags = new Map<string, Tag>();
306-
private readonly dynamicTags: any;
338+
private dynamicTags?: any;
307339
private readonly priorities = new Map<string, number>();
308340
private readonly tagFormatter: ITagFormatter;
309341
private readonly resourceTypeName: string;
310-
private readonly initialTagPriority = 50;
342+
private readonly externalTagPriority = 50;
343+
private readonly didHaveInitialTags: boolean;
311344

312-
constructor(tagType: TagType, resourceTypeName: string, tagStructure?: any, options: TagManagerOptions = { }) {
345+
constructor(tagType: TagType, resourceTypeName: string, initialTags?: any, options: TagManagerOptions = { }) {
313346
this.resourceTypeName = resourceTypeName;
314347
this.tagFormatter = TAG_FORMATTERS()[tagType];
315-
if (tagStructure !== undefined) {
316-
const parseTagsResult = this.tagFormatter.parseTags(tagStructure, this.initialTagPriority);
317-
this.dynamicTags = parseTagsResult.dynamicTags;
318-
this._setTag(...parseTagsResult.tags);
319-
}
320348
this.tagPropertyName = options.tagPropertyName || 'tags';
349+
this.parseExternalTags(initialTags);
350+
this.didHaveInitialTags = initialTags !== undefined;
321351

322352
this.renderedTags = Lazy.any({ produce: () => this.renderTags() });
323353
}
@@ -353,8 +383,13 @@ export class TagManager {
353383
* which will return a `Lazy` value that will resolve to the correct
354384
* tags at synthesis time.
355385
*/
356-
public renderTags(): any {
386+
public renderTags(combineWithTags?: any): any {
387+
if (combineWithTags !== undefined && this.didHaveInitialTags) {
388+
throw new Error('Specify external tags either during the creation of TagManager, or as a parameter to renderTags(), but not both');
389+
}
390+
this.parseExternalTags(combineWithTags);
357391
const formattedTags = this.tagFormatter.formatTags(this.sortedTags);
392+
358393
if (Array.isArray(formattedTags) || Array.isArray(this.dynamicTags)) {
359394
const ret = [...formattedTags ?? [], ...this.dynamicTags ?? []];
360395
return ret.length > 0 ? ret : undefined;
@@ -412,4 +447,17 @@ export class TagManager {
412447
return Array.from(this.tags.values())
413448
.sort((a, b) => a.key.localeCompare(b.key));
414449
}
450+
451+
/**
452+
* Parse external tags.
453+
*
454+
* Set the parseable ones into this tag manager. Save the rest (tokens, lazies) in `this.dynamicTags`.
455+
*/
456+
private parseExternalTags(initialTags: any) {
457+
if (initialTags !== undefined) {
458+
const parseTagsResult = this.tagFormatter.parseTags(initialTags, this.externalTagPriority);
459+
this.dynamicTags = parseTagsResult.dynamicTags;
460+
this._setTag(...parseTagsResult.tags);
461+
}
462+
}
415463
}

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

+32-9
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { Construct } from 'constructs';
2-
import { CfnResource, CfnResourceProps, RemoveTag, Stack, Tag, TagManager, TagType, Aspects, Tags } from '../lib';
2+
import { toCloudFormation } from './util';
3+
import { CfnResource, CfnResourceProps, RemoveTag, Stack, Tag, TagManager, TagType, Aspects, Tags, ITaggable, ITaggableV2 } from '../lib';
34
import { synthesize } from '../lib/private/synthesis';
45

5-
class TaggableResource extends CfnResource {
6+
class TaggableResource extends CfnResource implements ITaggable {
67
public readonly tags: TagManager;
78
constructor(scope: Construct, id: string, props: CfnResourceProps) {
89
super(scope, id, props);
@@ -14,7 +15,19 @@ class TaggableResource extends CfnResource {
1415
}
1516
}
1617

17-
class AsgTaggableResource extends CfnResource {
18+
class TaggableResource2 extends CfnResource implements ITaggableV2 {
19+
public readonly cdkTagManager: TagManager;
20+
constructor(scope: Construct, id: string, props: CfnResourceProps) {
21+
super(scope, id, props);
22+
const tags = props.properties?.tags;
23+
this.cdkTagManager = new TagManager(TagType.STANDARD, 'AWS::Fake::Resource', tags);
24+
}
25+
public testProperties() {
26+
return this.cfnProperties;
27+
}
28+
}
29+
30+
class AsgTaggableResource extends CfnResource implements ITaggable {
1831
public readonly tags: TagManager;
1932
constructor(scope: Construct, id: string, props: CfnResourceProps) {
2033
super(scope, id, props);
@@ -26,7 +39,7 @@ class AsgTaggableResource extends CfnResource {
2639
}
2740
}
2841

29-
class MapTaggableResource extends CfnResource {
42+
class MapTaggableResource extends CfnResource implements ITaggable {
3043
public readonly tags: TagManager;
3144
constructor(scope: Construct, id: string, props: CfnResourceProps) {
3245
super(scope, id, props);
@@ -39,12 +52,14 @@ class MapTaggableResource extends CfnResource {
3952
}
4053

4154
describe('tag aspect', () => {
42-
test('Tag visit all children of the applied node', () => {
55+
test.each([
56+
['TaggableResource', TaggableResource], ['TaggableResource2', TaggableResource2],
57+
])('Tag visit all children of the applied node, using class %s', (_, taggableClass) => {
4358
const root = new Stack();
44-
const res = new TaggableResource(root, 'FakeResource', {
59+
const res = new taggableClass(root, 'FakeResource', {
4560
type: 'AWS::Fake::Thing',
4661
});
47-
const res2 = new TaggableResource(res, 'FakeResource', {
62+
const res2 = new taggableClass(res, 'FakeResource', {
4863
type: 'AWS::Fake::Thing',
4964
});
5065
const asg = new AsgTaggableResource(res, 'AsgFakeResource', {
@@ -58,10 +73,18 @@ describe('tag aspect', () => {
5873

5974
synthesize(root);
6075

61-
expect(res.tags.renderTags()).toEqual([{ key: 'foo', value: 'bar' }]);
62-
expect(res2.tags.renderTags()).toEqual([{ key: 'foo', value: 'bar' }]);
76+
expect(TagManager.of(res)?.renderTags()).toEqual([{ key: 'foo', value: 'bar' }]);
77+
expect(TagManager.of(res2)?.renderTags()).toEqual([{ key: 'foo', value: 'bar' }]);
6378
expect(map.tags.renderTags()).toEqual({ foo: 'bar' });
6479
expect(asg.tags.renderTags()).toEqual([{ key: 'foo', value: 'bar', propagateAtLaunch: true }]);
80+
81+
const template = toCloudFormation(root);
82+
expect(template.Resources.FakeResource).toEqual({
83+
Type: 'AWS::Fake::Thing',
84+
Properties: {
85+
tags: [{ key: 'foo', value: 'bar' }],
86+
},
87+
});
6588
});
6689

6790
test('The last aspect applied takes precedence', () => {

packages/aws-cdk-lib/core/test/tag-manager.test.ts

+83
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,34 @@ describe('tag manager', () => {
99
expect(mgr.tagPropertyName).toEqual(tagPropName);
1010
});
1111

12+
test.each(['early' as const, 'late' as const])('supplying tags %s works for MAP tags', (when) => {
13+
const externalTags = { someTag: 'someValue' };
14+
const mgr = new TagManager(TagType.MAP, 'Foo', when === 'early' ? externalTags : undefined);
15+
mgr.setTag('givenTag', 'givenValue');
16+
17+
expect(mgr.renderTags(when === 'late' ? externalTags : undefined)).toEqual({
18+
givenTag: 'givenValue',
19+
someTag: 'someValue',
20+
});
21+
});
22+
23+
test.each(['early' as const, 'late' as const])('supplying tags %s works for STANDARD tags', (when) => {
24+
const externalTags = [{ key: 'someTag', value: 'someValue' }];
25+
const mgr = new TagManager(TagType.STANDARD, 'Foo', when === 'early' ? externalTags : undefined);
26+
mgr.setTag('givenTag', 'givenValue');
27+
28+
expect(mgr.renderTags(when === 'late' ? externalTags : undefined)).toEqual([
29+
{
30+
key: 'givenTag',
31+
value: 'givenValue',
32+
},
33+
{
34+
key: 'someTag',
35+
value: 'someValue',
36+
},
37+
]);
38+
});
39+
1240
test('#setTag() supports setting a tag regardless of Type', () => {
1341
const notTaggable = new TagManager(TagType.NOT_TAGGABLE, 'AWS::Resource::Type');
1442
notTaggable.setTag('key', 'value');
@@ -129,6 +157,61 @@ describe('tag manager', () => {
129157
]);
130158
});
131159

160+
test('can add direct tags: STANDARD', () => {
161+
// GIVEN
162+
const mgr = new TagManager(TagType.STANDARD, 'AWS::Resource::Type');
163+
164+
// WHEN
165+
mgr.setTag('key', 'value');
166+
const rendered = mgr.renderTags([
167+
{ key: 'key2', value: 'value2' },
168+
]);
169+
170+
// THEN
171+
expect(rendered).toEqual([
172+
{ key: 'key', value: 'value' },
173+
{ key: 'key2', value: 'value2' },
174+
]);
175+
});
176+
177+
test('can add direct tags: MAP', () => {
178+
// GIVEN
179+
const mgr = new TagManager(TagType.MAP, 'AWS::Resource::Type');
180+
181+
// WHEN
182+
mgr.setTag('key', 'value');
183+
const rendered = mgr.renderTags({
184+
key2: 'value2',
185+
});
186+
187+
// THEN
188+
expect(rendered).toEqual({
189+
key: 'value',
190+
key2: 'value2',
191+
});
192+
});
193+
194+
test('may not specify external tags both at TagManager creation AND into renderTags', () => {
195+
// GIVEN
196+
const mgr = new TagManager(TagType.MAP, 'AWS::Resource::Type', { initial: 'tag' });
197+
198+
// WHEN
199+
expect(() => mgr.renderTags({
200+
external: 'tag',
201+
})).toThrow(/not both/);
202+
});
203+
204+
test('it is safe to call renderTags multiple times with external tags', () => {
205+
// GIVEN
206+
const mgr = new TagManager(TagType.STANDARD, 'AWS::Resource::Type');
207+
mgr.setTag('tagOne', 'one');
208+
mgr.setTag('tagTwo', 'two');
209+
210+
// WHEN
211+
const renders = [1, 2].map(() => mgr.renderTags([{ key: 'external', value: 'tag' }]));
212+
expect(renders[0]).toEqual(renders[1]);
213+
});
214+
132215
test('excludeResourceTypes only tags resources that do not match', () => {
133216
const mgr = new TagManager(TagType.STANDARD, 'AWS::Fake::Resource');
134217

tools/@aws-cdk/cfn2ts/lib/codegen.ts

+3
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ enum TreeAttributes {
1717
CFN_PROPS = 'aws:cdk:cloudformation:props'
1818
}
1919

20+
export const LEGACY_TAGGING: Record<string, string> = {};
21+
2022
interface Dictionary<T> { [key: string]: T; }
2123

2224
export interface CodeGeneratorOptions {
@@ -351,6 +353,7 @@ export default class CodeGenerator {
351353
this.code.line();
352354
for (const prop of Object.values(propMap)) {
353355
if (schema.isTagPropertyName(upcaseFirst(prop)) && schema.isTaggableResource(spec)) {
356+
LEGACY_TAGGING[cfnName] = upcaseFirst(prop);
354357
this.code.line(`this.tags = new ${TAG_MANAGER}(${tagType(spec)}, ${cfnResourceTypeName}, props.${prop}, { tagPropertyName: '${prop}' });`);
355358
} else {
356359
this.code.line(`this.${prop} = props.${prop};`);

0 commit comments

Comments
 (0)