Everything should be automated. Usually things are done manually until the process hits an inflection point. I prefer to skip that inflection point and start with automation from beginnning. Automating later is always more difficult than doing it from t-zero. The act becomes second nature and part of all future decisions.

Thus I decided to automated SlashDeploy’s DNS records. SlashDeploy is AWS native and will use as many AWS services as possible. This means SlashDeploy uses Route53 for DNS. Cloudformation works well when automating simple AWS resource collections. So Cloudformation is a prefect fit for managing the Route53 HostedZone and the various RecordSets. The whole shebang must be automated and support continuous delivery. Cloudformation deploys are straight foward. If the CloudFormation stack exists then create it, otherwise update it. This can be done easily with a Bash program and the AWS cli.

Unfortunately I cannot share the complete source since I do not want to reveal all my DNS records. However I can share the good stuff: the Bash program to coordinate Clouformation calls and an editted Cloudformation template itself.

Bash Program

#!/usr/bin/env bash

set -euo pipefail

declare stack_name="dns"

usage() {
	echo "${0} COMMAND [options] [arguments]"
	echo
	echo "deploy  -- deploy clouformation changes"
	echo "status  -- show clouformation status"
	echo "ns      -- print NS records"
	echo "destroy -- Delete stack"
}

cloudformation() {
	aws --profile slashdeploy --region eu-west-1 cloudformation "$@"
}

r53() {
	aws --profile slashdeploy --region eu-west-1 route53 "$@"
}

deploy() {
	if cloudformation describe-stacks --stack-name "${stack_name}" &> /dev/null ; then
		cloudformation update-stack \
			--stack-name "${stack_name}" \
			--template-body "file://cloudformation.json"
	else
		cloudformation create-stack \
			--stack-name "${stack_name}" \
			--template-body "file://cloudformation.json"
	fi
}

destroy() {
	cloudformation delete-stack --stack-name "${stack_name}"
}

show_status() {
	cloudformation describe-stacks \
		--stack-name "${stack_name}" \
		| jq -re '.Stacks[0].StackStatus'
}

validate_stack() {
	cloudformation validate-template \
			--template-body "file://cloudformation.json"
}

show_ns() {
	declare zone_id

	zone_id="$(cloudformation describe-stacks --stack-name "${stack_name}" \
		| jq -re '.Stacks[0].Outputs[0].OutputValue')"

	r53 list-resource-record-sets --hosted-zone-id="${zone_id}" \
		| jq -re '.ResourceRecordSets[] | select(.Type == "NS") | .ResourceRecords | map(.Value)[]'
}

main() {
	case "${1:-}" in
		deploy)
			shift
			deploy "$@"
			;;
		destroy)
			shift
			destroy "$@"
			;;
		status)
			shift
			show_status "$@"
			;;
		validate)
			shift
			validate_stack "$@"
			;;
		ns)
			shift
			show_ns "$@"
			;;
		*)
			usage 1>&2
			return 1
			;;
	esac
}

main "$@"

The usage is called out right at the top of the program. The follow supports the following functions:

  • deploy - Create/Update the Cloudformation stack
  • destroy - Remove the stack (and all DNS records!)
  • status - Print the stack status, useful with watch to monitor rollout status
  • validate - Check the template JSON using the CloudFormation API
  • ns - Print the name servers for the AWS hosted zone. I added this so the AWS console is never needed. These values should be set in the registar’s side.

All in all there’s nothing super fancy there. I keep the file in bin/cf then run bin/cf deploy through CI. CI runs bin/cf validate

CloudFormation Template

{
	"AWSTemplateFormatVersion": "2010-09-09",
	"Description": "DNS for slashdeploy.com",
	"Parameters": {
		"TLD": {
			"Type": "String",
			"Default": "slashdeploy.com"
		}
	},
	"Resources": {
		"R53HostedZone": {
			"Type": "AWS::Route53::HostedZone",
			"Properties": {
				"Name": { "Ref": "TLD" }
			}
		},
		"MXRecord": {
			"Type": "AWS::Route53::RecordSet",
			"Properties": {
				"HostedZoneId": { "Ref" : "R53HostedZone" },
				"Name": { "Ref": "TLD" },
				"ResourceRecords": [
					"10 in1-smtp.messagingengine.com",
					"20 in2-smtp.messagingengine.com"
				],
				"Type": "MX",
				"TTL": "300"
			}
		},
		"SPFRecord": {
			"Type": "AWS::Route53::RecordSet",
			"Properties": {
				"HostedZoneId": { "Ref" : "R53HostedZone" },
				"Name": { "Ref": "TLD" },
				"ResourceRecords": [
					"\"v=spf1 include:spf.messagingengine.com -all\""
				],
				"Type": "SPF",
				"TTL": "300"
			}
		},
		"TXTRecord": {
			"Type": "AWS::Route53::RecordSet",
			"Properties": {
				"HostedZoneId": { "Ref" : "R53HostedZone" },
				"Name": { "Ref": "TLD" },
				"ResourceRecords": [
					"\"v=spf1 include:spf.messagingengine.com -all\""
				],
				"Type": "SPF",
				"TTL": "300"
			}
		}
	},
	"Outputs": {
		"ZoneId": {
			"Description": "Route53 ZoneId for TLD",
			"Value": { "Ref": "R53HostedZone" }
		}
	}
}

I am happy with this solution. There is only one tweak. The aws cloudformation update-stack call does need to be handled as a special case. There may be no updates to apply, so the output should captured and inspected accordingly. This comes up when using the deploy command in a continuous delivery context. It always surprises me how much you can get done from the CLI.