Skip to content

Commit

Permalink
Merge 739c515 into b8c51e5
Browse files Browse the repository at this point in the history
  • Loading branch information
bkrodgers committed Mar 1, 2016
2 parents b8c51e5 + 739c515 commit 5635cbb
Show file tree
Hide file tree
Showing 20 changed files with 1,057 additions and 28 deletions.
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).

**Note: Minor breaks in backwards compatibility on `AWS::EC2::Subnet`**

## [3.2.0] - 2016-03-02

### Added

- Added custom type to remotely manage Route 53 entries in another account (see [#75](https://github.com/MonsantoCo/cloudformation-template-generator/pull/75)).
- Added Cloudfront (see [#71](https://github.com/MonsantoCo/cloudformation-template-generator/pull/71)).

### Changed

- AvailabilityZone is optional for Subnet (see [#69](https://github.com/MonsantoCo/cloudformation-template-generator/pull/69))
- Fixed an issue in the NAT Gateway custom type that can cause an unrecoverable failure if the gateway was manually deleted.


## [3.1.2] - 2016-02-08

### Added
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ to your account and region. The code for these functions is found in this repo
## 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.
will be deprecated and removed. [**UPDATE 03/01**]: This has now been added to CF, but not yet implemented here.

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.
Expand All @@ -179,6 +179,9 @@ which will construct an ARN from the AWS account, region, and this default funct

Credit for the Lambda function script: http://www.spacevatican.org/2015/12/20/cloudformation-nat-gateway/

## Remote Route 53 entries
A given domain (or hosted zone, more specifically) must be managed out of a single AWS account. This poses problems if you want to create resources under that domain in templates that will run out of other accounts. A CloudFormation template can only work in one given account. However, with Cloud Formation's custom type functionality, we use custom code to assume a role in the account that owns the hosted zone. This requires some setup steps for each hosted zone and each account. For instructions, please see: https://github.com/MonsantoCo/cloudformation-template-generator/assets/custom-types/remote-route53/README.md for more.

## Working with Cloudformation Concatenating
In the CloudFormation DSL, there is support for concatenating strings, parameters, and function calls together to build strings.
This can get really ugly as they are chained together.
Expand Down
16 changes: 11 additions & 5 deletions assets/custom-types/nat-gateway/nat_gateway.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,12 @@ var deleteRoute = function(event, context) {
DestinationCidrBlock: destinationCidrBlock
}, function(err, data) {
if (err) {
if (err.code != "InvalidRoute.NotFound") {
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;
errMsg = "delete route failed: " + err;
console.log(errMsg);
response.send(event, context, response.FAILED, errMsg);
}
Expand Down Expand Up @@ -232,9 +232,15 @@ var deleteGateway = function(event, context) {
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);
if (err.code == "NatGatewayNotFound") {
errMsg = "WARNING: " + err;
console.log(errMsg);
response.send(event, context, response.SUCCESS, errMsg, {}, physicalId(event.ResourceProperties));
} else {
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);
Expand Down
1 change: 1 addition & 0 deletions assets/custom-types/remote-route53/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
target
51 changes: 51 additions & 0 deletions assets/custom-types/remote-route53/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#Setup

In order to manage a domain across accounts, you will need to set up the Lambda function and the appropriate roles. These changes are only needed when you add a new domain, or when you add a new account and/or region where you want to create CloudFormation stacks that use the domain.

The Lambda function needs to be uploaded once for every account that will use this function. When you upload it, it will also create SNS topics in every region that can be used for calls from CloudFormation stacks.

There is no harm in using this function in templates that are being run from the same account that owns the domain. While you don't need the cross-account functionality in this case and could use the native Route 53 types, if you want to author a template that will be used in multiple accounts, it may be more straightforward to use this function universally rather than try to create conditionals or multiple template versions.

## Pre-reqs:

1. If you haven't already, install the AWS CLI. You'll either want to setup profiles for both accounts (http://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html#cli-multiple-profiles), or just have both credentials ready and reconfigure.
2. When we refer to authenticating to an account below, you do this either by setting the `AWS_DEFAULT_PROFILE=<profile>` environment variable if you are using profiles, or by running `aws configure` and enter in the credentials for that account. If you are using profiles, and you want to execute against a different region than is in the profile, you can override it with the environment variable `AWS_DEFAULT_REGION=<region>`.
- References to the "Route 53 account" below refer to the AWS account that owns the hosted zone for the domain.
- References to the "CF account" below refer to another AWS account where you want to be able to run templates that create route 53 entries in the "Route 53 account."
3. You also need `npm` and `jq` installed.


## Per domain setup:

These steps need to be performed for each domain that you want to manage from other accounts. This only needs to be done once in the account that owns the domain, not in each AWS account.

1. You will need access to change IAM policies and roles in the Route53 account that is managing the domain ("Hosted Zone" in AWS terminology).
2. Authenticate to the **Route 53 account.**
3. Look up the hosted zone ID for the domain. The zone ID is an alphanumeric code, not the actual domain name.
4. From the `assets/custom-types/remote-route53` directory, run `./create-zone-admin.sh <zone-id-to-manage>`. If you've already setup another domain in this account, it will add this domain to the role.
5. The ARN of the role will be used as the "DestinationRole" parameter to the Cloud Formation resource, regardless of what account you are using to run the template.
6. If you are adding an additional domain but have already setup all the accounts and regions per the instructions below, you do not need to repeat these steps. They will pick up the new domain on the existing role.


## Per AWS account setup:

These steps need to be performed for each account where you want to run CloudFormation templates that use this function (the "CF Account").

1. You will need access to change IAM policies and roles in both the Route53 account and the "CF Account". You will also need permissions to create Lambda functions and SNS topics in the CF account.
2. Authenticate to the **CF account.** Regardless of the profile's region, `us-east-1` will be used, since that is where the AWS Route 53 endpoints live.
3. Run `./deploy.sh` to create the Lambda function and the appropriate execution role, along with an SNS topic for the function in all regions. Note that SNS topics have no charge for just existing, so there is no cost for topics in regions you aren't using here.
4. Look at the output from the script above and note the command it asks you to run (`./add-zone-admin-trust <role-name>`).
5. Authenticate to the **Route 53 account.**
6. Run the command from step 4. This will grant the function permissions to assume the Route 53 role created earlier.

## Now what?

Now that you have setup your roles, Lambda functions, and SNS topics, you will need two pieces of information to use this in your templates.

1. When you created the zone admin role, it output the ARN of the role. This value needs to be specified as the `DestinationRole` parameter to the Route 53 type in your template. It will take the form of `arn:aws:iam::<route-53-account-number>:role/remote-route53-cf-admin`. The value will be the same regardless of what account you are running the Cloud Formation template from. Therefore, this value can be hard coded in your template or be set as a default value if you want to parameterize it.
2. The SNS topic ARN for the account and region where the Cloud Formation template is running must be specified as the `ServiceToken` parameter to the Route 53 type in your template. It will take the form of `arn:aws:sns:<cf-region>:<cf-account-num>:cf-remote-route53`. Because the account and region should always be the values for where you are running the template, you can assemble this dynamically, using CF psuedo-parameters. This would look like `` `Fn::Join`(":", Seq("arn:aws:sns", `AWS::Region`, `AWS::AccountId`, "cf-remote-route53"))``. This can then be hard coded into your template and will fill in with the proper region and account ID automatically.

## Cleaning up

1. Authenticate to the **CF account** that you want to clean up. Run `./delete-lambda.sh` to remove the Lambda function in any region you have uploaded it, all SNS topics created for this function, and the Lambda execution role in this account.
2. Authenticate to the **Route 53 account** that you want to clean up. Run `./delete-zone-admin.sh` to remove the zone admin role in this account and its associated policies.
13 changes: 13 additions & 0 deletions assets/custom-types/remote-route53/add-zone-admin-trust.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
lambdaRoleARN=$1

route53RoleName=remote-route53-cf-admin

currentPolicy=$(aws iam get-role --role-name $route53RoleName | jq .AssumeRolePolicyDocument)

aws iam get-role --role-name $route53RoleName | \
jq ".Role.AssumeRolePolicyDocument | if (.Statement[0].Principal.AWS | type) == \"string\" then .Statement[0].Principal.AWS = [.Statement[0].Principal.AWS ] + [\"$lambdaRoleARN\"] else .Statement[0].Principal.AWS |= . + [\"$lambdaRoleARN\"] end" > target/tmp-zone-admin-trust-policy.json

aws iam update-assume-role-policy --role-name $route53RoleName --policy-document file://target/tmp-zone-admin-trust-policy.json

rm target/tmp-zone-admin-trust-policy.json
32 changes: 32 additions & 0 deletions assets/custom-types/remote-route53/create-zone-admin.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
hostedZoneId=$1

roleName=remote-route53-cf-admin

if ! aws iam get-role --role-name $roleName >/dev/null 2>&1 ; then
echo "Creating $roleName"

aws iam create-role --role-name $roleName \
--assume-role-policy-document file://zone-admin-trust-policy-stub.json >/dev/null

aws iam put-role-policy --role-name $roleName \
--policy-name list-zones \
--policy-document file://zone-admin-list-policy.json >/dev/null
else
echo "$roleName exists, adding zone to it"
fi

cat zone-admin-policy.json | jq ".Statement[0].Resource = \"arn:aws:route53:::hostedzone/$hostedZoneId\"" > target/tmp-zone-admin-policy.json

aws iam put-role-policy --role-name $roleName \
--policy-name zone-${hostedZoneId}-admin \
--policy-document file://target/tmp-zone-admin-policy.json >/dev/null

roleArn=$(aws iam get-role --role-name $roleName --query Role.Arn)

echo ""
echo "Role ARN: $roleArn"
echo "Use this ARN in the as the 'DestinationRole' parameter to the Cloud Formation resource, regardless of what account you are running the template from."
echo ""

rm target/tmp-zone-admin-policy.json
27 changes: 27 additions & 0 deletions assets/custom-types/remote-route53/delete-lambda.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env bash

role=lambda-execution-cf-remote-route53
function_name=cf-remote-route53
account_id=$(aws iam get-user | jq -r .User.Arn | perl -pe 's/arn:aws:iam::(\d+):.*/$1/')

for region in $(aws ec2 describe-regions | jq -r .Regions[].RegionName) ; do
echo "Checking region $region"
if aws lambda get-function --region $region --function-name $function_name >/dev/null 2>&1 ; then
echo " Deleting function in region $region"
aws lambda delete-function --region $region --function-name $function_name >/dev/null
fi

if aws sns get-topic-attributes --region $region --topic-arn arn:aws:sns:${region}:${account_id}:cf-remote-route53 >/dev/null 2>&1 ; then
echo " Deleting SNS topic in region $region"
aws sns delete-topic --region $region --topic-arn arn:aws:sns:${region}:${account_id}:cf-remote-route53 >/dev/null
fi
done

if aws iam get-role --role-name $role >/dev/null 2>&1 ; then
for policy in $(aws iam list-role-policies --role-name $role --query PolicyNames --output text) ; do
echo "Deleting policy $policy on role $role"
aws iam delete-role-policy --role-name $role --policy-name $policy
done
echo "Deleting role $role"
aws iam delete-role --role-name $role >/dev/null
fi
12 changes: 12 additions & 0 deletions assets/custom-types/remote-route53/delete-zone-admin.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/usr/bin/env bash

role=remote-route53-cf-admin

if aws iam get-role --role-name $role >/dev/null 2>&1 ; then
for policy in $(aws iam list-role-policies --role-name $role --query PolicyNames --output text) ; do
echo "Deleting policy $policy on role $role"
aws iam delete-role-policy --role-name $role --policy-name $policy
done
echo "Deleting role $role"
aws iam delete-role --role-name $role >/dev/null
fi
95 changes: 95 additions & 0 deletions assets/custom-types/remote-route53/deploy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/bin/bash

role=lambda-execution-cf-remote-route53
function_name=cf-remote-route53
account_id=$(aws iam get-user | jq -r .User.Arn | perl -pe 's/arn:aws:iam::(\d+):.*/$1/')

# Hard coded to us-east-1, since that's where Route 53 lives
lambdaRegion=us-east-1

echo "Packaging function..."
rm -rf target
mkdir -p target
cd target
cp ../remote_route53.js remote_route53.js
npm install aws-sdk
zip -r remote_route53.zip remote_route53.js node_modules/ >/dev/null
cd ..


if ! aws iam get-role --role-name $role >/dev/null 2>&1 ; then
echo "Creating role..."
aws iam create-role --role-name $role \
--assume-role-policy-document file://lambda-trust-policy.json >/dev/null
# The role seems to take a little time to settle down and work. Because...amazon.
echo "Giving Amazon time to settle"...
sleep 10
fi

echo "Setting role policy..."
aws iam put-role-policy --role-name $role \
--policy-name $role \
--policy-document file://lambda-policy.json



echo "Uploading function..."

if aws lambda get-function --region $lambdaRegion --function-name $function_name >/dev/null 2>&1 ; then
aws lambda update-function-code --region $lambdaRegion --function-name $function_name \
--zip-file fileb://target/remote_route53.zip > /dev/null
else
while ! aws lambda create-function --region $lambdaRegion --function-name $function_name \
--description "Custom CloudFormation function for managing Route 53 in another account" \
--runtime nodejs \
--role arn:aws:iam::${account_id}:role/${role} \
--handler remote_route53.handler \
--timeout 300 \
--zip-file fileb://target/remote_route53.zip > /dev/null ; 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
lambdaArn=$(aws lambda get-function --region $lambdaRegion --function-name $function_name --output text --query Configuration.FunctionArn)
lambdaRole=$(aws lambda get-function --region $lambdaRegion --function-name $function_name --output text --query Configuration.Role)




# Create SNS topic for each region.
for snsRegion in $(aws ec2 describe-regions | jq -r .Regions[].RegionName) ; do
topicSubscriptions=$(aws sns list-subscriptions-by-topic --region $snsRegion --topic-arn arn:aws:sns:${snsRegion}:${account_id}:cf-remote-route53 2>/dev/null)

if [[ $? != 0 ]] ; then
echo "Creating SNS topic in $snsRegion"
topicArn=$(aws sns create-topic --region $snsRegion --name cf-remote-route53 --query TopicArn --output text)

aws sns subscribe --region $snsRegion --topic-arn $topicArn --protocol lambda --notification-endpoint $lambdaArn > /dev/null
sid=$(cat /dev/urandom | env LC_CTYPE=C tr -dc a-zA-Z0-9 | head -c 16; echo)
aws lambda add-permission --region $lambdaRegion --function-name $lambdaArn \
--statement-id $sid --action lambda:invokeFunction \
--principal sns.amazonaws.com --source-arn $topicArn > /dev/null
else
topicIsSubscribed=$(echo "$topicSubscriptions" | jq -r 'select(.Subscriptions[].Endpoint == "'$lambdaArn'") | any')
if [[ "$topicIsSubscribed" != "true" ]] ; then
echo "Subscribing existing SNS topic in $snsRegion to Lambda function."
topicArn=$(aws sns get-topic-attributes --region $snsRegion --topic-arn arn:aws:sns:${snsRegion}:${account_id}:cf-remote-route53 --output text --query Attributes.TopicArn)

aws sns subscribe --region $snsRegion --topic-arn $topicArn --protocol lambda --notification-endpoint $lambdaArn > /dev/null
sid=$(cat /dev/urandom | env LC_CTYPE=C tr -dc a-zA-Z0-9 | head -c 16; echo)
aws lambda add-permission --region $lambdaRegion --function-name $lambdaArn \
--statement-id $sid --action lambda:invokeFunction \
--principal sns.amazonaws.com --source-arn $topicArn > /dev/null
else
echo "SNS topic already exists in $snsRegion"
fi

fi
done

echo ""
echo "To grant this function permission to manage Route 53, please re-authenticate to the Route 53 AWS account and run:"
echo "./add-zone-admin-trust.sh $lambdaRole"
echo ""
21 changes: 21 additions & 0 deletions assets/custom-types/remote-route53/lambda-policy.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:*:*:*"
},
{
"Effect": "Allow",
"Action": [
"sts:AssumeRole"
],
"Resource": "*"
}
]
}
16 changes: 16 additions & 0 deletions assets/custom-types/remote-route53/lambda-trust-policy.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": [
"lambda.amazonaws.com"
]
},
"Action": [
"sts:AssumeRole"
]
}
]
}
Loading

0 comments on commit 5635cbb

Please sign in to comment.