diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..66c7f9a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @petewilcock diff --git a/.github/workflows/testsuite-master.yaml b/.github/workflows/testsuite-master.yaml new file mode 100644 index 0000000..dbdb345 --- /dev/null +++ b/.github/workflows/testsuite-master.yaml @@ -0,0 +1,66 @@ +--- +name: test-suite-master +# yamllint disable-line rule:truthy +on: + push: + branches: + - master +jobs: + tflint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2.3.4 + - name: setup Terraform + uses: hashicorp/setup-terraform@v1.3.2 + with: + terraform_version: 0.15.5 + - name: Terraform init + run: terraform init --backend=false + - name: tflint + uses: reviewdog/action-tflint@master + with: + github_token: ${{ secrets.ACTIONS_TOKEN }} + reporter: github-check + filter_mode: added + flags: --module + level: error + tfsec: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2.3.4 + - name: setup Terraform + uses: hashicorp/setup-terraform@v1.3.2 + with: + terraform_version: 0.15.5 + - name: Terraform init + run: terraform init --backend=false + - name: tfsec + uses: reviewdog/action-tfsec@master + with: + github_token: ${{ secrets.ACTIONS_TOKEN }} + reporter: github-check + filter_mode: added + level: warning + misspell: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2.3.4 + - name: misspell + uses: reviewdog/action-misspell@v1 + with: + github_token: ${{ secrets.ACTIONS_TOKEN }} + locale: "US" + reporter: github-check + filter_mode: added + level: error + yamllint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2.3.4 + - name: yamllint + uses: reviewdog/action-yamllint@v1.2.0 + with: + github_token: ${{ secrets.ACTIONS_TOKEN }} + reporter: github-check + filter_mode: added + level: error diff --git a/.github/workflows/testsuite.yaml b/.github/workflows/testsuite.yaml new file mode 100644 index 0000000..dd1a909 --- /dev/null +++ b/.github/workflows/testsuite.yaml @@ -0,0 +1,93 @@ +--- +name: test-suite +# yamllint disable-line rule:truthy +on: + pull_request: +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2.3.4 + - name: Set up Python + uses: actions/setup-python@v2.2.2 + - name: Install prerequisites + run: ./bin/install-ubuntu.sh + - name: Terraform init + run: terraform init --backend=false + - name: pre-commit + uses: pre-commit/action@v2.0.3 + env: + AWS_DEFAULT_REGION: eu-west-1 + # many of these are covered by better reviewdog linters below + SKIP: >- + terraform_tflint_deep, + no-commit-to-branch, + terraform_tflint_nocreds, + terraform_tfsec + - uses: stefanzweifel/git-auto-commit-action@v4.11.0 + if: ${{ failure() }} + with: + commit_message: Apply automatic changes + commit_options: "--no-verify" + # Optional commit user and author settings + commit_user_name: Linter Bot + commit_user_email: noreply@techtospeech.com + commit_author: Linter Bot + tflint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2.3.4 + - name: setup Terraform + uses: hashicorp/setup-terraform@v1.3.2 + with: + terraform_version: 0.15.5 + - name: Terraform init + run: terraform init --backend=false + - name: tflint + uses: reviewdog/action-tflint@master + with: + github_token: ${{ secrets.ACTIONS_TOKEN }} + reporter: github-pr-check + filter_mode: added + flags: --module + level: error + tfsec: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2.3.4 + - name: setup Terraform + uses: hashicorp/setup-terraform@v1.3.2 + with: + terraform_version: 0.15.5 + - name: Terraform init + run: terraform init --backend=false + - name: tfsec + uses: reviewdog/action-tfsec@master + with: + github_token: ${{ secrets.ACTIONS_TOKEN }} + reporter: github-pr-check + filter_mode: added + level: warning + misspell: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2.3.4 + - name: misspell + uses: reviewdog/action-misspell@v1 + with: + github_token: ${{ secrets.ACTIONS_TOKEN }} + locale: "US" + reporter: github-pr-check + filter_mode: added + level: error + yamllint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2.3.4 + - name: yamllint + uses: reviewdog/action-yamllint@v1.2.0 + with: + github_token: ${{ secrets.ACTIONS_TOKEN }} + reporter: github-pr-check + filter_mode: added + level: error diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa0ed18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Build artifacts +modules/cloudfront/lambda_redirect/dst/* +modules/codebuild/codebuild_files/wordpress_docker.zip +modules/codebuild/codebuild_files/php.ini + +# Local .terraform directories +**/.terraform/* + +# .tfstate files +# *.tfstate +# *.tfstate.* +.terraform.lock.hcl +plan.plan +# Crash log files +crash.log + +# Ignore any .tfvars files that are generated automatically for each Terraform run. Most +# .tfvars files are managed as part of configuration and so should be included in +# version control. +# +# example.tfvars + +# Ignore override files as they are usually used to override resources locally and so +# are not checked in +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Include override files you do wish to add to version control using negated pattern +# +# !example_override.tf + +# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan +# example: *tfplan* + +.idea diff --git a/.header.md b/.header.md new file mode 100644 index 0000000..74ee2da --- /dev/null +++ b/.header.md @@ -0,0 +1,168 @@ +# terraform-aws-serverless-static-wordpress + +[![Test Suite](https://github.com/techtospeech/terraform-aws-serverless-static-wordpress/workflows/test-suite-master/badge.svg?branch=master&event=push)](https://github.com/techtospeech/terraform-aws-serverless-static-wordpress/actions/workflows/testsuite-master.yaml?query=branch%3Amaster+event%3Apush+workflow%3Atest-suite) +follow on Twitter + +## Introduction + +Serverless Static Wordpress is a Community Terraform Module from TechToSpeech that needs nothing more than a registered +domain name with its DNS pointed at AWS. + +It creates a complete infrastructure framework that allows you to launch a temporary, transient Wordpress container. +You then log in and customize it like any Wordpress site, and finally publish it as a static site fronted by a global +CloudFront CDN and S3 Origin. When you’re done you shut down the Wordpress container and it costs you almost nothing. + +The emphasis is on extremely minimal configuration as the majority of everything you’d need is pre-installed and +pre-configured in line with industry best practices and highly efficient running costs. + +## Architecture Overview + +![Architecture](docs/serverless-static-wordpress.png) + +## Pre-requisites + +- A domain name either hosted with AWS, or with its DNS delegated to a Route53 hosted zone. +- A VPC configured with at least one public subnet in your desired deployment region. + +## Provider Set-up + +Terraform best practice is to configure providers at the top-level module and pass them downwards through implicit +inheritance or explicit passing. Whilst the module and child-modules reference `required_providers`, it is also necessary +for you to provide a regional alias for operations that _must_ be executed in us-east-1 (CloudFront, ACM, and WAF). +As such you should include the following in your provider configuration: + +``` +terraform { + required_version = "> 0.15.1" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 3.0" + configuration_aliases = [aws.ue1] + } + } +} + +provider "aws" { + alias = "ue1" + region = "us-east-1" +} + +``` + +The `ue1` alias is essential for this module to work correctly. + +## Module instantiation example + +``` +locals { + aws_account_id = "998877676554" + aws_region = "eu-west-1" + site_name = "peterdotcloud" + profile = "peterdotcloud" + site_domain = "peter.cloud" +} + +data "aws_caller_identity" "current" {} + +module "peterdotcloud_website" { + source = "TechToSpeech/serverless-static-wordpress/aws" + version = "0.1.0" + main_vpc_id = "vpc-e121c09b" + subnet_ids = ["subnet-04b97235","subnet-08fb235","subnet-04b97734"] + aws_account_id = data.aws_caller_identity.current.account_id + + # site_name will be used to prepend resource names - use no spaces or special characters + site_name = local.site_name + site_domain = local.site_domain + wordpress_subdomain = "wordpress" + hosted_zone_id = "Z00437553UWAVIRHANGCN" + s3_region = local.aws_region + + # Send ECS and RDS events to Slack + slack_webhook = "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX" + ecs_cpu = 1024 + ecs_memory = 2048 + cloudfront_aliases = ["www.peter.cloud", "peter.cloud"] + waf_enabled = true + + # Provides the toggle to launch Wordpress container + launch = 0 + + ## Passing in Provider block to module is essential + providers = { + aws.ue1 = aws.ue1 + } +} +``` + +Do not to set `launch` to 1 initially as the module uses a Codebuild pipeline to take a vanilla version +of the Wordpress docker container and rebake it to include all of the pre-requisites required to publish the Wordpress +site to S3. + +The step to push the required Wordpress container from Dockerhub to your own ECR repository can be tied into your +module instantiation using our [helper module](https://github.com/TechToSpeech/terraform-aws-ecr-mirror) as follows: + +Note this requires Docker to be running on your Terraform environment with either a named AWS profile or credentials +otherwise available. +``` +module "docker_pullpush" { + source = "TechToSpeech/ecr-mirror/aws" + version = "0.0.6" + aws_account_id = data.aws_caller_identity.current.account_id + aws_region = local.aws_region + docker_source = "wordpress:php7.4-apache" + aws_profile = "peterdotcloud" + ecr_repo_name = module.peterdotcloud_website.wordpress_ecr_repository + ecr_repo_tag = "base" + depends_on = [module.peterdotcloud_website] +} +``` + +The CodeBuild pipeline takes a couple of minutes to run and pushes back a 'latest' tagged version of the container, +which is what will be used for the Wordpress container. This build either needs to be triggered manually from the +CodeBuild console, or you can use this snippet to trigger the build as part of your Terraform flow: + +``` +resource "null_resource" "trigger_build" { + triggers = { + codebuild_etag = module.peterdotcloud_website.codebuild_package_etag + } + provisioner "local-exec" { + command = <<-EOT + aws codebuild start-build --project-name "${module.peterdotcloud_website.codebuild_project_name}" + --profile "${local.profile}" --region "${local.aws_region}" + EOT + } + depends_on = [ + module.peterdotcloud_website, module.docker_pullpush + ] +} +``` + +Whilst this might feel convoluted (and you might ask: why not just provide a public customized Docker image?), it was +felt important that users should 'own' their own version of the Wordpress container, built transparently from the official Wordpress docker image with full provenance. + +Finally, if you wish to fully automate the creation _and_ update of the domain's nameservers if it's registered in +Route53 within the same account, you can add these additional snippets to include this in your flow. + +``` +resource "aws_route53_zone" "apex" { + name = "peter.cloud" +} + +resource "null_resource" "update_nameservers" { + triggers = { + nameservers = aws_route53_zone.apex.id + } + provisioner "local-exec" { + command = <<-EOT + aws route53domains update-domain-nameservers --region us-east-1 --domain-name "${local.site_domain}" + --nameservers Name="${aws_route53_zone.apex.name_servers.0}" Name="${aws_route53_zone.apex.name_servers.1}" + Name="${aws_route53_zone.apex.name_servers.2}" Name="${aws_route53_zone.apex.name_servers.3}" --profile peterdotcloud + EOT + } + depends_on = [aws_route53_zone.apex] +} +``` +See [examples](docs/examples) for full set-up example. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..affc785 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,73 @@ +--- +repos: + - repo: local + hooks: + - id: terraform-docs-main + name: terraform-docs-main + language: docker_image + entry: quay.io/terraform-docs/terraform-docs:latest + args: ["--output-file", "README.md", "markdown", "."] + pass_filenames: false + - id: terraform-docs-cloudfront + name: terraform-docs-cloudfront + language: docker_image + entry: quay.io/terraform-docs/terraform-docs:latest + args: ["--output-file", "README.md", "markdown", "modules/cloudfront"] + pass_filenames: false + - id: terraform-docs-codebuild + name: terraform-docs-codebuild + language: docker_image + entry: quay.io/terraform-docs/terraform-docs:latest + args: ["--output-file", "README.md", "markdown", "modules/codebuild"] + pass_filenames: false + - id: terraform-docs-lambda_slack + name: terraform-docs-lambda_slack + language: docker_image + entry: quay.io/terraform-docs/terraform-docs:latest + args: ["--output-file", "README.md", "markdown", "modules/lambda_slack"] + pass_filenames: false + - id: terraform-docs-waf + name: terraform-docs-waf + language: docker_image + entry: quay.io/terraform-docs/terraform-docs:latest + args: ["--output-file", "README.md", "markdown", "modules/waf"] + pass_filenames: false + - repo: https://github.com/antonbabenko/pre-commit-terraform + rev: v1.31.0 + hooks: + - id: terraform_fmt + - id: terraform_tflint + alias: terraform_tflint_nocreds + name: terraform_tflint_nocreds + - id: terraform_tfsec + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.0.0 + hooks: + - id: check-case-conflict + # - id: check-json + - id: check-merge-conflict + - id: check-symlinks + - id: check-yaml + args: + - --unsafe + - id: end-of-file-fixer + - id: mixed-line-ending + args: + - --fix=lf + - id: no-commit-to-branch + args: + - --branch + - main + - --branch + - master + - --branch + - prod + # - id: pretty-format-json + # args: + # - --autofix + # - --top-keys=name,Name + - id: trailing-whitespace + args: + - --markdown-linebreak-ext=md + exclude: README.md diff --git a/.terraform-docs.yml b/.terraform-docs.yml new file mode 100644 index 0000000..04a6fca --- /dev/null +++ b/.terraform-docs.yml @@ -0,0 +1,48 @@ +--- +version: "" + +formatter: + +header-from: .header.md +footer-from: "" + +sections: + hide: [] + show: [] + +content: |- + {{ .Header }} + {{ .Footer }} + {{ .Inputs }} + {{ .Modules }} + {{ .Outputs }} + {{ .Requirements }} + {{ .Resources }} + +output: + file: "" + mode: inject + template: |- + + {{ .Content }} + + +output-values: + enabled: false + from: "" + +sort: + enabled: true + by: name + +settings: + anchor: true + color: true + default: true + description: false + escape: true + html: true + indent: 2 + required: true + sensitive: true + type: true diff --git a/.tflint.hcl b/.tflint.hcl new file mode 100644 index 0000000..aaa72b0 --- /dev/null +++ b/.tflint.hcl @@ -0,0 +1,45 @@ +plugin "aws" { + enabled = true + // deep_check = true +} + +rule "terraform_deprecated_interpolation" { + enabled = true +} + +rule "terraform_unused_declarations" { + enabled = true +} + +rule "terraform_comment_syntax" { + enabled = true +} + +rule "terraform_documented_outputs" { + enabled = true +} + +rule "terraform_documented_variables" { + enabled = true +} + +rule "terraform_typed_variables" { + enabled = true +} + +rule "terraform_module_pinned_source" { + enabled = true +} + +rule "terraform_naming_convention" { + enabled = true + format = "snake_case" +} + +rule "terraform_required_version" { + enabled = true +} + +rule "terraform_required_providers" { + enabled = true +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bd50c04 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 - 19th June 2021 + +Initial release of Serverless Static Wordpress Terraform module. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e62ec04 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ +GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md index 8b13789..decc2fc 100644 --- a/README.md +++ b/README.md @@ -1 +1,258 @@ + +# terraform-aws-serverless-static-wordpress +[![Test Suite](https://github.com/techtospeech/terraform-aws-serverless-static-wordpress/workflows/test-suite-master/badge.svg?branch=master&event=push)](https://github.com/techtospeech/terraform-aws-serverless-static-wordpress/actions/workflows/testsuite-master.yaml?query=branch%3Amaster+event%3Apush+workflow%3Atest-suite) +follow on Twitter + +## Introduction + +Serverless Static Wordpress is a Community Terraform Module from TechToSpeech that needs nothing more than a registered +domain name with its DNS pointed at AWS. + +It creates a complete infrastructure framework that allows you to launch a temporary, transient Wordpress container. +You then log in and customize it like any Wordpress site, and finally publish it as a static site fronted by a global +CloudFront CDN and S3 Origin. When you’re done you shut down the Wordpress container and it costs you almost nothing. + +The emphasis is on extremely minimal configuration as the majority of everything you’d need is pre-installed and +pre-configured in line with industry best practices and highly efficient running costs. + +## Architecture Overview + +![Architecture](docs/serverless-static-wordpress.png) + +## Pre-requisites + +- A domain name either hosted with AWS, or with its DNS delegated to a Route53 hosted zone. +- A VPC configured with at least one public subnet in your desired deployment region. + +## Provider Set-up + +Terraform best practice is to configure providers at the top-level module and pass them downwards through implicit +inheritance or explicit passing. Whilst the module and child-modules reference `required_providers`, it is also necessary +for you to provide a regional alias for operations that _must_ be executed in us-east-1 (CloudFront, ACM, and WAF). +As such you should include the following in your provider configuration: + +``` +terraform { + required_version = "> 0.15.1" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 3.0" + configuration_aliases = [aws.ue1] + } + } +} + +provider "aws" { + alias = "ue1" + region = "us-east-1" +} + +``` + +The `ue1` alias is essential for this module to work correctly. + +## Module instantiation example + +``` +locals { + aws_account_id = "998877676554" + aws_region = "eu-west-1" + site_name = "peterdotcloud" + profile = "peterdotcloud" + site_domain = "peter.cloud" +} + +data "aws_caller_identity" "current" {} + +module "peterdotcloud_website" { + source = "TechToSpeech/serverless-static-wordpress/aws" + version = "0.1.0" + main_vpc_id = "vpc-e121c09b" + subnet_ids = ["subnet-04b97235","subnet-08fb235","subnet-04b97734"] + aws_account_id = data.aws_caller_identity.current.account_id + + # site_name will be used to prepend resource names - use no spaces or special characters + site_name = local.site_name + site_domain = local.site_domain + wordpress_subdomain = "wordpress" + hosted_zone_id = "Z00437553UWAVIRHANGCN" + s3_region = local.aws_region + + # Send ECS and RDS events to Slack + slack_webhook = "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX" + ecs_cpu = 1024 + ecs_memory = 2048 + cloudfront_aliases = ["www.peter.cloud", "peter.cloud"] + waf_enabled = true + + # Provides the toggle to launch Wordpress container + launch = 0 + + ## Passing in Provider block to module is essential + providers = { + aws.ue1 = aws.ue1 + } +} +``` + +Do not to set `launch` to 1 initially as the module uses a Codebuild pipeline to take a vanilla version +of the Wordpress docker container and rebake it to include all of the pre-requisites required to publish the Wordpress +site to S3. + +The step to push the required Wordpress container from Dockerhub to your own ECR repository can be tied into your +module instantiation using our [helper module](https://github.com/TechToSpeech/terraform-aws-ecr-mirror) as follows: + +Note this requires Docker to be running on your Terraform environment with either a named AWS profile or credentials +otherwise available. +``` +module "docker_pullpush" { + source = "TechToSpeech/ecr-mirror/aws" + version = "0.0.6" + aws_account_id = data.aws_caller_identity.current.account_id + aws_region = local.aws_region + docker_source = "wordpress:php7.4-apache" + aws_profile = "peterdotcloud" + ecr_repo_name = module.peterdotcloud_website.wordpress_ecr_repository + ecr_repo_tag = "base" + depends_on = [module.peterdotcloud_website] +} +``` + +The CodeBuild pipeline takes a couple of minutes to run and pushes back a 'latest' tagged version of the container, +which is what will be used for the Wordpress container. This build either needs to be triggered manually from the +CodeBuild console, or you can use this snippet to trigger the build as part of your Terraform flow: + +``` +resource "null_resource" "trigger_build" { + triggers = { + codebuild_etag = module.peterdotcloud_website.codebuild_package_etag + } + provisioner "local-exec" { + command = <<-EOT + aws codebuild start-build --project-name "${module.peterdotcloud_website.codebuild_project_name}" + --profile "${local.profile}" --region "${local.aws_region}" + EOT + } + depends_on = [ + module.peterdotcloud_website, module.docker_pullpush + ] +} +``` + +Whilst this might feel convoluted (and you might ask: why not just provide a public customized Docker image?), it was +felt important that users should 'own' their own version of the Wordpress container, built transparently from the official Wordpress docker image with full provenance. + +Finally, if you wish to fully automate the creation _and_ update of the domain's nameservers if it's registered in +Route53 within the same account, you can add these additional snippets to include this in your flow. + +``` +resource "aws_route53_zone" "apex" { + name = "peter.cloud" +} + +resource "null_resource" "update_nameservers" { + triggers = { + nameservers = aws_route53_zone.apex.id + } + provisioner "local-exec" { + command = <<-EOT + aws route53domains update-domain-nameservers --region us-east-1 --domain-name "${local.site_domain}" + --nameservers Name="${aws_route53_zone.apex.name_servers.0}" Name="${aws_route53_zone.apex.name_servers.1}" + Name="${aws_route53_zone.apex.name_servers.2}" Name="${aws_route53_zone.apex.name_servers.3}" --profile peterdotcloud + EOT + } + depends_on = [aws_route53_zone.apex] +} +``` +See [examples](docs/examples) for full set-up example. + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [aws\_account\_id](#input\_aws\_account\_id) | The AWS account ID into which resources will be launched. | `number` | n/a | yes | +| [cloudfront\_aliases](#input\_cloudfront\_aliases) | The domain and sub-domain aliases to use for the cloudfront distribution. | `list(any)` | `[]` | no | +| [cloudfront\_class](#input\_cloudfront\_class) | The [price class](https://aws.amazon.com/cloudfront/pricing/) for the distribution. One of: PriceClass\_All, PriceClass\_200, PriceClass\_100 | `string` | `"PriceClass_All"` | no | +| [ecs\_cpu](#input\_ecs\_cpu) | The CPU limit password to the Wordpress container definition. | `number` | `256` | no | +| [ecs\_memory](#input\_ecs\_memory) | The memory limit password to the Wordpress container definition. | `number` | `512` | no | +| [hosted\_zone\_id](#input\_hosted\_zone\_id) | The Route53 HostedZone ID to use to create records in. | `string` | n/a | yes | +| [launch](#input\_launch) | The number of tasks to launch of the Wordpress container. Used as a toggle to start/stop your Wordpress management session. | `number` | `"0"` | no | +| [main\_vpc\_id](#input\_main\_vpc\_id) | The VPC ID into which to launch resources. | `string` | n/a | yes | +| [s3\_region](#input\_s3\_region) | The regional endpoint to use for the creation of the S3 bucket for published static wordpress site. | `string` | n/a | yes | +| [site\_domain](#input\_site\_domain) | The site domain name to configure (without any subdomains such as 'www') | `string` | n/a | yes | +| [site\_name](#input\_site\_name) | The unique name for this instance of the module. Required to deploy multiple wordpress instances to the same AWS account (if desired). | `string` | n/a | yes | +| [site\_prefix](#input\_site\_prefix) | The subdomain prefix of the website domain. E.g. www | `string` | `"www"` | no | +| [slack\_webhook](#input\_slack\_webhook) | The Slack webhook URL where ECS Cluster EventBridge notifications will be sent. | `string` | `""` | no | +| [snapshot\_identifier](#input\_snapshot\_identifier) | To create the RDS cluster from a previous snapshot in the same region, specify it by name. | `string` | `null` | no | +| [subnet\_ids](#input\_subnet\_ids) | A list of subnet IDs within the specified VPC where resources will be launched. | `list(any)` | n/a | yes | +| [waf\_acl\_rules](#input\_waf\_acl\_rules) | List of WAF rules to apply. Can be customized to apply others created outside of module. | `list(any)` |
[
{
"cloudwatch_metrics_enabled": true,
"managed_rule_group_name": "AWSManagedRulesAmazonIpReputationList",
"metric_name": "AWS-AWSManagedRulesAmazonIpReputationList",
"name": "AWS-AWSManagedRulesAmazonIpReputationList",
"priority": 0,
"sampled_requests_enabled": true,
"vendor_name": "AWS"
},
{
"cloudwatch_metrics_enabled": true,
"managed_rule_group_name": "AWSManagedRulesKnownBadInputsRuleSet",
"metric_name": "AWS-AWSManagedRulesKnownBadInputsRuleSet",
"name": "AWS-AWSManagedRulesKnownBadInputsRuleSet",
"priority": 1,
"sampled_requests_enabled": true,
"vendor_name": "AWS"
},
{
"cloudwatch_metrics_enabled": true,
"managed_rule_group_name": "AWSManagedRulesBotControlRuleSet",
"metric_name": "AWS-AWSManagedRulesBotControlRuleSet",
"name": "AWS-AWSManagedRulesBotControlRuleSet",
"priority": 2,
"sampled_requests_enabled": true,
"vendor_name": "AWS"
}
]
| no | +| [waf\_enabled](#input\_waf\_enabled) | Flag to enable default WAF configuration in front of CloudFront. | `bool` | n/a | yes | +| [wordpress\_admin\_email](#input\_wordpress\_admin\_email) | The email address of the default wordpress admin user. | `string` | `"admin@example.com"` | no | +| [wordpress\_admin\_password](#input\_wordpress\_admin\_password) | The password of the default wordpress admin user. | `string` | `"techtospeech.com"` | no | +| [wordpress\_admin\_user](#input\_wordpress\_admin\_user) | The username of the default wordpress admin user. | `string` | `"supervisor"` | no | +| [wordpress\_subdomain](#input\_wordpress\_subdomain) | The subdomain used for the Wordpress container. | `string` | `"wordpress"` | no | +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [cloudfront](#module\_cloudfront) | ./modules/cloudfront | n/a | +| [codebuild](#module\_codebuild) | ./modules/codebuild | n/a | +| [lambda\_slack](#module\_lambda\_slack) | ./modules/lambda_slack | n/a | +| [waf](#module\_waf) | ./modules/waf | n/a | +## Outputs + +| Name | Description | +|------|-------------| +| [cloudfront\_ssl\_arn](#output\_cloudfront\_ssl\_arn) | The ARN of the ACM certificate used by CloudFront. | +| [codebuild\_package\_etag](#output\_codebuild\_package\_etag) | The etag of the codebuild package file. | +| [codebuild\_project\_name](#output\_codebuild\_project\_name) | The name of the created Wordpress codebuild project. | +| [wordpress\_ecr\_repository](#output\_wordpress\_ecr\_repository) | The name of the ECR repository where wordpress image is stored. | +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 0.15.1 | +| [aws](#requirement\_aws) | ~> 3.0 | +| [random](#requirement\_random) | ~> 3.1.0 | +## Resources + +| Name | Type | +|------|------| +| [aws_acm_certificate.wordpress_site](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/acm_certificate) | resource | +| [aws_acm_certificate_validation.wordpress_site](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/acm_certificate_validation) | resource | +| [aws_cloudwatch_log_group.serverless_wordpress](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_cloudwatch_log_group.wordpress_container](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_db_subnet_group.main_vpc](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/db_subnet_group) | resource | +| [aws_ecr_repository.serverless_wordpress](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecr_repository) | resource | +| [aws_ecs_cluster.wordpress_cluster](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_cluster) | resource | +| [aws_ecs_service.wordpress_service](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_service) | resource | +| [aws_ecs_task_definition.wordpress_container](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_task_definition) | resource | +| [aws_efs_access_point.wordpress_efs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/efs_access_point) | resource | +| [aws_efs_file_system.wordpress_persistent](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/efs_file_system) | resource | +| [aws_efs_mount_target.wordpress_efs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/efs_mount_target) | resource | +| [aws_iam_policy.wordpress_bucket_access](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | +| [aws_iam_role.wordpress_task](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy_attachment.wordpress_bucket_access](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_iam_role_policy_attachment.wordpress_role_attachment_cloudwatch](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_iam_role_policy_attachment.wordpress_role_attachment_ecs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_rds_cluster.serverless_wordpress](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/rds_cluster) | resource | +| [aws_route53_record.apex](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_record) | resource | +| [aws_route53_record.wordpress_acm_validation](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_record) | resource | +| [aws_route53_record.www](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_record) | resource | +| [aws_security_group.aurora_serverless_group](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | +| [aws_security_group.efs_security_group](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | +| [aws_security_group.wordpress_security_group](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | +| [aws_security_group_rule.aurora_sg_ingress_3306](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group_rule) | resource | +| [aws_security_group_rule.efs_ingress](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group_rule) | resource | +| [aws_security_group_rule.wordpress_sg_egress_2049](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group_rule) | resource | +| [aws_security_group_rule.wordpress_sg_egress_3306](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group_rule) | resource | +| [aws_security_group_rule.wordpress_sg_egress_443](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group_rule) | resource | +| [aws_security_group_rule.wordpress_sg_egress_80](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group_rule) | resource | +| [aws_security_group_rule.wordpress_sg_ingress_80](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group_rule) | resource | +| [random_id.rds_snapshot](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | +| [random_password.serverless_wordpress_password](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/password) | resource | +| [aws_iam_policy_document.ecs_assume_role_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.wordpress_bucket_access](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | + diff --git a/acm.tf b/acm.tf new file mode 100644 index 0000000..a28e696 --- /dev/null +++ b/acm.tf @@ -0,0 +1,34 @@ +resource "aws_acm_certificate" "wordpress_site" { + domain_name = var.site_domain + validation_method = "DNS" + + subject_alternative_names = ["${var.site_prefix}.${var.site_domain}"] + + lifecycle { + create_before_destroy = true + } + provider = aws.ue1 +} + +resource "aws_route53_record" "wordpress_acm_validation" { + for_each = { + for dvo in aws_acm_certificate.wordpress_site.domain_validation_options : dvo.domain_name => { + name = dvo.resource_record_name + record = dvo.resource_record_value + type = dvo.resource_record_type + } + } + + allow_overwrite = true + name = each.value.name + records = [each.value.record] + ttl = 60 + type = each.value.type + zone_id = var.hosted_zone_id +} + +resource "aws_acm_certificate_validation" "wordpress_site" { + provider = aws.ue1 + certificate_arn = aws_acm_certificate.wordpress_site.arn + validation_record_fqdns = [for record in aws_route53_record.wordpress_acm_validation : record.fqdn] +} diff --git a/bin/install-macos.sh b/bin/install-macos.sh new file mode 100755 index 0000000..4bc710b --- /dev/null +++ b/bin/install-macos.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +echo 'installing brew packages' +brew update +brew tap liamg/tfsec +brew install tfenv tflint terraform-docs pre-commit liamg/tfsec/tfsec coreutils +brew upgrade tfenv tflint terraform-docs pre-commit liamg/tfsec/tfsec coreutils + +echo 'installing pre-commit hooks' +pre-commit install + +echo 'setting pre-commit hooks to auto-install on clone in the future' +git config --global init.templateDir ~/.git-template +pre-commit init-templatedir ~/.git-template + +echo 'installing terraform with tfenv' +tfenv install min-required +tfenv use min-required diff --git a/bin/install-ubuntu.sh b/bin/install-ubuntu.sh new file mode 100755 index 0000000..70cb83e --- /dev/null +++ b/bin/install-ubuntu.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +echo 'installing dependencies' +sudo apt install python3-pip gawk &&\ +pip3 install pre-commit +curl -L "$(curl -sL https://api.github.com/repos/terraform-linters/tflint/releases/latest | grep -o -E "https://.+?_linux_amd64.zip")" > tflint.zip && unzip tflint.zip && rm tflint.zip && sudo mv tflint /usr/bin/ +curl -L "$(curl -sL https://api.github.com/repos/tfsec/tfsec/releases/latest | grep -o -E "https://.+?tfsec-linux-amd64" | head -1)" > tfsec && chmod +x tfsec && sudo mv tfsec /usr/bin/ +docker pull quay.io/terraform-docs/terraform-docs:latest +git clone https://github.com/tfutils/tfenv.git ~/.tfenv || true +mkdir -p ~/.local/bin/ +. ~/.profile +ln -s ~/.tfenv/bin/* ~/.local/bin + +echo 'installing pre-commit hooks' +pre-commit install + +echo 'setting pre-commit hooks to auto-install on clone in the future' +git config --global init.templateDir ~/.git-template +pre-commit init-templatedir ~/.git-template + +echo 'installing terraform with tfenv' +tfenv install min-required +tfenv use min-required diff --git a/docs/examples/main.tf b/docs/examples/main.tf new file mode 100644 index 0000000..61f9038 --- /dev/null +++ b/docs/examples/main.tf @@ -0,0 +1,93 @@ +locals { + aws_region = "eu-west-1" + site_name = "peterdotcloud" + profile = "peterdotcloud" + site_domain = "peter.cloud" +} + +data "aws_caller_identity" "current" {} + +module "peterdotcloud_vpc" { + source = "./vpc_setup" +} + +module "peterdotcloud_website" { + source = "TechToSpeech/serverless-static-wordpress/aws" + version = "0.1.0" + main_vpc_id = "vpc-e121c09b" + subnet_ids = ["subnet-04b97235", "subnet-08fb235", "subnet-04b97734"] + aws_account_id = data.aws_caller_identity.current.account_id + + # site_name will be used to prepend resource names - use no spaces or special characters + site_name = local.site_name + site_domain = local.site_domain + wordpress_subdomain = "wordpress" + hosted_zone_id = "Z00437553UWAVIRHANGCN" + s3_region = local.aws_region + slack_webhook = "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX" + ecs_cpu = 1024 + ecs_memory = 2048 + cloudfront_aliases = ["www.peter.cloud", "peter.cloud"] + waf_enabled = true + + # Provides the toggle to launch Wordpress container + launch = 0 + + ## Passing in Provider block to module is essential + providers = { + aws.ue1 = aws.ue1 + } +} + +# Optional (but highly recommended) helper module for pull/push official Wordpress docker image to ECR + +module "docker_pullpush" { + source = "TechToSpeech/ecr-mirror/aws" + version = "0.0.6" + aws_account_id = data.aws_caller_identity.current.account_id + aws_region = local.aws_region + docker_source = "wordpress:php7.4-apache" + aws_profile = "peterdotcloud" + ecr_repo_name = module.peterdotcloud_website.wordpress_ecr_repository + ecr_repo_tag = "base" + depends_on = [module.peterdotcloud_website] +} + + +# Optional helper resources to trigger CodeBuild + +resource "null_resource" "trigger_build" { + triggers = { + codebuild_etag = module.peterdotcloud_website.codebuild_package_etag + } + provisioner "local-exec" { + command = <<-EOT + aws codebuild start-build --project-name "${module.peterdotcloud_website.codebuild_project_name}" + --profile "${local.profile}" --region "${local.aws_region}" + EOT + } + depends_on = [ + module.peterdotcloud_website, module.docker_pullpush + ] +} + + +# Optional helper resources to update nameservers of newly-created Hosted Zone + +resource "aws_route53_zone" "apex" { + name = "peter.cloud" +} + +resource "null_resource" "update_nameservers" { + triggers = { + nameservers = aws_route53_zone.apex.id + } + provisioner "local-exec" { + command = <<-EOT + aws route53domains update-domain-nameservers --region us-east-1 --domain-name "${local.site_domain}" + --nameservers Name="${aws_route53_zone.apex.name_servers.0}" Name="${aws_route53_zone.apex.name_servers.1}" + Name="${aws_route53_zone.apex.name_servers.2}" Name="${aws_route53_zone.apex.name_servers.3}" --profile peterdotcloud + EOT + } + depends_on = [aws_route53_zone.apex] +} diff --git a/docs/examples/provider.tf b/docs/examples/provider.tf new file mode 100644 index 0000000..cf5151c --- /dev/null +++ b/docs/examples/provider.tf @@ -0,0 +1,20 @@ +terraform { + required_version = "> 0.15.1" + required_providers { + aws = { + source = "hashicorp/aws" + # https://github.com/hashicorp/terraform-provider-aws/blob/main/CHANGELOG.md + version = "~> 3.0" + configuration_aliases = [aws.ue1] + } + } +} + +provider "aws" { + region = "eu-west-1" +} + +provider "aws" { + alias = "ue1" + region = "us-east-1" +} diff --git a/docs/examples/vpc_setup/outputs.tf b/docs/examples/vpc_setup/outputs.tf new file mode 100644 index 0000000..24c81ba --- /dev/null +++ b/docs/examples/vpc_setup/outputs.tf @@ -0,0 +1,15 @@ +output "main_vpc_id" { + value = aws_vpc.main.id +} + +output "subnet_ids" { + value = toset(aws_subnet.main_public.*.id) +} + +output "route_table_private" { + value = aws_route_table.main_private +} + +output "route_table_public" { + value = aws_route_table.main_public +} diff --git a/docs/examples/vpc_setup/vpc.tf b/docs/examples/vpc_setup/vpc.tf new file mode 100644 index 0000000..d42ae59 --- /dev/null +++ b/docs/examples/vpc_setup/vpc.tf @@ -0,0 +1,122 @@ +resource "aws_vpc" "main" { + cidr_block = "10.0.0.0/16" + + tags = { + Name = "main" + } + + lifecycle { + prevent_destroy = true + } + + enable_dns_hostnames = true +} + +data "aws_availability_zones" "available" { + state = "available" +} + +data "aws_region" "current" {} + +data "aws_subnet_ids" "main_public" { + vpc_id = aws_vpc.main.id + tags = { + Visibility = "public" + } +} + +data "aws_subnet_ids" "main_private" { + vpc_id = aws_vpc.main.id + tags = { + Visibility = "private" + } +} + +resource "aws_subnet" "main_public" { + count = length(data.aws_availability_zones.available.names) + vpc_id = aws_vpc.main.id + cidr_block = "10.0.${10 + count.index}.0/24" + availability_zone = data.aws_availability_zones.available.names[count.index] + map_public_ip_on_launch = true + tags = { + Name = "main-public-${data.aws_availability_zones.available.names[count.index]}" + Visibility = "public" + } + lifecycle { + prevent_destroy = true + } +} + +resource "aws_subnet" "main_private" { + count = length(data.aws_availability_zones.available.names) + vpc_id = aws_vpc.main.id + cidr_block = "10.0.${20 + count.index}.0/24" + availability_zone = data.aws_availability_zones.available.names[count.index] + map_public_ip_on_launch = false + tags = { + Name = "main-private-${data.aws_availability_zones.available.names[count.index]}" + Visibility = "private" + } + lifecycle { + prevent_destroy = true + } +} + +resource "aws_internet_gateway" "main" { + vpc_id = aws_vpc.main.id + tags = { + Name = "main" + } + lifecycle { + prevent_destroy = true + } +} + +resource "aws_route_table" "main_public" { + vpc_id = aws_vpc.main.id + tags = { + Name = "main_public" + Visibility = "public" + } +} + +resource "aws_route_table" "main_private" { + vpc_id = aws_vpc.main.id + tags = { + Name = "main_private" + Visibility = "private" + } +} + +resource "aws_route_table_association" "main_subnets_public" { + count = length(data.aws_subnet_ids.main_public.ids) + subnet_id = tolist(data.aws_subnet_ids.main_public.ids)[count.index] + route_table_id = aws_route_table.main_public.id +} + +resource "aws_route_table_association" "main_subnets_private" { + count = length(data.aws_subnet_ids.main_private.ids) + subnet_id = tolist(data.aws_subnet_ids.main_private.ids)[count.index] + route_table_id = aws_route_table.main_private.id +} + +resource "aws_vpc_endpoint" "main_s3" { + vpc_id = aws_vpc.main.id + service_name = "com.amazonaws.${data.aws_region.current.name}.s3" +} + +resource "aws_vpc_endpoint_route_table_association" "main_s3_public" { + route_table_id = aws_route_table.main_public.id + vpc_endpoint_id = aws_vpc_endpoint.main_s3.id +} + +resource "aws_vpc_endpoint_route_table_association" "main_s3_private" { + route_table_id = aws_route_table.main_private.id + vpc_endpoint_id = aws_vpc_endpoint.main_s3.id +} + +resource "aws_route" "public_out_to_internet" { + route_table_id = aws_route_table.main_public.id + gateway_id = aws_internet_gateway.main.id + destination_cidr_block = "0.0.0.0/0" +} diff --git a/docs/serverless-static-wordpress.png b/docs/serverless-static-wordpress.png new file mode 100644 index 0000000..8f1fdbb Binary files /dev/null and b/docs/serverless-static-wordpress.png differ diff --git a/ecr.tf b/ecr.tf new file mode 100644 index 0000000..80ad725 --- /dev/null +++ b/ecr.tf @@ -0,0 +1,14 @@ +# TODO: Add optional custom KMS key for ECR +#tfsec:ignore:AWS093 +resource "aws_ecr_repository" "serverless_wordpress" { + name = "${var.site_name}-serverless-wordpress" + # TODO: Investigate enforcing immutability on tags + #tfsec:ignore:AWS078 + image_tag_mutability = "MUTABLE" + + image_scanning_configuration { + # TODO: Make ECR scan on push optional in future + #tfsec:ignore:AWS023 + scan_on_push = false + } +} diff --git a/ecs.tf b/ecs.tf new file mode 100644 index 0000000..21dd3f1 --- /dev/null +++ b/ecs.tf @@ -0,0 +1,246 @@ +resource "aws_efs_file_system" "wordpress_persistent" { + encrypted = true + lifecycle_policy { + transition_to_ia = "AFTER_7_DAYS" + } + tags = { + "Name" = "${var.site_name}_wordpress_persistent" + } +} + +data "aws_iam_policy_document" "ecs_assume_role_policy" { + statement { + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["ecs.amazonaws.com", "ecs-tasks.amazonaws.com"] + } + } +} + +data "aws_iam_policy_document" "wordpress_bucket_access" { + statement { + actions = ["s3:ListBucket"] + effect = "Allow" + resources = [module.cloudfront.wordpress_bucket_arn] + } + statement { + actions = ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"] + effect = "Allow" + resources = ["${module.cloudfront.wordpress_bucket_arn}/*"] + } + statement { + actions = ["ec2:DescribeNetworkInterfaces"] + effect = "Allow" + resources = ["*"] + } + statement { + actions = ["route53:ChangeResourceRecordSets"] + effect = "Allow" + resources = ["arn:aws:route53:::hostedzone/${var.hosted_zone_id}"] + } +} + +resource "aws_iam_policy" "wordpress_bucket_access" { + name = "${var.site_name}_WordpressBucketAccess" + description = "The role that allows Wordpress task to do necessary operations" + policy = data.aws_iam_policy_document.wordpress_bucket_access.json +} + +resource "aws_iam_role_policy_attachment" "wordpress_bucket_access" { + role = aws_iam_role.wordpress_task.name + policy_arn = aws_iam_policy.wordpress_bucket_access.arn +} + +resource "aws_iam_role" "wordpress_task" { + name = "${var.site_name}_WordpressTaskRole" + assume_role_policy = data.aws_iam_policy_document.ecs_assume_role_policy.json +} + +resource "aws_iam_role_policy_attachment" "wordpress_role_attachment_ecs" { + role = aws_iam_role.wordpress_task.name + policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" +} + +resource "aws_iam_role_policy_attachment" "wordpress_role_attachment_cloudwatch" { + role = aws_iam_role.wordpress_task.name + policy_arn = "arn:aws:iam::aws:policy/CloudWatchLogsFullAccess" +} + +resource "aws_efs_access_point" "wordpress_efs" { + file_system_id = aws_efs_file_system.wordpress_persistent.id +} + +resource "aws_security_group" "efs_security_group" { + name = "${var.site_name}_efs_sg" + description = "security group for wordpress" + vpc_id = var.main_vpc_id +} + +resource "aws_security_group_rule" "efs_ingress" { + security_group_id = aws_security_group.efs_security_group.id + type = "ingress" + from_port = 2049 + to_port = 2049 + protocol = "TCP" + source_security_group_id = aws_security_group.wordpress_security_group.id + description = "Ingress to EFS mount from Wordpress container" +} + +resource "aws_efs_mount_target" "wordpress_efs" { + for_each = toset(var.subnet_ids) + file_system_id = aws_efs_file_system.wordpress_persistent.id + subnet_id = each.value + security_groups = [aws_security_group.efs_security_group.id] +} + +#tfsec:ignore:AWS089 +resource "aws_cloudwatch_log_group" "wordpress_container" { + name = "/aws/ecs/${var.site_name}-serverless-wordpress-container" + retention_in_days = 7 +} + +resource "aws_ecs_task_definition" "wordpress_container" { + family = "${var.site_name}_wordpress" + container_definitions = templatefile("${path.module}/task-definitions/wordpress.json", { + db_host = aws_rds_cluster.serverless_wordpress.endpoint, + db_user = aws_rds_cluster.serverless_wordpress.master_username, + db_password = random_password.serverless_wordpress_password.result, + db_name = aws_rds_cluster.serverless_wordpress.database_name, + wordpress_image = "${aws_ecr_repository.serverless_wordpress.repository_url}:latest", + wp_dest = "https://${var.site_prefix}.${var.site_domain}", + wp_region = var.s3_region, + wp_bucket = module.cloudfront.wordpress_bucket_id, + container_dns = "${var.wordpress_subdomain}.${var.site_domain}", + container_dns_zone = var.hosted_zone_id, + container_cpu = var.ecs_cpu, + container_memory = var.ecs_memory + efs_source_volume = "${var.site_name}_wordpress_persistent" + wordpress_admin_user = var.wordpress_admin_user + wordpress_admin_password = var.wordpress_admin_password + wordpress_admin_email = var.wordpress_admin_email + site_name = var.site_name + }) + + cpu = var.ecs_cpu + memory = var.ecs_memory + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + execution_role_arn = aws_iam_role.wordpress_task.arn + task_role_arn = aws_iam_role.wordpress_task.arn + + volume { + name = "${var.site_name}_wordpress_persistent" + efs_volume_configuration { + file_system_id = aws_efs_file_system.wordpress_persistent.id + transit_encryption = "ENABLED" + authorization_config { + access_point_id = aws_efs_access_point.wordpress_efs.id + } + } + + } + tags = { + "Name" = "${var.site_name}_WordpressECS" + } + + depends_on = [ + aws_efs_file_system.wordpress_persistent + ] +} + +resource "aws_security_group" "wordpress_security_group" { + name = "${var.site_name}_wordpress_sg" + description = "security group for wordpress" + vpc_id = var.main_vpc_id +} + +resource "aws_security_group_rule" "wordpress_sg_ingress_80" { + security_group_id = aws_security_group.wordpress_security_group.id + type = "ingress" + from_port = 80 + to_port = 80 + protocol = "TCP" + #tfsec:ignore:AWS006 + cidr_blocks = ["0.0.0.0/0"] + description = "Allow ingress from world to Wordpress container" +} + +resource "aws_security_group_rule" "wordpress_sg_egress_2049" { + security_group_id = aws_security_group.wordpress_security_group.id + source_security_group_id = aws_security_group.efs_security_group.id + type = "egress" + from_port = 2049 + to_port = 2049 + protocol = "TCP" + description = "Egress to EFS mount from Wordpress container" +} + +resource "aws_security_group_rule" "wordpress_sg_egress_80" { + security_group_id = aws_security_group.wordpress_security_group.id + type = "egress" + from_port = 80 + to_port = 80 + protocol = "TCP" + #tfsec:ignore:AWS007 + cidr_blocks = ["0.0.0.0/0"] + description = "Egress from Wordpress container to world on HTTP" +} + +resource "aws_security_group_rule" "wordpress_sg_egress_443" { + security_group_id = aws_security_group.wordpress_security_group.id + type = "egress" + from_port = 443 + to_port = 443 + protocol = "TCP" + #tfsec:ignore:AWS007 + cidr_blocks = ["0.0.0.0/0"] + description = "Egress from Wordpress container to world on HTTPS" +} + + +resource "aws_security_group_rule" "wordpress_sg_egress_3306" { + security_group_id = aws_security_group.wordpress_security_group.id + type = "egress" + from_port = 3306 + to_port = 3306 + protocol = "TCP" + source_security_group_id = aws_security_group.aurora_serverless_group.id + description = "Egress from Wordpress container to Aurora Database" +} + + +resource "aws_ecs_service" "wordpress_service" { + name = "${var.site_name}_wordpress" + task_definition = "${aws_ecs_task_definition.wordpress_container.family}:${aws_ecs_task_definition.wordpress_container.revision}" + cluster = aws_ecs_cluster.wordpress_cluster.arn + desired_count = var.launch + # iam_role = + capacity_provider_strategy { + capacity_provider = "FARGATE_SPOT" + weight = "100" + base = "1" + } + propagate_tags = "SERVICE" + # Explicitly setting version here: https://stackoverflow.com/questions/62552562/one-or-more-of-the-requested-capabilities-are-not-supported-aws-fargate + platform_version = "1.4.0" + + network_configuration { + subnets = var.subnet_ids + security_groups = [aws_security_group.wordpress_security_group.id] + assign_public_ip = true + } +} + +# TODO: Add option to enable container insights +#tfsec:ignore:AWS090 +resource "aws_ecs_cluster" "wordpress_cluster" { + name = "${var.site_name}_wordpress" + capacity_providers = ["FARGATE_SPOT"] + default_capacity_provider_strategy { + capacity_provider = "FARGATE_SPOT" + weight = "100" + base = "1" + } +} diff --git a/main.tf b/main.tf new file mode 100644 index 0000000..00eda28 --- /dev/null +++ b/main.tf @@ -0,0 +1,43 @@ +module "lambda_slack" { + count = length(var.slack_webhook) > 5 ? 1 : 0 + source = "./modules/lambda_slack" + site_name = var.site_name + slack_webhook = var.slack_webhook + ecs_cluster_arn = aws_ecs_cluster.wordpress_cluster.arn +} + +module "codebuild" { + source = "./modules/codebuild" + site_name = var.site_name + site_domain = var.site_domain + codebuild_bucket = "${var.site_name}-build" + main_vpc_id = var.main_vpc_id + wordpress_ecr_repository = aws_ecr_repository.serverless_wordpress.name + aws_account_id = var.aws_account_id + container_memory = var.ecs_memory +} + +module "cloudfront" { + source = "./modules/cloudfront" + site_name = var.site_name + site_domain = var.site_domain + cloudfront_ssl = aws_acm_certificate.wordpress_site.arn + cloudfront_aliases = var.cloudfront_aliases + providers = { + aws.ue1 = aws.ue1 + } + depends_on = [aws_acm_certificate_validation.wordpress_site, + module.waf] + cloudfront_class = var.cloudfront_class + waf_acl_arn = module.waf[0].waf_acl_arn +} + +module "waf" { + count = var.waf_enabled ? 1 : 0 + source = "./modules/waf" + site_name = var.site_name + waf_acl_rules = var.waf_acl_rules + providers = { + aws.ue1 = aws.ue1 + } +} diff --git a/modules/cloudfront/.header.md b/modules/cloudfront/.header.md new file mode 100644 index 0000000..109d855 --- /dev/null +++ b/modules/cloudfront/.header.md @@ -0,0 +1,3 @@ +# cloudfront + +This module sets up the CloudFront distribution that fronts the static wordpress site. diff --git a/modules/cloudfront/README.md b/modules/cloudfront/README.md new file mode 100644 index 0000000..f28b636 --- /dev/null +++ b/modules/cloudfront/README.md @@ -0,0 +1,50 @@ + +# cloudfront + +This module sets up the CloudFront distribution that fronts the static wordpress site. + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [cloudfront\_aliases](#input\_cloudfront\_aliases) | The domain and sub-domain aliases to use for the cloudfront distribution. | `list(any)` | `[]` | no | +| [cloudfront\_class](#input\_cloudfront\_class) | The [price class](https://aws.amazon.com/cloudfront/pricing/) for the distribution. One of: PriceClass\_All, PriceClass\_200, PriceClass\_100 | `string` | `"PriceClass_All"` | no | +| [cloudfront\_ssl](#input\_cloudfront\_ssl) | The ARN of the ACM certificate used for the CloudFront domain. | `string` | n/a | yes | +| [site\_domain](#input\_site\_domain) | The site domain name to configure (without any subdomains such as 'www') | `string` | n/a | yes | +| [site\_name](#input\_site\_name) | The unique name for this instance of the module. Required to deploy multiple wordpress instances to the same AWS account (if desired). | `string` | n/a | yes | +| [site\_prefix](#input\_site\_prefix) | The subdomain prefix of the website domain. E.g. www | `string` | `"www"` | no | +| [waf\_acl\_arn](#input\_waf\_acl\_arn) | The ARN of the WAF ACL applied to the CloudFront distribution. | `string` | `null` | no | +## Modules + +No modules. +## Outputs + +| Name | Description | +|------|-------------| +| [wordpress\_bucket\_arn](#output\_wordpress\_bucket\_arn) | n/a | +| [wordpress\_bucket\_id](#output\_wordpress\_bucket\_id) | n/a | +| [wordpress\_cloudfront\_distribution\_domain\_name](#output\_wordpress\_cloudfront\_distribution\_domain\_name) | n/a | +| [wordpress\_cloudfront\_distrubtion\_hostedzone\_id](#output\_wordpress\_cloudfront\_distrubtion\_hostedzone\_id) | n/a | +## Requirements + +No requirements. +## Resources + +| Name | Type | +|------|------| +| [aws_cloudfront_distribution.wordpress_distribution](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_distribution) | resource | +| [aws_cloudfront_origin_access_identity.wordpress_distribution](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_origin_access_identity) | resource | +| [aws_cloudwatch_log_group.object_redirect](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_cloudwatch_log_group.object_redirect_ue1](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_cloudwatch_log_group.object_redirect_ue1_local](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_iam_role.lambda-edge](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy.lambda-edge-cloudwatch-logs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy_attachment.basic](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_lambda_function.object_redirect](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | +| [aws_s3_bucket.wordpress_bucket](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket) | resource | +| [aws_s3_bucket_policy.wordpress_bucket](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_policy) | resource | +| [aws_s3_bucket_public_access_block.wordpress_bucket](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_public_access_block) | resource | +| [archive_file.index_html](https://registry.terraform.io/providers/hashicorp/archive/latest/docs/data-sources/file) | data source | +| [aws_iam_policy_document.lambda-edge-cloudwatch-logs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.lambda-edge-service-role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | + diff --git a/modules/cloudfront/distribution.tf b/modules/cloudfront/distribution.tf new file mode 100644 index 0000000..9e79890 --- /dev/null +++ b/modules/cloudfront/distribution.tf @@ -0,0 +1,110 @@ +# TODO: Add optional logging for S3 bucket +# TODO: Add optional versioning for S3 bucket +#tfsec:ignore:AWS002 #tfsec:ignore:AWS077 +resource "aws_s3_bucket" "wordpress_bucket" { + bucket = "${var.site_prefix}.${var.site_domain}" + force_destroy = true + server_side_encryption_configuration { + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } + } +} + +resource "aws_s3_bucket_public_access_block" "wordpress_bucket" { + bucket = aws_s3_bucket.wordpress_bucket.id + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_cloudfront_origin_access_identity" "wordpress_distribution" { + comment = "${var.site_name} OAI for S3" +} + +# TODO: Add optional Access Logging configuration for Cloudfront +# TODO: Add optional WAF configuration in front of Cloudfront +#tfsec:ignore:AWS045 #tfsec:ignore:AWS071 +resource "aws_cloudfront_distribution" "wordpress_distribution" { + origin { + domain_name = aws_s3_bucket.wordpress_bucket.bucket_regional_domain_name + origin_id = "${var.site_name}_WordpressBucket" + + s3_origin_config { + origin_access_identity = aws_cloudfront_origin_access_identity.wordpress_distribution.cloudfront_access_identity_path + } + } + enabled = true + is_ipv6_enabled = true + comment = "${var.site_name} Distribution for Wordpress" + default_root_object = "index.html" + web_acl_id = var.waf_acl_arn + + aliases = var.cloudfront_aliases + + default_cache_behavior { + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + target_origin_id = "${var.site_name}_WordpressBucket" + compress = true + + forwarded_values { + query_string = true + + cookies { + forward = "none" + } + } + + lambda_function_association { + event_type = "origin-request" + lambda_arn = "${aws_lambda_function.object_redirect.arn}:${aws_lambda_function.object_redirect.version}" + } + + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 600 + max_ttl = 31536000 + } + + restrictions { + geo_restriction { + restriction_type = "none" + } + } + + price_class = var.cloudfront_class + + viewer_certificate { + minimum_protocol_version = "TLSv1.2_2019" + acm_certificate_arn = var.cloudfront_ssl + ssl_support_method = "sni-only" + + } + +} + +resource "aws_s3_bucket_policy" "wordpress_bucket" { + bucket = aws_s3_bucket.wordpress_bucket.id + + policy = jsonencode( + { + "Version" : "2008-10-17", + "Id" : "PolicyForCloudFrontPrivateContent", + "Statement" : [ + { + "Sid" : "1", + "Effect" : "Allow", + "Principal" : { + "AWS" : aws_cloudfront_origin_access_identity.wordpress_distribution.iam_arn + }, + "Action" : "s3:GetObject", + "Resource" : "${aws_s3_bucket.wordpress_bucket.arn}/*" + } + ] + } + ) +} diff --git a/modules/cloudfront/lambda_redirect/index_html/index.js b/modules/cloudfront/lambda_redirect/index_html/index.js new file mode 100644 index 0000000..44b6d6a --- /dev/null +++ b/modules/cloudfront/lambda_redirect/index_html/index.js @@ -0,0 +1,30 @@ +// Add index.html to any request URLs that end in / +// This allows "friendly" URLs in static websites, e.g. +// /about-us/ is converted to /about-us/index.html + +// https://aws.amazon.com/blogs/compute/implementing-default-directory-indexes-in-amazon-s3-backed-amazon-cloudfront-origins-using-lambdaedge/ + +'use strict'; +exports.handler = (event, context, callback) => { + // Extract the request from the CloudFront event that is sent to Lambda@Edge + var request = event.Records[0].cf.request; + + // Extract the URI from the request + var olduri = request.uri; + + // Match any '/' that occurs at the end of a URI. Replace it with a default index + // Match also any calls to 'index.php' which Wordpress would ordinarily look for + + var newuri = olduri.replace(/\/$/, '\/index.html'); + newuri = newuri.replace(/\/index.php$/, '\/index.html'); + + // For debugging: Log the URI as received by CloudFront and the new URI to be used to fetch from origin + // console.log("Old URI: " + olduri); + // console.log("New URI: " + newuri); + + // Replace the received URI with the URI that includes the index page + request.uri = newuri; + + // Return to CloudFront + return callback(null, request); +}; diff --git a/modules/cloudfront/main.tf b/modules/cloudfront/main.tf new file mode 100644 index 0000000..7659151 --- /dev/null +++ b/modules/cloudfront/main.tf @@ -0,0 +1,76 @@ +data "archive_file" "index_html" { + type = "zip" + source_dir = "${path.module}/lambda_redirect/index_html" + output_path = "${path.module}/lambda_redirect/dst/index_html.zip" +} + +#tfsec:ignore:AWS089 +resource "aws_cloudwatch_log_group" "object_redirect" { + name = "/aws/lambda/${var.site_name}_redirect_index_html" + retention_in_days = 7 +} + +#tfsec:ignore:AWS089 +resource "aws_cloudwatch_log_group" "object_redirect_ue1_local" { + name = "/aws/lambda/us-east-1.${var.site_name}_redirect_index_html" + retention_in_days = 7 +} + +# TODO: A solution to create/manage default log groups in all Edge Cache Regions +#tfsec:ignore:AWS089 +resource "aws_cloudwatch_log_group" "object_redirect_ue1" { + name = "/aws/lambda/us-east-1.${var.site_name}_redirect_index_html" + retention_in_days = 7 + provider = aws.ue1 +} + +resource "aws_lambda_function" "object_redirect" { + provider = aws.ue1 + filename = data.archive_file.index_html.output_path + function_name = "${var.site_name}_redirect_index_html" + role = aws_iam_role.lambda-edge.arn + handler = "index.handler" + source_code_hash = data.archive_file.index_html.output_base64sha256 + runtime = "nodejs12.x" + publish = true + memory_size = 128 + timeout = 3 + depends_on = [ + aws_cloudwatch_log_group.object_redirect, + aws_cloudwatch_log_group.object_redirect_ue1, + aws_cloudwatch_log_group.object_redirect_ue1_local + ] +} + +data "aws_iam_policy_document" "lambda-edge-service-role" { + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["edgelambda.amazonaws.com", "lambda.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "lambda-edge" { + name = "${var.site_name}-lambda-edge-service-role" + assume_role_policy = data.aws_iam_policy_document.lambda-edge-service-role.json +} + +resource "aws_iam_role_policy_attachment" "basic" { + role = aws_iam_role.lambda-edge.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +data "aws_iam_policy_document" "lambda-edge-cloudwatch-logs" { + statement { + actions = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"] + resources = ["arn:aws:logs:*:*:*"] + } +} + +resource "aws_iam_role_policy" "lambda-edge-cloudwatch-logs" { + name = "${var.site_name}-lambda-edge-cloudwatch-logs" + role = aws_iam_role.lambda-edge.name + policy = data.aws_iam_policy_document.lambda-edge-cloudwatch-logs.json +} diff --git a/modules/cloudfront/outputs.tf b/modules/cloudfront/outputs.tf new file mode 100644 index 0000000..095bb99 --- /dev/null +++ b/modules/cloudfront/outputs.tf @@ -0,0 +1,15 @@ +output "wordpress_bucket_id" { + value = aws_s3_bucket.wordpress_bucket.id +} + +output "wordpress_bucket_arn" { + value = aws_s3_bucket.wordpress_bucket.arn +} + +output "wordpress_cloudfront_distribution_domain_name" { + value = aws_cloudfront_distribution.wordpress_distribution.domain_name +} + +output "wordpress_cloudfront_distrubtion_hostedzone_id" { + value = aws_cloudfront_distribution.wordpress_distribution.hosted_zone_id +} diff --git a/modules/cloudfront/provider.tf b/modules/cloudfront/provider.tf new file mode 100644 index 0000000..aaae17a --- /dev/null +++ b/modules/cloudfront/provider.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + configuration_aliases = [aws.ue1] + } + } +} diff --git a/modules/cloudfront/variables.tf b/modules/cloudfront/variables.tf new file mode 100644 index 0000000..ad773fa --- /dev/null +++ b/modules/cloudfront/variables.tf @@ -0,0 +1,38 @@ +variable "site_domain" { + type = string + description = "The site domain name to configure (without any subdomains such as 'www')" +} + +variable "site_prefix" { + type = string + description = "The subdomain prefix of the website domain. E.g. www" + default = "www" +} + +variable "cloudfront_ssl" { + type = string + description = "The ARN of the ACM certificate used for the CloudFront domain." +} + +variable "site_name" { + type = string + description = "The unique name for this instance of the module. Required to deploy multiple wordpress instances to the same AWS account (if desired)." +} + +variable "cloudfront_aliases" { + type = list(any) + description = "The domain and sub-domain aliases to use for the cloudfront distribution." + default = [] +} + +variable "cloudfront_class" { + type = string + description = "The [price class](https://aws.amazon.com/cloudfront/pricing/) for the distribution. One of: PriceClass_All, PriceClass_200, PriceClass_100" + default = "PriceClass_All" +} + +variable "waf_acl_arn" { + type = string + default = null + description = "The ARN of the WAF ACL applied to the CloudFront distribution." +} diff --git a/modules/codebuild/.header.md b/modules/codebuild/.header.md new file mode 100644 index 0000000..aecfd99 --- /dev/null +++ b/modules/codebuild/.header.md @@ -0,0 +1,3 @@ +# codebuild + +This module sets up the build to take a vanilla Wordpress image and bake customisations into it for Serverless Static Wordpress. diff --git a/modules/codebuild/README.md b/modules/codebuild/README.md new file mode 100644 index 0000000..2dfab0f --- /dev/null +++ b/modules/codebuild/README.md @@ -0,0 +1,45 @@ + +# codebuild + +This module sets up the build to take a vanilla Wordpress image and bake customisations into it for Serverless Static Wordpress. + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [aws\_account\_id](#input\_aws\_account\_id) | The AWS account ID into which resources will be launched. | `number` | n/a | yes | +| [codebuild\_bucket](#input\_codebuild\_bucket) | The name of the bucket used for codebuild of the image. | `string` | n/a | yes | +| [container\_memory](#input\_container\_memory) | The memory allocated to the container (in MB) | `number` | n/a | yes | +| [main\_vpc\_id](#input\_main\_vpc\_id) | The VPC ID into which to launch resources. | `string` | n/a | yes | +| [site\_domain](#input\_site\_domain) | The site domain name to configure (without any subdomains such as 'www') | `string` | n/a | yes | +| [site\_name](#input\_site\_name) | The unique name for this instance of the module. Required to deploy multiple wordpress instances to the same AWS account (if desired). | `string` | n/a | yes | +| [wordpress\_ecr\_repository](#input\_wordpress\_ecr\_repository) | The ECR repository where the Wordpress image is stored. | `string` | n/a | yes | +## Modules + +No modules. +## Outputs + +| Name | Description | +|------|-------------| +| [codebuild\_package\_etag](#output\_codebuild\_package\_etag) | The etag of the codebuild package file. | +| [codebuild\_project\_name](#output\_codebuild\_project\_name) | The name of the created Wordpress codebuild project. | +## Requirements + +No requirements. +## Resources + +| Name | Type | +|------|------| +| [aws_cloudwatch_log_group.wordpress_docker_build](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_codebuild_project.wordpress_docker_build](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/codebuild_project) | resource | +| [aws_iam_role.codebuild_service_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy_attachment.codebuild_role_attachment](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_s3_bucket.code_source](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket) | resource | +| [aws_s3_bucket_object.wordpress_dockerbuild](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_object) | resource | +| [aws_s3_bucket_public_access_block.code_source](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_public_access_block) | resource | +| [aws_security_group.codebuild_security_group](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | +| [local_file.php_ini](https://registry.terraform.io/providers/hashicorp/local/latest/docs/resources/file) | resource | +| [archive_file.code_build_package](https://registry.terraform.io/providers/hashicorp/archive/latest/docs/data-sources/file) | data source | +| [aws_iam_policy_document.codebuild_assume_role_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_region.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/region) | data source | + diff --git a/modules/codebuild/codebuild_files/Dockerfile_serverless_wordpress b/modules/codebuild/codebuild_files/Dockerfile_serverless_wordpress new file mode 100644 index 0000000..8b09544 --- /dev/null +++ b/modules/codebuild/codebuild_files/Dockerfile_serverless_wordpress @@ -0,0 +1,12 @@ +ARG aws_account_id +ARG aws_region +ARG ecr_repo_name + +FROM ${aws_account_id}.dkr.ecr.${aws_region}.amazonaws.com/${ecr_repo_name}:base +COPY ["wp-cli.phar", "serverless-wordpress-wp2static.zip","serverless-wordpress-s3-addon.zip","/tmp/"] +COPY docker-entrypoint.sh /usr/local/bin/ +RUN apt-get update && apt-get install -y sudo jq awscli mariadb-client && chmod +x /usr/local/bin/docker-entrypoint.sh && chmod +x /tmp/wp-cli.phar && mv /tmp/wp-cli.phar /usr/local/bin/wp \ +&& rm -rf /var/lib/apt/lists/* + +RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" +COPY ["php.ini", "$PHP_INI_DIR/conf.d/"] diff --git a/modules/codebuild/codebuild_files/buildspec.yml b/modules/codebuild/codebuild_files/buildspec.yml new file mode 100644 index 0000000..5a181c7 --- /dev/null +++ b/modules/codebuild/codebuild_files/buildspec.yml @@ -0,0 +1,32 @@ +--- +version: 0.2 +phases: + install: + commands: + # yamllint disable-line rule:line-length + - nohup /usr/local/bin/dockerd --host=unix:///var/run/docker.sock --host=tcp://127.0.0.1:2375 --storage-driver=overlay2 & + - timeout 15 sh -c "until docker info; do echo .; sleep 1; done" + pre_build: + commands: + - echo Logging in to Amazon ECR... + - > + aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login + --username AWS --password-stdin + $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com + build: + commands: + - echo Build started on `date` + - echo Building the Docker image... + - > + docker build -t $IMAGE_REPO_NAME:$IMAGE_TAG --build-arg + aws_account_id=$AWS_ACCOUNT_ID --build-arg + aws_region=$AWS_DEFAULT_REGION --build-arg + ecr_repo_name=$IMAGE_REPO_NAME -f Dockerfile_serverless_wordpress . + # yamllint disable-line rule:line-length + - docker tag $IMAGE_REPO_NAME:$IMAGE_TAG $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG + post_build: + commands: + - echo Build completed on `date` + - echo Pushing the Docker image... + # yamllint disable-line rule:line-length + - docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG diff --git a/modules/codebuild/codebuild_files/docker-entrypoint.sh b/modules/codebuild/codebuild_files/docker-entrypoint.sh new file mode 100644 index 0000000..d7ee68e --- /dev/null +++ b/modules/codebuild/codebuild_files/docker-entrypoint.sh @@ -0,0 +1,336 @@ +#!/bin/bash +set -euo pipefail + +# usage: file_env VAR [DEFAULT] +# ie: file_env 'XYZ_DB_PASSWORD' 'example' +# (will allow for "$XYZ_DB_PASSWORD_FILE" to fill in the value of +# "$XYZ_DB_PASSWORD" from a file, especially for Docker's secrets feature) +file_env() { + local var="$1" + local fileVar="${var}_FILE" + local def="${2:-}" + if [ "${!var:-}" ] && [ "${!fileVar:-}" ]; then + echo >&2 "error: both $var and $fileVar are set (but are exclusive)" + exit 1 + fi + local val="$def" + if [ "${!var:-}" ]; then + val="${!var}" + elif [ "${!fileVar:-}" ]; then + val="$(< "${!fileVar}")" + fi + export "$var"="$val" + unset "$fileVar" +} + +if [[ "$1" == apache2* ]] || [ "$1" == php-fpm ]; then + if [ "$(id -u)" = '0' ]; then + case "$1" in + apache2*) + user="${APACHE_RUN_USER:-www-data}" + group="${APACHE_RUN_GROUP:-www-data}" + + # strip off any '#' symbol ('#1000' is valid syntax for Apache) + pound='#' + user="${user#$pound}" + group="${group#$pound}" + ;; + *) # php-fpm + user='www-data' + group='www-data' + ;; + esac + else + user="$(id -u)" + group="$(id -g)" + fi + + if [ ! -e index.php ] && [ ! -e wp-includes/version.php ]; then + # if the directory exists and WordPress doesn't appear to be installed AND the permissions of it are root:root, let's chown it (likely a Docker-created directory) + if [ "$(id -u)" = '0' ] && [ "$(stat -c '%u:%g' .)" = '0:0' ]; then + chown "$user:$group" . + fi + + echo >&2 "WordPress not found in $PWD - copying now..." + if [ -n "$(find -mindepth 1 -maxdepth 1 -not -name wp-content)" ]; then + echo >&2 "WARNING: $PWD is not empty! (copying anyhow)" + fi + sourceTarArgs=( + --create + --file - + --directory /usr/src/wordpress + --owner "$user" --group "$group" + ) + targetTarArgs=( + --extract + --file - + ) + if [ "$user" != '0' ]; then + # avoid "tar: .: Cannot utime: Operation not permitted" and "tar: .: Cannot change mode to rwxr-xr-x: Operation not permitted" + targetTarArgs+=( --no-overwrite-dir ) + fi + # loop over "pluggable" content in the source, and if it already exists in the destination, skip it + # https://github.com/docker-library/wordpress/issues/506 ("wp-content" persisted, "akismet" updated, WordPress container restarted/recreated, "akismet" downgraded) + for contentDir in /usr/src/wordpress/wp-content/*/*/; do + contentDir="${contentDir%/}" + [ -d "$contentDir" ] || continue + contentPath="${contentDir#/usr/src/wordpress/}" # "wp-content/plugins/akismet", etc. + if [ -d "$PWD/$contentPath" ]; then + echo >&2 "WARNING: '$PWD/$contentPath' exists! (not copying the WordPress version)" + sourceTarArgs+=( --exclude "./$contentPath" ) + fi + done + tar "${sourceTarArgs[@]}" . | tar "${targetTarArgs[@]}" + echo >&2 "Complete! WordPress has been successfully copied to $PWD" + if [ ! -e .htaccess ]; then + # NOTE: The "Indexes" option is disabled in the php:apache base image + cat > .htaccess <<-'EOF' + # BEGIN WordPress + + RewriteEngine On + RewriteBase / + RewriteRule ^index\.php$ - [L] + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule . /index.php [L] + + # END WordPress + EOF + chown "$user:$group" .htaccess + fi + fi + + # allow any of these "Authentication Unique Keys and Salts." to be specified via + # environment variables with a "WORDPRESS_" prefix (ie, "WORDPRESS_AUTH_KEY") + uniqueEnvs=( + AUTH_KEY + SECURE_AUTH_KEY + LOGGED_IN_KEY + NONCE_KEY + AUTH_SALT + SECURE_AUTH_SALT + LOGGED_IN_SALT + NONCE_SALT + ) + envs=( + WORDPRESS_DB_HOST + WORDPRESS_DB_USER + WORDPRESS_DB_PASSWORD + WORDPRESS_DB_NAME + WORDPRESS_DB_CHARSET + WORDPRESS_DB_COLLATE + "${uniqueEnvs[@]/#/WORDPRESS_}" + WORDPRESS_TABLE_PREFIX + WORDPRESS_DEBUG + WORDPRESS_CONFIG_EXTRA + ) + haveConfig= + for e in "${envs[@]}"; do + file_env "$e" + if [ -z "$haveConfig" ] && [ -n "${!e}" ]; then + haveConfig=1 + fi + done + + # linking backwards-compatibility + if [ -n "${!MYSQL_ENV_MYSQL_*}" ]; then + haveConfig=1 + # host defaults to "mysql" below if unspecified + : "${WORDPRESS_DB_USER:=${MYSQL_ENV_MYSQL_USER:-root}}" + if [ "$WORDPRESS_DB_USER" = 'root' ]; then + : "${WORDPRESS_DB_PASSWORD:=${MYSQL_ENV_MYSQL_ROOT_PASSWORD:-}}" + else + : "${WORDPRESS_DB_PASSWORD:=${MYSQL_ENV_MYSQL_PASSWORD:-}}" + fi + : "${WORDPRESS_DB_NAME:=${MYSQL_ENV_MYSQL_DATABASE:-}}" + fi + + # only touch "wp-config.php" if we have environment-supplied configuration values + if [ "$haveConfig" ]; then + : "${WORDPRESS_DB_HOST:=mysql}" + : "${WORDPRESS_DB_USER:=root}" + : "${WORDPRESS_DB_PASSWORD:=}" + : "${WORDPRESS_DB_NAME:=wordpress}" + : "${WORDPRESS_DB_CHARSET:=utf8}" + : "${WORDPRESS_DB_COLLATE:=}" + + # version 4.4.1 decided to switch to windows line endings, that breaks our seds and awks + # https://github.com/docker-library/wordpress/issues/116 + # https://github.com/WordPress/WordPress/commit/1acedc542fba2482bab88ec70d4bea4b997a92e4 + sed -ri -e 's/\r$//' wp-config* + + if [ ! -e wp-config.php ]; then + awk ' + /^\/\*.*stop editing.*\*\/$/ && c == 0 { + c = 1 + system("cat") + if (ENVIRON["WORDPRESS_CONFIG_EXTRA"]) { + print "// WORDPRESS_CONFIG_EXTRA" + print ENVIRON["WORDPRESS_CONFIG_EXTRA"] "\n" + } + } + { print } + ' wp-config-sample.php > wp-config.php <<'EOPHP' +// If we're behind a proxy server and using HTTPS, we need to alert WordPress of that fact +// see also http://codex.wordpress.org/Administration_Over_SSL#Using_a_Reverse_Proxy +if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') { + $_SERVER['HTTPS'] = 'on'; +} +EOPHP + chown "$user:$group" wp-config.php + elif [ -e wp-config.php ] && [ -n "$WORDPRESS_CONFIG_EXTRA" ] && [[ "$(< wp-config.php)" != *"$WORDPRESS_CONFIG_EXTRA"* ]]; then + # (if the config file already contains the requested PHP code, don't print a warning) + echo >&2 + echo >&2 'WARNING: environment variable "WORDPRESS_CONFIG_EXTRA" is set, but "wp-config.php" already exists' + echo >&2 ' The contents of this variable will _not_ be inserted into the existing "wp-config.php" file.' + echo >&2 ' (see https://github.com/docker-library/wordpress/issues/333 for more details)' + echo >&2 + fi + + # see http://stackoverflow.com/a/2705678/433558 + sed_escape_lhs() { + echo "$@" | sed -e 's/[]\/$*.^|[]/\\&/g' + } + sed_escape_rhs() { + echo "$@" | sed -e 's/[\/&]/\\&/g' + } + php_escape() { + local escaped="$(php -r 'var_export(('"$2"') $argv[1]);' -- "$1")" + if [ "$2" = 'string' ] && [ "${escaped:0:1}" = "'" ]; then + escaped="${escaped//$'\n'/"' + \"\\n\" + '"}" + fi + echo "$escaped" + } + set_config() { + key="$1" + value="$2" + var_type="${3:-string}" + start="(['\"])$(sed_escape_lhs "$key")\2\s*," + end="\);" + if [ "${key:0:1}" = '$' ]; then + start="^(\s*)$(sed_escape_lhs "$key")\s*=" + end=";" + fi + sed -ri -e "s/($start\s*).*($end)$/\1$(sed_escape_rhs "$(php_escape "$value" "$var_type")")\3/" wp-config.php + } + + set_config 'DB_HOST' "$WORDPRESS_DB_HOST" + set_config 'DB_USER' "$WORDPRESS_DB_USER" + set_config 'DB_PASSWORD' "$WORDPRESS_DB_PASSWORD" + set_config 'DB_NAME' "$WORDPRESS_DB_NAME" + set_config 'DB_CHARSET' "$WORDPRESS_DB_CHARSET" + set_config 'DB_COLLATE' "$WORDPRESS_DB_COLLATE" + + for unique in "${uniqueEnvs[@]}"; do + uniqVar="WORDPRESS_$unique" + if [ -n "${!uniqVar}" ]; then + set_config "$unique" "${!uniqVar}" + else + # if not specified, let's generate a random value + currentVal="$(sed -rn -e "s/define\(\s*(([\'\"])$unique\2\s*,\s*)(['\"])(.*)\3\s*\);/\4/p" wp-config.php)" + if [ "$currentVal" = 'put your unique phrase here' ]; then + set_config "$unique" "$(head -c1m /dev/urandom | sha1sum | cut -d' ' -f1)" + fi + fi + done + + if [ "$WORDPRESS_TABLE_PREFIX" ]; then + set_config '$table_prefix' "$WORDPRESS_TABLE_PREFIX" + fi + + if [ "$WORDPRESS_DEBUG" ]; then + set_config 'WP_DEBUG' 1 boolean + fi + + if ! TERM=dumb php -- <<'EOPHP' +connect_error) { + fwrite($stderr, "\n" . 'MySQL Connection Error: (' . $mysql->connect_errno . ') ' . $mysql->connect_error . "\n"); + --$maxTries; + if ($maxTries <= 0) { + exit(1); + } + sleep(3); + } +} while ($mysql->connect_error); +if (!$mysql->query('CREATE DATABASE IF NOT EXISTS `' . $mysql->real_escape_string($dbName) . '`')) { + fwrite($stderr, "\n" . 'MySQL "CREATE DATABASE" Error: ' . $mysql->error . "\n"); + $mysql->close(); + exit(1); +} +$mysql->close(); +EOPHP + then + echo >&2 + echo >&2 "WARNING: unable to establish a database connection to '$WORDPRESS_DB_HOST'" + echo >&2 ' continuing anyways (which might have unexpected results)' + echo >&2 + fi + fi + + # now that we're definitely done writing configuration, let's clear out the relevant environment variables (so that stray "phpinfo()" calls don't leak secrets from our code) + for e in "${envs[@]}"; do + unset "$e" + done +fi + +# echo $(curl ${ECS_CONTAINER_METADATA_URI_V4}/task || true) +# Get IPv4 of Running container +container_ip=$(curl ${ECS_CONTAINER_METADATA_URI_V4}/task | jq -r '.Containers[0].Networks[0].IPv4Addresses[0]') || true +echo "Private IP is: $container_ip" + +# Look up Public IP from network interface +public_ip=$(aws ec2 describe-network-interfaces --filters Name=addresses.private-ip-address,Values=$container_ip --query 'NetworkInterfaces[0].Association.PublicIp' --region $WPSTATIC_REGION --output=text) || true +echo "Public IP is: $public_ip" + +# Update DNS record for domain +update_response=$(aws route53 change-resource-record-sets --hosted-zone-id ${CONTAINER_DNS_ZONE} --change-batch "{ \"Comment\": \"Update wordpress endpoint with public IP\", \"Changes\": [ { \"Action\": \"UPSERT\", \"ResourceRecordSet\": { \"Name\": \"${CONTAINER_DNS}\", \"Type\": \"A\", \"TTL\": "60", \"ResourceRecords\": [ { \"Value\": \"${public_ip}\" } ] } } ]}" --region $WPSTATIC_REGION) +echo "$update_response" + +# Check if first time launch and install basic site if so +if ! sudo -u www-data wp core is-installed; then + sudo -u www-data wp core install --url=http://${CONTAINER_DNS} --title=Wordpress --admin_user=${WORDPRESS_ADMIN_USER} --admin_password=${WORDPRESS_ADMIN_PASSWORD} --admin_email=${WORDPRESS_ADMIN_EMAIL} --skip-email +fi +# List installed plugins to log +echo "$(sudo -u www-data wp plugin list)" +# Install WP2Static and S3 Add-on +if ! sudo -u www-data wp plugin is-installed wp2static; then + sudo -u www-data wp plugin install /tmp/serverless-wordpress-wp2static.zip --activate --path=/var/www/html || true +fi +if ! sudo -u www-data wp plugin is-installed wp2static-addon-s3; then + sudo -u www-data wp plugin install /tmp/serverless-wordpress-s3-addon.zip --activate --path=/var/www/html || true +fi +# # Update Wordpress options with IP of running container +sudo -u www-data wp option update siteurl "http://${CONTAINER_DNS}" || true +sudo -u www-data wp option update home "http://${CONTAINER_DNS}" || true + +# If environment variables for S3 static output is set, populate it in the plugin +if [ "${WPSTATIC_DEST-}" ]; then + sudo -u www-data wp wp2static options set deploymentURL $WPSTATIC_DEST || true +fi +if [ "${WPSTATIC_REGION-}" ]; then + sudo -u www-data wp db query "UPDATE wp_wp2static_addon_s3_options SET value = '$WPSTATIC_REGION' WHERE name = 's3Region';" +fi +if [ "${WPSTATIC_BUCKET-}" ]; then + sudo -u www-data wp db query "UPDATE wp_wp2static_addon_s3_options SET value = '$WPSTATIC_BUCKET' WHERE name = 's3Bucket';" +fi + +exec "$@" diff --git a/modules/codebuild/codebuild_files/serverless-wordpress-s3-addon.zip b/modules/codebuild/codebuild_files/serverless-wordpress-s3-addon.zip new file mode 100644 index 0000000..a31de89 Binary files /dev/null and b/modules/codebuild/codebuild_files/serverless-wordpress-s3-addon.zip differ diff --git a/modules/codebuild/codebuild_files/serverless-wordpress-wp2static.zip b/modules/codebuild/codebuild_files/serverless-wordpress-wp2static.zip new file mode 100644 index 0000000..cb76453 Binary files /dev/null and b/modules/codebuild/codebuild_files/serverless-wordpress-wp2static.zip differ diff --git a/modules/codebuild/codebuild_files/wp-cli.phar b/modules/codebuild/codebuild_files/wp-cli.phar new file mode 100644 index 0000000..137e383 Binary files /dev/null and b/modules/codebuild/codebuild_files/wp-cli.phar differ diff --git a/modules/codebuild/main.tf b/modules/codebuild/main.tf new file mode 100644 index 0000000..c70677a --- /dev/null +++ b/modules/codebuild/main.tf @@ -0,0 +1,150 @@ +data "aws_region" "current" {} + +# TODO: Add optional logging for S3 bucket +# TODO: Add optional versioning for S3 bucket +#tfsec:ignore:AWS002 #tfsec:ignore:AWS077 +resource "aws_s3_bucket" "code_source" { + bucket = var.codebuild_bucket + acl = "private" + force_destroy = true + server_side_encryption_configuration { + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } + } +} + +resource "aws_s3_bucket_public_access_block" "code_source" { + bucket = aws_s3_bucket.code_source.id + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +data "archive_file" "code_build_package" { + type = "zip" + output_path = "${path.module}/codebuild_files/wordpress_docker.zip" + excludes = ["wordpress_docker.zip"] + source_dir = "${path.module}/codebuild_files/" + depends_on = [ + local_file.php_ini + ] +} + +data "aws_iam_policy_document" "codebuild_assume_role_policy" { + statement { + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["codebuild.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "codebuild_service_role" { + name = "${var.site_name}_CodeBuildServiceRole" + assume_role_policy = data.aws_iam_policy_document.codebuild_assume_role_policy.json +} + +resource "aws_iam_role_policy_attachment" "codebuild_role_attachment" { + role = aws_iam_role.codebuild_service_role.name + policy_arn = "arn:aws:iam::aws:policy/PowerUserAccess" +} + +resource "aws_s3_bucket_object" "wordpress_dockerbuild" { + bucket = aws_s3_bucket.code_source.id + key = "wordpress_docker.zip" + source = "${path.module}/codebuild_files/wordpress_docker.zip" + etag = filemd5("${path.module}/codebuild_files/wordpress_docker.zip") +} + +resource "aws_security_group" "codebuild_security_group" { + name = "${var.site_name}_codebuild_sg" + description = "security group for codebuild" + vpc_id = var.main_vpc_id + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + #tfsec:ignore:AWS009 + cidr_blocks = ["0.0.0.0/0"] + } +} + +#tfsec:ignore:AWS089 +resource "aws_cloudwatch_log_group" "wordpress_docker_build" { + name = "/aws/codebuild/${var.site_name}-serverless-wordpress-docker-build" + retention_in_days = 7 +} + +resource "aws_codebuild_project" "wordpress_docker_build" { + name = "${var.site_name}-serverless-wordpress-docker-build" + description = "Builds an image of wordpress in docker" + build_timeout = "5" + service_role = aws_iam_role.codebuild_service_role.arn + + + artifacts { + type = "NO_ARTIFACTS" + } + + cache { + type = "S3" + # Requires ref by name rather than resource: https://github.com/hashicorp/terraform-provider-aws/issues/10195 + location = var.codebuild_bucket + } + + environment { + compute_type = "BUILD_GENERAL1_SMALL" + image = "aws/codebuild/standard:4.0" + type = "LINUX_CONTAINER" + image_pull_credentials_type = "CODEBUILD" + privileged_mode = true + + environment_variable { + name = "AWS_DEFAULT_REGION" + value = data.aws_region.current.name + } + environment_variable { + name = "AWS_ACCOUNT_ID" + value = var.aws_account_id + } + environment_variable { + name = "IMAGE_REPO_NAME" + value = var.wordpress_ecr_repository + } + environment_variable { + name = "IMAGE_TAG" + value = "latest" + } + } + + logs_config { + cloudwatch_logs { + status = "ENABLED" + group_name = aws_cloudwatch_log_group.wordpress_docker_build.name + } + } + + source { + type = "S3" + location = "${aws_s3_bucket.code_source.id}/${aws_s3_bucket_object.wordpress_dockerbuild.id}" + + } +} + +resource "local_file" "php_ini" { + content = <<-EOT + upload_max_filesize=64M + post_max_size=64M + max_execution_time=0 + max_input_vars=2000 + memory_limit=${var.container_memory}M + EOT + filename = "${path.module}/codebuild_files/php.ini" +} diff --git a/modules/codebuild/outputs.tf b/modules/codebuild/outputs.tf new file mode 100644 index 0000000..ba3cd11 --- /dev/null +++ b/modules/codebuild/outputs.tf @@ -0,0 +1,9 @@ +output "codebuild_project_name" { + value = aws_codebuild_project.wordpress_docker_build.name + description = "The name of the created Wordpress codebuild project." +} + +output "codebuild_package_etag" { + value = filemd5("${path.module}/codebuild_files/wordpress_docker.zip") + description = "The etag of the codebuild package file." +} diff --git a/modules/codebuild/provider.tf b/modules/codebuild/provider.tf new file mode 100644 index 0000000..f2702bf --- /dev/null +++ b/modules/codebuild/provider.tf @@ -0,0 +1,7 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + } + } +} diff --git a/modules/codebuild/variables.tf b/modules/codebuild/variables.tf new file mode 100644 index 0000000..f05c228 --- /dev/null +++ b/modules/codebuild/variables.tf @@ -0,0 +1,38 @@ +variable "codebuild_bucket" { + type = string + description = "The name of the bucket used for codebuild of the image. " +} + +variable "main_vpc_id" { + type = string + description = "The VPC ID into which to launch resources." + validation { + condition = length(var.main_vpc_id) > 4 && substr(var.main_vpc_id, 0, 4) == "vpc-" + error_message = "The main_vpc_id value must be a valid VPC id, starting with \"vpc-\"." + } +} + +variable "wordpress_ecr_repository" { + type = string + description = "The ECR repository where the Wordpress image is stored." +} + +variable "aws_account_id" { + type = number + description = "The AWS account ID into which resources will be launched." +} + +variable "site_domain" { + type = string + description = "The site domain name to configure (without any subdomains such as 'www')" +} + +variable "site_name" { + type = string + description = "The unique name for this instance of the module. Required to deploy multiple wordpress instances to the same AWS account (if desired)." +} + +variable "container_memory" { + type = number + description = "The memory allocated to the container (in MB)" +} diff --git a/modules/lambda_slack/.header.md b/modules/lambda_slack/.header.md new file mode 100644 index 0000000..19f1791 --- /dev/null +++ b/modules/lambda_slack/.header.md @@ -0,0 +1,3 @@ +# lambda-slack + +This module sets up a Lambda function to notify Slack of events happening in ECR and RDS. diff --git a/modules/lambda_slack/README.md b/modules/lambda_slack/README.md new file mode 100644 index 0000000..6383c21 --- /dev/null +++ b/modules/lambda_slack/README.md @@ -0,0 +1,46 @@ + +# lambda-slack + +This module sets up a Lambda function to notify Slack of events happening in ECR and RDS. + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [ecs\_cluster\_arn](#input\_ecs\_cluster\_arn) | The ARN of the ECS cluster where events are being monitored. | `string` | n/a | yes | +| [site\_name](#input\_site\_name) | Unique internal name for the site. | `string` | n/a | yes | +| [slack\_webhook](#input\_slack\_webhook) | The Slack webhook URL where ECS Cluster EventBridge notifications will be sent. | `string` | n/a | yes | +## Modules + +No modules. +## Outputs + +No outputs. +## Requirements + +No requirements. +## Resources + +| Name | Type | +|------|------| +| [aws_cloudwatch_event_rule.ecs_wordpress_instance_state](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | +| [aws_cloudwatch_event_rule.ecs_wordpress_service_deployment_state](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | +| [aws_cloudwatch_event_rule.ecs_wordpress_task_state](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | +| [aws_cloudwatch_event_rule.rds_wordpress_cluster_state](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | +| [aws_cloudwatch_event_target.lambda_slack_cluster_state](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | +| [aws_cloudwatch_event_target.lambda_slack_deployment_state](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | +| [aws_cloudwatch_event_target.lambda_slack_instance_state](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | +| [aws_cloudwatch_event_target.lambda_slack_task_state](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | +| [aws_cloudwatch_log_group.lambda_slack](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_iam_role.lambda](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy.lambda-cloudwatch-logs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy_attachment.lambda_basic](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_lambda_function.lambda_slack](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | +| [aws_lambda_permission.allow_rule_cluster_state](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | +| [aws_lambda_permission.allow_rule_deployment_state](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | +| [aws_lambda_permission.allow_rule_instance_state](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | +| [aws_lambda_permission.allow_rule_task_state](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | +| [archive_file.lambda_slack](https://registry.terraform.io/providers/hashicorp/archive/latest/docs/data-sources/file) | data source | +| [aws_iam_policy_document.lambda-cloudwatch-logs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.lambda-service-role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | + diff --git a/modules/lambda_slack/lambda_slack/dst/lambda_slack.zip b/modules/lambda_slack/lambda_slack/dst/lambda_slack.zip new file mode 100644 index 0000000..8f27bf8 Binary files /dev/null and b/modules/lambda_slack/lambda_slack/dst/lambda_slack.zip differ diff --git a/modules/lambda_slack/lambda_slack/function/index.py b/modules/lambda_slack/lambda_slack/function/index.py new file mode 100644 index 0000000..f0c4c87 --- /dev/null +++ b/modules/lambda_slack/lambda_slack/function/index.py @@ -0,0 +1,68 @@ +from __future__ import print_function + +import boto3 +import json +import logging +import os + +from base64 import b64decode +from urllib.request import Request, urlopen +from urllib.error import URLError, HTTPError + +# # value of the CiphertextBlob key in output of $ aws kms encrypt --key-id alias/ --plaintext "" +# ENCRYPTED_HOOK_URL = "CiC9..." +# HOOK_URL = "https://" + boto3.client('kms').decrypt(CiphertextBlob=b64decode(ENCRYPTED_HOOK_URL))['Plaintext'] + +# HOOK_URL = "https://" + boto3.client('kms').decrypt(CiphertextBlob=b64decode(ENCRYPTED_HOOK_URL))['Plaintext'] + +logger = logging.getLogger() +logger.setLevel(logging.INFO) +HOOK_URL = region = os.environ['HOOK_URL'] +SLACK_CHANNEL = "wordpress-alerts" + +def handler(event, context): + logger.info("Event: " + str(event)) + # message = json.loads(str(event['Records'][0]['Sns'])) + # logger.info("Message: " + str(message)) + + if event["detail-type"] == "ECS Deployment State Change": + event_type = event["detail"]["eventType"] + event_name = event["detail"]["eventName"] + message_text = "ECS Deployment Change: " + event_type + ": " + event_name + elif event["detail-type"] == "ECS Service Action": + event_type = event["detail"]["eventType"] + event_name = event["detail"]["eventName"] + message_text = "ECS Service Status Change: " + event_type + ": " + event_name + elif event["detail-type"] == "ECS Task State Change": + event_id = event["detail"]["taskArn"] + desired_status = event_type = event["detail"]["desiredStatus"] + launch_type = event_type = event["detail"]["launchType"] + last_status = event_type = event["detail"]["lastStatus"] + message_text = "ECS Task Status Change: " + launch_type + ":" + event_id + ": Desired was " + desired_status + " and last was " + last_status + elif event["detail-type"] == "ECS Container Instance State Change": + event_id = event["detail"]["containerInstanceArn"] + event_status = event_id = event["detail"]["status"] + message_text = "Container Instance State Change: " + event_id + ":" + event_status + elif event["detail-type"] == "RDS DB Cluster Event": + event_id = event["detail"]["SourceIdentifier"] + event_message = event_id = event["detail"]["Message"] + message_text = "RDS Cluster State Change: " + event_id + ":" + event_message + + else: + raise ValueError("detail-type for event is not a supported type. Exiting without notifying event.") + + + slack_message = { + 'channel': SLACK_CHANNEL, + 'text': message_text + } + + req = Request(HOOK_URL, json.dumps(slack_message).encode('utf-8')) + try: + response = urlopen(req) + response.read() + logger.info("Message posted to %s", slack_message['channel']) + except HTTPError as e: + logger.error("Request failed: %d %s", e.code, e.reason) + except URLError as e: + logger.error("Server connection failed: %s", e.reason) diff --git a/modules/lambda_slack/main.tf b/modules/lambda_slack/main.tf new file mode 100644 index 0000000..0879609 --- /dev/null +++ b/modules/lambda_slack/main.tf @@ -0,0 +1,239 @@ +data "archive_file" "lambda_slack" { + type = "zip" + source_dir = "${path.module}/lambda_slack/function" + output_path = "${path.module}/lambda_slack/dst/lambda_slack.zip" +} + +# resource "aws_security_group" "lambda_slack_security_group" { +# name = "lamba_slack_sg" +# description = "security group for Lamba Slack" +# vpc_id = var.main_vpc_id + +# egress { +# from_port = 0 +# to_port = 0 +# protocol = "-1" +# cidr_blocks = ["0.0.0.0/0"] +# } +# } + +#tfsec:ignore:AWS089 +resource "aws_cloudwatch_log_group" "lambda_slack" { + name = "/aws/lambda/${var.site_name}_lambda_slack" + retention_in_days = 7 +} + +resource "aws_lambda_function" "lambda_slack" { + filename = data.archive_file.lambda_slack.output_path + function_name = "${var.site_name}_lambda_slack" + role = aws_iam_role.lambda.arn + handler = "index.handler" + source_code_hash = data.archive_file.lambda_slack.output_base64sha256 + runtime = "python3.8" + environment { + variables = { + HOOK_URL = var.slack_webhook + } + } + publish = true + memory_size = 128 + timeout = 3 + # vpc_config { + # subnet_ids = var.subnet_ids + # security_group_ids = [ aws_security_group.lambda_slack_security_group.id ] + # } + depends_on = [aws_cloudwatch_log_group.lambda_slack] +} + +data "aws_iam_policy_document" "lambda-service-role" { + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "lambda" { + name = "${var.site_name}-lambda-service-role" + assume_role_policy = data.aws_iam_policy_document.lambda-service-role.json +} + +resource "aws_iam_role_policy_attachment" "lambda_basic" { + role = aws_iam_role.lambda.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" +} + +data "aws_iam_policy_document" "lambda-cloudwatch-logs" { + statement { + actions = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"] + resources = ["arn:aws:logs:*:*:*"] + } +} + +resource "aws_iam_role_policy" "lambda-cloudwatch-logs" { + name = "${var.site_name}-lambda-cloudwatch-logs" + role = aws_iam_role.lambda.name + policy = data.aws_iam_policy_document.lambda-cloudwatch-logs.json +} + +resource "aws_cloudwatch_event_rule" "ecs_wordpress_task_state" { + name = "${var.site_name}-ecs-wordpress-task-state" + description = "Event on Wordpress ECS Task State" + + event_pattern = jsonencode( + { + "source" : [ + "aws.ecs" + ], + "detail-type" : [ + "ECS Task State Change" + ], + "detail" : { + "clusterArn" : [ + var.ecs_cluster_arn + ] + } + } + ) +} + +resource "aws_cloudwatch_event_rule" "ecs_wordpress_instance_state" { + name = "${var.site_name}-ecs-wordpress-instance-state" + description = "Event on Wordpress ECS Instance State" + + event_pattern = jsonencode( + { + "source" : [ + "aws.ecs" + ], + "detail-type" : [ + "ECS Container Instance State Change" + ], + "detail" : { + "clusterArn" : [ + var.ecs_cluster_arn + ] + } + } + ) +} + +# resource "aws_cloudwatch_event_rule" "ecs_wordpress_service_action" { +# name = "ecs-wordpress-service-action" +# description = "Event on Wordpress ECS Service Action" + +# event_pattern = jsonencode( +# { +# "source": [ +# "aws.ecs" +# ], +# "detail-type": [ +# "ECS Service Action" +# ], +# "detail": { +# "clusterArn": [ +# aws_ecs_cluster.wordpress_cluster.arn +# ] +# } +# } +# ) +# } + +resource "aws_cloudwatch_event_rule" "ecs_wordpress_service_deployment_state" { + name = "${var.site_name}-ecs-wordpress-deployment-state" + description = "Event on Wordpress ECS Deployment State" + + event_pattern = jsonencode( + { + "source" : [ + "aws.ecs" + ], + "detail-type" : [ + "ECS Deployment State Change" + ] + } + ) +} + +resource "aws_cloudwatch_event_rule" "rds_wordpress_cluster_state" { + name = "${var.site_name}-rds-wordpress-cluster-state" + description = "Event on Wordpress RDS cluster State" + + event_pattern = jsonencode( + { + "source" : [ + "aws.rds" + ], + "detail-type" : [ + "RDS DB Cluster Event" + ] + } + ) +} + +resource "aws_cloudwatch_event_target" "lambda_slack_task_state" { + arn = aws_lambda_function.lambda_slack.arn + rule = aws_cloudwatch_event_rule.ecs_wordpress_task_state.id +} + +# resource "aws_cloudwatch_event_target" "lambda_slack_service_action" { +# arn = aws_lambda_function.lambda_slack.arn +# rule = aws_cloudwatch_event_rule.ecs_wordpress_service_action.id +# } + +resource "aws_cloudwatch_event_target" "lambda_slack_instance_state" { + arn = aws_lambda_function.lambda_slack.arn + rule = aws_cloudwatch_event_rule.ecs_wordpress_instance_state.id +} + +resource "aws_cloudwatch_event_target" "lambda_slack_deployment_state" { + arn = aws_lambda_function.lambda_slack.arn + rule = aws_cloudwatch_event_rule.ecs_wordpress_service_deployment_state.id +} + +resource "aws_cloudwatch_event_target" "lambda_slack_cluster_state" { + arn = aws_lambda_function.lambda_slack.arn + rule = aws_cloudwatch_event_rule.rds_wordpress_cluster_state.id +} + +resource "aws_lambda_permission" "allow_rule_task_state" { + statement_id = "AllowExecutionFromECSTaskState" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.lambda_slack.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.ecs_wordpress_task_state.arn +} + +# resource "aws_lambda_permission" "allow_rule_service_action" { +# statement_id = "AllowExecutionFromECSServiceAction" +# action = "lambda:InvokeFunction" +# function_name = aws_lambda_function.lambda_slack.function_name +# principal = "events.amazonaws.com" +# source_arn = aws_cloudwatch_event_rule.ecs_wordpress_service_action.arn +# } + +resource "aws_lambda_permission" "allow_rule_instance_state" { + statement_id = "AllowExecutionFromECSInstanceState" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.lambda_slack.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.ecs_wordpress_instance_state.arn +} + +resource "aws_lambda_permission" "allow_rule_deployment_state" { + statement_id = "AllowExecutionFromECSDeploymentState" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.lambda_slack.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.ecs_wordpress_service_deployment_state.arn +} + +resource "aws_lambda_permission" "allow_rule_cluster_state" { + statement_id = "AllowExecutionFromRDSClusterState" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.lambda_slack.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.rds_wordpress_cluster_state.arn +} diff --git a/modules/lambda_slack/provider.tf b/modules/lambda_slack/provider.tf new file mode 100644 index 0000000..f2702bf --- /dev/null +++ b/modules/lambda_slack/provider.tf @@ -0,0 +1,7 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + } + } +} diff --git a/modules/lambda_slack/variables.tf b/modules/lambda_slack/variables.tf new file mode 100644 index 0000000..fd29b8f --- /dev/null +++ b/modules/lambda_slack/variables.tf @@ -0,0 +1,12 @@ +variable "site_name" { + type = string + description = "Unique internal name for the site." +} +variable "slack_webhook" { + type = string + description = "The Slack webhook URL where ECS Cluster EventBridge notifications will be sent." +} +variable "ecs_cluster_arn" { + type = string + description = "The ARN of the ECS cluster where events are being monitored." +} diff --git a/modules/waf/.header.md b/modules/waf/.header.md new file mode 100644 index 0000000..a908fd7 --- /dev/null +++ b/modules/waf/.header.md @@ -0,0 +1,4 @@ +# WAF + +This module creates a minimal WAF with appropriate AWS-managed Rule Groups, but +allows for rule-definition override in the event you wish to customize further. diff --git a/modules/waf/README.md b/modules/waf/README.md new file mode 100755 index 0000000..8487002 --- /dev/null +++ b/modules/waf/README.md @@ -0,0 +1,29 @@ + +# WAF + +This module creates a minimal WAF with appropriate AWS-managed Rule Groups, but +allows for rule-definition override in the event you wish to customize further. + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [site\_name](#input\_site\_name) | The unique name for this instance of the module. Required to deploy multiple wordpress instances to the same AWS account (if desired). | `string` | n/a | yes | +| [waf\_acl\_rules](#input\_waf\_acl\_rules) | List of WAF rules to apply. Can be customized to apply others created outside of module. | `list(any)` | n/a | yes | +## Modules + +No modules. +## Outputs + +| Name | Description | +|------|-------------| +| [waf\_acl\_arn](#output\_waf\_acl\_arn) | n/a | +## Requirements + +No requirements. +## Resources + +| Name | Type | +|------|------| +| [aws_wafv2_web_acl.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/wafv2_web_acl) | resource | + diff --git a/modules/waf/main.tf b/modules/waf/main.tf new file mode 100644 index 0000000..af644fc --- /dev/null +++ b/modules/waf/main.tf @@ -0,0 +1,38 @@ +resource "aws_wafv2_web_acl" "default" { + provider = aws.ue1 + name = "${var.site_name}-WAF" + description = "${var.site_name} WAF" + scope = "CLOUDFRONT" + + default_action { + allow {} + } + + visibility_config { + sampled_requests_enabled = true + cloudwatch_metrics_enabled = true + metric_name = "${var.site_name}-WAF" + } + + dynamic "rule" { + for_each = toset(var.waf_acl_rules) + content { + name = rule.value.name + priority = rule.value.priority + override_action { + none {} + } + statement { + managed_rule_group_statement { + name = rule.value.managed_rule_group_name + vendor_name = rule.value.vendor_name + } + } + visibility_config { + cloudwatch_metrics_enabled = rule.value.cloudwatch_metrics_enabled + metric_name = rule.value.metric_name + sampled_requests_enabled = rule.value.sampled_requests_enabled + } + } + } +} diff --git a/modules/waf/outputs.tf b/modules/waf/outputs.tf new file mode 100644 index 0000000..cfd46cd --- /dev/null +++ b/modules/waf/outputs.tf @@ -0,0 +1,3 @@ +output "waf_acl_arn" { + value = aws_wafv2_web_acl.default.arn +} diff --git a/modules/waf/provider.tf b/modules/waf/provider.tf new file mode 100644 index 0000000..aaae17a --- /dev/null +++ b/modules/waf/provider.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + configuration_aliases = [aws.ue1] + } + } +} diff --git a/modules/waf/variables.tf b/modules/waf/variables.tf new file mode 100644 index 0000000..9d1a467 --- /dev/null +++ b/modules/waf/variables.tf @@ -0,0 +1,9 @@ +variable "site_name" { + type = string + description = "The unique name for this instance of the module. Required to deploy multiple wordpress instances to the same AWS account (if desired)." +} + +variable "waf_acl_rules" { + type = list(any) + description = "List of WAF rules to apply. Can be customized to apply others created outside of module." +} diff --git a/outputs.tf b/outputs.tf new file mode 100644 index 0000000..ea44bdc --- /dev/null +++ b/outputs.tf @@ -0,0 +1,19 @@ +output "cloudfront_ssl_arn" { + value = aws_acm_certificate.wordpress_site.arn + description = "The ARN of the ACM certificate used by CloudFront." +} + +output "wordpress_ecr_repository" { + value = aws_ecr_repository.serverless_wordpress.name + description = "The name of the ECR repository where wordpress image is stored." +} + +output "codebuild_project_name" { + value = module.codebuild.codebuild_project_name + description = "The name of the created Wordpress codebuild project." +} + +output "codebuild_package_etag" { + value = module.codebuild.codebuild_package_etag + description = "The etag of the codebuild package file." +} diff --git a/provider.tf b/provider.tf new file mode 100644 index 0000000..4766ad4 --- /dev/null +++ b/provider.tf @@ -0,0 +1,16 @@ +terraform { + required_version = ">= 0.15.1" + required_providers { + aws = { + source = "hashicorp/aws" + # https://github.com/hashicorp/terraform-provider-aws/blob/main/CHANGELOG.md + version = "~> 3.0" + configuration_aliases = [aws.ue1] + } + random = { + source = "hashicorp/random" + # https://github.com/hashicorp/terraform-provider-random/blob/main/CHANGELOG.md + version = "~> 3.1.0" + } + } +} diff --git a/r53.tf b/r53.tf new file mode 100644 index 0000000..bc15ba9 --- /dev/null +++ b/r53.tf @@ -0,0 +1,18 @@ +resource "aws_route53_record" "www" { + zone_id = var.hosted_zone_id + name = "www" + type = "CNAME" + ttl = "600" + records = [var.site_domain] +} + +resource "aws_route53_record" "apex" { + zone_id = var.hosted_zone_id + name = var.site_domain + type = "A" + alias { + name = module.cloudfront.wordpress_cloudfront_distribution_domain_name + zone_id = module.cloudfront.wordpress_cloudfront_distrubtion_hostedzone_id + evaluate_target_health = false + } +} diff --git a/rds.tf b/rds.tf new file mode 100644 index 0000000..3b99683 --- /dev/null +++ b/rds.tf @@ -0,0 +1,67 @@ +resource "random_password" "serverless_wordpress_password" { + length = 16 + special = true + override_special = "_%@" +} + +resource "aws_security_group" "aurora_serverless_group" { + name = "${var.site_domain}_aurora_mysql_sg" + description = "security group for serverless wordpress mysql aurora" + vpc_id = var.main_vpc_id +} + +resource "aws_security_group_rule" "aurora_sg_ingress_3306" { + security_group_id = aws_security_group.aurora_serverless_group.id + type = "ingress" + from_port = 3306 + to_port = 3306 + protocol = "TCP" + source_security_group_id = aws_security_group.wordpress_security_group.id + description = "Ingress on mySQL port to Aurora Serverless" +} + +resource "aws_db_subnet_group" "main_vpc" { + name = "${var.site_name}_main" + subnet_ids = var.subnet_ids + + tags = { + Name = "${var.site_domain} Subnet group for main VPC" + } +} + +resource "random_id" "rds_snapshot" { + byte_length = 8 +} + +#tfsec:ignore:AWS089 +resource "aws_cloudwatch_log_group" "serverless_wordpress" { + name = "/aws/rds/cluster/${var.site_name}-serverless-wordpress/error" + retention_in_days = 7 +} + +resource "aws_rds_cluster" "serverless_wordpress" { + vpc_security_group_ids = [aws_security_group.aurora_serverless_group.id] + db_subnet_group_name = aws_db_subnet_group.main_vpc.name + cluster_identifier = "${var.site_name}-serverless-wordpress" + engine = "aurora-mysql" + engine_version = "5.7.mysql_aurora.2.07.1" + engine_mode = "serverless" + database_name = "wordpress" + master_username = "wp_master" + enable_http_endpoint = true + iam_database_authentication_enabled = false + master_password = random_password.serverless_wordpress_password.result + backup_retention_period = 5 + storage_encrypted = true + scaling_configuration { + auto_pause = true + max_capacity = 1 + min_capacity = 1 + seconds_until_auto_pause = 300 + timeout_action = "ForceApplyCapacityChange" + } + skip_final_snapshot = false + final_snapshot_identifier = "${var.site_name}-serverless-wordpress-${random_id.rds_snapshot.dec}" + snapshot_identifier = var.snapshot_identifier + depends_on = [aws_cloudwatch_log_group.serverless_wordpress] +} diff --git a/task-definitions/wordpress.json b/task-definitions/wordpress.json new file mode 100644 index 0000000..86f8533 --- /dev/null +++ b/task-definitions/wordpress.json @@ -0,0 +1,48 @@ +[ + ${jsonencode({ + "cpu": tonumber(container_cpu), + "environment": [ + {"name": "ECS_ENABLE_CONTAINER_METADATA", "value": "true"}, + {"name": "WORDPRESS_DB_HOST", "value": "${db_host}"}, + {"name": "WORDPRESS_DB_USER", "value": "${db_user}"}, + {"name": "WORDPRESS_DB_PASSWORD", "value": "${db_password}"}, + {"name": "WORDPRESS_DB_NAME", "value": "${db_name}"}, + {"name": "WPSTATIC_DEST", "value": "${wp_dest}"}, + {"name": "WPSTATIC_REGION", "value": "${wp_region}"}, + {"name": "WPSTATIC_BUCKET", "value": "${wp_bucket}"}, + {"name": "CONTAINER_DNS", "value": "${container_dns}"}, + {"name": "CONTAINER_DNS_ZONE", "value": "${container_dns_zone}"}, + {"name": "WORDPRESS_ADMIN_USER", "value": "${wordpress_admin_user}"}, + {"name": "WORDPRESS_ADMIN_PASSWORD", "value": "${wordpress_admin_password}"}, + {"name": "WORDPRESS_ADMIN_EMAIL", "value": "${wordpress_admin_email}"} + ], + "essential": true, + "image": "${wordpress_image}", + "memory": tonumber(container_memory), + "name": "wordpress", + "portMappings": [ + { + "containerPort": 80, + "hostPort": 80, + "protocol": "tcp" + } + ], + "mountPoints" : [ + { + "sourceVolume": "${efs_source_volume}", + "containerPath": "/var/www/html", + "readOnly": false + } + ], + "volumesFrom" : [], + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/aws/ecs/${site_name}-serverless-wordpress-container", + "awslogs-region": "${wp_region}", + "awslogs-stream-prefix": "ecs" + } + } + })} + +] diff --git a/variables.tf b/variables.tf new file mode 100644 index 0000000..3dc71d7 --- /dev/null +++ b/variables.tf @@ -0,0 +1,168 @@ +variable "main_vpc_id" { + type = string + description = "The VPC ID into which to launch resources." + validation { + condition = length(var.main_vpc_id) > 4 && substr(var.main_vpc_id, 0, 4) == "vpc-" + error_message = "The main_vpc_id value must be a valid VPC id, starting with \"vpc-\"." + } +} + +variable "subnet_ids" { + type = list(any) + description = "A list of subnet IDs within the specified VPC where resources will be launched." +} + +variable "aws_account_id" { + type = number + description = "The AWS account ID into which resources will be launched." +} + +variable "site_domain" { + type = string + description = "The site domain name to configure (without any subdomains such as 'www')" +} + +variable "site_name" { + type = string + description = "The unique name for this instance of the module. Required to deploy multiple wordpress instances to the same AWS account (if desired)." + validation { + # regex(...) fails if it cannot find a match + condition = can(regex("^[0-9A-Za-z]+$", var.site_name)) + error_message = "For site_name value only a-z, A-Z and 0-9 are allowed." + } +} + +variable "site_prefix" { + type = string + description = "The subdomain prefix of the website domain. E.g. www" + default = "www" +} + +variable "s3_region" { + type = string + description = "The regional endpoint to use for the creation of the S3 bucket for published static wordpress site." +} + +variable "slack_webhook" { + type = string + description = "The Slack webhook URL where ECS Cluster EventBridge notifications will be sent." + default = "" + sensitive = true +} + +variable "launch" { + type = number + default = "0" + description = "The number of tasks to launch of the Wordpress container. Used as a toggle to start/stop your Wordpress management session." + validation { + condition = var.launch >= 0 && var.launch <= 1 + error_message = "The number of tasks to launch should be either 1 or 0 only." + } +} + +variable "ecs_cpu" { + type = number + description = "The CPU limit password to the Wordpress container definition." + default = 256 +} + +variable "ecs_memory" { + type = number + default = 512 + description = "The memory limit password to the Wordpress container definition." +} + +variable "snapshot_identifier" { + description = "To create the RDS cluster from a previous snapshot in the same region, specify it by name." + type = string + default = null +} + +# Backup functionality awaits: https://github.com/hashicorp/terraform-provider-aws/pull/18006 +# variable "efs_backups" { +# description = "A flag to set whether EFS default backups should be enabled (not yet implemented)." +# type = bool +# default = true +# } + +variable "cloudfront_aliases" { + type = list(any) + description = "The domain and sub-domain aliases to use for the cloudfront distribution." + default = [] +} + +variable "cloudfront_class" { + type = string + description = "The [price class](https://aws.amazon.com/cloudfront/pricing/) for the distribution. One of: PriceClass_All, PriceClass_200, PriceClass_100" + default = "PriceClass_All" +} + +variable "hosted_zone_id" { + type = string + description = "The Route53 HostedZone ID to use to create records in." +} + +variable "waf_enabled" { + type = bool + description = "Flag to enable default WAF configuration in front of CloudFront." +} + +variable "wordpress_subdomain" { + type = string + description = "The subdomain used for the Wordpress container." + default = "wordpress" +} + +variable "wordpress_admin_user" { + type = string + description = "The username of the default wordpress admin user." + default = "supervisor" +} + +variable "wordpress_admin_password" { + type = string + description = "The password of the default wordpress admin user." + #tfsec:ignore:GEN001 + default = "techtospeech.com" + sensitive = true +} + +variable "wordpress_admin_email" { + type = string + description = "The email address of the default wordpress admin user." + default = "admin@example.com" +} + +variable "waf_acl_rules" { + type = list(any) + description = "List of WAF rules to apply. Can be customized to apply others created outside of module." + default = [ + { + name = "AWS-AWSManagedRulesAmazonIpReputationList" + priority = 0 + managed_rule_group_name = "AWSManagedRulesAmazonIpReputationList" + vendor_name = "AWS" + cloudwatch_metrics_enabled = true + metric_name = "AWS-AWSManagedRulesAmazonIpReputationList" + sampled_requests_enabled = true + }, + { + name = "AWS-AWSManagedRulesKnownBadInputsRuleSet" + priority = 1 + managed_rule_group_name = "AWSManagedRulesKnownBadInputsRuleSet" + vendor_name = "AWS" + cloudwatch_metrics_enabled = true + metric_name = "AWS-AWSManagedRulesKnownBadInputsRuleSet" + sampled_requests_enabled = true + }, + { + name = "AWS-AWSManagedRulesBotControlRuleSet" + priority = 2 + managed_rule_group_name = "AWSManagedRulesBotControlRuleSet" + vendor_name = "AWS" + cloudwatch_metrics_enabled = true + metric_name = "AWS-AWSManagedRulesBotControlRuleSet" + sampled_requests_enabled = true + } + ] +}