diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml new file mode 100644 index 0000000..21d10a3 --- /dev/null +++ b/.github/workflows/deploy-production.yml @@ -0,0 +1,146 @@ +name: release + +on: + release: + types: + - published + +permissions: + id-token: write # This is required for requesting the JWT + contents: read # This is required for actions/checkout + +jobs: + production_deploy_plan: + runs-on: ubuntu-latest + environment: production + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + audience: sts.amazonaws.com + aws-region: ${{ vars.AWS_REGION }} + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + + - name: Get Image Metadata from Release + uses: dsaltares/fetch-gh-release-asset@v1.1.1 + with: + version: ${{ github.event.release.id }} + file: metadata.json + + - name: Set Image Digest from Metadata + id: set_image_digest + run: | + image_digest=$(cat /tmp/metadata.json | jq -r '."containerimage.digest"') + echo "image_digest=$image_digest" >> $GITHUB_OUTPUT + + - name: Export shared infrastructure SSM parameter values to auto.tfvars.json files + env: + deploy_path: ./deploy/tg/ecs + environment: ${{ vars.ENVIRONMENT }} + run: | + params=( apps/alb/${{ vars.ALB }} apps/ecr/${{ vars.ECR_REPOSITORY }} core rds/${{ vars.RDS_DB }} ) + for param in ${params[@]}; do + filename="$environment.${param//\//-}.auto.tfvars.json" + aws ssm get-parameters-by-path \ + --path "/$param/" \ + --recursive \ + --output json \ + --query 'Parameters[*]' \ + | jq '. |= map({ (.Name | split("/")[-1]): .Value }) | add' \ + > "$deploy_path/$filename" + done + + - name: Expose github environment as shell variables + env: + SECRETS_CONTEXT: ${{ toJson(secrets) }} + VARS_CONTEXT: ${{ toJson(vars) }} + run: | + EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) + to_envs() { jq -r "to_entries[] | \"\(.key)<<$EOF\n\(.value)\n$EOF\n\""; } + echo "$VARS_CONTEXT" | to_envs >> $GITHUB_ENV + echo "$SECRETS_CONTEXT" | to_envs >> $GITHUB_ENV + + - name: Terragrunt Plan + id: terragrunt_plan + uses: gruntwork-io/terragrunt-action@v2.0.0 + with: + tf_version: '1.5.7' + tg_version: '0.51.0' + tg_dir: './deploy/tg' + tg_command: 'run-all plan' + env: + TF_INPUT: 0 + TF_IN_AUTOMATION: true + # get the image digest from the build job with optional override from vars context + TF_VAR_image: ${{ vars.IMAGE || steps.set_image_digest.outputs.image_digest }} + + production_deploy_apply: + runs-on: ubuntu-latest + environment: staging + needs: [production_deploy_plan] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + audience: sts.amazonaws.com + aws-region: ${{ vars.AWS_REGION }} + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + + - name: Get Image Metadata from Release + uses: dsaltares/fetch-gh-release-asset@v1.1.1 + with: + version: ${{ github.event.release.id }} + file: metadata.json + + - name: Set Image Digest from Metadata + id: set_image_digest + run: | + image_digest=$(cat /tmp/metadata.json | jq -r '."containerimage.digest"') + echo "image_digest=$image_digest" >> $GITHUB_OUTPUT + + - name: Export shared infrastructure SSM parameter values to auto.tfvars.json files + env: + deploy_path: ./deploy/tg/ecs + environment: ${{ vars.ENVIRONMENT }} + run: | + params=( apps/alb/${{ vars.ALB }} apps/ecr/${{ vars.ECR_REPOSITORY }} core rds/${{ vars.RDS_DB }} ) + for param in ${params[@]}; do + filename="$environment.${param//\//-}.auto.tfvars.json" + aws ssm get-parameters-by-path \ + --path "/$param/" \ + --recursive \ + --output json \ + --query 'Parameters[*]' \ + | jq '. |= map({ (.Name | split("/")[-1]): .Value }) | add' \ + > "$deploy_path/$filename" + done + + - name: Expose github environment as shell variables + env: + SECRETS_CONTEXT: ${{ toJson(secrets) }} + VARS_CONTEXT: ${{ toJson(vars) }} + run: | + EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) + to_envs() { jq -r "to_entries[] | \"\(.key)<<$EOF\n\(.value)\n$EOF\n\""; } + echo "$VARS_CONTEXT" | to_envs >> $GITHUB_ENV + echo "$SECRETS_CONTEXT" | to_envs >> $GITHUB_ENV + + - name: Terragrunt Apply + id: terragrunt_plan + uses: gruntwork-io/terragrunt-action@v2.0.0 + with: + tf_version: '1.5.7' + tg_version: '0.51.0' + tg_dir: './deploy/tg' + tg_command: 'run-all apply' + env: + TF_INPUT: 0 + TF_IN_AUTOMATION: true + # get the image digest from the build job with optional override from vars context + TF_VAR_image: ${{ vars.IMAGE || steps.set_image_digest.outputs.image_digest }} diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml new file mode 100644 index 0000000..4cb387d --- /dev/null +++ b/.github/workflows/deploy-staging.yml @@ -0,0 +1,203 @@ +name: Deploy to Staging + +on: + push: + branches: + - master + +permissions: + id-token: write # This is required for requesting the JWT + contents: read # This is required for actions/checkout + +jobs: + build_test_push: + runs-on: ubuntu-latest + environment: staging + outputs: + image_digest: ${{ steps.build_and_push.outputs.digest }} + image_metadata: ${{ steps.build_and_push.outputs.metadata }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Setup Docker Structure Test + run: > + curl -LO + https://storage.googleapis.com/container-structure-test/latest/container-structure-test-linux-amd64 + && chmod +x container-structure-test-linux-amd64 && sudo mv container-structure-test-linux-amd64 + /usr/local/bin/container-structure-test + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + audience: sts.amazonaws.com + aws-region: ${{ vars.AWS_REGION }} + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + + - name: Login to ECR + uses: docker/login-action@v3 + with: + registry: ${{ vars.ECR_REGISTRY }} + + - name: Build Docker Image + uses: docker/build-push-action@v5 + with: + context: . + load: true + tags: ${{ vars.ECR_REPOSITORY }}:latest + + - name: Test Docker Image + run: | + container-structure-test test --image ${{ vars.ECR_REPOSITORY }}:latest --config tests/config.yaml + + - name: Build and Push Docker Image + id: build_and_push + uses: docker/build-push-action@v5 + with: + context: . +# Only building for AMD64 for now +# platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ vars.ECR_REGISTRY }}/${{ vars.ECR_REPOSITORY }}:latest + + staging_deploy_plan: + runs-on: ubuntu-latest + environment: staging + needs: build_test_push + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + audience: sts.amazonaws.com + aws-region: ${{ vars.AWS_REGION }} + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + + - name: Export shared infrastructure SSM parameter values to auto.tfvars.json files + env: + deploy_path: ./deploy/tg/ecs + environment: ${{ vars.ENVIRONMENT }} + run: | + params=( apps/alb/${{ vars.ALB }} apps/ecr/${{ vars.ECR_REPOSITORY }} core rds/${{ vars.RDS_DB }} ) + for param in ${params[@]}; do + filename="$environment.${param//\//-}.auto.tfvars.json" + aws ssm get-parameters-by-path \ + --path "/$param/" \ + --recursive \ + --output json \ + --query 'Parameters[*]' \ + | jq '. |= map({ (.Name | split("/")[-1]): .Value }) | add' \ + > "$deploy_path/$filename" + done + + - name: Expose github environment as shell variables + env: + SECRETS_CONTEXT: ${{ toJson(secrets) }} + VARS_CONTEXT: ${{ toJson(vars) }} + run: | + EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) + to_envs() { jq -r "to_entries[] | \"\(.key)<<$EOF\n\(.value)\n$EOF\n\""; } + echo "$VARS_CONTEXT" | to_envs >> $GITHUB_ENV + echo "$SECRETS_CONTEXT" | to_envs >> $GITHUB_ENV + + - name: Terragrunt Plan + id: terragrunt_plan + uses: gruntwork-io/terragrunt-action@v2.0.0 + with: + tf_version: '1.5.7' + tg_version: '0.51.0' + tg_dir: './deploy/tg' + tg_command: 'run-all plan' + env: + TF_INPUT: 0 + TF_IN_AUTOMATION: true + # get the image digest from the build job with optional override from vars context + TF_VAR_image: ${{ vars.IMAGE || needs.build_test_push.outputs.image_digest }} + + staging_deploy_apply: + runs-on: ubuntu-latest + environment: staging + needs: [staging_deploy_plan, build_test_push] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + audience: sts.amazonaws.com + aws-region: ${{ vars.AWS_REGION }} + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + + - name: Export shared infrastructure SSM parameter values to auto.tfvars.json files + env: + deploy_path: ./deploy/tg/ecs + environment: ${{ vars.ENVIRONMENT }} + run: | + params=( apps/alb/${{ vars.ALB }} apps/ecr/${{ vars.ECR_REPOSITORY }} core rds/${{ vars.RDS_DB }} ) + for param in ${params[@]}; do + filename="$environment.${param//\//-}.auto.tfvars.json" + aws ssm get-parameters-by-path \ + --path "/$param/" \ + --recursive \ + --output json \ + --query 'Parameters[*]' \ + | jq '. |= map({ (.Name | split("/")[-1]): .Value }) | add' \ + > "$deploy_path/$filename" + done + + - name: Expose github environment as shell variables + env: + SECRETS_CONTEXT: ${{ toJson(secrets) }} + VARS_CONTEXT: ${{ toJson(vars) }} + run: | + EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) + to_envs() { jq -r "to_entries[] | \"\(.key)<<$EOF\n\(.value)\n$EOF\n\""; } + echo "$VARS_CONTEXT" | to_envs >> $GITHUB_ENV + echo "$SECRETS_CONTEXT" | to_envs >> $GITHUB_ENV + + - name: Terragrunt Apply + id: terragrunt_plan + uses: gruntwork-io/terragrunt-action@v2.0.0 + with: + tf_version: '1.5.7' + tg_version: '0.51.0' + tg_dir: './deploy/tg' + tg_command: 'run-all apply' + env: + TF_INPUT: 0 + TF_IN_AUTOMATION: true + # get the image digest from the build job with optional override from vars context + TF_VAR_image: ${{ vars.IMAGE || needs.build_test_push.outputs.image_digest }} + + create_release: + name: Create Release + runs-on: ubuntu-latest + needs: [build_test_push] + steps: + - name: Write image metadata to file + id: metadata_to_file + run: ${{ needs.build_test_push.outputs.image_metadata }} > metadata.json + - name: Create Draft Release + id: create_draft_release + uses: softprops/action-gh-release@v1 + with: + name: Draft Release ${{ github.ref }} + body: | + ## Info + Commit ${{ github.sha }} was deployed to `staging`. [See code diff](${{ github.event.compare }}). + + It was initialized by [${{ github.event.sender.login }}](${{ github.event.sender.html_url }}). + + ## How to Promote? + In order to promote this to prod, edit the draft and press **"Publish release"**. + draft: true + files: metadata.json diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..fd69c98 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,32 @@ +name: Run Pre-commit Checks + +on: + pull_request: + branches: + - master + +permissions: + id-token: write # This is required for requesting the JWT + contents: read # This is required for actions/checkout + +jobs: + pre_commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "1.5.7" + - name: Setup Terragrunt + id: setup_terragrunt + run: | + wget https://github.com/gruntwork-io/terragrunt/releases/download/v${tg_version}/terragrunt_linux_amd64 \ + && mv terragrunt_linux_amd64 terragrunt \ + && chmod +x terragrunt \ + && mv terragrunt /usr/local/bin/terragrunt + env: + tg_version: '0.51.0' + - uses: pre-commit/action@v3.0.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 63e6355..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: release - -on: - release: - types: - - published - -env: - AWS_REGION: ap-southeast-2 - FAMILY: api-service - TAG: ${{ github.event.release.tag_name }} - -permissions: - id-token: write # This is required for requesting the JWT - contents: read # This is required for actions/checkout - -jobs: - build_test_push: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Set up docker structure test - run: > - curl -LO - https://storage.googleapis.com/container-structure-test/latest/container-structure-test-linux-amd64 - && chmod +x container-structure-test-linux-amd64 && sudo mv container-structure-test-linux-amd64 - /usr/local/bin/container-structure-test - - - name: Configure AWS Credentials - if: ${{ !env.ACT }} - uses: aws-actions/configure-aws-credentials@v4 - with: - audience: sts.amazonaws.com - aws-region: ${{ env.AWS_REGION }} - role-to-assume: ${{ secrets.AWS_ROLE_ARN }} - - - name: Login to ECR - if: ${{ !env.ACT }} - uses: docker/login-action@v3 - with: - registry: ${{ vars.ECR_REGISTRY }} - - - name: Build - uses: docker/build-push-action@v5 - with: - context: . - load: true - tags: ${{ env.TAG }} - - - name: Test - run: | - container-structure-test test --image ${{ env.TAG }} --config tests/config.yaml - - - name: Build and Push - if: ${{ !env.ACT }} - uses: docker/build-push-action@v5 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: | - ${{ vars.ECR_REGISTRY }}/${{ vars.ECR_REPOSITORY }}:${{ env.TAG }} - ${{ vars.ECR_REGISTRY }}/${{ vars.ECR_REPOSITORY }}:latest - - render_and_deploy: - runs-on: ubuntu-latest - needs: build_test_push - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Configure AWS Credentials - if: ${{ !env.ACT }} - uses: aws-actions/configure-aws-credentials@v4 - with: - audience: sts.amazonaws.com - aws-region: ${{ env.AWS_REGION }} - role-to-assume: ${{ secrets.AWS_ROLE_ARN }} - - - name: Get Current Task Definition - id: get-current-task-definition - run: > - aws ecs describe-task-definition --task-definition ${{ env.FAMILY }} - --query taskDefinition > task-definition.json - - - name: Update API image tag - id: update-api-image-tag - uses: aws-actions/amazon-ecs-render-task-definition@v1 - with: - task-definition: task-definition.json - container-name: api - image: ${{ vars.ECR_REGISTRY }}/${{ vars.ECR_REPOSITORY }}:${{ env.TAG }} - - - name: Display Rendered Template - if: ${{ env.ACT }} - id: display-rendered-template - run: cat ${{ steps.update-api-image-tag.outputs.task-definition }} | jq -r - - - name: Deploy to Amazon ECS service - if: ${{ !env.ACT }} - uses: aws-actions/amazon-ecs-deploy-task-definition@v1 - with: - task-definition: ${{ steps.update-api-image-tag.outputs.task-definition }} - service: api-service - cluster: api-cluster - force-new-deployment: true - wait-for-service-stability: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b291403..9f44abc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,20 +1,16 @@ -name: build and test +name: Build and Test on: pull_request: branches: - - "master" - -env: - AWS_REGION: ap-southeast-2 - TAG_PREFIX: test + - master permissions: id-token: write # This is required for requesting the JWT contents: read # This is required for actions/checkout jobs: - build_test_push: + build_and_test: runs-on: ubuntu-latest steps: - name: Checkout @@ -26,45 +22,20 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: set tag - run: | - BRANCH_NAME=${{ github.head_ref || github.ref_name }} - echo TAG=${{ env.TAG_PREFIX}}-${BRANCH_NAME/\//_} >> $GITHUB_ENV - - - name: Set up docker structure test + - name: Setup Docker Structure Test run: > curl -LO https://storage.googleapis.com/container-structure-test/latest/container-structure-test-linux-amd64 && chmod +x container-structure-test-linux-amd64 && sudo mv container-structure-test-linux-amd64 /usr/local/bin/container-structure-test - - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - audience: sts.amazonaws.com - aws-region: ${{ env.AWS_REGION }} - role-to-assume: ${{ secrets.AWS_ROLE_ARN }} - - - name: Login to ECR - uses: docker/login-action@v3 - with: - registry: ${{ vars.ECR_REGISTRY }} - - - name: Build + - name: Build Docker Image uses: docker/build-push-action@v5 with: context: . load: true - tags: ${{ env.TAG }} + tags: ${{ vars.ECR_REPOSITORY }} - - name: Test + - name: Test Docker Image run: | - container-structure-test test --image ${{ env.TAG }} --config tests/config.yaml - - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ vars.ECR_REGISTRY }}/${{ vars.ECR_REPOSITORY }}:${{ env.TAG }} + container-structure-test test --image ${{ vars.ECR_REPOSITORY }} --config tests/config.yaml diff --git a/.gitignore b/.gitignore index 2527c5e..1ab3559 100644 --- a/.gitignore +++ b/.gitignore @@ -106,8 +106,8 @@ venv.bak/ # IDE related config .idea/ -# Terraform directories and files +# Terraform/Terragrunt directories and files **/.terraform/* +**/.terragrunt-cache/ *.tfstate *.tfstate.* -.terraform.lock.hcl diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f468b2a..9780638 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,3 +19,6 @@ repos: hooks: - id: terraform_fmt - id: terraform_validate + args: + - --tf-init-args=-backend=false + - id: terragrunt_fmt diff --git a/deploy/bastion.tf b/deploy/bastion.tf deleted file mode 100644 index af75cc8..0000000 --- a/deploy/bastion.tf +++ /dev/null @@ -1,61 +0,0 @@ -data "aws_ami" "amazon_linux" { - most_recent = true - filter { - name = "name" - values = ["amzn2-ami-hvm-2.0.*-x86_64-gp2"] - } - owners = ["amazon"] -} - -data "http" "myip" { - url = "https://wtfismyip.com/text" -} - -resource "tls_private_key" "bastion" { - rsa_bits = 4096 - algorithm = "ED25519" -} - -resource "local_file" "private_key" { - content = tls_private_key.bastion.private_key_openssh - filename = "id_${local.prefix}_bastion" - file_permission = "0600" -} - -resource "aws_key_pair" "bastion" { - public_key = tls_private_key.bastion.public_key_openssh - key_name = "${local.prefix}-bastion" -} - -resource "aws_instance" "bastion" { - ami = data.aws_ami.amazon_linux.id - instance_type = "t2.micro" - key_name = aws_key_pair.bastion.key_name - vpc_security_group_ids = [aws_security_group.bastion_host.id] - - tags = { - Name = "${local.prefix}-bastion" - } -} - -resource "aws_security_group" "bastion_host" { - name = "${local.prefix}-bastion" - description = "Bastion security group" - - ingress { - description = "Allow SSH in from specific IP" - from_port = 22 - to_port = 22 - protocol = "tcp" - - cidr_blocks = ["${trimspace(data.http.myip.response_body)}/32"] - } - - egress { - from_port = 0 - to_port = 0 - protocol = "-1" - description = "Allow bastion general internet access" - cidr_blocks = ["0.0.0.0/0"] - } -} diff --git a/deploy/locals.tf b/deploy/locals.tf deleted file mode 100644 index 23e4412..0000000 --- a/deploy/locals.tf +++ /dev/null @@ -1,3 +0,0 @@ -locals { - prefix = "stefan-${terraform.workspace}" -} diff --git a/deploy/network.tf b/deploy/network.tf deleted file mode 100644 index 01fa6b0..0000000 --- a/deploy/network.tf +++ /dev/null @@ -1,15 +0,0 @@ -resource "aws_vpc" "ecs_test" { - cidr_block = "10.1.0.0/16" - enable_dns_hostnames = true - enable_dns_support = true - tags = { - Name = "${local.prefix}-vpc" - } -} - -resource "aws_internet_gateway" "ecs_test" { - vpc_id = aws_vpc.ecs_test.id - tags = { - Name = "${local.prefix}-igw" - } -} diff --git a/deploy/terraform.tf b/deploy/terraform.tf deleted file mode 100644 index 1276560..0000000 --- a/deploy/terraform.tf +++ /dev/null @@ -1,27 +0,0 @@ -terraform { - required_version = "> 1.5.0" - required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.0" - } - } - backend "s3" { - bucket = "tfstate-450356697252-ap-northeast-1" - key = "recipe-app.tfstate" - region = "ap-northeast-1" - encrypt = true - dynamodb_table = "tfstate-450356697252-ap-northeast-1" - } -} - -provider "aws" { - region = "ap-northeast-1" - allowed_account_ids = ["450356697252"] - default_tags { - tags = { - Owner = "Stefan" - Project = "ECS Training" - } - } -} diff --git a/deploy/tf/ecs/alb.tf b/deploy/tf/ecs/alb.tf new file mode 100644 index 0000000..0dd9c69 --- /dev/null +++ b/deploy/tf/ecs/alb.tf @@ -0,0 +1,41 @@ +resource "aws_lb_target_group" "app" { + name = "sample-django-app" + port = 80 + protocol = "HTTP" + target_type = "ip" + vpc_id = var.vpc_id + + health_check { + enabled = true + path = "/health" + } +} + +resource "aws_route53_record" "app" { + for_each = toset(var.app_hostnames) + zone_id = var.zone_id + name = each.value + type = "A" + + alias { + name = var.alb_dns_name + zone_id = var.alb_zone_id + evaluate_target_health = true + } +} + +resource "aws_lb_listener_rule" "app_fgate" { + for_each = toset(var.app_hostnames) + listener_arn = var.alb_https_listener_arn + + action { + type = "forward" + target_group_arn = aws_lb_target_group.app.arn + } + + condition { + host_header { + values = [aws_route53_record.app[each.value].fqdn] + } + } +} diff --git a/deploy/tf/ecs/ecs.tf b/deploy/tf/ecs/ecs.tf new file mode 100644 index 0000000..cfab5c6 --- /dev/null +++ b/deploy/tf/ecs/ecs.tf @@ -0,0 +1,196 @@ +locals { + # set container definition variables with default fallback values from ssm if available + allowed_hosts = var.allowed_hosts + allowed_cidr_nets = coalesce(var.allowed_cidr_nets, var.subnets_private_cidr) + django_secret_key = var.django_secret_key + db_host = coalesce(var.db_host, var.rds_url) + db_name = var.db_name + db_user = var.db_user + db_secret_name = var.db_secret_name + db_secret_region = var.db_secret_region + s3_storage_bucket_name = var.s3_storage_bucket_name + s3_storage_bucket_region = var.s3_storage_bucket_region + + ecr_registry = split("/", var.ecr_repository_url)[0] +} + +module "ecs" { + source = "terraform-aws-modules/ecs/aws" + version = "~> 5.7.0" + + # Cluster Configuration + cluster_name = "sample-django-app-${var.environment}" + cluster_configuration = { + name = "containerInsights" + value = "enabled" + } + create_task_exec_iam_role = true + fargate_capacity_providers = { + FARGATE = { + default_capacity_provider_strategy = { + weight = 50 + } + } + FARGATE_SPOT = { + default_capacity_provider_strategy = { + weight = 50 + } + } + } + + # Service Configuration + services = { + + "sample-django-app-${var.environment}" = { + capacity_provider_strategy = { + dedicated = { + base = 0 + capacity_provider = "FARGATE" + weight = 100 + } + # spot = { + # base = 0 + # capacity_provider = "FARGATE_SPOT" + # weight = 100 + # } + } + + # allow ECS exec commands on containers (e.g. to get a shell session) + enable_execute_command = true + + # resources + cpu = 512 + memory = 1024 + + # Container definition(s) + container_definitions = { + api = { + name = "api" + image = startswith(var.image, "sha256") ? "${var.ecr_repository_url}@${var.image}" : "${var.ecr_repository_url}:${var.image}" + health_check = { + command = ["CMD-SHELL", "uwsgi-is-ready --stats-socket /tmp/statsock > /dev/null 2>&1 || exit 1"] + } + readonly_root_filesystem = false + essential = true + memory_reservation = 256 + environment = [ + { name = "ALLOWED_HOSTS", value = local.allowed_hosts }, + { name = "ALLOWED_CIDR_NETS", value = local.allowed_cidr_nets }, + { name = "DJANGO_SECRET_KEY", value = local.django_secret_key }, + { name = "DB_HOST", value = local.db_host }, + { name = "DB_NAME", value = local.db_name }, + { name = "DB_USER", value = local.db_user }, + { name = "DB_SECRET_NAME", value = local.db_secret_name }, + { name = "DB_SECRET_REGION", value = local.db_secret_region }, + { name = "S3_STORAGE_BUCKET_NAME", value = local.s3_storage_bucket_name }, + { name = "S3_STORAGE_BUCKET_REGION", value = local.s3_storage_bucket_region } + ] + port_mappings = [ + { + name = "api" + containerPort = 9000 + hostPort = 9000 + } + ] + mount_points = [ + { + readOnly = false + containerPath = "/vol/web" + sourceVolume = "static" + } + ] + } + proxy = { + name = "proxy" + image = "${local.ecr_registry}/nginx-proxy:latest" + health_check = { + command = ["CMD-SHELL", "curl -so /dev/null http://localhost/health || exit 1"] + } + readonly_root_filesystem = false + essential = true + memory_reservation = 256 + environment = [ + { name = "APP_HOST", value = "127.0.0.1" }, + { name = "APP_PORT", value = 9000 }, + { name = "LISTEN_PORT", value = var.container_port } + ] + port_mappings = [ + { + name = "nginx" + containerPort = var.container_port + hostPort = var.container_port + } + ] + mount_points = [ + { + readOnly = false + containerPath = "/vol/static" + sourceVolume = "static" + } + ] + } + } + + deployment_circuit_breaker = { + enable = true + rollback = true + } + + load_balancer = { + service = { + target_group_arn = aws_lb_target_group.app.arn + container_name = "proxy" + container_port = var.container_port + } + } + + subnet_ids = split(",", var.subnets_private) + + security_group_rules = { + ingress_vpc = { + type = "ingress" + from_port = var.container_port + to_port = var.container_port + protocol = "tcp" + cidr_blocks = [var.vpc_cidr] + } + egress_all = { + type = "egress" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + } + + tasks_iam_role_statements = [ + { + actions = [ + "s3:PutObject", + "s3:GetObjectAcl", + "s3:GetObject", + "s3:ListBucket", + "s3:DeleteObject", + "s3:PutObjectAcl" + ] + resources = [ + "arn:aws:s3:::${var.s3_storage_bucket_name}", + "arn:aws:s3:::${var.s3_storage_bucket_name}/*" + ] + }, + { + actions = [ + "secretsmanager:GetSecretValue" + ] + resources = [ + "arn:aws:secretsmanager:${var.db_secret_region}:*:secret:${var.db_secret_name}*" + ] + } + ] + + volume = { + static = {} + } + } + } +} diff --git a/deploy/tf/ecs/main.tf b/deploy/tf/ecs/main.tf new file mode 100644 index 0000000..e69de29 diff --git a/deploy/tf/ecs/outputs.tf b/deploy/tf/ecs/outputs.tf new file mode 100644 index 0000000..e69de29 diff --git a/deploy/tf/ecs/variables.tf b/deploy/tf/ecs/variables.tf new file mode 100644 index 0000000..f065aa4 --- /dev/null +++ b/deploy/tf/ecs/variables.tf @@ -0,0 +1,122 @@ +variable "alb_dns_name" { + description = "The DNS name of the application load-balancer" + type = string +} + +variable "alb_https_listener_arn" { + description = "The ARN of the application load-balancer listener" + type = string +} + +variable "alb_zone_id" { + description = "The DNS zone ID of the application load-balancer" + type = string +} + +variable "app_hostnames" { + description = "Hostnames to associate with the application" + type = list(string) +} + +variable "container_port" { + description = "The port to expose to the load balancer on the container" + type = number + default = 80 +} + +variable "db_host" { + description = "Override variable for database host" + type = string +} + +variable "ecr_repository_url" { + description = "The name of the repository to pull the image from" + type = string +} + +variable "image" { + description = "The digest/tag of the docker image to pull from ECR" + type = string +} + +variable "environment" { + description = "Environment name to prepend/append to resource names" + type = string +} + +variable "rds_url" { + description = "The hostname of the database instance" + type = string +} + +variable "subnets_private" { + description = "ID's of the subnets to deploy the container too" + type = string +} + +variable "subnets_private_cidr" { + description = "CIDR's of the subnets to deploy the container too" + type = string +} + +variable "vpc_cidr" { + description = "The CIDR of the VPC" + type = string +} + +variable "vpc_id" { + description = "The ID of the VPC" + type = string +} + +variable "zone_id" { + description = "The ID of the route53 zone" + type = string +} + +# Container environment variables +variable "allowed_hosts" { + description = "Hosts allowed to access the application container (i.e. 127.0.0.1)" + type = string +} + +variable "allowed_cidr_nets" { + description = "Subnet CIDR's allowed to access the application container" + type = string +} + +variable "django_secret_key" { + description = "The secret key for django app" + type = string +} + +variable "db_name" { + description = "The name of the database" + type = string +} + +variable "db_user" { + description = "The user to connect to the database with" + type = string +} + +variable "db_secret_name" { + description = "The name of the secret to fetch DB login credentials from" + type = string +} + +variable "db_secret_region" { + description = "The region to fetch the secret from" + type = string + default = "ap-southeast-2" +} + +variable "s3_storage_bucket_name" { + description = "Name of the S3 bucket to use" + type = string +} + +variable "s3_storage_bucket_region" { + description = "The bucket region" + type = string +} diff --git a/deploy/tf/ecs/versions.tf b/deploy/tf/ecs/versions.tf new file mode 100644 index 0000000..91041e5 --- /dev/null +++ b/deploy/tf/ecs/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_version = "~> 1.5.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.12" + } + } +} diff --git a/deploy/tf/ssm-parameter/main.tf b/deploy/tf/ssm-parameter/main.tf new file mode 100644 index 0000000..290f69c --- /dev/null +++ b/deploy/tf/ssm-parameter/main.tf @@ -0,0 +1,7 @@ +locals { + params = jsondecode(data.aws_ssm_parameter.shared.value) +} + +data "aws_ssm_parameter" "shared" { + name = var.parameter_name +} diff --git a/deploy/tf/ssm-parameter/outputs.tf b/deploy/tf/ssm-parameter/outputs.tf new file mode 100644 index 0000000..ae346cf --- /dev/null +++ b/deploy/tf/ssm-parameter/outputs.tf @@ -0,0 +1,3 @@ +output "parameter_values" { + value = nonsensitive(local.params) +} diff --git a/deploy/tf/ssm-parameter/variables.tf b/deploy/tf/ssm-parameter/variables.tf new file mode 100644 index 0000000..b8c86f6 --- /dev/null +++ b/deploy/tf/ssm-parameter/variables.tf @@ -0,0 +1,3 @@ +variable "parameter_name" { + type = string +} diff --git a/deploy/tg/ecs/.terraform.lock.hcl b/deploy/tg/ecs/.terraform.lock.hcl new file mode 100644 index 0000000..a648e46 --- /dev/null +++ b/deploy/tg/ecs/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.29.0" + constraints = ">= 4.66.1" + hashes = [ + "h1:+4qYlyPaktjZTFP9UbpEaz55jfwWapdibViCPoRFf+s=", + "h1:SyiKAX/D3ZE9My7P03DrRMf65pNnfSDQXPb0g11lCS0=", + "zh:0453c1c64e51cd7050ce46d9280a0195b9073592508077ebf1a1c45f7026f3f5", + "zh:3ee87d1a2870b61fdcc80f3f96b669dbcc8171aadb821bec0e1fa0e6fb9595b6", + "zh:423c0304eba345167cc37dcd300712f24f03fe4de8eecc15edb0d4f88b29ec79", + "zh:6816ce0ed702263297a8e02467bb712c509a9f6e4f132a152a10f1cc19191a81", + "zh:6feb8a0aedabd778216238e72273f5c2ee86d8841acc3fb3dc9d8014a2bbdc51", + "zh:709ccdc8b37f975d422e7955814671548887613931e234e06249da629b0f2f95", + "zh:76c55744020dbdafea25be634f8ac37c1e371f8c397f73bd89bc270d00ee0834", + "zh:7e48d6fc488b9dbe2fd4bebefa1b485d04da38b11a6799f8cba178173b7f8782", + "zh:951d7ef2adbfb96b1d3e9c4780b2ab4375caf9c6b522a2d023c02ff0698d8e2a", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:b0bf5974bc1a7d2ce3f3a9a31a8238ad15ad02211f1e84c54832541ec4bd5d10", + "zh:cc56d4ab9bcbee95f73dbe90f11d4ff7299b835dddf2b30cfda526a2cccd0f9f", + "zh:cfe3a4394f2f7044e03bb63f4fb9c691926607c6784417ac9c0724943da60d09", + "zh:d6f82e13f33f70de8df480287b5a961ced5606f041d1c589f706b112f68db890", + "zh:fb7be5bcff62d0ca9edd4a1bee4d2ed16e9428e3f9eff3ea4d898ecb234505a3", + ] +} diff --git a/deploy/tg/ecs/terragrunt.hcl b/deploy/tg/ecs/terragrunt.hcl new file mode 100644 index 0000000..d4c09d0 --- /dev/null +++ b/deploy/tg/ecs/terragrunt.hcl @@ -0,0 +1,46 @@ +dependency "s3" { + config_path = "../s3" + mock_outputs = { + wrapper = { + "sample-django-app-${local.global.environment}-${local.global.aws_account}" = { + s3_bucket_arn = "arn:aws:s3:::sample-django-app-${local.global.environment}-${local.global.aws_account}" + s3_bucket_id = "sample-django-app-${local.global.environment}-${local.global.aws_account}" + s3_bucket_region = "ap-southeast-2" + } + } + } +} + +include "global" { + path = find_in_parent_folders("terragrunt.hcl") + expose = true +} + +inputs = { + environment = local.global.environment + + # DNS hostnames to associate with the container + app_hostnames = ["api-${local.global.environment}"] + + # get docker environment variable values with default fallback values + allowed_hosts = get_env("ALLOWED_HOSTS", "*") + allowed_cidr_nets = get_env("ALLOWED_CIDR_NETS", "") + django_secret_key = get_env("DJANGO_SECRET_KEY", "changeme") + db_host = get_env("DB_HOST", "") + db_name = get_env("DB_NAME", "api") + db_user = get_env("DB_USER", "api") + db_secret_name = get_env("DB_SECRET_NAME", "/rds/stefan-db/primary/evaluation/api") + db_secret_region = get_env("DB_SECRET_REGION", "ap-southeast-2") + s3_storage_bucket_name = get_env("S3_STORAGE_BUCKET_NAME", + dependency.s3.outputs.wrapper["sample-django-app-${local.global.environment}-${local.global.aws_account}"].s3_bucket_id) + s3_storage_bucket_region = get_env("S3_STORAGE_BUCKET_REGION", + dependency.s3.outputs.wrapper["sample-django-app-${local.global.environment}-${local.global.aws_account}"].s3_bucket_region) +} + +locals { + global = include.global.locals +} + +terraform { + source = "${get_repo_root()}//deploy/tf/ecs" +} diff --git a/deploy/tg/s3/.terraform.lock.hcl b/deploy/tg/s3/.terraform.lock.hcl new file mode 100644 index 0000000..700fcab --- /dev/null +++ b/deploy/tg/s3/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.29.0" + constraints = ">= 4.9.0" + hashes = [ + "h1:+4qYlyPaktjZTFP9UbpEaz55jfwWapdibViCPoRFf+s=", + "h1:SyiKAX/D3ZE9My7P03DrRMf65pNnfSDQXPb0g11lCS0=", + "zh:0453c1c64e51cd7050ce46d9280a0195b9073592508077ebf1a1c45f7026f3f5", + "zh:3ee87d1a2870b61fdcc80f3f96b669dbcc8171aadb821bec0e1fa0e6fb9595b6", + "zh:423c0304eba345167cc37dcd300712f24f03fe4de8eecc15edb0d4f88b29ec79", + "zh:6816ce0ed702263297a8e02467bb712c509a9f6e4f132a152a10f1cc19191a81", + "zh:6feb8a0aedabd778216238e72273f5c2ee86d8841acc3fb3dc9d8014a2bbdc51", + "zh:709ccdc8b37f975d422e7955814671548887613931e234e06249da629b0f2f95", + "zh:76c55744020dbdafea25be634f8ac37c1e371f8c397f73bd89bc270d00ee0834", + "zh:7e48d6fc488b9dbe2fd4bebefa1b485d04da38b11a6799f8cba178173b7f8782", + "zh:951d7ef2adbfb96b1d3e9c4780b2ab4375caf9c6b522a2d023c02ff0698d8e2a", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:b0bf5974bc1a7d2ce3f3a9a31a8238ad15ad02211f1e84c54832541ec4bd5d10", + "zh:cc56d4ab9bcbee95f73dbe90f11d4ff7299b835dddf2b30cfda526a2cccd0f9f", + "zh:cfe3a4394f2f7044e03bb63f4fb9c691926607c6784417ac9c0724943da60d09", + "zh:d6f82e13f33f70de8df480287b5a961ced5606f041d1c589f706b112f68db890", + "zh:fb7be5bcff62d0ca9edd4a1bee4d2ed16e9428e3f9eff3ea4d898ecb234505a3", + ] +} diff --git a/deploy/tg/s3/terragrunt.hcl b/deploy/tg/s3/terragrunt.hcl new file mode 100644 index 0000000..011ac0b --- /dev/null +++ b/deploy/tg/s3/terragrunt.hcl @@ -0,0 +1,27 @@ +include "global" { + path = find_in_parent_folders("terragrunt.hcl") + expose = true +} + +inputs = { + items = { + "sample-django-app-${local.global.environment}-${local.global.aws_account}" = { + bucket = "sample-django-app-${local.global.environment}-${local.global.aws_account}" + acl = "public-read" + block_public_acls = false + block_public_policy = false + ignore_public_acls = false + control_object_ownership = true + object_ownership = "BucketOwnerPreferred" + restrict_public_buckets = false + } + } +} + +locals { + global = include.global.locals +} + +terraform { + source = "tfr:///terraform-aws-modules/s3-bucket/aws//wrappers?version=3.15.1" +} diff --git a/deploy/tg/terragrunt.hcl b/deploy/tg/terragrunt.hcl new file mode 100644 index 0000000..fc61db7 --- /dev/null +++ b/deploy/tg/terragrunt.hcl @@ -0,0 +1,48 @@ +locals { + aws_account = get_env("AWS_ACCOUNT_ID") + aws_region = get_env("AWS_REGION") + environment = get_env("ENVIRONMENT") + project_name = "sample-django-app" + repo_url = run_cmd("--terragrunt-quiet", "sh", "-c", "git config --get remote.origin.url") + state_bucket = "tfstate-${local.aws_account}-${local.aws_region}" + state_key = "apps/${local.project_name}/${local.environment}/${basename(get_terragrunt_dir())}.tfstate" +} + +generate "providers" { + path = "providers.tf" + if_exists = "overwrite_terragrunt" + contents = <=10.1.0,<10.2.0 uwsgi>=2.0.23,<2.1.0 flake8>=6.1.0,<6.2.0 uwsgi-readiness-check>=0.2.0,<0.3.0 +pre-commit>=3.5.0,<3.6.0