in lib/presence-stack.ts [138:383]
constructor(scope: CDK.Construct, id: string, props?: CDK.StackProps) {
super(scope, id, props);
/**
* Network:
*
* Here we define a VPC with two subnet groups.
* The CDK automatically creates subnets in at least 2 AZs by default
* You can change the behavior using the `maxAzs` parameter.
*
* Subnet types can be:
* - ISOLATED: fully isolated (example: used for Redis Cluster or lambda functions accessing it)
* - PRIVATE: could be used for a Lambda function that would require internet access through a NAT Gateway
* - PUBLIC: required if there is a PRIVATE subnet to setup a NAT Gateway
*
**/
this.vpc = new EC2.Vpc(this, 'PresenceVPC', {
cidr: "10.42.0.0/16",
subnetConfiguration: [
// Subnet group for Redis
{
cidrMask: 24,
name: "Redis",
subnetType: EC2.SubnetType.ISOLATED
},
// Subnet group for Lambda functions
{
cidrMask: 24,
name: "Lambda",
subnetType: EC2.SubnetType.ISOLATED
}
]
});
// Create two different security groups:
// One for the redis cluster, one for the lambda function.
// This is to allow traffic only from our functions to the redis cluster
const redisSG = new EC2.SecurityGroup(this, "redisSg", {
vpc: this.vpc,
description: "Security group for Redis Cluster"
});
this.lambdaSG = new EC2.SecurityGroup(this, "lambdaSg", {
vpc: this.vpc,
description: "Security group for Lambda functions"
});
// Redis SG accepts TCP connections from the Lambda SG on Redis port.
redisSG.addIngressRule(
this.lambdaSG,
EC2.Port.tcp(this.redisPort)
);
/**
* Redis cache cluster
* Uses T3 small instances to start withs
*
* Note those are level 1 constructs in CDK.
* So props like `cacheSubnetGroupName` have misleading names and require a name
* in CloudFormation sense, which is actually a "ref" for reference.
*/
const redisSubnets = new ElastiCache.CfnSubnetGroup(this, "RedisSubnets", {
cacheSubnetGroupName: "RedisSubnets",
description: "Subnet Group for Redis Cluster",
subnetIds: this.vpc.selectSubnets({ subnetGroupName: "Redis"}).subnetIds
});
this.redisCluster = new ElastiCache.CfnReplicationGroup(this, "PresenceCluster", {
replicationGroupDescription: "PresenceReplicationGroup",
cacheNodeType: "cache.t3.small",
engine: "redis",
numCacheClusters: 2,
automaticFailoverEnabled: true,
multiAzEnabled: true,
cacheSubnetGroupName: redisSubnets.ref,
securityGroupIds: [redisSG.securityGroupId],
port: this.redisPort
});
/**
* Lambda functions creation:
*
* - Define the layer to add nodejs redis module
* - Add the functions
*/
this.redisLayer = new Lambda.LayerVersion(this, "redisModule", {
code: Lambda.Code.fromAsset(path.join(__dirname, '../src/layer/')),
compatibleRuntimes: [Lambda.Runtime.NODEJS_12_X],
layerVersionName: "presenceLayer"
});
// Use arrow function to keep "this" scope
['heartbeat','status','disconnect','timeout'].forEach(
(fn) => { this.addFunction(fn); }
);
// On disconnect function does not require access to redis
this.addFunction("on_disconnect", false);
/**
* The GraphQL API
*
* Default authorization is set to use API_KEY. This is good for development and test,
* in production, we recommend using a COGNITO or OPEN_ID user based authentification.
*
* We also force the API key to expire after 7 days starting from the last deployment
*/
this.api = new AppSync.GraphqlApi(this, "PresenceAPI", {
name: "PresenceAPI",
authorizationConfig: {
defaultAuthorization: {
authorizationType: AppSync.AuthorizationType.API_KEY,
apiKeyConfig: {
name: "PresenceKey",
expires: CDK.Expiration.after(CDK.Duration.days(7))
}
},
additionalAuthorizationModes: [
{ authorizationType: AppSync.AuthorizationType.IAM }
]
},
schema: PresenceSchema(),
logConfig: { fieldLogLevel: AppSync.FieldLogLevel.ALL }
});
// Configure sources and resolvers
const heartbeatDS = this.createResolver("Query", "heartbeat", {source: "heartbeat"});
this.createResolver("Query", "status", {source: "status"});
this.createResolver("Mutation", "connect", {source: heartbeatDS} ); // Note: reusing heartbeat lambda here
this.createResolver("Mutation", "disconnect", {source: "disconnect"} );
// The "disconnected" mutation is called on disconnection, and
// is the one AppSync client will subscribe too.
// It uses a NoneDataSource with simple templates passing its argument,
// so that it could trigger the notifications.
const noneDS = this.api.addNoneDataSource("disconnectedDS");
const requestMappingTemplate = AppSync.MappingTemplate.fromString(`
{
"version": "2017-02-28",
"payload": {
"id": "$context.arguments.id",
"status": "offline"
}
}
`);
const responseMappingTemplate = AppSync.MappingTemplate.fromString(`
$util.toJson($context.result)
`);
this.createResolver("Mutation", "disconnected", {
source: noneDS,
requestMappingTemplate,
responseMappingTemplate
});
/**
* Event bus
*
* We could use the Default Bus with EventBridge, but a custom bus
* might be better for further extensions.
*/
const presenceBus = new AwsEvents.EventBus(this, "PresenceBus");
// Rule to trigger lambda timeout every minute
new AwsEvents.Rule(this, "PresenceTimeoutRule", {
schedule: AwsEvents.Schedule.cron({minute:"*"}),
targets: [new AwsEventsTargets.LambdaFunction(this.getFn("timeout"))],
enabled: true
});
// Rule for disconnection event: triggers the on_disconnect
// lambda function, according to the given pattern
new AwsEvents.Rule(this, "PresenceDisconnectRule", {
eventBus: presenceBus,
description: "Rule for presence disconnection",
eventPattern: {
detailType: ["presence.disconnected"],
source: ["api.presence"]
},
targets: [new AwsEventsTargets.LambdaFunction(this.getFn("on_disconnect"))],
enabled: true
});
// Add an interface endpoint for EventBridge: this allow
// the lambda inside the VPC to call EventBridge without requiring a NAT Gateway
// It also requires a security group that allows TCP 80 communications from the Lambdas security groups.
const eventsEndPointSG = new EC2.SecurityGroup(this, "eventsEndPointSG", {
vpc: this.vpc,
description: "EventBrige interface endpoint SG"
});
eventsEndPointSG.addIngressRule(this.lambdaSG, EC2.Port.tcp(80));
this.vpc.addInterfaceEndpoint("eventsEndPoint", {
service: EC2.InterfaceVpcEndpointAwsService.CLOUDWATCH_EVENTS,
subnets: this.vpc.selectSubnets({subnetGroupName: "Lambda"}),
securityGroups: [eventsEndPointSG]
});
/**
* Finalize configuration for lambda functions
*
* - Add environment variables to access api
* - Add IAM policy statement for GraphQL access
* - Add IAM policy statement for event bus access (putEvents)
* - Add the timeout
*/
const allowEventBridge = new IAM.PolicyStatement({ effect: IAM.Effect.ALLOW });
allowEventBridge.addActions("events:PutEvents");
allowEventBridge.addResources(presenceBus.eventBusArn);
this.getFn("timeout").addEnvironment("TIMEOUT", "10000")
.addEnvironment("EVENT_BUS", presenceBus.eventBusName)
.addToRolePolicy(allowEventBridge);
this.getFn('disconnect')
.addEnvironment("EVENT_BUS", presenceBus.eventBusName)
.addToRolePolicy(allowEventBridge);
this.getFn("heartbeat")
.addEnvironment("EVENT_BUS", presenceBus.eventBusName)
.addToRolePolicy(allowEventBridge);
const allowAppsync = new IAM.PolicyStatement({ effect: IAM.Effect.ALLOW });
allowAppsync.addActions("appsync:GraphQL");
allowAppsync.addResources(this.api.arn + "/*");
this.getFn("on_disconnect")
.addEnvironment("GRAPHQL_ENDPOINT", this.api.graphqlUrl)
.addToRolePolicy(allowAppsync);
/**
* The CloudFormation stack output
*
* Contains:
* - the GraphQL API Endpoint
* - The API Key for the integration tests (could be removed in production)
* - The region (required to configure AppSync client in integration tests)
*
* Use the `-O, --outputs-file` option with `cdk deploy` to output those in a JSON file
* `npm run deploy` uses this option as default
*/
new CDK.CfnOutput(this, "presence-api", {
value: this.api.graphqlUrl,
description: "Presence api endpoint",
exportName: "presenceEndpoint"
});
new CDK.CfnOutput(this, "api-key", {
value: this.api.apiKey || '',
description: "Presence api key",
exportName: "apiKey"
});
new CDK.CfnOutput(this, "region", {
value: process.env.CDK_DEFAULT_REGION || '',
description: "Presence api region",
exportName: "region"
});
}