Skip to content

Commit

Permalink
feat(synthetics): add vpc configuration (aws#18447)
Browse files Browse the repository at this point in the history
This PR adds vpc support to synthetics and is a continuation of aws#11865.

See [Running a canary on a vpc](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_VPC.html).

Fixes aws#9954

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
RichiCoder1 committed Mar 15, 2022
1 parent 122d723 commit c991e92
Show file tree
Hide file tree
Showing 8 changed files with 934 additions and 363 deletions.
26 changes: 26 additions & 0 deletions packages/@aws-cdk/aws-synthetics/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,32 @@ new synthetics.Canary(this, 'Bucket Canary', {
>
> See Synthetics [docs](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_WritingCanary.html).
### Running a canary on a VPC

You can specify what [VPC a canary executes in](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_VPC.html).
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.
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`.

```ts
import * as ec2 from '@aws-cdk/aws-ec2';

declare const vpc: ec2.IVpc;
new synthetics.Canary(this, 'Vpc Canary', {
test: synthetics.Test.custom({
code: synthetics.Code.fromAsset(path.join(__dirname, 'canary')),
handler: 'index.handler',
}),
runtime: synthetics.Runtime.SYNTHETICS_NODEJS_PUPPETEER_3_3,
vpc,
});
```

> **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).
>
> Ensure that the Canary is placed in a VPC either with internet connectivity or with VPC Endpoints for S3 and CloudWatch enabled and configured.
>
> See [Synthetics VPC docs](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_VPC.html).
### Alarms

You can configure a CloudWatch Alarm on a canary metric. Metrics are emitted by CloudWatch automatically and can be accessed by the following APIs:
Expand Down
107 changes: 101 additions & 6 deletions packages/@aws-cdk/aws-synthetics/lib/canary.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as crypto from 'crypto';
import { Metric, MetricOptions, MetricProps } from '@aws-cdk/aws-cloudwatch';
import * as ec2 from '@aws-cdk/aws-ec2';
import * as iam from '@aws-cdk/aws-iam';
import * as s3 from '@aws-cdk/aws-s3';
import * as cdk from '@aws-cdk/core';
Expand Down Expand Up @@ -179,12 +180,36 @@ export interface CanaryProps {
* @default - No environment variables.
*/
readonly environmentVariables?: { [key: string]: string };

/**
* The VPC where this canary is run.
*
* Specify this if the canary needs to access resources in a VPC.
*
* @default - Not in VPC
*/
readonly vpc?: ec2.IVpc;

/**
* Where to place the network interfaces within the VPC. You must provide `vpc` when using this prop.
*
* @default - the Vpc default strategy if not specified
*/
readonly vpcSubnets?: ec2.SubnetSelection;

/**
* The list of security groups to associate with the canary's network interfaces. You must provide `vpc` when using this prop.
*
* @default - If the canary is placed within a VPC and a security group is
* not specified a dedicated security group will be created for this canary.
*/
readonly securityGroups?: ec2.ISecurityGroup[];
}

/**
* Define a new Canary
*/
export class Canary extends cdk.Resource {
export class Canary extends cdk.Resource implements ec2.IConnectable {
/**
* Execution role associated with this Canary.
*/
Expand Down Expand Up @@ -213,6 +238,14 @@ export class Canary extends cdk.Resource {
*/
public readonly artifactsBucket: s3.IBucket;

/**
* Actual connections object for the underlying Lambda
*
* May be unset, in which case the canary Lambda is not configured for use in a VPC.
* @internal
*/
private readonly _connections?: ec2.Connections;

public constructor(scope: Construct, id: string, props: CanaryProps) {
if (props.canaryName && !cdk.Token.isUnresolved(props.canaryName)) {
validateName(props.canaryName);
Expand All @@ -229,7 +262,12 @@ export class Canary extends cdk.Resource {
enforceSSL: true,
});

this.role = props.role ?? this.createDefaultRole(props.artifactsBucketLocation?.prefix);
this.role = props.role ?? this.createDefaultRole(props);

if (props.vpc) {
// Security Groups are created and/or appended in `createVpcConfig`.
this._connections = new ec2.Connections({});
}

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

this.canaryId = resource.attrId;
this.canaryState = resource.attrState;
this.canaryName = this.getResourceNameAttribute(resource.ref);
}

/**
* Access the Connections object
*
* Will fail if not a VPC-enabled Canary
*/
public get connections(): ec2.Connections {
if (!this._connections) {
// eslint-disable-next-line max-len
throw new Error('Only VPC-associated Canaries have security groups to manage. Supply the "vpc" parameter when creating the Canary.');
}
return this._connections;
}

/**
* Measure the Duration of a single canary run, in seconds.
*
Expand Down Expand Up @@ -289,7 +341,9 @@ export class Canary extends cdk.Resource {
/**
* Returns a default role for the canary
*/
private createDefaultRole(prefix?: string): iam.IRole {
private createDefaultRole(props: CanaryProps): iam.IRole {
const prefix = props.artifactsBucketLocation?.prefix;

// Created role will need these policies to run the Canary.
// https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-synthetics-canary.html#cfn-synthetics-canary-executionrolearn
const policy = new iam.PolicyDocument({
Expand Down Expand Up @@ -318,11 +372,19 @@ export class Canary extends cdk.Resource {
],
});

const managedPolicies: iam.IManagedPolicy[] = [];

if (props.vpc) {
// Policy that will have ENI creation permissions
managedPolicies.push(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole'));
}

return new iam.Role(this, 'ServiceRole', {
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
inlinePolicies: {
canaryPolicy: policy,
},
managedPolicies,
});
}

Expand Down Expand Up @@ -352,6 +414,15 @@ export class Canary extends cdk.Resource {
};
}

private createRunConfig(props: CanaryProps): CfnCanary.RunConfigProperty | undefined {
if (!props.environmentVariables) {
return undefined;
}
return {
environmentVariables: props.environmentVariables,
};
}

/**
* Returns a canary schedule object
*/
Expand All @@ -362,12 +433,36 @@ export class Canary extends cdk.Resource {
};
}

private createRunConfig(props: CanaryProps): CfnCanary.RunConfigProperty | undefined {
if (!props.environmentVariables) {
private createVpcConfig(props: CanaryProps): CfnCanary.VPCConfigProperty | undefined {
if (!props.vpc) {
if (props.vpcSubnets != null || props.securityGroups != null) {
throw new Error("You must provide the 'vpc' prop when using VPC-related properties.");
}

return undefined;
}

const { subnetIds } = props.vpc.selectSubnets(props.vpcSubnets);
if (subnetIds.length < 1) {
throw new Error('No matching subnets found in the VPC.');
}

let securityGroups: ec2.ISecurityGroup[];
if (props.securityGroups && props.securityGroups.length > 0) {
securityGroups = props.securityGroups;
} else {
const securityGroup = new ec2.SecurityGroup(this, 'SecurityGroup', {
vpc: props.vpc,
description: 'Automatic security group for Canary ' + cdk.Names.uniqueId(this),
});
securityGroups = [securityGroup];
}
this._connections!.addSecurityGroup(...securityGroups);

return {
environmentVariables: props.environmentVariables,
vpcId: props.vpc.vpcId,
subnetIds,
securityGroupIds: cdk.Lazy.list({ produce: () => this.connections.securityGroups.map(sg => sg.securityGroupId) }),
};
}

Expand Down
2 changes: 2 additions & 0 deletions packages/@aws-cdk/aws-synthetics/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
},
"dependencies": {
"@aws-cdk/aws-cloudwatch": "0.0.0",
"@aws-cdk/aws-ec2": "0.0.0",
"@aws-cdk/aws-iam": "0.0.0",
"@aws-cdk/aws-s3": "0.0.0",
"@aws-cdk/aws-s3-assets": "0.0.0",
Expand All @@ -98,6 +99,7 @@
},
"peerDependencies": {
"@aws-cdk/aws-cloudwatch": "0.0.0",
"@aws-cdk/aws-ec2": "0.0.0",
"@aws-cdk/aws-iam": "0.0.0",
"@aws-cdk/aws-s3": "0.0.0",
"@aws-cdk/aws-s3-assets": "0.0.0",
Expand Down
119 changes: 119 additions & 0 deletions packages/@aws-cdk/aws-synthetics/test/canary.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Match, Template } from '@aws-cdk/assertions';
import * as ec2 from '@aws-cdk/aws-ec2';
import * as iam from '@aws-cdk/aws-iam';
import * as s3 from '@aws-cdk/aws-s3';
import { Duration, Lazy, Stack } from '@aws-cdk/core';
Expand Down Expand Up @@ -441,6 +442,124 @@ test('can specify custom test', () => {
});
});

describe('canary in a vpc', () => {
test('can specify vpc', () => {
// GIVEN
const stack = new Stack();
const vpc = new ec2.Vpc(stack, 'VPC', { maxAzs: 2 });

// WHEN
new synthetics.Canary(stack, 'Canary', {
test: synthetics.Test.custom({
handler: 'index.handler',
code: synthetics.Code.fromInline(`
exports.handler = async () => {
console.log(\'hello world\');
};`),
}),
runtime: synthetics.Runtime.SYNTHETICS_NODEJS_2_0,
vpc,
});

// THEN
Template.fromStack(stack).hasResourceProperties('AWS::Synthetics::Canary', {
Code: {
Handler: 'index.handler',
Script: `
exports.handler = async () => {
console.log(\'hello world\');
};`,
},
VPCConfig: {
VpcId: {
Ref: Match.anyValue(),
},
},
});
});

test('default security group and subnets', () => {
// GIVEN
const stack = new Stack();
const vpc = new ec2.Vpc(stack, 'VPC', { maxAzs: 2 });

// WHEN
new synthetics.Canary(stack, 'Canary', {
test: synthetics.Test.custom({
handler: 'index.handler',
code: synthetics.Code.fromInline(`
exports.handler = async () => {
console.log(\'hello world\');
};`),
}),
runtime: synthetics.Runtime.SYNTHETICS_NODEJS_2_0,
vpc,
});

// THEN
Template.fromStack(stack).hasResourceProperties('AWS::Synthetics::Canary', {
Code: {
Handler: 'index.handler',
Script: `
exports.handler = async () => {
console.log(\'hello world\');
};`,
},
VPCConfig: {
VpcId: {
Ref: Match.anyValue(),
},
SecurityGroupIds: Match.anyValue(),
SubnetIds: [...vpc.privateSubnets.map(subnet => ({ Ref: Match.stringLikeRegexp(subnet.node.id) }))],
},
});
});

test('provided security group', () => {
// GIVEN
const stack = new Stack();
const vpc = new ec2.Vpc(stack, 'VPC', { maxAzs: 2 });
const sg = new ec2.SecurityGroup(stack, 'Sg', { vpc });

// WHEN
new synthetics.Canary(stack, 'Canary', {
test: synthetics.Test.custom({
handler: 'index.handler',
code: synthetics.Code.fromInline(`
exports.handler = async () => {
console.log(\'hello world\');
};`),
}),
runtime: synthetics.Runtime.SYNTHETICS_NODEJS_2_0,
vpc,
securityGroups: [sg],
});

// THEN
const template = Template.fromStack(stack);
const sgTemplate = template.findResources('AWS::EC2::SecurityGroup');
const sgIds = Object.keys(sgTemplate);

expect(sgIds).toHaveLength(1);

template.hasResourceProperties('AWS::Synthetics::Canary', {
Code: {
Handler: 'index.handler',
Script: `
exports.handler = async () => {
console.log(\'hello world\');
};`,
},
VPCConfig: {
VpcId: {
Ref: Match.anyValue(),
},
SecurityGroupIds: [{ 'Fn::GetAtt': [sgIds[0], 'GroupId'] }],
},
});
});
});

test('Role policy generated as expected', () => {
// GIVEN
const stack = new Stack();
Expand Down
Loading

0 comments on commit c991e92

Please sign in to comment.