Skip to content

Commit

Permalink
[RFC] VPC construct
Browse files Browse the repository at this point in the history
  • Loading branch information
t-richard committed Jul 8, 2021
1 parent 54d73d8 commit c01f816
Show file tree
Hide file tree
Showing 12 changed files with 191 additions and 17 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"@aws-cdk/aws-cloudfront": "^1.110.1",
"@aws-cdk/aws-cloudfront-origins": "^1.110.1",
"@aws-cdk/aws-cloudwatch": "^1.110.1",
"@aws-cdk/aws-ec2": "^1.110.1",
"@aws-cdk/aws-events": "^1.110.1",
"@aws-cdk/aws-iam": "^1.110.1",
"@aws-cdk/aws-lambda": "^1.110.1",
Expand Down
3 changes: 2 additions & 1 deletion src/classes/AwsConstruct.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Construct as CdkConstruct } from "@aws-cdk/core";
import { AwsCfInstruction } from "@serverless/typescript";
import { ConstructInterface } from ".";
import { AwsProvider } from "./AwsProvider";

Expand Down Expand Up @@ -29,5 +30,5 @@ export abstract class AwsConstruct extends CdkConstruct implements ConstructInte
/**
* CloudFormation references
*/
abstract references(): Record<string, Record<string, unknown>>;
abstract references(): Record<string, AwsCfInstruction>;
}
42 changes: 36 additions & 6 deletions src/classes/AwsProvider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { App, CfnOutput, Stack } from "@aws-cdk/core";
import { get, merge } from "lodash";
import { AwsCfInstruction, AwsLambdaVpcConfig } from "@serverless/typescript";
import { getStackOutput } from "../CloudFormation";
import { CloudformationTemplate, Provider as LegacyAwsProvider, Serverless } from "../types/serverless";
import { awsRequest } from "./aws";
Expand All @@ -8,6 +9,7 @@ import { StaticConstructInterface } from "./Construct";
import ServerlessError from "../utils/error";
import { Storage } from "../constructs/Storage";
import { Queue } from "../constructs/Queue";
import { VPC } from "../constructs/VPC";
import { Webhook } from "../constructs/Webhook";
import { StaticWebsite } from "../constructs/StaticWebsite";

Expand Down Expand Up @@ -42,14 +44,13 @@ export class AwsProvider {
public naming: { getStackName: () => string; getLambdaLogicalId: (functionName: string) => string };

constructor(private readonly serverless: Serverless) {
this.stackName = serverless.getProvider("aws").naming.getStackName();
this.app = new App();
this.stack = new Stack(this.app);
serverless.stack = this.stack;
this.stackName = serverless.getProvider("aws").naming.getStackName();

this.legacyProvider = serverless.getProvider("aws");
this.naming = this.legacyProvider.naming;
this.region = serverless.getProvider("aws").getRegion();
serverless.stack = this.stack;
}

create(type: string, id: string): ConstructInterface {
Expand Down Expand Up @@ -84,6 +85,35 @@ export class AwsProvider {
this.serverless.service.setFunctionNames(this.serverless.processedInput.options);
}

/**
* @internal
*/
setVpcConfig(securityGroup: AwsCfInstruction, subnets: AwsCfInstruction[]): void {
if (this.getVpcConfig() !== null) {
throw new ServerlessError(
"Can't register more than one VPC.\n" +
'Either you have several "vpc" constructs \n' +
'or you already defined "provider.vpc" in serverless.yml',
"LIFT_ONLY_ONE_VPC"
);
}

this.serverless.service.provider.vpc = {
securityGroupIds: [securityGroup], // TODO : merge with existing groups ?
subnetIds: subnets,
};
}

/**
* This function can be used by other constructs to reference
* global subnets or security groups in their resources
*
* @internal
*/
getVpcConfig(): AwsLambdaVpcConfig | null {
return this.serverless.service.provider.vpc ?? null;
}

/**
* Resolves the value of a CloudFormation stack output.
*/
Expand All @@ -94,8 +124,8 @@ export class AwsProvider {
/**
* Returns a CloudFormation intrinsic function, like Fn::Ref, GetAtt, etc.
*/
getCloudFormationReference(value: string): Record<string, unknown> {
return Stack.of(this.stack).resolve(value) as Record<string, unknown>;
getCloudFormationReference(value: string): AwsCfInstruction {
return Stack.of(this.stack).resolve(value) as AwsCfInstruction;
}

/**
Expand All @@ -120,4 +150,4 @@ export class AwsProvider {
* If they use TypeScript, `registerConstructs()` will validate that the construct class
* implements both static fields (type, schema, create(), …) and non-static fields (outputs(), references(), …).
*/
AwsProvider.registerConstructs(Storage, Queue, Webhook, StaticWebsite);
AwsProvider.registerConstructs(Storage, Queue, Webhook, StaticWebsite, VPC);
3 changes: 2 additions & 1 deletion src/classes/Construct.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AwsCfInstruction } from "@serverless/typescript";
import { PolicyStatement } from "../CloudFormation";
import { AwsProvider } from "./AwsProvider";
import { CliOptions } from "../types/serverless";
Expand All @@ -11,7 +12,7 @@ export interface ConstructInterface {
/**
* CloudFormation references
*/
references(): Record<string, Record<string, unknown>>;
references(): Record<string, AwsCfInstruction>;

/**
* Post-CloudFormation deployment
Expand Down
7 changes: 4 additions & 3 deletions src/constructs/Queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { PurgeQueueRequest, SendMessageRequest } from "aws-sdk/clients/sqs";
import ora from "ora";
import { spawnSync } from "child_process";
import * as inquirer from "inquirer";
import { AwsCfInstruction } from "@serverless/typescript";
import { AwsConstruct, AwsProvider } from "../classes";
import { pollMessages, retryMessages } from "./queue/sqs";
import { sleep } from "../utils/sleep";
Expand Down Expand Up @@ -188,7 +189,7 @@ export class Queue extends AwsConstruct {
};
}

references(): Record<string, Record<string, unknown>> {
references(): Record<string, AwsCfInstruction> {
return {
queueUrl: this.referenceQueueUrl(),
queueArn: this.referenceQueueArn(),
Expand Down Expand Up @@ -218,11 +219,11 @@ export class Queue extends AwsConstruct {
this.provider.addFunction(`${this.id}Worker`, this.configuration.worker);
}

private referenceQueueArn(): Record<string, unknown> {
private referenceQueueArn(): AwsCfInstruction {
return this.provider.getCloudFormationReference(this.queue.queueArn);
}

private referenceQueueUrl(): Record<string, unknown> {
private referenceQueueUrl(): AwsCfInstruction {
return this.provider.getCloudFormationReference(this.queue.queueUrl);
}

Expand Down
3 changes: 2 additions & 1 deletion src/constructs/StaticWebsite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { S3Origin } from "@aws-cdk/aws-cloudfront-origins";
import * as acm from "@aws-cdk/aws-certificatemanager";
import { flatten } from "lodash";
import { ErrorResponse } from "@aws-cdk/aws-cloudfront/lib/distribution";
import { AwsCfInstruction } from "@serverless/typescript";
import { log } from "../utils/logger";
import { s3Sync } from "../utils/s3-sync";
import { AwsConstruct, AwsProvider } from "../classes";
Expand Down Expand Up @@ -165,7 +166,7 @@ export class StaticWebsite extends AwsConstruct {
};
}

references(): Record<string, Record<string, unknown>> {
references(): Record<string, AwsCfInstruction> {
return {};
}

Expand Down
7 changes: 4 additions & 3 deletions src/constructs/Storage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BlockPublicAccess, Bucket, BucketEncryption, StorageClass } from "@aws-cdk/aws-s3";
import { Construct as CdkConstruct, CfnOutput, Duration, Fn, Stack } from "@aws-cdk/core";
import { FromSchema } from "json-schema-to-ts";
import { AwsCfInstruction } from "@serverless/typescript";
import { AwsConstruct, AwsProvider } from "../classes";
import { PolicyStatement } from "../CloudFormation";

Expand Down Expand Up @@ -65,7 +66,7 @@ export class Storage extends AwsConstruct {
});
}

references(): Record<string, Record<string, unknown>> {
references(): Record<string, AwsCfInstruction> {
return {
bucketArn: this.referenceBucketArn(),
bucketName: this.referenceBucketName(),
Expand All @@ -91,11 +92,11 @@ export class Storage extends AwsConstruct {
};
}

referenceBucketName(): Record<string, unknown> {
referenceBucketName(): AwsCfInstruction {
return this.provider.getCloudFormationReference(this.bucket.bucketName);
}

referenceBucketArn(): Record<string, unknown> {
referenceBucketArn(): AwsCfInstruction {
return this.provider.getCloudFormationReference(this.bucket.bucketArn);
}

Expand Down
50 changes: 50 additions & 0 deletions src/constructs/VPC.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { SecurityGroup, Vpc } from "@aws-cdk/aws-ec2";
import { Construct as CdkConstruct } from "@aws-cdk/core";
import { FromSchema } from "json-schema-to-ts";
import { AwsCfInstruction } from "@serverless/typescript";
import { AwsConstruct, AwsProvider } from "../classes";

const VPC_DEFINITION = {
type: "object",
properties: {
type: { const: "vpc" },
},
additionalProperties: false,
required: [],
} as const;

type Configuration = FromSchema<typeof VPC_DEFINITION>;

export class VPC extends AwsConstruct {
public static type = "vpc";
public static schema = VPC_DEFINITION;

private readonly vpc: Vpc;

constructor(scope: CdkConstruct, id: string, configuration: Configuration, private provider: AwsProvider) {
super(scope, id);

this.vpc = new Vpc(this, "VPC", {
maxAzs: 2,
});

const privateSubnets = this.vpc.privateSubnets;

const lambdaSecurityGroup = new SecurityGroup(this, "AppSecurityGroup", {
vpc: this.vpc,
});

provider.setVpcConfig(
provider.getCloudFormationReference(lambdaSecurityGroup.securityGroupName),
privateSubnets.map((subnet) => provider.getCloudFormationReference(subnet.subnetId))
);
}

outputs(): Record<string, () => Promise<string | undefined>> {
return {};
}

references(): Record<string, AwsCfInstruction> {
return {};
}
}
5 changes: 3 additions & 2 deletions src/constructs/Webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Function } from "@aws-cdk/aws-lambda";
import { EventBus } from "@aws-cdk/aws-events";
import { FromSchema } from "json-schema-to-ts";
import { PolicyDocument, PolicyStatement, Role, ServicePrincipal } from "@aws-cdk/aws-iam";
import { AwsCfInstruction } from "@serverless/typescript";
import { AwsConstruct, AwsProvider } from "../classes";
import ServerlessError from "../utils/error";

Expand Down Expand Up @@ -148,7 +149,7 @@ export class Webhook extends AwsConstruct {
};
}

references(): Record<string, Record<string, unknown>> {
references(): Record<string, AwsCfInstruction> {
return {
busName: this.referenceBusName(),
};
Expand Down Expand Up @@ -190,7 +191,7 @@ export class Webhook extends AwsConstruct {
return apiEndpoint + path;
}

private referenceBusName(): Record<string, unknown> {
private referenceBusName(): AwsCfInstruction {
return this.provider.getCloudFormationReference(this.bus.eventBusName);
}
}
13 changes: 13 additions & 0 deletions test/fixtures/vpc/serverless.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
service: vpc
configValidationMode: error

provider:
name: aws

functions:
foo:
handler: worker.handler

constructs:
vpc:
type: vpc
Empty file added test/fixtures/vpc/worker.js
Empty file.
74 changes: 74 additions & 0 deletions test/unit/vpc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { get } from "lodash";
import { AwsCfInstruction } from "@serverless/typescript";
import { baseConfig, pluginConfigExt, runServerless } from "../utils/runServerless";
import ServerlessError from "../../src/utils/error";

describe("vpc", () => {
it("should put Lambda functions in the VPC", async () => {
const { cfTemplate, computeLogicalId } = await runServerless({
fixture: "vpc",
configExt: pluginConfigExt,
command: "package",
});

const vpcConfig = get(cfTemplate.Resources.FooLambdaFunction, "Properties.VpcConfig") as Record<
string,
unknown
>;
expect(vpcConfig).toHaveProperty("SecurityGroupIds");
expect((vpcConfig.SecurityGroupIds as AwsCfInstruction[])[0]).toMatchObject({
Ref: computeLogicalId("vpc", "AppSecurityGroup"),
});
expect(vpcConfig).toHaveProperty("SubnetIds");
expect(vpcConfig.SubnetIds).toContainEqual({
Ref: computeLogicalId("vpc", "VPC", "PrivateSubnet1", "Subnet"),
});
expect(vpcConfig.SubnetIds).toContainEqual({
Ref: computeLogicalId("vpc", "VPC", "PrivateSubnet2", "Subnet"),
});
});
it("throws an error when using the construct twice", async () => {
expect.assertions(2);

try {
await runServerless({
config: Object.assign(baseConfig, {
constructs: {
vpc1: {
type: "vpc",
},
vpc2: {
type: "vpc",
},
},
}),
command: "package",
});
} catch (error) {
expect(error).toBeInstanceOf(ServerlessError);
expect(error).toHaveProperty("code", "LIFT_ONLY_ONE_VPC");
}
});
it("throws an error when there is an existing VPC config", async () => {
expect.assertions(2);

try {
await runServerless({
fixture: "vpc",
configExt: Object.assign(pluginConfigExt, {
provider: {
name: "aws",
vpc: {
securityGroupIds: ["sg-00000000000000000"],
subnetIds: ["subnet-01234567899999999", "subnet-00000000099999999"],
},
},
}),
command: "package",
});
} catch (error) {
expect(error).toBeInstanceOf(ServerlessError);
expect(error).toHaveProperty("code", "LIFT_ONLY_ONE_VPC");
}
});
});

0 comments on commit c01f816

Please sign in to comment.