import * as cdk from '@aws-cdk/core';
import * as apigateway from '@aws-cdk/aws-apigateway';
import * as lambdaNode from '@aws-cdk/aws-lambda-nodejs';
import * as lambda from '@aws-cdk/aws-lambda';
import * as ec2 from '@aws-cdk/aws-ec2';
import * as iam from '@aws-cdk/aws-iam';
import * as sqs from '@aws-cdk/aws-sqs';
import * as route53 from '@aws-cdk/aws-route53';
import * as route53Targets from '@aws-cdk/aws-route53-targets';
import * as acm from '@aws-cdk/aws-certificatemanager';
import * as dynamodb from '@aws-cdk/aws-dynamodb';
import * as path from 'path';
import { Config } from '../config';
import { RemovalPolicy } from '@aws-cdk/core';
export interface ServiceStackProps extends cdk.StackProps {
config: Config;
}
interface LambdaDef {
name: string;
path: string;
routes: {
path: string | string[];
httpMethod: 'POST' | 'GET' | 'PUT' | 'DELETE' | 'ANY';
}[];
}
export class ServiceStack extends cdk.Stack {
private config: Config;
private privateLambdaDefs: LambdaDef[] = [
{
name: 'metaFn',
routes: [
{
path: 'meta',
httpMethod: 'GET',
},
],
path: 'api/meta/index.ts',
},
{
name: 'echoFn',
routes: [
{
path: 'echo',
httpMethod: 'POST',
},
],
path: 'api/echo/index.ts',
},
{
name: 'accountsFn',
routes: [
{
path: ['accounts', '{orgId}'],
httpMethod: 'GET',
},
{
path: 'accounts',
httpMethod: 'POST',
},
],
path: 'api/accounts/index.ts',
},
];
private publicLambdaDefs: LambdaDef[] = [
{
name: 'publishRailsbankWebhookEventFn',
routes: [
{
path: 'railsbank-webhook',
httpMethod: 'POST',
},
],
path: 'api/railsbank-webhook/index.ts',
},
];
constructor(scope: cdk.Construct, id: string, props: ServiceStackProps) {
super(scope, id, props);
this.config = props.config;
const vpc = ec2.Vpc.fromLookup(this, 'VPC', { isDefault: true });
const bankingTable = this.buildDynamoDbBankingTable();
const { railsbankWebhookQueue } = this.buildRailsbankWebhookQueue();
const envVars = this.buildEnvVariables({
RAILSBANK_WEBHOOK_QUEUE_ARN: railsbankWebhookQueue.queueArn,
});
this.buildPrivateApi(vpc, envVars, bankingTable, railsbankWebhookQueue);
this.buildPublicApi(envVars, bankingTable);
}
buildPublicApi(
envVars: Record<string, string>,
bankingTable: dynamodb.Table,
) {
if (!this.privateLambdaDefs.length) {
return;
}
const publicApiGateway = this.buildPublicApiGateway();
this.buildLambdas(
publicApiGateway.root.addResource('api'),
this.publicLambdaDefs,
envVars,
bankingTable,
);
}
buildPublicApi2(
envVars: Record<string, string>,
bankingTable: dynamodb.Table,
railsbankWebhookQueue: sqs.Queue,
) {
const publicApiGateway = this.buildPublicApiGateway();
this.buildRailsbankWebhookIntegration(
publicApiGateway,
railsbankWebhookQueue,
);
this.setupDomainFor(publicApiGateway);
}
buildPrivateApi(
vpc: ec2.IVpc,
envVars: Record<string, string>,
bankingTable: dynamodb.Table,
railsbankWebhookQueue: sqs.Queue,
) {
if (!this.privateLambdaDefs.length) {
return;
}
const privateApiGateway = this.buildPrivateApiGateway(vpc);
const lambdas = this.buildLambdas(
privateApiGateway.root.addResource('api'),
this.privateLambdaDefs,
envVars,
bankingTable,
);
if (!lambdas.publishRailsbankWebhookEventFn) {
throw new Error('There is no Railsbank webhook events publisher lambda');
}
railsbankWebhookQueue.grantSendMessages(
lambdas.publishRailsbankWebhookEventFn,
);
}
buildLambdas(
rootResource: apigateway.IResource,
lambdaDefs: LambdaDef[],
envVars: Record<string, string>,
bankingTable: dynamodb.Table,
): { [name: string]: lambdaNode.NodejsFunction } {
const builtLambdas = {} as { [name: string]: lambdaNode.NodejsFunction };
lambdaDefs.forEach(lambdaDef => {
const lambda = this.buildLambda(lambdaDef.name, lambdaDef.path, envVars);
bankingTable.grantReadWriteData(lambda);
lambdaDef.routes.forEach(route => {
const gatewayIntegration = new apigateway.LambdaIntegration(lambda);
const pathParts = ([] as string[]).concat(route.path);
let currentLambdaPath = rootResource;
pathParts.forEach(pathPart => {
const existingResource = currentLambdaPath.getResource(pathPart);
currentLambdaPath =
existingResource ?? currentLambdaPath.addResource(pathPart);
});
currentLambdaPath.addMethod(route.httpMethod, gatewayIntegration);
});
builtLambdas[lambdaDef.name] = lambda;
});
return builtLambdas;
}
private buildEnvVariables(extraEnvVars: Record<string, string> = {}) {
return {
AWS_ACCOUNT: this.account,
RAILSBANK_API_URL: this.config.railsbank.apiUrl,
RAILSBANK_KEY: this.config.railsbank.apiKey,
RAILSBANK_PARTNER_PRODUCT: this.config.railsbank.partnerProduct,
RAILSBANK_WEBHOOK_SECRET: this.config.railsbank.webhookSecret,
REMAGINE_SERVICE_VERSION: this.config.version,
REMAGINE_DEPLOY_ENV: this.config.deployEnv,
REMAGINE_VPN_IP: this.config.vpnIp,
... extraEnvVars
};
}
private buildPublicApiGateway(): apigateway.RestApi {
const gatewayId = `${this.config.serviceNameSlug}-api`;
const api = new apigateway.RestApi(this, gatewayId, {
restApiName: `${this.stackName} public API`,
description: `This a public API for ${this.stackName}`,
});
this.addTagsTo(api);
return api;
}
private buildPrivateApiGateway(vpc: ec2.IVpc): apigateway.RestApi {
const endpoint = new ec2.InterfaceVpcEndpoint(
this,
'kaminoApiVpcEndpoint',
{
vpc,
service: {
name: `com.amazonaws.${this.config.aws.region}.execute-api`,
port: 443,
},
lookupSupportedAzs: true,
privateDnsEnabled: false,
},
);
const gatewayId = `${this.config.serviceNameSlug}-private-api`;
const api = new apigateway.RestApi(this, gatewayId, {
restApiName: `${this.stackName} private API`,
description: `This a private API for ${this.stackName}`,
endpointConfiguration: {
types: [apigateway.EndpointType.PRIVATE],
vpcEndpoints: [endpoint],
},
policy: new iam.PolicyDocument({
statements: [
new iam.PolicyStatement({
principals: [new iam.AnyPrincipal()],
actions: ['execute-api:Invoke'],
resources: ['execute-api:/*'],
effect: iam.Effect.DENY,
conditions: this.denyAccessingServiceWhenAllConditionsSatisfied(
endpoint,
),
}),
new iam.PolicyStatement({
principals: [new iam.AnyPrincipal()],
actions: ['execute-api:Invoke'],
resources: ['execute-api:/*'],
effect: iam.Effect.ALLOW,
}),
],
}),
});
this.addTagsTo(api);
this.addTagsTo(endpoint);
new cdk.CfnOutput(this, 'kaminoInterfaceVpcEndpointDns', {
value: cdk.Fn.select(0, endpoint.vpcEndpointDnsEntries),
});
return api;
}
// The method allows accessing Kamino only in following scenarios:
// 1. A request was sent from specified VPC Endpoint ID
// 2. A request was sent from specified IP if the one was given
denyAccessingServiceWhenAllConditionsSatisfied(
endpoint: ec2.InterfaceVpcEndpoint,
) {
const conditions: iam.PolicyStatementProps['conditions'] = {
StringNotEquals: {
'aws:SourceVpce': endpoint.vpcEndpointId,
},
};
if (this.config.vpnIp) {
conditions.NotIpAddress = {
'aws:SourceIp': this.config.vpnIp,
};
}
return conditions;
}
private buildLambda(
name: string,
pathToIndex: string,
env: { [key: string]: string } = {},
handler: string = 'handler',
): lambdaNode.NodejsFunction {
const fn = new lambdaNode.NodejsFunction(this, name, {
entry: path.resolve(__dirname, '..', `./functions/${pathToIndex}`),
handler,
runtime: lambda.Runtime.NODEJS_14_X,
timeout: cdk.Duration.minutes(3),
environment: env,
});
this.addTagsTo(fn);
return fn;
}
setupDomainFor(api: apigateway.RestApi): void {
const { domainName, appDomain } = this.config;
if (!domainName || !appDomain) {
return;
}
const hostedZone = this.lookupHostedZone(domainName);
const remagineCertificate = new acm.Certificate(this, 'KaminoCertificate', {
domainName: appDomain,
validation: acm.CertificateValidation.fromDns(hostedZone),
});
cdk.Tags.of(remagineCertificate).add('domainName', appDomain);
this.addTagsTo(remagineCertificate);
api.addDomainName('KaminoDomainName', {
domainName: appDomain,
certificate: remagineCertificate,
securityPolicy: apigateway.SecurityPolicy.TLS_1_2,
});
new route53.ARecord(this, 'AliasRecord', {
recordName: appDomain,
zone: hostedZone,
target: route53.RecordTarget.fromAlias(
new route53Targets.ApiGateway(api),
),
});
}
buildDynamoDbBankingTable() {
return new dynamodb.Table(this, 'Banking', {
partitionKey: {
name: '_pk',
type: dynamodb.AttributeType.STRING,
},
sortKey: {
name: '_sk',
type: dynamodb.AttributeType.STRING,
},
tableName: 'Banking',
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
pointInTimeRecovery: true,
removalPolicy: RemovalPolicy.RETAIN,
});
}
buildRailsbankWebhookQueue() {
const railsbankWebhookDeadLetterQueue = new sqs.Queue(
this,
'railsbankWebhookDlq',
);
const railsbankWebhookQueue = new sqs.Queue(this, 'railsbankWebhook', {
queueName: `${this.config.servicePrefix}-railsbankWebhook-${this.account}`,
visibilityTimeout: cdk.Duration.seconds(90),
retentionPeriod: cdk.Duration.hours(72),
deadLetterQueue: {
queue: railsbankWebhookDeadLetterQueue,
maxReceiveCount: 5,
},
});
this.addTagsTo(railsbankWebhookDeadLetterQueue);
this.addTagsTo(railsbankWebhookQueue);
return {
railsbankWebhookQueue,
};
}
buildRailsbankWebhookIntegration(
apiGw: apigateway.IRestApi,
railsbankWebhookQueue: sqs.Queue,
) {
const railsbankWebhookPublisherRole = new iam.Role(
this,
'railsbankWebhookPublisherRole',
{
assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com'),
},
);
railsbankWebhookPublisherRole.addToPolicy(
new iam.PolicyStatement({
resources: [railsbankWebhookQueue.queueArn],
actions: ['sqs:SendMessage'],
}),
);
const railsbankWebhookApiGatewayIntegration = new apigateway.AwsIntegration(
{
service: 'sqs',
integrationHttpMethod: 'POST',
options: {
passthroughBehavior: apigateway.PassthroughBehavior.NEVER,
credentialsRole: railsbankWebhookPublisherRole,
requestParameters: {
'integration.request.header.Content-Type':
"'application/x-www-form-urlencoded'",
},
requestTemplates: {
'application/json':
'Action=SendMessage&MessageBody=$util.urlEncode("$input.body")',
},
integrationResponses: [
{
statusCode: '200',
responseTemplates: {
'application/json': '{"success":true}',
},
},
{
statusCode: '500',
responseTemplates: {
'application/json': '{"success":false}',
},
selectionPattern: '500',
},
],
},
path: `${cdk.Aws.ACCOUNT_ID}/${railsbankWebhookQueue.queueName}`,
},
);
const apiKeyAuthorizer = new RequestAuthorizer(this, 'apiKeyAuthorizer', {
handler: this.buildLambda(
'apiKeyAuthorizerFn',
'api/railsbank-webhook/api-key-authorizer/index.ts',
),
identitySources: ['method.request.body.secret'],
});
const railsbankWebhookEndpoint = apiGw.root.addResource(
'railsbank-webhook',
);
railsbankWebhookEndpoint.addMethod(
'POST',
railsbankWebhookApiGatewayIntegration,
{
methodResponses: [
{
statusCode: '200',
responseParameters: {
'method.response.header.Content-Type': true,
},
},
{
statusCode: '500',
responseParameters: {
'method.response.header.Content-Type': true,
},
},
],
authorizer: apiKeyAuthorizer,
},
);
return {
railsbankWebhookQueue,
};
}
lookupHostedZone(domainName: string) {
return route53.HostedZone.fromLookup(this, 'hostedZone', {
domainName,
});
}
addTagsTo(construct: cdk.Construct) {
cdk.Tags.of(construct).add('environment', this.config.deployEnv);
cdk.Tags.of(construct).add('tier', 'kamino');
cdk.Tags.of(construct).add('createdBy', 'cdk');
}
}
import * as cdk from '@aws-cdk/core';
import * as apigateway from '@aws-cdk/aws-apigateway';
import * as route53 from '@aws-cdk/aws-route53';
import * as route53Targets from '@aws-cdk/aws-route53-targets';
import * as acm from '@aws-cdk/aws-certificatemanager';
import * as ec2 from '@aws-cdk/aws-ec2';
import * as ecs from '@aws-cdk/aws-ecs';
import * as ecsPatterns from '@aws-cdk/aws-ecs-patterns';
import * as servicediscovery from '@aws-cdk/aws-servicediscovery';
import * as path from 'path';
import { Config } from '../config';
export interface SharedCdkStackProps extends cdk.StackProps {
config: Config;
}
export class SharedCdkStack extends cdk.Stack {
private config: Config;
constructor(scope: cdk.Construct, id: string, props: SharedCdkStackProps) {
super(scope, id, props);
this.config = props.config;
const vpc = ec2.Vpc.fromLookup(this, 'VPC', { isDefault: true });
// const azs = vpc.availabilityZones.slice().sort();
// const publicSubnetIds = vpc.publicSubnets
// .slice()
// .map(s => s.subnetId)
// .sort();
// const privateSubnet1 = new ec2.Subnet(this, 'privateSubnet1', {
// vpcId: vpc.vpcId,
// cidrBlock: '172.31.48.0/20',
// availabilityZone: azs[0],
// mapPublicIpOnLaunch: false,
// });
// const privateSubnet2 = new ec2.Subnet(this, 'privateSubnet2', {
// vpcId: vpc.vpcId,
// cidrBlock: '172.31.64.0/20',
// availabilityZone: azs[1],
// mapPublicIpOnLaunch: false,
// });
// const natGateway = new ec2.CfnNatGateway(this, 'sharedNat', {
// subnetId: publicSubnetIds[0],
// allocationId: new ec2.CfnEIP(this, 'sharedNatElasticIp', {
// domain: 'vpc',
// }).attrAllocationId,
// });
// new ec2.CfnRoute(this, 'privateSubnet1NatRoute', {
// routeTableId: privateSubnet1.routeTable.routeTableId,
// natGatewayId: natGateway.ref,
// destinationCidrBlock: '0.0.0.0/0',
// });
// new ec2.CfnRoute(this, 'privateSubnet2NatRoute', {
// routeTableId: privateSubnet2.routeTable.routeTableId,
// natGatewayId: natGateway.ref,
// destinationCidrBlock: '0.0.0.0/0',
// });
const publicApiGateway = this.buildPublicApiGateway();
this.setupPublicApiDomainFor(publicApiGateway);
// Cloud Map Namespace
const namespace = new servicediscovery.PrivateDnsNamespace(
this,
'MyNamespace',
{
name: 'private.dev-remagine.net',
vpc,
},
);
new cdk.CfnOutput(this, 'sharedCloudMapNamespaceArn', {
value: namespace.namespaceArn,
});
new cdk.CfnOutput(this, 'sharedCloudMapNamespaceName', {
value: namespace.namespaceName,
});
new cdk.CfnOutput(this, 'sharedCloudMapNamespaceId', {
value: namespace.namespaceId,
});
const ecs = this.buildEcsInstance(vpc);
ecs.service.enableCloudMap({
cloudMapNamespace: namespace,
dnsTtl: cdk.Duration.seconds(30),
failureThreshold: 1,
name: 'foobar',
});
ecs.targetGroup.configureHealthCheck({
path: '/',
port: String(3000)
});
}
private buildEcsInstance(vpc: ec2.IVpc) {
const cluster = new ecs.Cluster(this, 'sharedStackEcsSampleCluster', {
vpc,
});
return new ecsPatterns.ApplicationLoadBalancedFargateService(
this,
'sharedStackEcsSample',
{
publicLoadBalancer: false,
assignPublicIp: true,
cluster,
taskImageOptions: {
image: ecs.ContainerImage.fromRegistry('amazon/amazon-ecs-sample'),
containerPort: 3000,
environment: {},
},
},
);
}
private buildPrivateApiGateway(): apigateway.RestApi {
const gatewayId = `${this.config.stackName}-public-api`;
const publicApiGateway = new apigateway.RestApi(this, gatewayId, {
restApiName: `Remagine public API (${this.stackName})`,
description: `This is Remagine public API, which is accessible from outside world`,
});
publicApiGateway.root.addResource('api').addMethod('ANY');
return publicApiGateway;
}
private buildPublicApiGateway(): apigateway.RestApi {
const gatewayId = `${this.config.stackName}-public-api`;
const publicApiGateway = new apigateway.RestApi(this, gatewayId, {
restApiName: `Remagine public API (${this.stackName})`,
description: `This is Remagine public API, which is accessible from outside world`,
});
publicApiGateway.root.addResource('api').addMethod('ANY');
return publicApiGateway;
}
private setupPublicApiDomainFor(api: apigateway.RestApi): void {
const { publicApiDomain, domainName } = this.config;
if (!domainName || !publicApiDomain) {
return;
}
const remagineHostedZone = this.lookupHostedZone(domainName);
const remagineCertificate = new acm.Certificate(
this,
`RemagineCertificate`,
{
domainName: publicApiDomain,
validation: acm.CertificateValidation.fromDns(remagineHostedZone),
},
);
this.addTagsTo(remagineCertificate);
api.addDomainName('PublicApiDomainName', {
domainName: publicApiDomain,
certificate: remagineCertificate,
securityPolicy: apigateway.SecurityPolicy.TLS_1_2,
});
new route53.ARecord(this, 'PublicApiAliasRecord', {
recordName: publicApiDomain,
zone: remagineHostedZone,
target: route53.RecordTarget.fromAlias(
new route53Targets.ApiGateway(api),
),
});
}
lookupHostedZone(domainName: string) {
return route53.HostedZone.fromLookup(this, 'hostedZone', {
domainName,
});
}
addTagsTo(construct: cdk.Construct) {
cdk.Tags.of(construct).add('environment', this.config.deployEnv);
cdk.Tags.of(construct).add('tier', 'shared-cdk');
cdk.Tags.of(construct).add('createdBy', 'cdk');
}
}