Skip to content

Commit 0cdce20

Browse files
authored
feat(core): configure SNS topics to receive stack events on the Stack construct (#30551)
### Issue # (if applicable) #8581. ### Reason for this change It is easier and clearer to specify the SNS Topic ARNs on the stack construct itself instead of passing it as a command line argument. ### Description of changes Added a new optional stack prop, `notificationArns`, that is written to the CloudAssembly and concatenated with the CLI option `--notification-arns`. When I added CLI integ tests, I discovered that the existing framework is unable to use your local code. It always retrieves the latest release, which is not what you want when running it locally. This fixes that. Don't forget to select stacks by hierarchical ID (currently display name, in our tests) when writing certain test code. Otherwise, the tests may not select the stack you expected. ### Description of how you validated changes Unit tests + CLI integ test. ### Checklist - [x] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and [DESIGN GUIDELINES](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md) ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent f1af7fc commit 0cdce20

File tree

19 files changed

+489
-119
lines changed

19 files changed

+489
-119
lines changed

packages/@aws-cdk-testing/cli-integ/lib/package-sources/repo-source.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,14 @@ const YARN_MONOREPO_CACHE: Record<string, any> = {};
7575
*
7676
* Cached in YARN_MONOREPO_CACHE.
7777
*/
78-
async function findYarnPackages(root: string): Promise<Record<string, string>> {
78+
export async function findYarnPackages(root: string): Promise<Record<string, string>> {
7979
if (!(root in YARN_MONOREPO_CACHE)) {
80-
const output: YarnWorkspacesOutput = JSON.parse(await shell(['yarn', 'workspaces', '--silent', 'info'], {
80+
const outputDataString: string = JSON.parse(await shell(['yarn', 'workspaces', '--json', 'info'], {
8181
captureStderr: false,
8282
cwd: root,
8383
show: 'error',
84-
}));
84+
})).data;
85+
const output: YarnWorkspacesOutput = JSON.parse(outputDataString);
8586

8687
const ret: Record<string, string> = {};
8788
for (const [k, v] of Object.entries(output)) {
@@ -96,7 +97,7 @@ async function findYarnPackages(root: string): Promise<Record<string, string>> {
9697
* Find the root directory of the repo from the current directory
9798
*/
9899
export async function autoFindRoot() {
99-
const found = await findUp('release.json');
100+
const found = findUp('release.json');
100101
if (!found) {
101102
throw new Error(`Could not determine repository root: 'release.json' not found from ${process.cwd()}`);
102103
}

packages/@aws-cdk-testing/cli-integ/lib/with-cdk-app.ts

+12
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as os from 'os';
44
import * as path from 'path';
55
import { outputFromStack, AwsClients } from './aws';
66
import { TestContext } from './integ-test';
7+
import { findYarnPackages } from './package-sources/repo-source';
78
import { IPackageSource } from './package-sources/source';
89
import { packageSourceInSubprocess } from './package-sources/subprocess';
910
import { RESOURCES_DIR } from './resources';
@@ -612,6 +613,17 @@ function defined<A>(x: A): x is NonNullable<A> {
612613
* for Node's dependency lookup mechanism).
613614
*/
614615
export async function installNpmPackages(fixture: TestFixture, packages: Record<string, string>) {
616+
if (process.env.REPO_ROOT) {
617+
const monoRepo = await findYarnPackages(process.env.REPO_ROOT);
618+
619+
// Replace the install target with the physical location of this package
620+
for (const key of Object.keys(packages)) {
621+
if (key in monoRepo) {
622+
packages[key] = monoRepo[key];
623+
}
624+
}
625+
}
626+
615627
fs.writeFileSync(path.join(fixture.integTestDir, 'package.json'), JSON.stringify({
616628
name: 'cdk-integ-tests',
617629
private: true,

packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js

+11
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,13 @@ class BuiltinLambdaStack extends cdk.Stack {
637637
}
638638
}
639639

640+
class NotificationArnPropStack extends cdk.Stack {
641+
constructor(parent, id, props) {
642+
super(parent, id, props);
643+
new sns.Topic(this, 'topic');
644+
}
645+
}
646+
640647
const app = new cdk.App({
641648
context: {
642649
'@aws-cdk/core:assetHashSalt': process.env.CODEBUILD_BUILD_ID, // Force all assets to be unique, but consistent in one build
@@ -677,6 +684,10 @@ switch (stackSet) {
677684
new DockerStack(app, `${stackPrefix}-docker`);
678685
new DockerStackWithCustomFile(app, `${stackPrefix}-docker-with-custom-file`);
679686

687+
new NotificationArnPropStack(app, `${stackPrefix}-notification-arn-prop`, {
688+
notificationArns: [`arn:aws:sns:${defaultEnv.region}:${defaultEnv.account}:${stackPrefix}-test-topic-prop`],
689+
});
690+
680691
// SSO stacks
681692
new SsoInstanceAccessControlConfig(app, `${stackPrefix}-sso-access-control`);
682693
new SsoAssignment(app, `${stackPrefix}-sso-assignment`);

packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts

+29-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { promises as fs, existsSync } from 'fs';
22
import * as os from 'os';
33
import * as path from 'path';
4-
import { integTest, cloneDirectory, shell, withDefaultFixture, retry, sleep, randomInteger, withSamIntegrationFixture, RESOURCES_DIR, withCDKMigrateFixture, withExtendedTimeoutFixture, randomString } from '../../lib';
4+
import { integTest, cloneDirectory, shell, withDefaultFixture, retry, sleep, randomInteger, withSamIntegrationFixture, RESOURCES_DIR, withCDKMigrateFixture, withExtendedTimeoutFixture, randomString, withoutBootstrap } from '../../lib';
55

66
jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime
77

@@ -187,7 +187,10 @@ integTest('context setting', withDefaultFixture(async (fixture) => {
187187
}
188188
}));
189189

190-
integTest('context in stage propagates to top', withDefaultFixture(async (fixture) => {
190+
// bootstrapping also performs synthesis. As it turns out, bootstrap-stage synthesis still causes the lookups to be cached, meaning that the lookup never
191+
// happens when we actually call `cdk synth --no-lookups`. This results in the error never being thrown, because it never tries to lookup anything.
192+
// Fix this by not trying to bootstrap; there's no need to bootstrap anyway, since the test never tries to deploy anything.
193+
integTest('context in stage propagates to top', withoutBootstrap(async (fixture) => {
191194
await expect(fixture.cdkSynth({
192195
// This will make it error to prove that the context bubbles up, and also that we can fail on command
193196
options: ['--no-lookups'],
@@ -466,11 +469,12 @@ integTest('deploy with parameters multi', withDefaultFixture(async (fixture) =>
466469
);
467470
}));
468471

469-
integTest('deploy with notification ARN', withDefaultFixture(async (fixture) => {
470-
const topicName = `${fixture.stackNamePrefix}-test-topic`;
472+
integTest('deploy with notification ARN as flag', withDefaultFixture(async (fixture) => {
473+
const topicName = `${fixture.stackNamePrefix}-test-topic-flag`;
471474

472475
const response = await fixture.aws.sns('createTopic', { Name: topicName });
473476
const topicArn = response.TopicArn!;
477+
474478
try {
475479
await fixture.cdkDeploy('test-2', {
476480
options: ['--notification-arns', topicArn],
@@ -488,6 +492,27 @@ integTest('deploy with notification ARN', withDefaultFixture(async (fixture) =>
488492
}
489493
}));
490494

495+
integTest('deploy with notification ARN as prop', withDefaultFixture(async (fixture) => {
496+
const topicName = `${fixture.stackNamePrefix}-test-topic-prop`;
497+
498+
const response = await fixture.aws.sns('createTopic', { Name: topicName });
499+
const topicArn = response.TopicArn!;
500+
501+
try {
502+
await fixture.cdkDeploy('notification-arn-prop');
503+
504+
// verify that the stack we deployed has our notification ARN
505+
const describeResponse = await fixture.aws.cloudFormation('describeStacks', {
506+
StackName: fixture.fullStackName('notification-arn-prop'),
507+
});
508+
expect(describeResponse.Stacks?.[0].NotificationARNs).toEqual([topicArn]);
509+
} finally {
510+
await fixture.aws.sns('deleteTopic', {
511+
TopicArn: topicArn,
512+
});
513+
}
514+
}));
515+
491516
// NOTE: this doesn't currently work with modern-style synthesis, as the bootstrap
492517
// role by default will not have permission to iam:PassRole the created role.
493518
integTest('deploy with role', withDefaultFixture(async (fixture) => {

packages/aws-cdk-lib/cloud-assembly-schema/lib/cloud-assembly/artifact-schema.ts

+7
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ export interface AwsCloudFormationStackProperties {
5555
*/
5656
readonly tags?: { [id: string]: string };
5757

58+
/**
59+
* SNS Notification ARNs that should receive CloudFormation Stack Events.
60+
*
61+
* @default - No notification arns
62+
*/
63+
readonly notificationArns?: string[];
64+
5865
/**
5966
* The name to use for the CloudFormation stack.
6067
* @default - name derived from artifact ID

packages/aws-cdk-lib/cloud-assembly-schema/schema/cloud-assembly.schema.json

+6
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,12 @@
345345
"type": "string"
346346
}
347347
},
348+
"notificationArns": {
349+
"type": "array",
350+
"items": {
351+
"type": "string"
352+
}
353+
},
348354
"stackName": {
349355
"description": "The name to use for the CloudFormation stack. (Default - name derived from artifact ID)",
350356
"type": "string"
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"version":"36.0.0"}
1+
{"version":"37.0.0"}

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

+12
Original file line numberDiff line numberDiff line change
@@ -1242,6 +1242,18 @@ const stack = new Stack(app, 'StackName', {
12421242
});
12431243
```
12441244

1245+
### Receiving CloudFormation Stack Events
1246+
1247+
You can add one or more SNS Topic ARNs to any Stack:
1248+
1249+
```ts
1250+
const stack = new Stack(app, 'StackName', {
1251+
notificationArns: ['arn:aws:sns:us-east-1:23456789012:Topic'],
1252+
});
1253+
```
1254+
1255+
Stack events will be sent to any SNS Topics in this list.
1256+
12451257
### CfnJson
12461258

12471259
`CfnJson` allows you to postpone the resolution of a JSON blob from

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

+1
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export function addStackArtifactToAssembly(
4848
terminationProtection: stack.terminationProtection,
4949
tags: nonEmptyDict(stack.tags.tagValues()),
5050
validateOnSynth: session.validateOnSynth,
51+
notificationArns: stack._notificationArns,
5152
...stackProps,
5253
...stackNameProperty,
5354
};

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

+15
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,13 @@ export interface StackProps {
127127
*/
128128
readonly tags?: { [key: string]: string };
129129

130+
/**
131+
* SNS Topic ARNs that will receive stack events.
132+
*
133+
* @default - no notfication arns.
134+
*/
135+
readonly notificationArns?: string[];
136+
130137
/**
131138
* Synthesis method to use while deploying this stack
132139
*
@@ -364,6 +371,13 @@ export class Stack extends Construct implements ITaggable {
364371
*/
365372
public readonly _crossRegionReferences: boolean;
366373

374+
/**
375+
* SNS Notification ARNs to receive stack events.
376+
*
377+
* @internal
378+
*/
379+
public readonly _notificationArns: string[];
380+
367381
/**
368382
* Logical ID generation strategy
369383
*/
@@ -450,6 +464,7 @@ export class Stack extends Construct implements ITaggable {
450464
throw new Error(`Stack name must be <= 128 characters. Stack name: '${this._stackName}'`);
451465
}
452466
this.tags = new TagManager(TagType.KEY_VALUE, 'aws:cdk:stack', props.tags);
467+
this._notificationArns = props.notificationArns ?? [];
453468

454469
if (!VALID_STACK_NAME_REGEX.test(this.stackName)) {
455470
throw new Error(`Stack name must match the regular expression: ${VALID_STACK_NAME_REGEX.toString()}, got '${this.stackName}'`);

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

+15
Original file line numberDiff line numberDiff line change
@@ -2075,6 +2075,21 @@ describe('stack', () => {
20752075
expect(asm.getStackArtifact(stack2.artifactId).tags).toEqual(expected);
20762076
});
20772077

2078+
test('stack notification arns are reflected in the stack artifact properties', () => {
2079+
// GIVEN
2080+
const NOTIFICATION_ARNS = ['arn:aws:sns:bermuda-triangle-1337:123456789012:MyTopic'];
2081+
const app = new App({ stackTraces: false });
2082+
const stack1 = new Stack(app, 'stack1', {
2083+
notificationArns: NOTIFICATION_ARNS,
2084+
});
2085+
2086+
// THEN
2087+
const asm = app.synth();
2088+
const expected = { foo: 'bar' };
2089+
2090+
expect(asm.getStackArtifact(stack1.artifactId).notificationArns).toEqual(NOTIFICATION_ARNS);
2091+
});
2092+
20782093
test('Termination Protection is reflected in Cloud Assembly artifact', () => {
20792094
// if the root is an app, invoke "synth" to avoid double synthesis
20802095
const app = new App();

packages/aws-cdk-lib/cx-api/lib/artifacts/cloudformation-artifact.ts

+6
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ export class CloudFormationStackArtifact extends CloudArtifact {
5454
*/
5555
public readonly tags: { [id: string]: string };
5656

57+
/**
58+
* SNS Topics that will receive stack events.
59+
*/
60+
public readonly notificationArns: string[];
61+
5762
/**
5863
* The physical name of this stack.
5964
*/
@@ -158,6 +163,7 @@ export class CloudFormationStackArtifact extends CloudArtifact {
158163
// We get the tags from 'properties' if available (cloud assembly format >= 6.0.0), otherwise
159164
// from the stack metadata
160165
this.tags = properties.tags ?? this.tagsFromMetadata();
166+
this.notificationArns = properties.notificationArns ?? [];
161167
this.assumeRoleArn = properties.assumeRoleArn;
162168
this.assumeRoleExternalId = properties.assumeRoleExternalId;
163169
this.cloudFormationExecutionRoleArn = properties.cloudFormationExecutionRoleArn;

packages/aws-cdk-lib/cx-api/test/stack-artifact.test.ts

+18
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,24 @@ afterEach(() => {
2121
rimraf(builder.outdir);
2222
});
2323

24+
test('read notification arns from artifact properties', () => {
25+
// GIVEN
26+
const NOTIFICATION_ARNS = ['arn:aws:sns:bermuda-triangle-1337:123456789012:MyTopic'];
27+
builder.addArtifact('Stack', {
28+
...stackBase,
29+
properties: {
30+
...stackBase.properties,
31+
notificationArns: NOTIFICATION_ARNS,
32+
},
33+
});
34+
35+
// WHEN
36+
const assembly = builder.buildAssembly();
37+
38+
// THEN
39+
expect(assembly.getStackByName('Stack').notificationArns).toEqual(NOTIFICATION_ARNS);
40+
});
41+
2442
test('read tags from artifact properties', () => {
2543
// GIVEN
2644
builder.addArtifact('Stack', {

packages/aws-cdk/lib/api/deploy-stack.ts

+10
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,12 @@ async function canSkipDeploy(
644644
return false;
645645
}
646646

647+
// Notification arns have changed
648+
if (!arrayEquals(cloudFormationStack.notificationArns, deployStackOptions.notificationArns ?? [])) {
649+
debug(`${deployName}: notification arns have changed`);
650+
return false;
651+
}
652+
647653
// Termination protection has been updated
648654
if (!!deployStackOptions.stack.terminationProtection !== !!cloudFormationStack.terminationProtection) {
649655
debug(`${deployName}: termination protection has been updated`);
@@ -694,3 +700,7 @@ function suffixWithErrors(msg: string, errors?: string[]) {
694700
? `${msg}: ${errors.join(', ')}`
695701
: msg;
696702
}
703+
704+
function arrayEquals(a: any[], b: any[]): boolean {
705+
return a.every(item => b.includes(item)) && b.every(item => a.includes(item));
706+
}

packages/aws-cdk/lib/api/util/cloudformation.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,21 @@ export class CloudFormationStack {
138138
/**
139139
* The stack's current tags
140140
*
141-
* Empty list of the stack does not exist
141+
* Empty list if the stack does not exist
142142
*/
143143
public get tags(): CloudFormation.Tags {
144144
return this.stack?.Tags || [];
145145
}
146146

147+
/**
148+
* SNS Topic ARNs that will receive stack events.
149+
*
150+
* Empty list if the stack does not exist
151+
*/
152+
public get notificationArns(): CloudFormation.NotificationARNs {
153+
return this.stack?.NotificationARNs ?? [];
154+
}
155+
147156
/**
148157
* Return the names of all current parameters to the stack
149158
*

packages/aws-cdk/lib/cdk-toolkit.ts

+12-11
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,6 @@ export class CdkToolkit {
161161
let changeSet = undefined;
162162

163163
if (options.changeSet) {
164-
165164
let stackExists = false;
166165
try {
167166
stackExists = await this.props.deployments.stackExists({
@@ -214,14 +213,6 @@ export class CdkToolkit {
214213
return this.watch(options);
215214
}
216215

217-
if (options.notificationArns) {
218-
options.notificationArns.map( arn => {
219-
if (!validateSnsTopicArn(arn)) {
220-
throw new Error(`Notification arn ${arn} is not a valid arn for an SNS topic`);
221-
}
222-
});
223-
}
224-
225216
const startSynthTime = new Date().getTime();
226217
const stackCollection = await this.selectStacksForDeploy(options.selector, options.exclusively,
227218
options.cacheCloudAssembly, options.ignoreNoStacks);
@@ -318,7 +309,17 @@ export class CdkToolkit {
318309
}
319310
}
320311

321-
const stackIndex = stacks.indexOf(stack)+1;
312+
let notificationArns: string[] = [];
313+
notificationArns = notificationArns.concat(options.notificationArns ?? []);
314+
notificationArns = notificationArns.concat(stack.notificationArns);
315+
316+
notificationArns.map(arn => {
317+
if (!validateSnsTopicArn(arn)) {
318+
throw new Error(`Notification arn ${arn} is not a valid arn for an SNS topic`);
319+
}
320+
});
321+
322+
const stackIndex = stacks.indexOf(stack) + 1;
322323
print('%s: deploying... [%s/%s]', chalk.bold(stack.displayName), stackIndex, stackCollection.stackCount);
323324
const startDeployTime = new Date().getTime();
324325

@@ -335,7 +336,7 @@ export class CdkToolkit {
335336
roleArn: options.roleArn,
336337
toolkitStackName: options.toolkitStackName,
337338
reuseAssets: options.reuseAssets,
338-
notificationArns: options.notificationArns,
339+
notificationArns,
339340
tags,
340341
execute: options.execute,
341342
changeSetName: options.changeSetName,

0 commit comments

Comments
 (0)