serverless-vpc-plugin
Version:
Serverless Plugin to generate a VPC
471 lines (411 loc) • 14.6 kB
JavaScript
const {
DEFAULT_VPC_EIP_LIMIT,
PUBLIC_SUBNET,
DB_SUBNET,
APP_SUBNET,
VALID_SUBNET_GROUPS,
} = require('./constants');
const { splitSubnets } = require('./subnets');
const { buildAvailabilityZones } = require('./az');
const {
buildVpc,
buildInternetGateway,
buildAppSecurityGroup,
buildDHCPOptions,
} = require('./vpc');
const { buildAppNetworkAcl, buildPublicNetworkAcl, buildDBNetworkAcl } = require('./nacl');
const { buildSubnetGroups } = require('./subnet_groups');
const { buildEndpointServices } = require('./vpce');
const { buildLogBucket, buildLogBucketPolicy, buildVpcFlowLogs } = require('./flow_logs');
const { buildBastion } = require('./bastion');
const { buildNatInstance, buildNatSecurityGroup } = require('./nat_instance');
const { buildOutputs } = require('./outputs');
const { buildParameter } = require('./parameters');
class ServerlessVpcPlugin {
constructor(serverless, options) {
this.serverless = serverless;
this.options = options || {};
this.provider = this.serverless.getProvider('aws');
this.hooks = {
'after:package:initialize': this.afterInitialize.bind(this),
};
}
async afterInitialize() {
let cidrBlock = '10.0.0.0/16';
let zones = [];
let services = ['s3', 'dynamodb'];
let createNatGateway = false;
let createNetworkAcl = false;
let createDbSubnet = true;
let createFlowLogs = false;
let createNatInstance = false;
let createBastionHost = false;
let createParameters = false;
let bastionHostKeyName = null;
let exportOutputs = false;
let subnetGroups = VALID_SUBNET_GROUPS;
const { vpcConfig } = this.serverless.service.custom;
if (vpcConfig) {
if (vpcConfig.enabled === false) {
this.serverless.cli.log('VPC plugin disabled');
return;
}
if (vpcConfig.cidrBlock && typeof vpcConfig.cidrBlock === 'string') {
({ cidrBlock } = vpcConfig);
}
if ('createNatGateway' in vpcConfig) {
({ createNatGateway } = vpcConfig);
}
if ('createNetworkAcl' in vpcConfig && typeof vpcConfig.createNetworkAcl === 'boolean') {
({ createNetworkAcl } = vpcConfig);
}
if (Array.isArray(vpcConfig.zones) && vpcConfig.zones.length > 0) {
zones = vpcConfig.zones.map((z) => z.trim().toLowerCase());
}
if (Array.isArray(vpcConfig.services)) {
services = vpcConfig.services.map((s) => s.trim().toLowerCase());
}
if (Array.isArray(vpcConfig.subnetGroups)) {
subnetGroups = vpcConfig.subnetGroups.map((s) => s.trim().toLowerCase());
}
if ('createDbSubnet' in vpcConfig && typeof vpcConfig.createDbSubnet === 'boolean') {
({ createDbSubnet } = vpcConfig);
}
if ('createFlowLogs' in vpcConfig && typeof vpcConfig.createFlowLogs === 'boolean') {
({ createFlowLogs } = vpcConfig);
}
if ('createBastionHost' in vpcConfig && typeof vpcConfig.createBastionHost === 'boolean') {
({ createBastionHost } = vpcConfig);
}
if ('bastionHostKeyName' in vpcConfig && typeof vpcConfig.bastionHostKeyName === 'string') {
({ bastionHostKeyName } = vpcConfig);
}
if (createBastionHost && !bastionHostKeyName) {
throw new this.serverless.classes.Error(
'bastionHostKeyName must be provided if createBastionHost is true',
);
}
if ('createNatInstance' in vpcConfig && typeof vpcConfig.createNatInstance === 'boolean') {
({ createNatInstance } = vpcConfig);
}
if (createNatGateway && createNatInstance) {
throw new this.serverless.classes.Error(
'Please choose either createNatGateway or createNatInstance, not both',
);
}
if ('exportOutputs' in vpcConfig && typeof vpcConfig.exportOutputs === 'boolean') {
({ exportOutputs } = vpcConfig);
}
if ('createParameters' in vpcConfig && typeof vpcConfig.createParameters === 'boolean') {
({ createParameters } = vpcConfig);
}
}
const region = this.provider.getRegion();
if (zones.length < 1) {
this.serverless.cli.log(`Discovering available zones in ${region}...`);
zones = await this.getZonesPerRegion(region);
}
const numZones = zones.length;
let prefixLists = null;
if (services.includes('s3') || services.includes('dynamodb')) {
this.serverless.cli.log(`Getting managed prefix lists in ${region}...`);
prefixLists = await this.getPrefixLists();
}
if (createNatGateway) {
if (typeof createNatGateway !== 'boolean' && typeof createNatGateway !== 'number') {
throw new this.serverless.classes.Error(
'createNatGateway must be either a boolean or a number',
);
}
if (typeof createNatGateway === 'boolean') {
createNatGateway = createNatGateway ? numZones : 0;
} else if (createNatGateway > numZones) {
createNatGateway = numZones;
}
if (createNatGateway > DEFAULT_VPC_EIP_LIMIT) {
this.serverless.cli.log(
`WARNING: Number of NAT gateways (${createNatGateway}) is greater than default ` +
`EIP limit (${DEFAULT_VPC_EIP_LIMIT}). Please ensure you requested ` +
`an AWS EIP limit increase.`,
);
}
}
this.serverless.cli.log(
`Generating a VPC in ${region} (${cidrBlock}) across ${numZones} AZs: ${zones}`,
);
const providerObj = this.serverless.service.provider;
const resources = providerObj.compiledCloudFormationTemplate.Resources;
let vpcNatAmi = null;
if (createNatInstance) {
this.serverless.cli.log('Finding latest VPC NAT Instance AMI...');
const images = await this.getImagesByName('amzn-ami-vpc-nat*');
if (Array.isArray(images) && images.length > 0) {
[vpcNatAmi] = images;
} else {
throw new this.serverless.classes.Error(
`Could not find an available VPC NAT Instance AMI in ${region}`,
);
}
}
const subnets = splitSubnets(cidrBlock, zones);
Object.assign(
resources,
buildVpc(cidrBlock),
buildInternetGateway(),
buildAvailabilityZones(subnets, zones, {
numNatGateway: createNatGateway,
createDbSubnet,
createNatInstance: !!(createNatInstance && vpcNatAmi),
}),
buildAppSecurityGroup(prefixLists),
buildDHCPOptions(region),
);
if (createNetworkAcl) {
this.serverless.cli.log('Provisioning Network ACLs');
Object.assign(
resources,
buildPublicNetworkAcl(zones.length),
buildAppNetworkAcl(zones.length),
);
if (createDbSubnet) {
Object.assign(resources, buildDBNetworkAcl(subnets.get(APP_SUBNET)));
}
}
if (createNatInstance && vpcNatAmi) {
this.serverless.cli.log(`Provisioning NAT Instance using AMI ${vpcNatAmi}`);
Object.assign(resources, buildNatSecurityGroup(), buildNatInstance(vpcNatAmi, zones));
}
if (createBastionHost) {
this.serverless.cli.log(`Provisioning bastion host using key pair "${bastionHostKeyName}"`);
// @see https://docs.aws.amazon.com/systems-manager/latest/userguide/parameter-store-public-parameters.html#parameter-store-public-parameters-ami
providerObj.compiledCloudFormationTemplate.Parameters = {
LatestAmiId: {
Type: 'AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>',
Default: '/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2',
},
};
Object.assign(resources, await buildBastion(bastionHostKeyName, zones.length));
}
if (services.length > 0) {
const invalid = await this.validateServices(region, services);
if (invalid.length > 0) {
throw new this.serverless.classes.Error(
`WARNING: Requested services are not available in ${region}: ${invalid.join(', ')}`,
);
}
this.serverless.cli.log(`Provisioning VPC endpoints for: ${services.join(', ')}`);
Object.assign(resources, buildEndpointServices(services, numZones));
}
if (createDbSubnet) {
if (numZones < 2) {
this.serverless.cli.log('WARNING: less than 2 AZs; skipping subnet group provisioning');
} else {
const invalidGroup = subnetGroups.some((group) => !VALID_SUBNET_GROUPS.includes(group));
if (invalidGroup) {
throw new this.serverless.classes.Error(
'WARNING: Invalid subnetGroups option. Valid options: rds, redshift, elasticache, dax',
);
}
Object.assign(resources, buildSubnetGroups(numZones, subnetGroups));
}
}
if (createFlowLogs) {
this.serverless.cli.log('Enabling VPC Flow Logs to S3');
Object.assign(resources, buildLogBucket(), buildLogBucketPolicy(), buildVpcFlowLogs());
}
// SSM Parameters
if (createParameters) {
Object.assign(resources, buildParameter('VPC'), buildParameter('AppSecurityGroup'));
if (createDbSubnet && numZones > 1) {
if (subnetGroups.includes('rds')) {
Object.assign(resources, buildParameter('RDSSubnetGroup'));
}
if (subnetGroups.includes('elasticache')) {
Object.assign(resources, buildParameter('ElastiCacheSubnetGroup'));
}
if (subnetGroups.includes('redshift')) {
Object.assign(resources, buildParameter('RedshiftSubnetGroup'));
}
if (subnetGroups.includes('dax')) {
Object.assign(resources, buildParameter('DAXSubnetGroup'));
}
}
const publicSubnets = [];
const appSubnets = [];
const dbSubnets = [];
for (let index = 1; index <= numZones; index += 1) {
publicSubnets.push({ Ref: `${PUBLIC_SUBNET}Subnet${index}` });
appSubnets.push({ Ref: `${APP_SUBNET}Subnet${index}` });
if (createDbSubnet) {
dbSubnets.push({ Ref: `${DB_SUBNET}Subnet${index}` });
}
}
Object.assign(
resources,
buildParameter('PublicSubnets', { Value: publicSubnets }),
buildParameter('AppSubnets', { Value: appSubnets }),
);
if (dbSubnets.length > 0) {
Object.assign(resources, buildParameter('DBSubnets', { Value: dbSubnets }));
}
}
this.serverless.cli.log('Updating Lambda VPC configuration');
const { vpc = {} } = providerObj;
if (!Array.isArray(vpc.securityGroupIds)) {
vpc.securityGroupIds = [];
}
vpc.securityGroupIds.push({ Ref: 'AppSecurityGroup' });
if (!Array.isArray(vpc.subnetIds)) {
vpc.subnetIds = [];
}
for (let i = 1; i <= numZones; i += 1) {
vpc.subnetIds.push({ Ref: `${APP_SUBNET}Subnet${i}` });
}
const outputs = providerObj.compiledCloudFormationTemplate.Outputs;
// remove rds subnet group if not creating DB subnet
const pos = subnetGroups.indexOf('rds');
if (!createDbSubnet && pos !== -1) {
subnetGroups.splice(pos, 1);
}
Object.assign(
outputs,
buildOutputs({
createBastionHost,
createDbSubnet,
subnetGroups,
subnets: vpc.subnetIds,
exportOutputs,
}),
);
this.serverless.service.provider.vpc = vpc;
}
/**
* Return an array of availability zones for a given region.
*
* @param {String} region
* @return {Array}
*/
async getZonesPerRegion(region) {
const params = {
Filters: [
{
Name: 'region-name',
Values: [region],
},
{
Name: 'opt-in-status',
Values: ['opt-in-not-required'],
},
{
Name: 'state',
Values: ['available'],
},
],
};
const data = await this.provider.request('EC2', 'describeAvailabilityZones', params);
return data.AvailabilityZones.map((z) => z.ZoneName).sort();
}
/**
* Return an array of available VPC endpoint services for a given region.
*
* @return {Array}
*/
async getVpcEndpointServicesPerRegion() {
const params = {
MaxResults: 1000,
};
const data = await this.provider.request('EC2', 'describeVpcEndpointServices', params);
return data.ServiceNames.sort();
}
/**
* Return an array of AMI images which match the VPC NAT Instance image
*
* @param {String} name AMI name to search for
* @return {Array}
*/
async getImagesByName(name) {
const params = {
Owners: ['amazon'],
Filters: [
{
Name: 'architecture',
Values: ['x86_64'],
},
{
Name: 'image-type',
Values: ['machine'],
},
{
Name: 'is-public',
Values: ['true'],
},
{
Name: 'name',
Values: [name],
},
{
Name: 'state',
Values: ['available'],
},
{
Name: 'root-device-type',
Values: ['ebs'],
},
{
Name: 'virtualization-type',
Values: ['hvm'],
},
],
IncludeDeprecated: true,
};
const data = await this.provider.request('EC2', 'describeImages', params);
return data.Images.sort((a, b) => {
if (a.CreationDate > b.CreationDate) {
return -1;
}
if (a.CreationDate < b.CreationDate) {
return 1;
}
return 0;
}).map((image) => image.ImageId);
}
/**
* Return an array of provided services that are not available within the provided region.
*
* @param {String} region
* @param {Array} services
* @return {Array}
*/
async validateServices(region, services = []) {
if (!Array.isArray(services) || services.length < 1) {
return [];
}
const available = await this.getVpcEndpointServicesPerRegion();
return services
.map((service) => `com.amazonaws.${region}.${service}`)
.filter((service) => !available.includes(service));
}
/**
* Return an array of prefix lists in the provided region.
*
* @return {Object}
*/
async getPrefixLists() {
const params = {
Filters: [
{
Name: 'owner-id',
Values: ['AWS'],
},
],
};
const data = await this.provider.request('EC2', 'describeManagedPrefixLists', params);
const results = {};
data.PrefixLists.forEach((prefixList) => {
const service = prefixList.PrefixListName.split('.').slice(3).join('.');
results[service] = prefixList.PrefixListId;
});
return results;
}
}
module.exports = ServerlessVpcPlugin;