diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..e91ba2fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Compiled files +*.tfstate +*.tfstate.backup +*.terraform.tfstate* +# Module directory +.terraform/ + +.idea diff --git a/LICENSE b/LICENSE index 8dada3ed..6b9d898e 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright {yyyy} {name of copyright owner} + Copyright 2017 Cloud Posse, LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 9e08acef..c14a293e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,91 @@ -# tf_cdn -Terraform module to easily provision CloudFront CDN with an S3 or custom origin +# tf_cdn_s3 + +Terraform module to easily provision CloudFront CDN with an S3 or custom origin. + +## Usage + +module "cdn_s3" { + source = "git::https://github.com/cloudposse/tf_cdn_s3.git?ref=master" + namespace = "${var.namespace}" + stage = "${var.stage}" + name = "${var.name}" + aliases = "${var.hostname}" + parent_zone_name = "${var.parent_zone_name}" +} + +### Generating ACM Certificate + +Use the AWS cli to [request new ACM certifiates](http://docs.aws.amazon.com/acm/latest/userguide/gs-acm-request.html) (requires email validation) +``` +aws acm request-certificate --domain-name example.com --subject-alternative-names a.example.com b.example.com *.c.example.com +``` + + +## Variables + +| Name | Default | Description | Required | +|:-------------------------------|:----------------------:|:------------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------:| +| `namespace` | `` | Namespace (e.g. `cp` or `cloudposse`) | Yes | +| `stage` | `` | Stage (e.g. `prod`, `dev`, `staging`) | Yes | +| `name` | `` | Name (e.g. `bastion` or `db`) | Yes | +| `attributes` | `[]` | Additional attributes (e.g. `policy` or `role`) | No | +| `tags` | `{}` | Additional tags (e.g. `map("BusinessUnit","XYZ")` | No | +| `acm_certificate_arn` | `` | Existing ACM Certificate ARN | No | +| `aliases` | `[]` | List of aliases | Yes | +| `allowed_methods` | `["*"]` | List of allowed methods (e.g. ` GET, PUT, POST, DELETE, HEAD`) for AWS CloudFront | No | +| `cached_methods` | `["GET", "HEAD"]` | List of cached methods (e.g. ` GET, PUT, POST, DELETE, HEAD`) | No | +| `comment` | `Managed by Terraform` | Comment for the origin access identity | No | +| `compress` | `false` | Compress content for web requests that include Accept-Encoding: gzip in the request header | No | +| `cors_allowed_headers` | `["*"]` | List of allowed headers for S3 bucket | No | +| `cors_allowed_methods` | `["GET"]` | List of allowed methods (e.g. ` GET, PUT, POST, DELETE, HEAD`) for S3 bucket | No | +| `cors_allowed_origins` | `["*"]` | List of allowed origins (e.g. ` example.com, test.com`) for S3 bucket | No | +| `cors_max_age_seconds` | `3600` | Time in seconds that browser can cache the response for S3 bucket | No | +| `cors_expose_headers` | `["ETag"]` | List of expose header in the response for S3 bucket | No | +| `bucket_domain_format` | `%s.s3.amazonaws.com` | Format of bucket domain name | No | +| `default_root_object` | `index.html` | Object that CloudFront return when requests the root URL | No | +| `enabled` | `true` | State of CloudFront | No | +| `forward_cookies` | `none` | Forward cookies to the origin that is associated with this cache behavior | No | +| `forward_query_string` | `false` | Forward query strings to the origin that is associated with this cache behavior | No | +| `geo_restriction_locations` | `[]` | List of country codes for which CloudFront either to distribute content (whitelist) or not distribute your content (blacklist) | No | +| `geo_restriction_type` | `none` | Method that use to restrict distribution of your content by country: `none`, `whitelist`, or `blacklist` | No | +| `is_ipv6_enabled` | `true` | State of CloudFront IPv6 | No | +| `log_standard_transition_days` | `30` | Number of days to persist in the standard storage tier before moving to the glacier tier | No | +| `log_glacier_transition_days` | `60` | Number of days to persist in the standard storage tier before moving to the infrequent access | No | +| `log_expiration_days` | `90` | Number of days after which to expunge the objects | No | +| `log_include_cookies` | `false` | Include cookies in access logs | No | +| `log_prefix` | `` | Path of logs in S3 bucket | No | +| `min_ttl` | `0` | Minimum amount of time that you want objects to stay in CloudFront caches | No | +| `default_ttl` | `60` | Default amount of time (in seconds) that an object is in a CloudFront cache | No | +| `max_ttl` | `31536000` | Maximum amount of time (in seconds) that an object is in a CloudFront cache | No | +| `null` | `` | Empty string | No | +| `price_class` | `PriceClass_100` | Price class for this distribution: `PriceClass_All`, `PriceClass_200`, `PriceClass_100` | No | +| `viewer_protocol_policy` | `redirect-to-https` | Element to specify the protocol: `allow-all`, `https-only`, `redirect-to-https` | No | +| `null` | `` | Empty string | No | +| `origin_force_destroy` | `` | Delete all objects from the bucket so that the bucket can be destroyed without error (e.g. `true` or `false`) | No | +| `origin_bucket` | `` | Name of S3 bucket | No | +| `origin_path` | `` | Element that causes CloudFront to request your content from a directory in your Amazon S3 bucket. Begins with `/`. CAUTION! Do not use bare `/` as `origin_path`. | No | +| `parent_zone_id` | `` | ID of the hosted zone to contain this record (or specify `parent_zone_name`) | Yes | +| `parent_zone_name` | `` | Name of the hosted zone to contain this record (or specify `parent_zone_id`) | Yes | + + +## Outputs + +| Name | Description | +|:------------------------|:--------------------------------------------------| +| `cf_arn` | ID of AWS CloudFront distribution | +| `cf_domain_name` | Domain name corresponding to the distribution | +| `cf_etag` | Current version of the distribution's information | +| `cf_hosted_zone_id` | CloudFront Route 53 zone ID | +| `cf_id` | ID of AWS CloudFront distribution | +| `cf_status` | Current status of the distribution | +| `s3_bucket` | Name of S3 bucket | +| `s3_bucket_domain_name` | Domain of S3 bucket | + + +## Known Issues + +If the bucket is created in a region other than `us-east-1`, it will take a while for the distribution to become fully operational. + +> All buckets have at least two REST endpoint hostnames. In eu-west-1, they are example-bucket.s3-eu-west-1.amazonaws.com and example-bucket.s3.amazonaws.com. The first one will be immediately valid when the bucket is created. The second one -- sometimes referred to as the "global endpoint" -- which is the one CloudFront uses -- will not, unless the bucket is in us-east-1. Over a period of seconds to minutes, variable by location and other factors, it becomes globally accessible as well. Before that, the 307 redirect is returned. Hence, the bucket was not ready. + +Via: https://stackoverflow.com/questions/38706424/aws-cloudfront-returns-http-307-when-origin-is-s3-bucket diff --git a/main.tf b/main.tf new file mode 100644 index 00000000..be1aadee --- /dev/null +++ b/main.tf @@ -0,0 +1,171 @@ +module "origin_label" { + source = "git::https://github.com/cloudposse/tf_label.git?ref=tags/0.2.0" + namespace = "${var.namespace}" + stage = "${var.stage}" + name = "${var.name}" + delimiter = "${var.delimiter}" + attributes = ["origin"] + tags = "${var.tags}" +} + +resource "aws_cloudfront_origin_access_identity" "default" { + comment = "${module.distribution_label.id}" +} + +data "aws_iam_policy_document" "origin" { + statement { + actions = ["s3:GetObject"] + resources = ["arn:aws:s3:::$${bucket_name}$${origin_path}*"] + + principals { + type = "AWS" + identifiers = ["${aws_cloudfront_origin_access_identity.default.iam_arn}"] + } + } + + statement { + actions = ["s3:ListBucket"] + resources = ["arn:aws:s3:::$${bucket_name}"] + + principals { + type = "AWS" + identifiers = ["${aws_cloudfront_origin_access_identity.default.iam_arn}"] + } + } +} + +data "template_file" "default" { + template = "${data.aws_iam_policy_document.origin.json}" + + vars { + origin_path = "${coalesce(var.origin_path, "/")}" + bucket_name = "${null_resource.default.triggers.bucket}" + } +} + +resource "aws_s3_bucket_policy" "default" { + bucket = "${null_resource.default.triggers.bucket}" + policy = "${data.template_file.default.rendered}" +} + +resource "aws_s3_bucket" "origin" { + count = "${signum(length(var.origin_bucket)) == 1 ? 0 : 1}" + bucket = "${module.origin_label.id}" + acl = "private" + tags = "${module.origin_label.tags}" + force_destroy = "${var.origin_force_destroy}" + + cors_rule { + allowed_headers = "${var.cors_allowed_headers}" + allowed_methods = "${var.cors_allowed_methods}" + allowed_origins = "${sort(distinct(compact(concat(var.cors_allowed_origins, var.aliases))))}" + expose_headers = "${var.cors_expose_headers}" + max_age_seconds = "${var.cors_max_age_seconds}" + } +} + +module "logs" { + source = "git::https://github.com/cloudposse/tf_log_storage.git?ref=tags/0.1.0" + namespace = "${var.namespace}" + stage = "${var.stage}" + name = "${var.name}" + delimiter = "${var.delimiter}" + attributes = ["logs"] + tags = "${var.tags}" + prefix = "${var.log_prefix}" + standard_transition_days = "${var.log_standard_transition_days}" + glacier_transition_days = "${var.log_glacier_transition_days}" + expiration_days = "${var.log_expiration_days}" +} + +module "distribution_label" { + source = "git::https://github.com/cloudposse/tf_label.git?ref=tags/0.2.0" + namespace = "${var.namespace}" + stage = "${var.stage}" + name = "${var.name}" + delimiter = "${var.delimiter}" + tags = "${var.tags}" +} + +resource "null_resource" "default" { + triggers { + bucket = "${element(compact(concat(list(var.origin_bucket), aws_s3_bucket.origin.*.bucket)), 0)}" + bucket_domain_name = "${format(var.bucket_domain_format, element(compact(concat(list(var.origin_bucket), aws_s3_bucket.origin.*.bucket)), 0))}" + } + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_cloudfront_distribution" "default" { + enabled = "${var.enabled}" + is_ipv6_enabled = "${var.is_ipv6_enabled}" + comment = "${var.comment}" + default_root_object = "${var.default_root_object}" + price_class = "${var.price_class}" + depends_on = ["aws_s3_bucket.origin"] + + logging_config = { + include_cookies = "${var.log_include_cookies}" + bucket = "${module.logs.bucket_domain_name}" + prefix = "${var.log_prefix}" + } + + aliases = ["${var.aliases}"] + + origin { + domain_name = "${null_resource.default.triggers.bucket_domain_name}" + origin_id = "${module.distribution_label.id}" + origin_path = "${var.origin_path}" + + s3_origin_config { + origin_access_identity = "${aws_cloudfront_origin_access_identity.default.cloudfront_access_identity_path}" + } + } + + viewer_certificate { + acm_certificate_arn = "${var.acm_certificate_arn}" + ssl_support_method = "sni-only" + minimum_protocol_version = "TLSv1" + cloudfront_default_certificate = true + } + + default_cache_behavior { + allowed_methods = "${var.allowed_methods}" + cached_methods = "${var.cached_methods}" + target_origin_id = "${module.distribution_label.id}" + compress = "${var.compress}" + + forwarded_values { + query_string = "${var.forward_query_string}" + + cookies { + forward = "${var.forward_cookies}" + } + } + + viewer_protocol_policy = "${var.viewer_protocol_policy}" + default_ttl = "${var.default_ttl}" + min_ttl = "${var.min_ttl}" + max_ttl = "${var.max_ttl}" + } + + restrictions { + geo_restriction { + restriction_type = "${var.geo_restriction_type}" + locations = "${var.geo_restriction_locations}" + } + } + + tags = "${module.distribution_label.tags}" +} + +module "dns" { + source = "git::https://github.com/cloudposse/tf_vanity.git?ref=tags/0.2.1" + aliases = "${var.aliases}" + parent_zone_id = "${var.parent_zone_id}" + parent_zone_name = "${var.parent_zone_name}" + target_dns_name = "${aws_cloudfront_distribution.default.domain_name}" + target_zone_id = "${aws_cloudfront_distribution.default.hosted_zone_id}" +} diff --git a/outputs.tf b/outputs.tf new file mode 100644 index 00000000..b37b588b --- /dev/null +++ b/outputs.tf @@ -0,0 +1,31 @@ +output "cf_id" { + value = "${aws_cloudfront_distribution.default.id}" +} + +output "cf_arn" { + value = "${aws_cloudfront_distribution.default.arn}" +} + +output "cf_status" { + value = "${aws_cloudfront_distribution.default.status}" +} + +output "cf_domain_name" { + value = "${aws_cloudfront_distribution.default.domain_name}" +} + +output "cf_etag" { + value = "${aws_cloudfront_distribution.default.etag}" +} + +output "cf_hosted_zone_id" { + value = "${aws_cloudfront_distribution.default.hosted_zone_id}" +} + +output "s3_bucket" { + value = "${null_resource.default.bucket}" +} + +output "s3_bucket_domain_name" { + value = "${null_resource.default.bucket_domain_name}" +} diff --git a/variables.tf b/variables.tf new file mode 100644 index 00000000..590500a7 --- /dev/null +++ b/variables.tf @@ -0,0 +1,170 @@ +variable "name" {} +variable "namespace" {} +variable "stage" {} + +variable "tags" { + default = {} +} + +variable "delimiter" { + default = "-" +} + +variable "enabled" { + default = "true" +} + +variable "acm_certificate_arn" { + description = "Existing ACM Certificate ARN" + default = "" +} + +variable "aliases" { + type = "list" + default = [] +} + +variable "origin_bucket" { + default = "" +} + +variable "origin_path" { + # http://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/distribution-web-values-specify.html#DownloadDistValuesOriginPath + description = "(Optional) - An optional element that causes CloudFront to request your content from a directory in your Amazon S3 bucket or your custom origin. It must begin with a /. Do not add a / at the end of the path." + default = "" +} + +variable "origin_force_destroy" { + default = "false" +} + +variable "bucket_domain_format" { + default = "%s.s3.amazonaws.com" +} + +variable "compress" { + default = "false" +} + +variable "is_ipv6_enabled" { + default = "true" +} + +variable "default_root_object" { + default = "index.html" +} + +variable "comment" { + default = "Managed by Terraform" +} + +variable "log_include_cookies" { + default = "false" +} + +variable "log_prefix" { + default = "" +} + +variable "log_standard_transition_days" { + description = "Number of days to persist in the standard storage tier before moving to the glacier tier" + default = "30" +} + +variable "log_glacier_transition_days" { + description = "Number of days after which to move the data to the glacier storage tier" + default = "60" +} + +variable "log_expiration_days" { + description = "Number of days after which to expunge the objects" + default = "90" +} + +variable "forward_query_string" { + default = "false" +} + +variable "cors_allowed_headers" { + type = "list" + default = ["*"] +} + +variable "cors_allowed_methods" { + type = "list" + default = ["GET"] +} + +variable "cors_allowed_origins" { + type = "list" + default = [] +} + +variable "cors_expose_headers" { + type = "list" + default = ["ETag"] +} + +variable "cors_max_age_seconds" { + default = "3600" +} + +variable "forward_cookies" { + default = "none" +} + +variable "price_class" { + default = "PriceClass_100" +} + +variable "viewer_protocol_policy" { + description = "allow-all, redirect-to-https" + default = "redirect-to-https" +} + +variable "allowed_methods" { + type = "list" + default = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] +} + +variable "cached_methods" { + type = "list" + default = ["GET", "HEAD"] +} + +variable "default_ttl" { + default = "60" +} + +variable "min_ttl" { + default = "0" +} + +variable "max_ttl" { + default = "31536000" +} + +variable "geo_restriction_type" { + # e.g. "whitelist" + default = "none" +} + +variable "geo_restriction_locations" { + type = "list" + + # e.g. ["US", "CA", "GB", "DE"] + default = [] +} + +variable "parent_zone_id" { + default = "" +} + +variable "parent_zone_name" { + default = "" +} + +variable "null" { + description = "an empty string" + default = "" +}