Skip to content

Commit 72f189d

Browse files
authored
feat(assertions): add stack tagging assertions (#29247)
Adds a `Tag` class to the assertions library that permits assertions against tags on synthesized CDK stacks. Tags on AWS resources can be checked via assertions with the Template class, but since stack tags only appear on the cloud assembly manifest as a sibling of the template, a separate assertion mechanism is required. > [!NOTE] > Previously submitted as #27633, which was closed automatically while waiting for review. > > This PR is complete to the best of my knowledge, needing only an exemption from integration tests. As far as I can tell, this area of the library has no integration tests directly associated. ### API ```ts class Tags { public static fromStack(stack: Stack) : Tags; public hasValues(props: any): void; public all(): { [key: string]: string }; } ``` ### Usage This new class permits tests of the form: ```ts import { App, Stack } from 'aws-cdk-lib'; import { Tags } from 'aws-cdk-lib/assertions'; const app = new App(); const stack = new Stack(app, 'MyStack', { tags: { 'tag-name': 'tag-value' } }); const tags = Tags.fromStack(stack); // using a default 'objectLike' Matcher tags.hasValues({ 'tag-name': 'tag-value' }); // or another Matcher tags.hasValues({ 'tag-name': Match.anyValue() }); ``` You can also get the set of tags to test them in other ways: ```ts tags.all() ``` ## Issues ### No tags case One might expect that the case where no tags are present would match `undefined` or `null`, but since the Cloud Assembly API defaults tags to `{}` when none are present, this isn't possible. It's also not practical to directly test the `artifact.manifest.properties.tags` value directly, as there is a legacy case that the API handles. This means that the `artifact.tags` property is the best value to check against. The tests for this PR show that matching with `Match.absent()` will fail when there are no tags, but testing against the empty object will succeed. I think that this behaviour (defaulting to empty) will be OK, but potentially require a callout on the assertion method. ### API method naming The current suggested API went through some evolution, starting with: ```ts class Tags { public static fromStack(stack: Stack) : Tags; public hasTags(props: any): void; public getTags(): { [key: string]: string }; } ``` But this stuttered, and `getTags()` wasn't compatible with Java. I considered: ```ts class Tags { public static fromStack(stack: Stack) : Tags; public hasValues(props: any): void; public values(): { [key: string]: string }; } ``` and ```ts class Tags { public static fromStack(stack: Stack) : Tags; public has(props: any): void; public all(): { [key: string]: string }; } ``` ... before settling on a mix of the two. I think the current iteration fits with the rest of the `assertions` API and makes sense by itself, but very open to changes. Closes #27620. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 58ebabc commit 72f189d

File tree

5 files changed

+285
-2
lines changed

5 files changed

+285
-2
lines changed

packages/aws-cdk-lib/assertions/README.md

+54
Original file line numberDiff line numberDiff line change
@@ -595,3 +595,57 @@ Annotations.fromStack(stack).hasError(
595595
Match.stringLikeRegexp('.*Foo::Bar.*'),
596596
);
597597
```
598+
599+
## Asserting Stack tags
600+
601+
Tags applied to a `Stack` are not part of the rendered template: instead, they
602+
are included as properties in the Cloud Assembly Manifest. To test that stacks
603+
are tagged as expected, simple assertions can be written.
604+
605+
Given the following setup:
606+
607+
```ts nofixture
608+
import { App, Stack } from 'aws-cdk-lib';
609+
import { Tags } from 'aws-cdk-lib/assertions';
610+
611+
const app = new App();
612+
const stack = new Stack(app, 'MyStack', {
613+
tags: {
614+
'tag-name': 'tag-value',
615+
},
616+
});
617+
```
618+
619+
It is possible to test against these values:
620+
621+
```ts
622+
const tags = Tags.fromStack(stack);
623+
624+
// using a default 'objectLike' Matcher
625+
tags.hasValues({
626+
'tag-name': 'tag-value',
627+
});
628+
629+
// ... with Matchers embedded
630+
tags.hasValues({
631+
'tag-name': Match.stringLikeRegexp('value'),
632+
});
633+
634+
// or another object Matcher at the top level
635+
tags.hasValues(Match.objectEquals({
636+
'tag-name': Match.anyValue(),
637+
}));
638+
```
639+
640+
When tags are not defined on the stack, it is represented as an empty object
641+
rather than `undefined`. To make this more obvious, there is a `hasNone()`
642+
method that can be used in place of `Match.exactly({})`. If `Match.absent()` is
643+
passed, an error will result.
644+
645+
```ts
646+
// no tags present
647+
Tags.fromStack(stack).hasNone();
648+
649+
// don't use absent() at the top level, it won't work
650+
expect(() => { Tags.fromStack(stack).hasValues(Match.absent()); }).toThrow(/will never match/i);
651+
```

packages/aws-cdk-lib/assertions/lib/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ export * from './capture';
22
export * from './template';
33
export * from './match';
44
export * from './matcher';
5-
export * from './annotations';
5+
export * from './annotations';
6+
export * from './tags';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { Match } from './match';
2+
import { Matcher } from './matcher';
3+
import { Stack, Stage } from '../../core';
4+
5+
type ManifestTags = { [key: string]: string };
6+
7+
/**
8+
* Allows assertions on the tags associated with a synthesized CDK stack's
9+
* manifest. Stack tags are not part of the synthesized template, so can only be
10+
* checked from the manifest in this manner.
11+
*/
12+
export class Tags {
13+
/**
14+
* Find tags associated with a synthesized CDK `Stack`.
15+
*
16+
* @param stack the CDK Stack to find tags on.
17+
*/
18+
public static fromStack(stack: Stack): Tags {
19+
return new Tags(getManifestTags(stack));
20+
}
21+
22+
private readonly _tags: ManifestTags;
23+
24+
private constructor(tags: ManifestTags) {
25+
this._tags = tags;
26+
}
27+
28+
/**
29+
* Assert that the given Matcher or object matches the tags associated with
30+
* the synthesized CDK Stack's manifest.
31+
*
32+
* @param tags the expected set of tags. This should be a
33+
* string or Matcher object.
34+
*/
35+
public hasValues(tags: any): void {
36+
// The Cloud Assembly API defaults tags to {} when undefined. Using
37+
// Match.absent() will not work as the caller expects, so we push them
38+
// towards a working API.
39+
if (Matcher.isMatcher(tags) && tags.name === 'absent') {
40+
throw new Error(
41+
'Match.absent() will never match Tags because "{}" is the default value. Use Tags.hasNone() instead.',
42+
);
43+
}
44+
45+
const matcher = Matcher.isMatcher(tags) ? tags : Match.objectLike(tags);
46+
47+
const result = matcher.test(this.all());
48+
if (result.hasFailed()) {
49+
throw new Error(
50+
'Stack tags did not match as expected:\n' + result.renderMismatch(),
51+
);
52+
}
53+
}
54+
55+
/**
56+
* Assert that the there are no tags associated with the synthesized CDK
57+
* Stack's manifest.
58+
*
59+
* This is a convenience method over `hasValues(Match.exact({}))`, and is
60+
* present because the more obvious method of detecting no tags
61+
* (`Match.absent()`) will not work. Manifests default the tag set to an empty
62+
* object.
63+
*/
64+
public hasNone(): void {
65+
this.hasValues(Match.exact({}));
66+
}
67+
68+
/**
69+
* Get the tags associated with the manifest. This will be an empty object if
70+
* no tags were supplied.
71+
*
72+
* @returns The tags associated with the stack's synthesized manifest.
73+
*/
74+
public all(): ManifestTags {
75+
return this._tags;
76+
}
77+
}
78+
79+
function getManifestTags(stack: Stack): ManifestTags {
80+
const root = stack.node.root;
81+
if (!Stage.isStage(root)) {
82+
throw new Error('unexpected: all stacks must be part of a Stage or an App');
83+
}
84+
85+
// synthesis is not forced: the stack will only be synthesized once regardless
86+
// of the number of times this is called.
87+
const assembly = root.synth();
88+
89+
const artifact = assembly.getStackArtifact(stack.artifactId);
90+
return artifact.tags;
91+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { App, Stack } from '../../core';
2+
import { Match, Tags } from '../lib';
3+
4+
describe('Tags', () => {
5+
let app: App;
6+
7+
beforeEach(() => {
8+
app = new App();
9+
});
10+
11+
describe('hasValues', () => {
12+
test('simple match', () => {
13+
const stack = new Stack(app, 'stack', {
14+
tags: { 'tag-one': 'tag-one-value' },
15+
});
16+
const tags = Tags.fromStack(stack);
17+
tags.hasValues({
18+
'tag-one': 'tag-one-value',
19+
});
20+
});
21+
22+
test('with matchers', () => {
23+
const stack = new Stack(app, 'stack', {
24+
tags: { 'tag-one': 'tag-one-value' },
25+
});
26+
const tags = Tags.fromStack(stack);
27+
tags.hasValues({
28+
'tag-one': Match.anyValue(),
29+
});
30+
});
31+
32+
describe('given multiple tags', () => {
33+
const stack = new Stack(app, 'stack', {
34+
tags: {
35+
'tag-one': 'tag-one-value',
36+
'tag-two': 'tag-2-value',
37+
'tag-three': 'tag-3-value',
38+
'tag-four': 'tag-4-value',
39+
},
40+
});
41+
const tags = Tags.fromStack(stack);
42+
43+
test('partial match succeeds', ()=>{
44+
tags.hasValues({
45+
'tag-one': Match.anyValue(),
46+
});
47+
});
48+
49+
test('complex match succeeds', ()=>{
50+
tags.hasValues(Match.objectEquals({
51+
'tag-one': Match.anyValue(),
52+
'non-existent': Match.absent(),
53+
'tag-three': Match.stringLikeRegexp('-3-'),
54+
'tag-two': 'tag-2-value',
55+
'tag-four': Match.anyValue(),
56+
}));
57+
});
58+
});
59+
60+
test('no tags with absent matcher will fail', () => {
61+
const stack = new Stack(app, 'stack');
62+
const tags = Tags.fromStack(stack);
63+
64+
// Since the tags are defaulted to the empty object, using the `absent()`
65+
// matcher will never work, instead throwing an error.
66+
expect(() => tags.hasValues(Match.absent())).toThrow(
67+
/^match.absent\(\) will never match Tags/i,
68+
);
69+
});
70+
71+
test('no tags matches empty object successfully', () => {
72+
const stack = new Stack(app, 'stack');
73+
const tags = Tags.fromStack(stack);
74+
75+
tags.hasValues(Match.exact({}));
76+
});
77+
78+
test('no match', () => {
79+
const stack = new Stack(app, 'stack', {
80+
tags: { 'tag-one': 'tag-one-value' },
81+
});
82+
const tags = Tags.fromStack(stack);
83+
84+
expect(() =>
85+
tags.hasValues({
86+
'tag-one': 'mismatched value',
87+
}),
88+
).toThrow(/Expected mismatched value but received tag-one-value/);
89+
});
90+
});
91+
92+
describe('hasNone', () => {
93+
test.each([undefined, {}])('matches empty: %s', (v) => {
94+
const stack = new Stack(app, 'stack', { tags: v });
95+
const tags = Tags.fromStack(stack);
96+
97+
tags.hasNone();
98+
});
99+
100+
test.each(<Record<string, string>[]>[
101+
{ ['tagOne']: 'single-tag' },
102+
{ ['tagOne']: 'first-value', ['tag-two']: 'second-value' },
103+
])('does not match with values: %s', (v) => {
104+
const stack = new Stack(app, 'stack', { tags: v });
105+
const tags = Tags.fromStack(stack);
106+
107+
expect(() => tags.hasNone()).toThrow(/unexpected key/i);
108+
});
109+
});
110+
111+
describe('all', () => {
112+
test('simple match', () => {
113+
const stack = new Stack(app, 'stack', {
114+
tags: { 'tag-one': 'tag-one-value' },
115+
});
116+
const tags = Tags.fromStack(stack);
117+
expect(tags.all()).toStrictEqual({
118+
'tag-one': 'tag-one-value',
119+
});
120+
});
121+
122+
test('no tags', () => {
123+
const stack = new Stack(app, 'stack');
124+
const tags = Tags.fromStack(stack);
125+
126+
expect(tags.all()).toStrictEqual({});
127+
});
128+
129+
test('empty tags', () => {
130+
const stack = new Stack(app, 'stack', { tags: {} });
131+
const tags = Tags.fromStack(stack);
132+
133+
expect(tags.all()).toStrictEqual({});
134+
});
135+
});
136+
});

packages/aws-cdk-lib/rosetta/assertions/default.ts-fixture

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Construct } from 'constructs';
22
import { Aspects, CfnResource, Stack } from 'aws-cdk-lib';
3-
import { Annotations, Capture, Match, Template } from 'aws-cdk-lib/assertions';
3+
import { Annotations, Capture, Match, Tags, Template } from 'aws-cdk-lib/assertions';
44

55
interface Expect {
66
toEqual(what: any): void;
7+
toThrow(what?: any): void;
78
}
89

910
declare function expect(what: any): Expect;

0 commit comments

Comments
 (0)