Commit a6a9f3b5 authored by Adam Hawkins's avatar Adam Hawkins

Initial commit

parents
dist/
.jekyll-metadata
FROM ruby:2.2
ENV LC_ALL C.UTF-8
RUN mkdir -p /usr/src/app/vendor
WORKDIR /usr/src/app
COPY Gemfile Gemfile.lock /usr/src/app/
COPY vendor/cache vendor/cache
RUN bundle install --local -j $(nproc)
CMD [ "bundle", "exec", "jekyll" ]
source 'https://rubygems.org'
gem 'jekyll', github: 'jekyll/jekyll'
# gem 'jekyll', '~> 3.1.1'
GIT
remote: git://github.com/jekyll/jekyll.git
revision: 4c8c59dfcb8b80c8dd3d8df562a9d664be10348c
specs:
jekyll (3.1.1)
colorator (~> 0.1)
jekyll-sass-converter (~> 1.0)
jekyll-watch (~> 1.1)
kramdown (~> 1.3)
liquid (~> 3.0)
mercenary (~> 0.3.3)
rouge (~> 1.7)
safe_yaml (~> 1.0)
GEM
remote: https://rubygems.org/
specs:
colorator (0.1)
ffi (1.9.10)
jekyll-sass-converter (1.4.0)
sass (~> 3.4)
jekyll-watch (1.3.1)
listen (~> 3.0)
kramdown (1.9.0)
liquid (3.0.6)
listen (3.0.6)
rb-fsevent (>= 0.9.3)
rb-inotify (>= 0.9.7)
mercenary (0.3.5)
rb-fsevent (0.9.7)
rb-inotify (0.9.7)
ffi (>= 0.5.0)
rouge (1.10.1)
safe_yaml (1.0.4)
sass (3.4.21)
PLATFORMS
ruby
DEPENDENCIES
jekyll!
BUNDLED WITH
1.11.2
DOCKER_IMAGE:=tmp/image
.DEFAULT_GOAL:=dist
.PHONY: check
check:
jq --version
aws --version
docker --version
bats --version
Gemfile.lock: Gemfile
docker run --rm -it -v $(PWD):/data -w /data ruby:2.2 \
bundle package
$(DOCKER_IMAGE): Dockerfile Gemfile.lock
docker build -t slashdeploy/blog .
mkdir -p $(@D)
touch $@
.PHONY: init
init: $(DOCKER_IMAGE)
docker run --rm -it -v $(PWD):/data -w /data slashdeploy/blog \
bundle exec jekyll new src
.PHONY: dist
dist: $(DOCKER_IMAGE)
mkdir -p dist
docker run --rm -it -v $(PWD):/data -w /data -e JEKYLL_ENV=production slashdeploy/blog \
bundle exec jekyll build -d /data/dist -s /data/src
.PHONY: test-dist
test-dist:
env DIST_PATH=$(PWD)/dist test/dist_test.bats
.PHONY: test-shellcheck
test-shellcheck:
docker run --rm -it -v $(PWD):/data -w /data jrotter/shellcheck \
shellcheck -s bash \
$(shell find bin script -type f -exec test -x {} \; -print | paste -s -d ' ' -)
.PHONY: test-blog
test-blog:
bin/blog validate
.PHONY: test-ci
test-ci:
$(MAKE) test-shellcheck
$(MAKE) test-dist
$(MAKE) test-blog
.PHONY: clean
clean:
rm -rf $(DOCKER_IMAGE)
# SlashDeploy Website
This repository contains code to build and deploy
[blog.slashdeploy.com](http://blog.slashdeploy.com). The site itself
is statically generated with Jekyll. There is a CloudFormation stack
to create manage the DNS, S3, Cloudfront distribution, and Route53 DNS
records. The process is coordinated through a few key files.
* `bin/website` - Cloudformation manager
* `script/ci/deploy` - Build and deploy the thing
* `script/server` - Start a development server
## Developing
$ make check
$ script/server # do work
$ make test-ci
## Tests
* `make test-dist`: Ensure generated site has artifacts required
CloudFront artifacts.
* `make test-shellcheck`: Run shell programs through shellcheck
* `make test-website`: Validate CloudFormation template
## Deploying
First ensure the `slashdeploy.com` Route53 hosted zone is ready. See
the [DNS][].
[dns]: https://gitlab.com/slashdeploy/dns
#!/usr/bin/env bash
set -euo pipefail
declare stack_name="blog"
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"
echo "outputs -- Print cloudformation outputs"
}
cloudformation() {
aws --profile slashdeploy --region eu-west-1 cloudformation "$@"
}
s3() {
aws --profile slashdeploy --region eu-west-1 s3 "$@"
}
deploy() {
if cloudformation describe-stacks --stack-name "${stack_name}" &> /dev/null ; then
declare output update_status
# NOTE: temporarily unset -e. This is because the update-stack
# call fails if there is nothing to do. This case must be captured
# and handled appropriately based on output.
set +e
output="$(cloudformation update-stack \
--stack-name "${stack_name}" \
--template-body "file://cloudformation.json" 2>&1)"
update_status=$?
set -e
if echo "${output}" | grep -qF 'No updates'; then
echo "CloudFormation stack up-to-date"
return 0
else
return $update_status
fi
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_outputs() {
cloudformation describe-stacks --stack-name "${stack_name}" \
| jq -re '.Stacks[0].Outputs | map("\(.OutputKey): \(.OutputValue)") | .[]'
}
publish() {
declare bucket
bucket="$(show_outputs | grep -iF 'bucket' | cut -d ' ' -f 2)"
s3 sync dist "s3://${bucket}/"
}
main() {
case "${1:-}" in
deploy)
shift
deploy "$@"
;;
destroy)
shift
destroy "$@"
;;
status)
shift
show_status "$@"
;;
validate)
shift
validate_stack "$@"
;;
outputs)
shift
show_outputs "$@"
;;
publish)
shift
publish "$@"
;;
*)
usage 1>&2
return 1
;;
esac
}
main "$@"
{
"AWSTemplateFormatVersion": "2010-09-09",
"Description": "Blog: S3 bucket, Cloudfront, & DNS",
"Parameters": {
"Subdomain": {
"Type": "String",
"Default": "blog"
},
"TLD": {
"Type": "String",
"Default": "slashdeploy.com"
}
},
"Resources": {
"Bucket": {
"Type": "AWS::S3::Bucket",
"Properties": {
"BucketName": "slashdeploy-blog",
"WebsiteConfiguration": {
"ErrorDocument": "error.html",
"IndexDocument": "index.html"
}
}
},
"Policy": {
"Type": "AWS::S3::BucketPolicy",
"Properties": {
"Bucket": { "Ref": "Bucket" },
"PolicyDocument": {
"Version": "2012-10-17",
"Statement": [{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": [ "s3:GetObject" ],
"Resource": [{
"Fn::Join": [ "", [
"arn:aws:s3:::",
{ "Ref": "Bucket" },
"/*"
]]
}]
}]
}
}
},
"Distribution": {
"Type": "AWS::CloudFront::Distribution",
"Properties": {
"DistributionConfig": {
"Aliases": [
{
"Fn::Join": [ ".", [
{ "Ref": "Subdomain" },
{ "Ref": "TLD" }
]]
}
],
"Comment": "Blog",
"DefaultRootObject": "index.html",
"Enabled": "true",
"Origins": [
{
"Id": "blog-s3-website",
"DomainName": {
"Fn::Join": [ ".", [
{ "Ref": "Bucket" },
{ "Fn::Join": [ "-", [ "s3-website", { "Ref": "AWS::Region" } ]] },
"amazonaws.com"
]]
},
"CustomOriginConfig": {
"OriginProtocolPolicy": "http-only"
}
}
],
"DefaultCacheBehavior": {
"ForwardedValues": {
"Cookies": {
"Forward": "none"
},
"QueryString": "false"
},
"TargetOriginId": "blog-s3-website",
"ViewerProtocolPolicy": "allow-all"
},
"PriceClass": "PriceClass_All",
"ViewerCertificate": {
"CloudFrontDefaultCertificate": "true"
}
}
}
},
"DNS": {
"Type": "AWS::Route53::RecordSet",
"Properties": {
"HostedZoneName": {
"Fn::Join": [ "", [ { "Ref": "TLD" }, "." ] ]
},
"Name": {
"Fn::Join": [ ".", [
{ "Ref": "Subdomain" },
{ "Ref": "TLD" }
]]
},
"Type": "A",
"AliasTarget": {
"HostedZoneId": "Z2FDTNDATAQYW2",
"DNSName": { "Fn::GetAtt": [ "Distribution", "DomainName" ] }
}
}
}
},
"Outputs": {
"Bucket": {
"Description": "S3 Bucket",
"Value": { "Ref": "Bucket" }
},
"OriginURL": {
"Description": "S3 Website URL",
"Value": { "Fn::GetAtt": [ "Bucket", "WebsiteURL" ] }
},
"DistributionURL": {
"Description": "Cloudfront URL",
"Value": { "Fn::GetAtt": [ "Distribution", "DomainName" ] }
}
}
}
#!/usr/bin/env bash
set -euo pipefail
make clean dist
bin/blog deploy
bin/blog publish
#!/usr/bin/env bash
set -euo pipefail
make clean dist
make test-ci
#!/usr/bin/env bash
set -euo pipefail
if ! make -q tmp/image; then
make tmp/image
fi
docker run --rm -it -p 4000:4000 -v "${PWD}:/data" slashdeploy/blog \
jekyll serve \
--host 0.0.0.0 --port 4000 \
--force_polling \
--drafts \
--future \
-s /data/src -d /data/dist
_site
.sass-cache
.jekyll-metadata
# Welcome to Jekyll!
#
# This config file is meant for settings that affect your whole blog, values
# which you are expected to set up once and rarely need to edit after that.
# For technical reasons, this file is *NOT* reloaded automatically when you use
# 'jekyll serve'. If you change this file, please restart the server process.
# Site settings
title: /Deploy
email: hi@slashdeploy.com
description: > # this means to ignore newlines until "baseurl:"
Need help deploying, monitoring, or automating parts of
your system? Get in touch. We'd love to help you.
baseurl: "" # the subpath of your site, e.g. /blog
url: "http://blog.slashdeploy.com" # the base hostname & protocol for your site
twitter: slashdeploy
time_zone: Asia/Colombo
source_url: "https://gitlab.com/slashdeploy/blog"
permalink: "/:year/:month/:day/:title/"
# Build settings
markdown: kramdown
kramdown:
input: GFM
ahawkins:
name: Adam Hawkins
email: adam@hawkins.io
twitter: adman65
<footer class="site-footer">
<div class="wrapper">
<div class="bordered">
<p>{{ site.description }}</p>
<p class="links">
<a href="mailto:{{ site.email }}">{{ site.email }}</a>
- <a href="http://twitter.com/{{ site.twitter }}">@{{ site.twitter }}</a>
- <a href="{{ "/feed.xml" | prepend: site.baseurl }}">Subscribe</a>
- <a href="{{ site.source_url }}">Source</a>
</p>
</div>
</div>
</footer>
{%if jekyll.environment == "production" %}
<script type="text/javascript">
var _gauges = _gauges || [];
(function() {
var t = document.createElement('script');
t.type = 'text/javascript';
t.async = true;
t.id = 'gauges-tracker';
t.setAttribute('data-site-id', '56da8d15c88d903d870008f9');
t.setAttribute('data-track-path', 'https://track.gaug.es/track.gif');
t.src = 'https://d36ee2fcip1434.cloudfront.net/track.js';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(t, s);
})();
</script>
{% endif %}
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% if page.title %}{{ page.title | escape }}{% else %}{{ site.title | escape }}{% endif %}</title>
<meta name="description" content="{% if page.excerpt %}{{ page.excerpt | strip_html | strip_newlines | truncate: 160 }}{% else %}{{ site.description }}{% endif %}">
<link rel="stylesheet" href="{{ "/css/main.css" | prepend: site.baseurl }}">
<link rel="canonical" href="{{ page.url | replace:'index.html','' | prepend: site.baseurl | prepend: site.url }}">
<link rel="alternate" type="application/rss+xml" title="{{ site.title }}" href="{{ "/feed.xml" | prepend: site.baseurl | prepend: site.url }}">
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Source+Sans+Pro:300,300i,600">
</head>
<a href="https://twitter.com/{{ include.username }}"><span class="icon icon--twitter">{% include icon-twitter.svg %}</span><span class="username">{{ include.username }}</span></a>
<svg viewBox="0 0 16 16"><path fill="#828282" d="M15.969,3.058c-0.586,0.26-1.217,0.436-1.878,0.515c0.675-0.405,1.194-1.045,1.438-1.809c-0.632,0.375-1.332,0.647-2.076,0.793c-0.596-0.636-1.446-1.033-2.387-1.033c-1.806,0-3.27,1.464-3.27,3.27 c0,0.256,0.029,0.506,0.085,0.745C5.163,5.404,2.753,4.102,1.14,2.124C0.859,2.607,0.698,3.168,0.698,3.767 c0,1.134,0.577,2.135,1.455,2.722C1.616,6.472,1.112,6.325,0.671,6.08c0,0.014,0,0.027,0,0.041c0,1.584,1.127,2.906,2.623,3.206 C3.02,9.402,2.731,9.442,2.433,9.442c-0.211,0-0.416-0.021-0.615-0.059c0.416,1.299,1.624,2.245,3.055,2.271 c-1.119,0.877-2.529,1.4-4.061,1.4c-0.264,0-0.524-0.015-0.78-0.046c1.447,0.928,3.166,1.469,5.013,1.469 c6.015,0,9.304-4.983,9.304-9.304c0-0.142-0.003-0.283-0.009-0.423C14.976,4.29,15.531,3.714,15.969,3.058z"/></svg>
<!DOCTYPE html>
<html>
{% include head.html %}
<body>
<header class="site-header">
<div class="wrapper">
<h1 class="site-title">
<a href="{{ site.baseurl }}/">{{ site.title }}</a>
</h1>
<nav class="site-nav">
{% for my_page in site.pages %}
{% if my_page.title %}
<a class="page-link" href="{{ my_page.url | prepend: site.baseurl }}">{{ my_page.title | downcase }}</a>
{% endif %}
{% endfor %}
</nav>
</div>
</header>
<div class="page-content">
<div class="wrapper">
{{ content }}
</div>
</div>
{% include footer.html %}
</body>
</html>
---
layout: default
---
<article class="post">
<header class="post-header">
<h1 class="post-title">{{ page.title }}</h1>
</header>
<div class="post-content">
{{ content }}
</div>
</article>
<!DOCTYPE html>
<html>
{% include head.html %}
<body>
<header class="site-header small">
<div class="wrapper">
<h1 class="site-title">
<a href="{{ site.baseurl }}/">{{ site.title }}</a>
</h1>
</div>
</header>
<div class="page-content">
<div class="wrapper">
<article class="post" itemscope itemtype="http://schema.org/BlogPosting">
<header class="post-header">
<h1 class="post-title" itemprop="name headline">{{ page.title }}</h1>
<p class="post-meta"><time datetime="{{ page.date | date_to_xmlschema }}" itemprop="datePublished">{{ page.date | date: "%B %-d, %Y" }}</time></p>
</header>
<div class="post-content" itemprop="articleBody">
{{ content }}
</div>
<div class="post-author">
{% assign author = site.data.people[page.author] %}
<p>
<img src="{{ author.email | to_gravatar }}?s=70" class="author-avatar">
<span itemprop="author" itemscope itemtype="http://schema.org/Person">
<a href="mailto:{{ author.email }}"><span itemprop="name">{{ author.name }}</span></a><br >
<a href="https://twitter.com/{{ author.twitter }}">@{{ author.twitter }}</a>
</span>
</p>
</div>
</article>
</div>
</div>
{% include footer.html %}
</body>
</html>
require 'digest/md5'
module Jekyll
module GravatarFilter
def to_gravatar(email)
"//www.gravatar.com/avatar/#{Digest::MD5.hexdigest(email.to_s.strip.downcase.strip)}"
end
end
end
Liquid::Template.register_filter(Jekyll::GravatarFilter)
---
title: Bootstrapping DNS on AWS
layout: post
author: ahawkins
---
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
```bash
#!/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