Skip to content

Commit c991e92

Browse files
authored
feat(synthetics): add vpc configuration (#18447)
This PR adds vpc support to synthetics and is a continuation of #11865. See [Running a canary on a vpc](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_VPC.html). Fixes #9954 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 122d723 commit c991e92

File tree

8 files changed

+934
-363
lines changed

8 files changed

+934
-363
lines changed

packages/@aws-cdk/aws-synthetics/README.md

+26
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,32 @@ new synthetics.Canary(this, 'Bucket Canary', {
173173
>
174174
> See Synthetics [docs](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_WritingCanary.html).
175175
176+
### Running a canary on a VPC
177+
178+
You can specify what [VPC a canary executes in](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_VPC.html).
179+
This can allow for monitoring services that may be internal to a specific VPC. To place a canary within a VPC, you can specify the `vpc` property with the desired `VPC` to place then canary in.
180+
This will automatically attach the appropriate IAM permissions to attach to the VPC. This will also create a Security Group and attach to the default subnets for the VPC unless specified via `vpcSubnets` and `securityGroups`.
181+
182+
```ts
183+
import * as ec2 from '@aws-cdk/aws-ec2';
184+
185+
declare const vpc: ec2.IVpc;
186+
new synthetics.Canary(this, 'Vpc Canary', {
187+
test: synthetics.Test.custom({
188+
code: synthetics.Code.fromAsset(path.join(__dirname, 'canary')),
189+
handler: 'index.handler',
190+
}),
191+
runtime: synthetics.Runtime.SYNTHETICS_NODEJS_PUPPETEER_3_3,
192+
vpc,
193+
});
194+
```
195+
196+
> **Note:** By default, the Synthetics runtime needs access to the S3 and CloudWatch APIs, which will fail in a private subnet without internet access enabled (e.g. an isolated subnnet).
197+
>
198+
> Ensure that the Canary is placed in a VPC either with internet connectivity or with VPC Endpoints for S3 and CloudWatch enabled and configured.
199+
>
200+
> See [Synthetics VPC docs](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_VPC.html).
201+
176202
### Alarms
177203

178204
You can configure a CloudWatch Alarm on a canary metric. Metrics are emitted by CloudWatch automatically and can be accessed by the following APIs:

packages/@aws-cdk/aws-synthetics/lib/canary.ts

+101-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as crypto from 'crypto';
22
import { Metric, MetricOptions, MetricProps } from '@aws-cdk/aws-cloudwatch';
3+
import * as ec2 from '@aws-cdk/aws-ec2';
34
import * as iam from '@aws-cdk/aws-iam';
45
import * as s3 from '@aws-cdk/aws-s3';
56
import * as cdk from '@aws-cdk/core';
@@ -179,12 +180,36 @@ export interface CanaryProps {
179180
* @default - No environment variables.
180181
*/
181182
readonly environmentVariables?: { [key: string]: string };
183+
184+
/**
185+
* The VPC where this canary is run.
186+
*
187+
* Specify this if the canary needs to access resources in a VPC.
188+
*
189+
* @default - Not in VPC
190+
*/
191+
readonly vpc?: ec2.IVpc;
192+
193+
/**
194+
* Where to place the network interfaces within the VPC. You must provide `vpc` when using this prop.
195+
*
196+
* @default - the Vpc default strategy if not specified
197+
*/
198+
readonly vpcSubnets?: ec2.SubnetSelection;
199+
200+
/**
201+
* The list of security groups to associate with the canary's network interfaces. You must provide `vpc` when using this prop.
202+
*
203+
* @default - If the canary is placed within a VPC and a security group is
204+
* not specified a dedicated security group will be created for this canary.
205+
*/
206+
readonly securityGroups?: ec2.ISecurityGroup[];
182207
}
183208

184209
/**
185210
* Define a new Canary
186211
*/
187-
export class Canary extends cdk.Resource {
212+
export class Canary extends cdk.Resource implements ec2.IConnectable {
188213
/**
189214
* Execution role associated with this Canary.
190215
*/
@@ -213,6 +238,14 @@ export class Canary extends cdk.Resource {
213238
*/
214239
public readonly artifactsBucket: s3.IBucket;
215240

241+
/**
242+
* Actual connections object for the underlying Lambda
243+
*
244+
* May be unset, in which case the canary Lambda is not configured for use in a VPC.
245+
* @internal
246+
*/
247+
private readonly _connections?: ec2.Connections;
248+
216249
public constructor(scope: Construct, id: string, props: CanaryProps) {
217250
if (props.canaryName && !cdk.Token.isUnresolved(props.canaryName)) {
218251
validateName(props.canaryName);
@@ -229,7 +262,12 @@ export class Canary extends cdk.Resource {
229262
enforceSSL: true,
230263
});
231264

232-
this.role = props.role ?? this.createDefaultRole(props.artifactsBucketLocation?.prefix);
265+
this.role = props.role ?? this.createDefaultRole(props);
266+
267+
if (props.vpc) {
268+
// Security Groups are created and/or appended in `createVpcConfig`.
269+
this._connections = new ec2.Connections({});
270+
}
233271

234272
const resource: CfnCanary = new CfnCanary(this, 'Resource', {
235273
artifactS3Location: this.artifactsBucket.s3UrlForObject(props.artifactsBucketLocation?.prefix),
@@ -242,13 +280,27 @@ export class Canary extends cdk.Resource {
242280
successRetentionPeriod: props.successRetentionPeriod?.toDays(),
243281
code: this.createCode(props),
244282
runConfig: this.createRunConfig(props),
283+
vpcConfig: this.createVpcConfig(props),
245284
});
246285

247286
this.canaryId = resource.attrId;
248287
this.canaryState = resource.attrState;
249288
this.canaryName = this.getResourceNameAttribute(resource.ref);
250289
}
251290

291+
/**
292+
* Access the Connections object
293+
*
294+
* Will fail if not a VPC-enabled Canary
295+
*/
296+
public get connections(): ec2.Connections {
297+
if (!this._connections) {
298+
// eslint-disable-next-line max-len
299+
throw new Error('Only VPC-associated Canaries have security groups to manage. Supply the "vpc" parameter when creating the Canary.');
300+
}
301+
return this._connections;
302+
}
303+
252304
/**
253305
* Measure the Duration of a single canary run, in seconds.
254306
*
@@ -289,7 +341,9 @@ export class Canary extends cdk.Resource {
289341
/**
290342
* Returns a default role for the canary
291343
*/
292-
private createDefaultRole(prefix?: string): iam.IRole {
344+
private createDefaultRole(props: CanaryProps): iam.IRole {
345+
const prefix = props.artifactsBucketLocation?.prefix;
346+
293347
// Created role will need these policies to run the Canary.
294348
// https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-synthetics-canary.html#cfn-synthetics-canary-executionrolearn
295349
const policy = new iam.PolicyDocument({
@@ -318,11 +372,19 @@ export class Canary extends cdk.Resource {
318372
],
319373
});
320374

375+
const managedPolicies: iam.IManagedPolicy[] = [];
376+
377+
if (props.vpc) {
378+
// Policy that will have ENI creation permissions
379+
managedPolicies.push(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole'));
380+
}
381+
321382
return new iam.Role(this, 'ServiceRole', {
322383
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
323384
inlinePolicies: {
324385
canaryPolicy: policy,
325386
},
387+
managedPolicies,
326388
});
327389
}
328390

@@ -352,6 +414,15 @@ export class Canary extends cdk.Resource {
352414
};
353415
}
354416

417+
private createRunConfig(props: CanaryProps): CfnCanary.RunConfigProperty | undefined {
418+
if (!props.environmentVariables) {
419+
return undefined;
420+
}
421+
return {
422+
environmentVariables: props.environmentVariables,
423+
};
424+
}
425+
355426
/**
356427
* Returns a canary schedule object
357428
*/
@@ -362,12 +433,36 @@ export class Canary extends cdk.Resource {
362433
};
363434
}
364435

365-
private createRunConfig(props: CanaryProps): CfnCanary.RunConfigProperty | undefined {
366-
if (!props.environmentVariables) {
436+
private createVpcConfig(props: CanaryProps): CfnCanary.VPCConfigProperty | undefined {
437+
if (!props.vpc) {
438+
if (props.vpcSubnets != null || props.securityGroups != null) {
439+
throw new Error("You must provide the 'vpc' prop when using VPC-related properties.");
440+
}
441+
367442
return undefined;
368443
}
444+
445+
const { subnetIds } = props.vpc.selectSubnets(props.vpcSubnets);
446+
if (subnetIds.length < 1) {
447+
throw new Error('No matching subnets found in the VPC.');
448+
}
449+
450+
let securityGroups: ec2.ISecurityGroup[];
451+
if (props.securityGroups && props.securityGroups.length > 0) {
452+
securityGroups = props.securityGroups;
453+
} else {
454+
const securityGroup = new ec2.SecurityGroup(this, 'SecurityGroup', {
455+
vpc: props.vpc,
456+
description: 'Automatic security group for Canary ' + cdk.Names.uniqueId(this),
457+
});
458+
securityGroups = [securityGroup];
459+
}
460+
this._connections!.addSecurityGroup(...securityGroups);
461+
369462
return {
370-
environmentVariables: props.environmentVariables,
463+
vpcId: props.vpc.vpcId,
464+
subnetIds,
465+
securityGroupIds: cdk.Lazy.list({ produce: () => this.connections.securityGroups.map(sg => sg.securityGroupId) }),
371466
};
372467
}
373468

packages/@aws-cdk/aws-synthetics/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
},
9191
"dependencies": {
9292
"@aws-cdk/aws-cloudwatch": "0.0.0",
93+
"@aws-cdk/aws-ec2": "0.0.0",
9394
"@aws-cdk/aws-iam": "0.0.0",
9495
"@aws-cdk/aws-s3": "0.0.0",
9596
"@aws-cdk/aws-s3-assets": "0.0.0",
@@ -98,6 +99,7 @@
9899
},
99100
"peerDependencies": {
100101
"@aws-cdk/aws-cloudwatch": "0.0.0",
102+
"@aws-cdk/aws-ec2": "0.0.0",
101103
"@aws-cdk/aws-iam": "0.0.0",
102104
"@aws-cdk/aws-s3": "0.0.0",
103105
"@aws-cdk/aws-s3-assets": "0.0.0",

packages/@aws-cdk/aws-synthetics/test/canary.test.ts

+119
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Match, Template } from '@aws-cdk/assertions';
2+
import * as ec2 from '@aws-cdk/aws-ec2';
23
import * as iam from '@aws-cdk/aws-iam';
34
import * as s3 from '@aws-cdk/aws-s3';
45
import { Duration, Lazy, Stack } from '@aws-cdk/core';
@@ -441,6 +442,124 @@ test('can specify custom test', () => {
441442
});
442443
});
443444

445+
describe('canary in a vpc', () => {
446+
test('can specify vpc', () => {
447+
// GIVEN
448+
const stack = new Stack();
449+
const vpc = new ec2.Vpc(stack, 'VPC', { maxAzs: 2 });
450+
451+
// WHEN
452+
new synthetics.Canary(stack, 'Canary', {
453+
test: synthetics.Test.custom({
454+
handler: 'index.handler',
455+
code: synthetics.Code.fromInline(`
456+
exports.handler = async () => {
457+
console.log(\'hello world\');
458+
};`),
459+
}),
460+
runtime: synthetics.Runtime.SYNTHETICS_NODEJS_2_0,
461+
vpc,
462+
});
463+
464+
// THEN
465+
Template.fromStack(stack).hasResourceProperties('AWS::Synthetics::Canary', {
466+
Code: {
467+
Handler: 'index.handler',
468+
Script: `
469+
exports.handler = async () => {
470+
console.log(\'hello world\');
471+
};`,
472+
},
473+
VPCConfig: {
474+
VpcId: {
475+
Ref: Match.anyValue(),
476+
},
477+
},
478+
});
479+
});
480+
481+
test('default security group and subnets', () => {
482+
// GIVEN
483+
const stack = new Stack();
484+
const vpc = new ec2.Vpc(stack, 'VPC', { maxAzs: 2 });
485+
486+
// WHEN
487+
new synthetics.Canary(stack, 'Canary', {
488+
test: synthetics.Test.custom({
489+
handler: 'index.handler',
490+
code: synthetics.Code.fromInline(`
491+
exports.handler = async () => {
492+
console.log(\'hello world\');
493+
};`),
494+
}),
495+
runtime: synthetics.Runtime.SYNTHETICS_NODEJS_2_0,
496+
vpc,
497+
});
498+
499+
// THEN
500+
Template.fromStack(stack).hasResourceProperties('AWS::Synthetics::Canary', {
501+
Code: {
502+
Handler: 'index.handler',
503+
Script: `
504+
exports.handler = async () => {
505+
console.log(\'hello world\');
506+
};`,
507+
},
508+
VPCConfig: {
509+
VpcId: {
510+
Ref: Match.anyValue(),
511+
},
512+
SecurityGroupIds: Match.anyValue(),
513+
SubnetIds: [...vpc.privateSubnets.map(subnet => ({ Ref: Match.stringLikeRegexp(subnet.node.id) }))],
514+
},
515+
});
516+
});
517+
518+
test('provided security group', () => {
519+
// GIVEN
520+
const stack = new Stack();
521+
const vpc = new ec2.Vpc(stack, 'VPC', { maxAzs: 2 });
522+
const sg = new ec2.SecurityGroup(stack, 'Sg', { vpc });
523+
524+
// WHEN
525+
new synthetics.Canary(stack, 'Canary', {
526+
test: synthetics.Test.custom({
527+
handler: 'index.handler',
528+
code: synthetics.Code.fromInline(`
529+
exports.handler = async () => {
530+
console.log(\'hello world\');
531+
};`),
532+
}),
533+
runtime: synthetics.Runtime.SYNTHETICS_NODEJS_2_0,
534+
vpc,
535+
securityGroups: [sg],
536+
});
537+
538+
// THEN
539+
const template = Template.fromStack(stack);
540+
const sgTemplate = template.findResources('AWS::EC2::SecurityGroup');
541+
const sgIds = Object.keys(sgTemplate);
542+
543+
expect(sgIds).toHaveLength(1);
544+
545+
template.hasResourceProperties('AWS::Synthetics::Canary', {
546+
Code: {
547+
Handler: 'index.handler',
548+
Script: `
549+
exports.handler = async () => {
550+
console.log(\'hello world\');
551+
};`,
552+
},
553+
VPCConfig: {
554+
VpcId: {
555+
Ref: Match.anyValue(),
556+
},
557+
SecurityGroupIds: [{ 'Fn::GetAtt': [sgIds[0], 'GroupId'] }],
558+
},
559+
});
560+
});
561+
});
562+
444563
test('Role policy generated as expected', () => {
445564
// GIVEN
446565
const stack = new Stack();

0 commit comments

Comments
 (0)