diff --git a/CHANGELOG.md b/CHANGELOG.md index 772a9779..d6cb36b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,19 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). -## [Unreleased] + +## [3.0.7] - 2016-01-21 + +### Added + +- Add ability to work with NAT gateways. Requires a custom Lambda function. See README.md. +- Added `AWS::CloudFormation::WaitCondition` and `AWS::CloudFormation::WaitConditionHandle` to support pausing + for resources to do things. + +### Changed + +- Improved EIP model to better handle VPC vs Classic EIPs. Changes are backwards compatible, but + existing methods are now deprecated. ## [3.0.4] - 2015-11-30 diff --git a/README.md b/README.md index 38fbaafa..00a206fc 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ describe("Template Lookup") { - AWS::AutoScaling::AutoScalingGroup - AWS::AutoScaling::LaunchConfiguration - AWS::AutoScaling::ScalingPolicy +- AWS::CloudFormation::WaitCondition +- AWS::CloudFormation::WaitConditionHandle - AWS::CloudWatch::Alarm - AWS::DynamoDB::Table - AWS::EC2::CustomerGateway @@ -134,6 +136,40 @@ describe("Template Lookup") { - AWS::SQS::Queue - AWS::SQS::QueuePolicy +### Custom types + +This project packages certain useful custom CloudFormation types. These are Lambda backed types that perform +tasks that CloudFormation does not natively support. In order to use them, you must upload the Lambda function +to your account and region. The code for these functions is found in this repo under assets/custom-types. + +## NAT Gateways +CloudFormation does not yet support the new managed NAT gateways. In order to make use of these, a custom +function has been implemented. At whatever time Amazon updates CF to support these natively, this functionality +will be deprecated and removed. + +If you use the raw `Custom::NatGateway` and `Custom::NatGatewayRoute` objects directly, you'll need to set up +WaitCondition and WaitConditionHandles as well. See the `withNAT()` implementations for more details. +We highly recommend using the `Builder`'s `withNAT()` function, as it takes care of the complexity of this. + +To set up the necessary Lambda functions: + +1. Open a shell with the `aws` cli installed and configured for the AWS account and region you want to deploy to. + You must have permissions to create Lambda functions and IAM roles. You also need `npm` installed. +2. `git clone` this repo. +3. `cd /assets/custom-types/nat-gateway` +4. Review the code in nat_gateway.js and the policies we're about to create for you, along with deploy.sh. + (Not that you can't trust us, but we're about to upload code to your account and create an IAM role to do things.) +5. WARNING: This will deploy the Lambda function as `cf-nat-gateway` in your account. + *IN THE UNLIKELY EVENT YOU ARE ALREADY USING THIS NAME, IT WILL BE OVERWRITTEN!* You can change this in the script, + but will need to pass in the ARN, instead of using the default as described below. +6. Run ./deploy.sh + +The `ServiceToken` parameter (or `cfNATLambdaARN` parameter in `withNat()`) needs to be the ARN to the Lambda function. +If you are deploying the function to the default name of `cf-nat-gateway`, you can use `Custom::NatGateway.defaultServiceToken`, +which will construct an ARN from the AWS account, region, and this default function name. + +Credit for the Lambda function script: http://www.spacevatican.org/2015/12/20/cloudformation-nat-gateway/ + ## Releasing This project uses the sbt release plugin. After the changes you want to diff --git a/assets/custom-types/nat-gateway/.gitignore b/assets/custom-types/nat-gateway/.gitignore new file mode 100644 index 00000000..eb5a316c --- /dev/null +++ b/assets/custom-types/nat-gateway/.gitignore @@ -0,0 +1 @@ +target diff --git a/assets/custom-types/nat-gateway/deploy.sh b/assets/custom-types/nat-gateway/deploy.sh new file mode 100755 index 00000000..63dfdf1c --- /dev/null +++ b/assets/custom-types/nat-gateway/deploy.sh @@ -0,0 +1,46 @@ +#!/bin/bash +rm -rf target +mkdir -p target +cd target +cp ../nat_gateway.js nat_gateway.js +npm install aws-sdk +zip -r nat_gateway.zip nat_gateway.js node_modules/ +cd .. + +account_id=$(aws iam get-user | jq -r .User.Arn | perl -pe 's/arn:aws:iam::(\d+):.*/$1/') + +role=lambda-execution-cf-nat-gateway +function_name=cf-nat-gateway + +if ! aws iam get-role --role-name $role >/dev/null 2>&1 ; then + aws iam create-role --role-name $role \ + --assume-role-policy-document file://trust-policy.json +fi + +aws iam put-role-policy --role-name $role \ + --policy-name $role \ + --policy-document file://policy.json + +# The role seems to take a little time to settle down and work. Because...amazon. + +sleep 10 + +if aws lambda get-function --function-name $function_name >/dev/null 2>&1 ; then + aws lambda update-function-code --function-name $function_name \ + --zip-file fileb://target/nat_gateway.zip +else + while ! aws lambda create-function --function-name $function_name \ + --description "Custom CloudFormation function for managing NAT Gateways" \ + --runtime nodejs \ + --role arn:aws:iam::${account_id}:role/${role} \ + --handler nat_gateway.handler \ + --timeout 300 \ + --zip-file fileb://target/nat_gateway.zip ; do + + echo "The 'The role defined for the function cannot be assumed by Lambda' error you may have just seen is time based. Sleeping 1 and trying again." + echo "If you saw a different error or this doesn't resolve itself after a few tries, hit ctrl-c" + sleep 1 + done +fi + + diff --git a/assets/custom-types/nat-gateway/nat_gateway.js b/assets/custom-types/nat-gateway/nat_gateway.js new file mode 100644 index 00000000..799d9bc6 --- /dev/null +++ b/assets/custom-types/nat-gateway/nat_gateway.js @@ -0,0 +1,353 @@ +// Source http://www.spacevatican.org/2015/12/20/cloudformation-nat-gateway/ +// https://gist.github.com/fcheung/baec53381350a4b11037 + +var aws = require('aws-sdk'); + +exports.handler = function(event, context) { + if (event.ResourceType === 'Custom::NatGateway') { + handleGateway(event, context); + } else if (event.ResourceType === 'Custom::NatGatewayRoute') { + handleRoute(event, context); + } else { + errMsg = "unknown resource type: " + event.ResourceType; + console.log(errMsg); + response.send(event, context, response.FAILED, errMsg); + } +}; + +var handleRoute = function(event, context) { + var destinationCidrBlock = event.ResourceProperties.DestinationCidrBlock; + var routeTableId = event.ResourceProperties.RouteTableId; + if (!destinationCidrBlock) { + errMsg = "missing parameter DestinationCidrBlock"; + console.log(errMsg); + response.send(event, context, response.FAILED, errMsg); + return; + } + else { + if (!routeTableId) { + errMsg = "missing parameter RouteTableId"; + console.log(errMsg); + response.send(event, context, response.FAILED, errMsg); + return; + } + } + + if (event.RequestType === 'Delete') { + deleteRoute(event, context); + } else if (event.RequestType === 'Create') { + createRoute(event, context); + } else if (event.RequestType === 'Update') { + if (event.ResourceProperties.DestinationCIDRBlock === event.OldResourceProperties.DestinationCIDRBlock && + event.ResourceProperties.RouteTableId === event.OldResourceProperties.RouteTableId) { + replaceRoute(event, context); + } else { + createRoute(event, context); + } + } else { + errMsg = "unknown request type: " + event.RequestType; + console.log(errMsg); + response.send(event, context, response.FAILED, errMsg); + } +}; + +var deleteRoute = function(event, context) { + var destinationCidrBlock = event.ResourceProperties.DestinationCidrBlock; + var routeTableId = event.ResourceProperties.RouteTableId; + + if(event.PhysicalResourceId.match(/^gateway-route-/)){ + var ec2 = new aws.EC2(); + ec2.deleteRoute({ + RouteTableId: routeTableId, + DestinationCidrBlock: destinationCidrBlock + }, function(err, data) { + if (err) { + if (err.code != "InvalidRoute.NotFound") { + errMsg = "WARNING: " + err; + console.log(errMsg); + response.send(event, context, response.SUCCESS, errMsg, {}, physicalId(event.ResourceProperties)); + } else { + errMsg = "delete route failed" + err; + console.log(errMsg); + response.send(event, context, response.FAILED, errMsg); + } + } else { + response.send(event, context, response.SUCCESS, null, {}, physicalId(event.ResourceProperties)); + } + }); + } else { + errMsg = "unexpected physical id for route " + event.PhysicalResourceId + " - ignoring"; + console.log(errMsg); + response.send(event, context, response.SUCCESS, errMsg); + } +}; + + +var createRoute = function(event, context) { + var destinationCidrBlock = event.ResourceProperties.DestinationCidrBlock; + var routeTableId = event.ResourceProperties.RouteTableId; + var natGatewayId = event.ResourceProperties.NatGatewayId; + + if (natGatewayId) { + var ec2 = new aws.EC2(); + ec2.createRoute({ + RouteTableId: routeTableId, + DestinationCidrBlock: destinationCidrBlock, + NatGatewayId: natGatewayId + }, function(err, data) { + if (err) { + errMsg = "create route failed: " + err; + console.log(errMsg); + response.send(event, context, response.FAILED, errMsg); + + } else { + response.send(event, context, response.SUCCESS, null, {}, physicalId(event.ResourceProperties)); + } + }); + } else { + errMsg = "missing parameter natGatewayId"; + console.log(errMsg); + response.send(event, context, response.FAILED, errMsg); + return; + } +}; + +var replaceRoute = function(event, context) { + var destinationCidrBlock = event.ResourceProperties.DestinationCidrBlock; + var routeTableId = event.ResourceProperties.RouteTableId; + var natGatewayId = event.ResourceProperties.NatGatewayId; + + if (natGatewayId) { + var ec2 = new aws.EC2(); + ec2.replaceRoute({ + RouteTableId: routeTableId, + DestinationCidrBlock: destinationCidrBlock, + NatGatewayId: natGatewayId + }, function(err, data) { + if (err) { + errMsg = "create route failed: " + err; + console.log(errMsg); + response.send(event, context, response.FAILED, errMsg); + + } else { + response.send(event, context, response.SUCCESS, null, {}, physicalId(event.ResourceProperties)); + } + }); + } else { + errMsg = "missing parameter natGatewayId"; + console.log(errMsg); + response.send(event, context, response.FAILED, errMsg); + return; + } +}; + + +var physicalId = function(properties) { + return 'gateway-route-' + properties.RouteTableId + '-' + properties.DestinationCIDRBlock; +}; + + +var handleGateway = function(event, context) { + if (event.RequestType === 'Delete') { + deleteGateway(event, context); + } else if (event.RequestType === 'Update' || event.RequestType === 'Create') { + createGateway(event, context); + } else { + errMsg = "unknown type: " + event.RequestType; + console.log(errMsg); + response.send(event, context, response.FAILED, errMsg); + } +}; + +var createGateway = function(event, context) { + var subnetId = event.ResourceProperties.SubnetId; + var allocationId = event.ResourceProperties.AllocationId; + var waitHandle = event.ResourceProperties.WaitHandle; + + if (subnetId && allocationId) { + var ec2 = new aws.EC2(); + ec2.createNatGateway({ + AllocationId: allocationId, + SubnetId: subnetId + }, function(err, data) { + if (err) { + errMsg = "create gateway failed: " + err; + console.log(errMsg); + response.send(event, context, response.FAILED, errMsg); + } else { + response.send(event, context, response.SUCCESS, null, {}, data.NatGateway.NatGatewayId, true); + + waitForGatewayStateChange(data.NatGateway.NatGatewayId, ['available', 'failed'], function(state){ + if(waitHandle){ + signalData = { + "Status": state == 'available' ? 'SUCCESS' : 'FAILURE', + "UniqueId": data.NatGateway.NatGatewayId, + "Data": "Gateway has state " + state, + "Reason": "" + }; + sendSignal(waitHandle, context, signalData); + }else{ + if(state != 'available'){ + console.log("gateway state is not available"); + } + context.done(); + } + }); + } + }) + } else { + if (!subnetId) { + errMsg = "subnet id not specified"; + console.log(errMsg); + response.send(event, context, response.FAILED, errMsg); + } else { + errMsg = "allocationId not specified"; + console.log(errMsg); + response.send(event, context, response.FAILED, errMsg); + } + } +}; + +var waitForGatewayStateChange = function (id, states, onComplete){ + var ec2 = new aws.EC2(); + ec2.describeNatGateways({NatGatewayIds: [id], Filter: [{Name: "state", Values: states}]}, function(err, data){ + if(err){ + console.log("could not describeNatGateways " + err); + onComplete('failed'); + }else{ + if(data.NatGateways.length > 0){ + onComplete(data.NatGateways[0].State) + }else{ + console.log("gateway not ready; waiting"); + setTimeout(function(){ waitForGatewayStateChange(id, states, onComplete);}, 15000); + } + } + }); +}; + +var deleteGateway = function(event, context) { + if (event.PhysicalResourceId && event.PhysicalResourceId.match(/^nat-/)) { + var ec2 = new aws.EC2(); + ec2.deleteNatGateway({ + NatGatewayId: event.PhysicalResourceId + }, function(err, data) { + if (err) { + errMsg = "delete gateway failed " + err; + console.log(errMsg); + response.send(event, context, response.FAILED, errMsg, null, event.PhysicalResourceId); + } else { + waitForGatewayStateChange(event.PhysicalResourceId, ['deleted'], function(state){ + response.send(event, context, response.SUCCESS, null, {}, event.PhysicalResourceId); + }); + } + }) + } else { + errMsg = "No valid physical resource id passed to destroy - ignoring " + event.PhysicalResourceId + console.log(errMsg); + response.send(event, context, response.SUCCESS, errMsg, null, event.PhysicalResourceId); + } +}; + + +var sendSignal = function(handle, context, data){ + var body = JSON.stringify(data); + var https = require("https"); + var url = require("url"); + console.log("signal body:\n", body); + + var parsedUrl = url.parse(handle); + var options = { + hostname: parsedUrl.hostname, + port: 443, + path: parsedUrl.path, + method: "PUT", + headers: { + "content-type": "", + "content-length": body.length + } + }; + + var request = https.request(options, function(response) { + console.log("Status code: " + response.statusCode); + console.log("Status message: " + response.statusMessage); + context.done(); + }); + + request.on("error", function(error) { + console.log("sendSignal(..) failed executing https.request(..): " + error); + context.done(); + }); + + request.write(body); + request.end(); +}; +/* The below section is adapted from the cfn-response module, as published at: + + http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-code.html + + */ + +/* Copyright 2015 Amazon Web Services, Inc. or its affiliates. All Rights Reserved. + This file is licensed to you under the AWS Customer Agreement (the "License"). + You may not use this file except in compliance with the License. + A copy of the License is located at http://aws.amazon.com/agreement/. + This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, express or implied. + See the License for the specific language governing permissions and limitations under the License. */ +var response = {}; + +response.SUCCESS = "SUCCESS"; +response.FAILED = "FAILED"; + +response.send = function(event, context, responseStatus, responseReason, responseData, physicalResourceId, continueFuncton) { + reason = "CloudWatch Log Stream: " + context.logGroupName + " -- " + context.logStreamName + if (responseReason) { + reason = responseReason + " - " + reason + } + + if (!responseData) { + responseData = {} + } + + var responseBody = JSON.stringify({ + Status: responseStatus, + Reason: reason, + PhysicalResourceId: physicalResourceId || context.logStreamName, + StackId: event.StackId, + RequestId: event.RequestId, + LogicalResourceId: event.LogicalResourceId, + Data: responseData + }); + + console.log("Response body:\n", responseBody); + + var https = require("https"); + var url = require("url"); + + var parsedUrl = url.parse(event.ResponseURL); + var options = { + hostname: parsedUrl.hostname, + port: 443, + path: parsedUrl.path, + method: "PUT", + headers: { + "content-type": "", + "content-length": responseBody.length + } + }; + + var request = https.request(options, function(response) { + console.log("Status code: " + response.statusCode); + console.log("Status message: " + response.statusMessage); + if(!continueFuncton){ + context.done(); + } + }); + + request.on("error", function(error) { + console.log("send(..) failed executing https.request(..): " + error); + context.done(); + }); + + request.write(responseBody); + request.end(); +}; diff --git a/assets/custom-types/nat-gateway/policy.json b/assets/custom-types/nat-gateway/policy.json new file mode 100644 index 00000000..91b5b9b7 --- /dev/null +++ b/assets/custom-types/nat-gateway/policy.json @@ -0,0 +1,26 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": "arn:aws:logs:*:*:*" + }, + { + "Effect": "Allow", + "Action": [ + "ec2:DescribeNatGateways", + "ec2:DeleteRoute", + "ec2:ReplaceRoute", + "ec2:CreateRoute", + "ec2:DeleteNatGateway", + "ec2:CreateNatGateway" + ], + "Resource": "*" + } + ] +} diff --git a/assets/custom-types/nat-gateway/trust-policy.json b/assets/custom-types/nat-gateway/trust-policy.json new file mode 100644 index 00000000..73d46c16 --- /dev/null +++ b/assets/custom-types/nat-gateway/trust-policy.json @@ -0,0 +1,16 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + }, + "Action": [ + "sts:AssumeRole" + ] + } + ] +} diff --git a/build.sbt b/build.sbt index 8c19b7f4..b9573e7a 100644 --- a/build.sbt +++ b/build.sbt @@ -70,3 +70,19 @@ git.remoteRepo := "git@github.com:MonsantoCo/cloudformation-template-generator.g bintrayOrganization := Some("monsanto") licenses += ("BSD", url("http://opensource.org/licenses/BSD-3-Clause")) + +bintrayReleaseOnPublish := ! isSnapshot.value + +publishTo := { + if (isSnapshot.value) + Some("Artifactory Realm" at "https://oss.jfrog.org/oss-snapshot-local/") + else + publishTo.value /* Value set by bintray-sbt plugin */ +} + +credentials := { + if (isSnapshot.value) + List(Path.userHome / ".bintray" / ".artifactory").filter(_.exists).map(Credentials(_)) + else + credentials.value /* Value set by bintray-sbt plugin */ +} diff --git a/src/main/scala/com/monsanto/arch/cloudformation/model/Template.scala b/src/main/scala/com/monsanto/arch/cloudformation/model/Template.scala index 1375e34e..c6a58061 100644 --- a/src/main/scala/com/monsanto/arch/cloudformation/model/Template.scala +++ b/src/main/scala/com/monsanto/arch/cloudformation/model/Template.scala @@ -120,7 +120,11 @@ object Template extends DefaultJsonProtocol { } implicit def fromResource[R <: Resource[R]](r: R): Template = Template("", None, None, None, Some(Seq(r)), None, None) + implicit def fromResources[R <: Resource[R]](r: Seq[R]): Template = Template("", None, None, None, Some(r), None, None) implicit def fromOutput(o: Output[_]): Template = Template("", None, None, None, None, None, Some(Seq(o))) + implicit def fromOutputs(o: Seq[Output[_]]): Template = Template("", None, None, None, None, None, Some(o)) implicit def fromSecurityGroupRoutable[R <: Resource[R]](sgr: SecurityGroupRoutable[R]): Template = Template("", None, None, None, Some(sgr.resources), Some(Seq(sgr)), None) + implicit def fromSecurityGroupRoutables[R <: Resource[R]](sgrs: Seq[SecurityGroupRoutable[R]]): Template = + Template("", None, None, None, Some(sgrs.flatMap(sgr => sgr.resources)), Some(sgrs), None) } diff --git a/src/main/scala/com/monsanto/arch/cloudformation/model/resource/CloudFormation.scala b/src/main/scala/com/monsanto/arch/cloudformation/model/resource/CloudFormation.scala new file mode 100644 index 00000000..6fc4bf74 --- /dev/null +++ b/src/main/scala/com/monsanto/arch/cloudformation/model/resource/CloudFormation.scala @@ -0,0 +1,40 @@ +package com.monsanto.arch.cloudformation.model.resource + +import com.monsanto.arch.cloudformation.model.{Token, ConditionRef} +import spray.json.JsonFormat + +/** + * Created by bkrodg on 1/13/16. + */ + +case class `AWS::CloudFormation::WaitConditionHandle`(name: String, + override val Condition: Option[ConditionRef] = None, + override val DependsOn: Option[Seq[String]] = None) + extends Resource[`AWS::CloudFormation::WaitConditionHandle`] { + def when(newCondition: Option[ConditionRef] = Condition) = copy(Condition = newCondition) +} + +object `AWS::CloudFormation::WaitConditionHandle` { + + import spray.json.DefaultJsonProtocol._ + + implicit val format: JsonFormat[`AWS::CloudFormation::WaitConditionHandle`] = jsonFormat3(`AWS::CloudFormation::WaitConditionHandle`.apply) +} + +case class `AWS::CloudFormation::WaitCondition`(name: String, + Handle: Token[`AWS::CloudFormation::WaitConditionHandle`], + Timeout: Token[Int], + Count: Option[Token[Int]], + override val Condition: Option[ConditionRef] = None, + override val DependsOn: Option[Seq[String]] = None) + extends Resource[`AWS::CloudFormation::WaitCondition`] { + def when(newCondition: Option[ConditionRef] = Condition) = copy(Condition = newCondition) +} + +object `AWS::CloudFormation::WaitCondition` { + + import spray.json.DefaultJsonProtocol._ + + implicit val format: JsonFormat[`AWS::CloudFormation::WaitCondition`] = jsonFormat6(`AWS::CloudFormation::WaitCondition`.apply) +} + diff --git a/src/main/scala/com/monsanto/arch/cloudformation/model/resource/Custom.scala b/src/main/scala/com/monsanto/arch/cloudformation/model/resource/Custom.scala new file mode 100644 index 00000000..12b54341 --- /dev/null +++ b/src/main/scala/com/monsanto/arch/cloudformation/model/resource/Custom.scala @@ -0,0 +1,41 @@ +package com.monsanto.arch.cloudformation.model.resource + +import com.monsanto.arch.cloudformation.model._ +import spray.json.JsonFormat + +/** + * Created by bkrodg on 1/11/16. + */ +case class `Custom::NatGateway`(name: String, + AllocationId: Token[String], + SubnetId: Token[`AWS::EC2::Subnet`], + WaitHandle: Token[`AWS::CloudFormation::WaitConditionHandle`], + ServiceToken: Token[String], + override val Condition: Option[ConditionRef] = None, + override val DependsOn: Option[Seq[String]] = None) + extends Resource[`Custom::NatGateway`] { + def when(newCondition: Option[ConditionRef] = Condition) = copy(Condition = newCondition) +} + +object `Custom::NatGateway` { + val defaultServiceToken = `Fn::Join`(":", Seq("arn:aws:lambda", `AWS::Region`, `AWS::AccountId`, "function:cf-nat-gateway")) + import spray.json.DefaultJsonProtocol._ + + implicit def format: JsonFormat[`Custom::NatGateway`] = jsonFormat7(`Custom::NatGateway`.apply) +} + +case class `Custom::NatGatewayRoute`(name: String, + RouteTableId: Token[`AWS::EC2::RouteTable`], + DestinationCidrBlock: Token[CidrBlock], + NatGatewayId: Token[`Custom::NatGateway`], + ServiceToken: Token[String], + override val Condition: Option[ConditionRef] = None, + override val DependsOn: Option[Seq[String]] = None) + extends Resource[`Custom::NatGatewayRoute`] { + def when(newCondition: Option[ConditionRef] = Condition) = copy(Condition = newCondition) +} + +object `Custom::NatGatewayRoute` { + import spray.json.DefaultJsonProtocol._ + implicit def format: JsonFormat[`Custom::NatGatewayRoute`] = jsonFormat7(`Custom::NatGatewayRoute`.apply) +} diff --git a/src/main/scala/com/monsanto/arch/cloudformation/model/resource/EC2.scala b/src/main/scala/com/monsanto/arch/cloudformation/model/resource/EC2.scala index 2f9578f8..cc98685f 100644 --- a/src/main/scala/com/monsanto/arch/cloudformation/model/resource/EC2.scala +++ b/src/main/scala/com/monsanto/arch/cloudformation/model/resource/EC2.scala @@ -10,18 +10,45 @@ import scala.language.implicitConversions * Created by Ryan Richt on 2/28/15 */ -case class `AWS::EC2::EIP`( +case class `AWS::EC2::EIP` private ( name: String, - Domain: String, - InstanceId: Token[ResourceRef[`AWS::EC2::Instance`]], - override val Condition: Option[ConditionRef] = None, - override val DependsOn: Option[Seq[String]] = None + Domain: Option[String], + InstanceId: Option[Token[ResourceRef[`AWS::EC2::Instance`]]], + override val Condition: Option[ConditionRef], + override val DependsOn: Option[Seq[String]] ) extends Resource[`AWS::EC2::EIP`]{ def when(newCondition: Option[ConditionRef] = Condition) = copy(Condition = newCondition) } object `AWS::EC2::EIP` extends DefaultJsonProtocol { - implicit val format: JsonFormat[`AWS::EC2::EIP`] = jsonFormat5(`AWS::EC2::EIP`.apply) + // Need to be explicit here to get it to pick the apply above, not the backwards compatibility one below. + implicit val format: JsonFormat[`AWS::EC2::EIP`] = + jsonFormat5[String, + Option[String], + Option[Token[ResourceRef[`AWS::EC2::Instance`]]], + Option[ConditionRef], + Option[Seq[String]], + `AWS::EC2::EIP`](`AWS::EC2::EIP`.apply) + + @deprecated(message = "Use .vpc() or .classic() instead.", since = "v3.0.6") + def apply(name: String, + Domain: String, + InstanceId: Token[ResourceRef[`AWS::EC2::Instance`]], + Condition: Option[ConditionRef] = None, + DependsOn: Option[Seq[String]] = None): `AWS::EC2::EIP` = + `AWS::EC2::EIP`(name, Some(Domain), Some(InstanceId), Condition, DependsOn) + + def vpc(name: String, + InstanceId: Option[Token[ResourceRef[`AWS::EC2::Instance`]]], + Condition: Option[ConditionRef] = None, + DependsOn: Option[Seq[String]] = None): `AWS::EC2::EIP` = + `AWS::EC2::EIP`(name, Some("vpc"), InstanceId, Condition, DependsOn) + + def classic(name: String, + InstanceId: Option[Token[ResourceRef[`AWS::EC2::Instance`]]], + Condition: Option[ConditionRef] = None, + DependsOn: Option[Seq[String]] = None): `AWS::EC2::EIP` = + `AWS::EC2::EIP`(name, None, InstanceId, Condition, DependsOn) } case class `AWS::EC2::EIPAssociation`( diff --git a/src/main/scala/com/monsanto/arch/cloudformation/model/simple/Builders.scala b/src/main/scala/com/monsanto/arch/cloudformation/model/simple/Builders.scala index 38b2d6d0..9bac41fd 100644 --- a/src/main/scala/com/monsanto/arch/cloudformation/model/simple/Builders.scala +++ b/src/main/scala/com/monsanto/arch/cloudformation/model/simple/Builders.scala @@ -85,17 +85,33 @@ trait Route { trait Instance { implicit class RichInstance(ec2: `AWS::EC2::Instance`) { + + @deprecated(message = "Use withEIPInVPC or withEIPInClassic", since = "v3.0.6") def withEIP( name: String, domain: String = "vpc", dependsOn: Option[Seq[String]] = None - ) = - `AWS::EC2::EIP`( - name = name, - Domain = domain, - InstanceId = ResourceRef(ec2), - DependsOn = dependsOn - ) + ) : `AWS::EC2::EIP` = withEIPInVPC(name, dependsOn) + + def withEIPInVPC( + name: String, + dependsOn: Option[Seq[String]] = None + ) : `AWS::EC2::EIP` = + `AWS::EC2::EIP`.vpc( + name = name, + InstanceId = Some(ResourceRef(ec2)), + DependsOn = dependsOn + ) + + def withEIPInClassic( + name: String, + dependsOn: Option[Seq[String]] = None + ) : `AWS::EC2::EIP` = + `AWS::EC2::EIP`.classic( + name = name, + InstanceId = Some(ResourceRef(ec2)), + DependsOn = dependsOn + ) def alarmOnSystemFailure(name: String, description: String) = `AWS::CloudWatch::Alarm`( @@ -121,6 +137,20 @@ trait Subnet extends AvailabilityZone with Outputs { def withRouteTableAssoc(visibility: String, subnetOrdinal: Int, routeTable: Token[ResourceRef[`AWS::EC2::RouteTable`]])(implicit s: `AWS::EC2::Subnet`) = s.withRouteTableAssoc( visibility, subnetOrdinal, routeTable ) + def withNAT( + ordinal: Int, + vpcGatewayAttachmentResource: `AWS::EC2::VPCGatewayAttachment`, + privateRouteTables: Seq[`AWS::EC2::RouteTable`], + cfNATLambdaARN: Token[String])(implicit s: `AWS::EC2::Subnet`) = + s.withNAT(ordinal, vpcGatewayAttachmentResource, privateRouteTables, cfNATLambdaARN) + + def withNAT( + ordinal: Int, + vpcGatewayAttachmentResource: `AWS::EC2::VPCGatewayAttachment`, + privateRouteTable: `AWS::EC2::RouteTable`, + cfNATLambdaARN: Token[String])(implicit s: `AWS::EC2::Subnet`) = + s.withNAT(ordinal, vpcGatewayAttachmentResource, privateRouteTable, cfNATLambdaARN) + implicit class RichSubnet(s: `AWS::EC2::Subnet`){ def withRouteTableAssoc(visibility: String, subnetOrdinal: Int, routeTable: Token[ResourceRef[`AWS::EC2::RouteTable`]]) = `AWS::EC2::SubnetRouteTableAssociation`( @@ -128,6 +158,55 @@ trait Subnet extends AvailabilityZone with Outputs { SubnetId = s, RouteTableId = routeTable ) + def withNAT( + ordinal: Int, + vpcGatewayAttachmentResource: `AWS::EC2::VPCGatewayAttachment`, + privateRouteTable: `AWS::EC2::RouteTable`, + cfNATLambdaARN: Token[String]): Template = + withNAT(ordinal, vpcGatewayAttachmentResource, Seq(privateRouteTable), cfNATLambdaARN) + + def withNAT( + ordinal: Int, + vpcGatewayAttachmentResource: `AWS::EC2::VPCGatewayAttachment`, + privateRouteTables: Seq[`AWS::EC2::RouteTable`], + cfNATLambdaARN: Token[String]): Template = { + val natEIP = `AWS::EC2::EIP`.vpc( + name = s"NAT${ordinal}EIP", + InstanceId = None, + DependsOn = Some(Seq(vpcGatewayAttachmentResource.name)) + ) + + val natWaitHandle = `AWS::CloudFormation::WaitConditionHandle`(s"NAT${ordinal}WaitHandle") + + val nat = `Custom::NatGateway`( + name=s"NAT${ordinal}", + ServiceToken = cfNATLambdaARN, + AllocationId = `Fn::GetAtt`(Seq(natEIP.name, "AllocationId")), + SubnetId = ResourceRef(s), + WaitHandle = ResourceRef(natWaitHandle) + ) + + val natWaitCondition = `AWS::CloudFormation::WaitCondition`( + s"NAT${ordinal}WaitCondition", + Handle = ResourceRef(natWaitHandle), + Timeout = 240, + Count = None, + DependsOn = Some(Seq(nat.name)) + ) + val privateRoutes = privateRouteTables.map{ + privateRouteTable => `Custom::NatGatewayRoute`( + name = s"NAT${ordinal}Route", + ServiceToken = cfNATLambdaARN, + RouteTableId = ResourceRef(privateRouteTable), + NatGatewayId = ResourceRef(nat), + DestinationCidrBlock = CidrBlock(0,0,0,0,0), + DependsOn = Some(Seq(natWaitCondition.name)) + ) + } + + Template.fromResource(nat) ++ natEIP.andOutput(s"NAT${ordinal}EIP", s"NAT ${ordinal} EIP") ++ + natWaitCondition ++ natWaitHandle ++ privateRoutes + } } private def ucFirst(s: String): String = (s.head.toUpper +: s.tail.toCharArray).mkString @@ -533,6 +612,7 @@ trait ElasticLoadBalancing { name: String, subnets: Seq[Token[ResourceRef[`AWS::EC2::Subnet`]]], healthCheckTarget: String, + loadBalancerName: Option[Token[String]] = None, condition: Option[ConditionRef] = None, scheme: Option[ELBScheme] = None, loggingBucket: Option[Token[ResourceRef[`AWS::S3::Bucket`]]] = None, @@ -553,6 +633,7 @@ trait ElasticLoadBalancing { Scheme = scheme, Subnets = subnets, Listeners = listeners, + LoadBalancerName = loadBalancerName, HealthCheck = Some(healthCheck), Tags = AmazonTag.fromName(name), AccessLoggingPolicy = loggingBucket match { @@ -572,6 +653,7 @@ trait ElasticLoadBalancing { name: String, subnets: Seq[Token[ResourceRef[`AWS::EC2::Subnet`]]], healthCheckTarget: String, + loadBalancerName: Option[Token[String]] = None, condition: Option[ConditionRef] = None, scheme: Option[ELBScheme] = None, loggingBucket: Option[Token[ResourceRef[`AWS::S3::Bucket`]]] = None, @@ -586,5 +668,5 @@ trait ElasticLoadBalancing { Interval = "30", Timeout = "5") )(implicit vpc: `AWS::EC2::VPC`) = - elbL(name, subnets, healthCheckTarget, condition, scheme, loggingBucket, dependsOn)(Seq(listener))(healthCheck) + elbL(name, subnets, healthCheckTarget, loadBalancerName, condition, scheme, loggingBucket, dependsOn)(Seq(listener))(healthCheck) } diff --git a/src/test/scala/com/monsanto/arch/cloudformation/model/CloudFormation_AT.scala b/src/test/scala/com/monsanto/arch/cloudformation/model/CloudFormation_AT.scala index 1ac4dd48..1ddd54cb 100644 --- a/src/test/scala/com/monsanto/arch/cloudformation/model/CloudFormation_AT.scala +++ b/src/test/scala/com/monsanto/arch/cloudformation/model/CloudFormation_AT.scala @@ -928,25 +928,22 @@ object StaxTemplate { AmazonTag("CostCenter", ParameterRef(costCenterParam)) ) - private val jumpEIPResource = `AWS::EC2::EIP`( + private val jumpEIPResource = `AWS::EC2::EIP`.vpc( "JumpEIP", DependsOn = Some(Seq(gatewayAttachmentResource.name)), - Domain = "vpc", - InstanceId = ResourceRef(jumpInstanceResource) + InstanceId = Some(ResourceRef(jumpInstanceResource)) ) - private val nat1EIPResource = `AWS::EC2::EIP`( + private val nat1EIPResource = `AWS::EC2::EIP`.vpc( "NAT1EIP", DependsOn = Some(Seq(gatewayAttachmentResource.name)), - Domain = "vpc", - InstanceId = ResourceRef(nat1InstanceResource) + InstanceId = Some(ResourceRef(nat1InstanceResource)) ) - private val nat2EIPResource = `AWS::EC2::EIP`( + private val nat2EIPResource = `AWS::EC2::EIP`.vpc( "NAT2EIP", DependsOn = Some(Seq(gatewayAttachmentResource.name)), - Domain = "vpc", - InstanceId = ResourceRef(nat2InstanceResource) + InstanceId = Some(ResourceRef(nat2InstanceResource)) ) val itsaDockerStack = Template( diff --git a/src/test/scala/com/monsanto/arch/cloudformation/model/TemplateDoc_AT.scala b/src/test/scala/com/monsanto/arch/cloudformation/model/TemplateDoc_AT.scala index 2636f151..8551ab0c 100644 --- a/src/test/scala/com/monsanto/arch/cloudformation/model/TemplateDoc_AT.scala +++ b/src/test/scala/com/monsanto/arch/cloudformation/model/TemplateDoc_AT.scala @@ -90,7 +90,7 @@ class TemplateDoc_AT extends FunSpec with Matchers { ) val sshToBastion = ParameterRef(allowSSHFromParameter) ->- 22 ->- bastion Template.fromSecurityGroupRoutable(bastion) ++ - bastion.map(_.withEIP("BastionEIP").andOutput("BastionEIP", "Bastion Host EIP")) ++ + bastion.map(_.withEIPInVPC("BastionEIP").andOutput("BastionEIP", "Bastion Host EIP")) ++ Template.collapse(sshToBastion) } }