diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..fd00eaa962 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# Include any files or directories that you don't want to be copied to your +# container here (e.g., local build artifacts, temporary files, etc.). +# +# For more help, visit the .dockerignore file reference guide at +# https://docs.docker.com/go/build-context-dockerignore/ + +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.next +**/.cache +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/charts +**/docker-compose* +**/compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +**/build +**/dist +LICENSE +README.md diff --git a/.github/workflows/awsdeploy.yml b/.github/workflows/awsdeploy.yml new file mode 100644 index 0000000000..9d73404ad6 --- /dev/null +++ b/.github/workflows/awsdeploy.yml @@ -0,0 +1,30 @@ +# Based on https://docs.github.com/en/actions/deployment/deploying-to-your-cloud-provider/deploying-to-amazon-elastic-container-service + +name: aws ecs deploy - blake + +on: + push: + branches: + - main + +jobs: + deploy-core: + uses: ./.github/workflows/deploy-template.yml + with: + service-name: core + environment: staging + env-proper-name: blake + secrets: + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + + deploy-jobs: + uses: ./.github/workflows/deploy-template.yml + needs: deploy-core + with: + service-name: jobs + environment: staging + env-proper-name: blake + secrets: + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} diff --git a/.github/workflows/deploy-template.yml b/.github/workflows/deploy-template.yml new file mode 100644 index 0000000000..17daa25c84 --- /dev/null +++ b/.github/workflows/deploy-template.yml @@ -0,0 +1,85 @@ +# Based on https://docs.github.com/en/actions/deployment/deploying-to-your-cloud-provider/deploying-to-amazon-elastic-container-service + +name: aws ecs deploy template + +on: + workflow_call: + inputs: + service-name: # example: core + required: true + type: string + env-proper-name: # example: blake + required: true + type: string + environment: # example: staging + required: true + type: string + secrets: + AWS_ACCESS_KEY_ID: + required: true + AWS_SECRET_ACCESS_KEY: + required: true + +env: + AWS_REGION: us-east-1 + ECR_REPOSITORY: pubpub-v7-${{ inputs.service-name }} + ECS_SERVICE: ${{ inputs.env-proper-name }}-${{inputs.service-name}} + ECS_CLUSTER: ${{inputs.env-proper-name}}-ecs-cluster-${{inputs.environment}} + ECS_TASK_DEFINITION_TEMPLATE: ${{ inputs.env-proper-name }}-${{inputs.service-name}} + CONTAINER_NAME: ${{inputs.service-name}} + +jobs: + deploy: + name: Deploy + 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: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Get short sha + id: shortsha + run: echo "sha_short=$(git describe --always --abbrev=40 --dirty)" >> $GITHUB_OUTPUT + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Retrieve Task Definition contents from template + id: get-taskdef + run: | + aws ecs describe-task-definition \ + --task-definition $ECS_TASK_DEFINITION_TEMPLATE \ + --query taskDefinition >> template_task_def.json + + - name: Interpolate image tag + id: write-tag + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + IMAGE_TAG: ${{ steps.shortsha.outputs.sha_short }} + run: | + echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT + + - name: Fill in the new image ID in the Amazon ECS task definition + id: task-def + uses: aws-actions/amazon-ecs-render-task-definition@c804dfbdd57f713b6c079302a4c01db7017a36fc + with: + task-definition: template_task_def.json + container-name: ${{ env.CONTAINER_NAME }} + image: ${{ steps.write-tag.outputs.image }} + + - name: Deploy Amazon ECS task definition + uses: aws-actions/amazon-ecs-deploy-task-definition@df9643053eda01f169e64a0e60233aacca83799a + with: + task-definition: ${{ steps.task-def.outputs.task-definition }} + service: ${{ env.ECS_SERVICE }} + cluster: ${{ env.ECS_CLUSTER }} + wait-for-service-stability: false diff --git a/.github/workflows/ecrbuild-all.yml b/.github/workflows/ecrbuild-all.yml new file mode 100644 index 0000000000..412cf65e17 --- /dev/null +++ b/.github/workflows/ecrbuild-all.yml @@ -0,0 +1,37 @@ +# Based on https://docs.github.com/en/actions/deployment/deploying-to-your-cloud-provider/deploying-to-amazon-elastic-container-service + +name: docker build to ECR + +on: + push: + branches: + - main + pull_request: + types: + - opened + - synchronize + +jobs: + build-core: + uses: ./.github/workflows/ecrbuild-template.yml + with: + package-name: core + secrets: + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + + build-intg-submissions: + uses: ./.github/workflows/ecrbuild-template.yml + with: + package-name: integration-submissions + secrets: + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + + build-jobs: + uses: ./.github/workflows/ecrbuild-template.yml + with: + package-name: jobs + secrets: + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} diff --git a/.github/workflows/ecrbuild-template.yml b/.github/workflows/ecrbuild-template.yml new file mode 100644 index 0000000000..1ef1fcaac0 --- /dev/null +++ b/.github/workflows/ecrbuild-template.yml @@ -0,0 +1,64 @@ +# Based on https://docs.github.com/en/actions/deployment/deploying-to-your-cloud-provider/deploying-to-amazon-elastic-container-service + +name: aws ecr build template + +on: + workflow_call: + inputs: + package-name: + required: true + type: string + secrets: + AWS_ACCESS_KEY_ID: + required: true + AWS_SECRET_ACCESS_KEY: + required: true + +env: + AWS_REGION: us-east-1 # set this to your preferred AWS region, e.g. us-west-1 + ECR_REPOSITORY_PREFIX: pubpub-v7 # set this to your Amazon ECR repository name + +jobs: + build: + name: Build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ vars.IAM_ROLE_TO_ASSUME }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Get short sha + id: shortsha + run: echo "sha_short=$(git describe --always --abbrev=40 --dirty)" >> $GITHUB_OUTPUT + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build, tag, and push image to Amazon ECR + id: build-image + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + IMAGE_TAG: ${{ steps.shortsha.outputs.sha_short }} + PACKAGE: ${{ inputs.package-name }} + run: | + # Build a docker container and + # push it to ECR so that it can + # be deployed to ECS. + docker build \ + --platform linux/amd64 \ + --build-arg PACKAGE=$PACKAGE \ + --build-arg PACKAGE_DIR=$PACKAGE_DIR \ + -t $ECR_REGISTRY/$ECR_REPOSITORY_PREFIX-$PACKAGE:$IMAGE_TAG \ + . + docker push $ECR_REGISTRY/$ECR_REPOSITORY_PREFIX-$PACKAGE:$IMAGE_TAG + echo "image=$ECR_REGISTRY/$ECR_REPOSITORY_PREFIX-$PACKAGE:$IMAGE_TAG" >> $GITHUB_OUTPUT + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..2da8da2fa6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,62 @@ +# syntax=docker/dockerfile:1 + +# Comments are provided throughout this file to help you get started. +# If you need more help, visit the Dockerfile reference guide at +# https://docs.docker.com/go/dockerfile-reference/ + +ARG NODE_VERSION=20.6.0 +ARG PNPM_VERSION=8.14.3 + +ARG PACKAGE=core +ARG PORT=3000 + +################################################################################ +# Use node image for base image for all stages. +FROM node:${NODE_VERSION}-alpine as base + +# Install python deps for node-gyp +RUN apk add g++ make py3-pip + +# Set working directory for all build stages. +WORKDIR /usr/src/app + +# Install pnpm. +RUN --mount=type=cache,target=/root/.npm \ + npm install -g pnpm@${PNPM_VERSION} + +################################################################################ +# Create a stage for building the application. +FROM base as monorepo +ARG PACKAGE + +# Copy the rest of the source files into the image. +COPY . . + +# Run the build script. +RUN ./bin/render-build.sh ${PACKAGE} + +RUN pnpm --filter ${PACKAGE} --prod deploy /tmp/app + +################################################################################ +# Create a new stage to run the application with minimal runtime dependencies +# where the necessary files are copied from the build stage. +FROM base AS app +ARG PORT + +# # needed so that the CMD can use this var +# ENV PACKAGE=$PACKAGE + +# Use production node environment by default. +ENV NODE_ENV production + +# Copy package.json so that package manager commands can be used. +COPY --from=monorepo /tmp/app \ + ./ + +# Run the application as a non-root user. +USER node + +# Expose the port that the application listens on. +EXPOSE $PORT +# Run the application. +CMD pnpm start diff --git a/README.md b/README.md index feb8c883b6..cd29025ebb 100644 --- a/README.md +++ b/README.md @@ -80,3 +80,87 @@ We currently have a race condition where dev will sometimes fail because we can' - https://github.com/vercel/turbo/issues/460 `core` depends on `ui` which depends on `utils`. `utils` often takes longer to build than it does for `ui` to start building, which causes an error to be thrown because `utils` d.ts file has been cleared out during its build and hasn't been replaced yet. This generates an error, but is quick to resolve, so doesn't break actual dev work from beginning. It does make the console output messier though. + + +## Building and deploying for AWS environments + +All change management to Knowledge Futures' production environment is done through github actions. +This environment runs on AWS ECS and leverages terraform to allow reproducible, parametric environments. + +Services running in AWS ECS are scheduled using "Task Definitions", which are CRUDdy resources +including all details for a container. We don't want to tie code releases to terraform "infrastructure" changes, +but the service "declaration" relies on this Task Definition to exist. + +Therefore based on community patterns we have seen, the flow is roughly this: +1. The infrastructure code in terraform declares a "template" Task Definition. +2. Terraform is told not to change the "service" based on changes to the Task Definition. +3. Any changes to the template will be picked up by the next deploy, which is done outside of Terraform. +4. Github Actions builds new containers on merge, and will use AWS-provided Actions to template the literal correct Task Definition and update the Service. + +### Updating deployment topology and/or environment variables/container settings + +To change "infrastructure settings", which include anything from networking to env vars, +make changes to `./infrastructure/terraform/aws`. Use `terraform apply` there to update +the infrastructure and/or Task Definition Template. See that directory for more info. + +Then you must perform a Github Actions Deploy, either by pushing your changes to main or +with local `act` CLI. + +### Updating container versions with github actions + +The core automation workflow can be examined in [`awsdeploy.yml`](./.github/workflows/awsdeploy.yml) + +There is a Dockerfile in this repository that builds a container for one package. You can use it like: + +``` +docker build \ + --platform linux/amd64 \ + --build-arg PACKAGE=core \ + --build-arg PACKAGE_DIR=core \ + -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG \ + . +``` + +Note how this matches the invocation used in the Actions workflow file. + +We automatically build and push a container to AWS ECR with the github SHA as a tag. + +### Using `act` to run the deploy events locally + +If you need to build or validate a change and deploy to production, you can use the [`act` cli](https://github.com/nektos/act): + +``` +act \ + -W .github/workflows/awsdeploy.yml \ + --container-architecture linux/amd64 \ + --secret-file ~/.aws/pubpub.secrets \ + -j deploy +``` + +**AWS CLI access in `act`:** +When you setup `act` locally for the first time, you can choose whether to do a Small, Medium, or Large install. +The Large install is *very large*, but the medium install does not include the AWS CLI. + +If you choose to work with the medium install, you will need to customize afterward by editing your `~/.actrc` file +to use an image that includes the AWS CLI, for example your .actrc might look like: +``` +-P ubuntu-latest=eveships/act-plusplus:latest +-P ubuntu-22.04=catthehacker/ubuntu:full-22.04 +-P ubuntu-20.04=catthehacker/ubuntu:full-20.04 +-P ubuntu-18.04=catthehacker/ubuntu:full-18.04 +``` + +**Secrets:** Though you will have an `~/.aws/credentials` file, this is not the format for secrets access that +`act` requires, so I copy the key-value pairs in that file into a file that matches the Github +secrets called `~/.aws/pubpub.secrets`: + +``` +AWS_ACCESS_KEY_ID=... +AWS_SECRET_ACCESS_KEY... +``` + +**Dirty worktree:** If you run `act` like this, the image will be conveniently tagged with the latest SHA plus `-dirty`. +Images tagged with a SHA alone should be idempotently built, but `-dirty` can be changed/overwritten. + +**TODO:** +- [ ] allow deploying without a rebuild, so that a rollback is convenient diff --git a/bin/render-build.sh b/bin/render-build.sh old mode 100644 new mode 100755 diff --git a/infrastructure/terraform/.gitignore b/infrastructure/terraform/.gitignore new file mode 100644 index 0000000000..d4f7de49c1 --- /dev/null +++ b/infrastructure/terraform/.gitignore @@ -0,0 +1,5 @@ +**/*.tfstate +**/*.tfstate.* +**/.terraform +**/*.tfvars +**/*.tfbackend diff --git a/infrastructure/terraform/aws/.terraform.lock.hcl b/infrastructure/terraform/aws/.terraform.lock.hcl new file mode 100644 index 0000000000..1a136df7fd --- /dev/null +++ b/infrastructure/terraform/aws/.terraform.lock.hcl @@ -0,0 +1,44 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.33.0" + constraints = ">= 4.0.0, >= 5.0.0" + hashes = [ + "h1:kPm7PkwHh6tZ74pUj5C/QRPtauxdnzrEG2yhCJla/4o=", + "zh:10bb683f2a9306e881f51a971ad3b2bb654ac94b54945dd63769876a343b5b04", + "zh:3916406db958d5487ea0c2d2320012d1907c29e6d01bf693560fe05e38ee0601", + "zh:3cb54b76b2f9e30620f3281ab7fb20633b1e4584fc84cc4ecd5752546252e86f", + "zh:513bcfd6971482215c5d64725189f875cbcbd260c6d11f0da4d66321efd93a92", + "zh:545a34427ebe7a950056627e7c980c9ba16318bf086d300eb808ffc41c52b7a8", + "zh:5a44b90faf1c8e8269f389c04bfac25ad4766d26360e7f7ac371be12a442981c", + "zh:64e1ef83162f78538dccad8b035577738851395ba774d6919cb21eb465a21e3a", + "zh:7315c70cb6b7f975471ea6129474639a08c58c071afc95a36cfaa41a13ae7fb9", + "zh:9806faae58938d638b757f54414400be998dddb45edfd4a29c85e827111dc93d", + "zh:997fa2e2db242354d9f772fba7eb17bd6d18d28480291dd93f85a18ca0a67ac2", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f9e076b7e9752971f39eead6eda69df1c5e890c82ba2ca95f56974af7adfe79", + "zh:b1d6af047f96de7f97d38b685654f1aed4356d5060b0e696d87d0270f5d49f75", + "zh:bfb0654b6f34398aeffdf907b744af06733d168db610a2c5747263380f817ac7", + "zh:e25203ee8cedccf60bf450950d533d3c172509bda8af97dbc3bc817d2a503c57", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.6.0" + hashes = [ + "h1:I8MBeauYA8J8yheLJ8oSMWqB0kovn16dF/wKZ1QTdkk=", + "zh:03360ed3ecd31e8c5dac9c95fe0858be50f3e9a0d0c654b5e504109c2159287d", + "zh:1c67ac51254ba2a2bb53a25e8ae7e4d076103483f55f39b426ec55e47d1fe211", + "zh:24a17bba7f6d679538ff51b3a2f378cedadede97af8a1db7dad4fd8d6d50f829", + "zh:30ffb297ffd1633175d6545d37c2217e2cef9545a6e03946e514c59c0859b77d", + "zh:454ce4b3dbc73e6775f2f6605d45cee6e16c3872a2e66a2c97993d6e5cbd7055", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:91df0a9fab329aff2ff4cf26797592eb7a3a90b4a0c04d64ce186654e0cc6e17", + "zh:aa57384b85622a9f7bfb5d4512ca88e61f22a9cea9f30febaa4c98c68ff0dc21", + "zh:c4a3e329ba786ffb6f2b694e1fd41d413a7010f3a53c20b432325a94fa71e839", + "zh:e2699bc9116447f96c53d55f2a00570f982e6f9935038c3810603572693712d0", + "zh:e747c0fd5d7684e5bfad8aa0ca441903f15ae7a98a737ff6aca24ba223207e2c", + "zh:f1ca75f417ce490368f047b63ec09fd003711ae48487fba90b4aba2ccf71920e", + ] +} diff --git a/infrastructure/terraform/aws/README.md b/infrastructure/terraform/aws/README.md new file mode 100644 index 0000000000..3eebe1fde3 --- /dev/null +++ b/infrastructure/terraform/aws/README.md @@ -0,0 +1,26 @@ +# ECS Cluster Environment + +## Dependencies + +### State file bucket/config +You must have some way of storing terraform state files. +We use and recommend the s3 backend, but you can change +that configuration. In order to keep this code generic, however, +we omit the specific bucket config. + +You can fill in your own backend config in a file such as +`demo-env.s3.tfbackend` and supply this config on command-line: + +```bash +terraform init -backend-config=demo-env.s3.tfbackend +``` + +This file is only needed for `terraform init`. Once that is done, +you don't need to supply the backend config to `terraform plan/apply`. +If you need to change the backend, update this file and `init` again. + +### Vars file + +the module exposes its configuration area, but those configurations +need to be supplied at plan/apply time using the flag `-var-file=demo-env.tfvars`. + diff --git a/infrastructure/terraform/aws/main.tf b/infrastructure/terraform/aws/main.tf new file mode 100644 index 0000000000..df090ffdfd --- /dev/null +++ b/infrastructure/terraform/aws/main.tf @@ -0,0 +1,86 @@ +# aws terraform provider config + +terraform { + required_version = ">= 1.5.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + backend "s3" { + # contents provided in NAME.s3.tfbackend + } +} + +provider "aws" { + region = var.region +} + +module "cluster" { + source = "./modules/v7-cluster" + + name = var.name + environment = var.environment + region = var.region + + container_ingress_port = 3000 + + availability_zones = ["us-east-1a", "us-east-1c"] +} + +module "core_dependency_services" { + source = "./modules/core-services" + + cluster_info = module.cluster.cluster_info +} + +module "service_core" { + source = "./modules/ecs-service" + + service_name = "core" + cluster_info = module.cluster.cluster_info + + repository_url = module.cluster.ecr_repository_urls.core + + configuration = { + container_port = 3000 + environment = [ + { name = "DATABASE_URL", value = module.core_dependency_services.rds_connection_string_sans_password }, + { name = "ASSETS_REGION", value = var.region }, + { name = "ASSETS_BUCKET_NAME", value = var.ASSETS_BUCKET_NAME }, + { name = "ASSETS_UPLOAD_KEY", value = var.ASSETS_UPLOAD_KEY }, + { name = "NEXT_PUBLIC_PUBPUB_URL", value = var.pubpub_url }, + { name = "MAILGUN_SMTP_USERNAME", value = var.MAILGUN_SMTP_USERNAME }, + { name = "NEXT_PUBLIC_SUPABASE_URL", value = var.NEXT_PUBLIC_SUPABASE_URL }, + ] + + secrets = [ + { name = "DATABASE_PASSWORD", valueFrom = module.core_dependency_services.rds_db_password_id }, + { name = "API_KEY", valueFrom = module.core_dependency_services.api_key_secret_id }, + ] + } +} + +module "service_flock" { + source = "./modules/ecs-service" + + service_name = "jobs" + cluster_info = module.cluster.cluster_info + + repository_url = module.cluster.ecr_repository_urls.jobs + + configuration = { + container_port = 3000 + environment = [ + {name = "PUBPUB_URL", value = var.pubpub_url }, + {name = "DATABASE_URL", value = module.core_dependency_services.rds_connection_string_sans_password }, + # Secrets - TODO move these to aws secrets + ] + + secrets = [ + { name = "DATABASE_PASSWORD", valueFrom = module.core_dependency_services.rds_db_password_id }, + { name = "API_KEY", valueFrom = module.core_dependency_services.api_key_secret_id }, + ] + } +} diff --git a/infrastructure/terraform/aws/modules/core-services/README.md b/infrastructure/terraform/aws/modules/core-services/README.md new file mode 100644 index 0000000000..1620c6aa0e --- /dev/null +++ b/infrastructure/terraform/aws/modules/core-services/README.md @@ -0,0 +1,98 @@ +# Setup +In a `main.tf` file for a workspace that needs a cluster, +you can use this module like: +``` +module "cluster" { + source = "../path/to/this/directory" + // version is disallowed when using path-based modules + + environment = "staging" + region = "us-east-2" + hosted_zone_id = "SOME-ZONE-ID" + // all other variables are optional +} +``` + +then +``` +terraform init +terraform apply +``` + +You will see these resources under `module.cluster.xyz`. + +## Managing the ECS Task Definition +Working with ECS task definitions in Terraform +is kind of awkward. + +This module creates an ECS task definition, +so that it can set up the ECS service that uses that task definition, +but expects that future task revisions will be created by a CI pipeline +as new images are created and pushed. +The `deploy_on_merge.yml` has an example of such a pipeline. +In this pipeline, +we get the ECS task definition from a file, +and interpolate the image, +to create a new revision. +Terraform ignores changes +made by the pipeline, +due to the `lifecycle` setting +on the ECS service resource. + +If you make changes to the task definition resource in this module, +and run `terraform apply` in the `ecs-staging` directory, +Terraform will update the task, +but the next time a new commit is pushed to git, +that change will be overwritten +by the definition in the `ecs-staging` directory, +that's used by the pipeline. + +Ideally these definitions would come from the same source +so if you're reading this +perhaps today is the day +to make that refactor! + +More information about the general wonkiness +of managing ECS with Terraform +can be found in [this Terraform issue.](https://github.com/hashicorp/terraform-provider-aws/issues/632) + +## Rotating the RDS Password +The RDS password is retrieved from AWS Secrets Manager +but that password is managed manually, +and rotating it requires downtime. + +To rotate it, you'll need to perform the following steps: +- Update the value of the Secrets Manager entry through the AWS console +- Update the value in the RDS instance through the AWS console. (At this point, the core container will stop being able to access the database.) +- Recreate the core container's service with `aws update-service cluster $CLUSTER_NAME --service $SERVICE_NAME --force-new-deployment` + +In the future the RDS should probably [manage its own password](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/rds-secrets-manager.html) +which will probably require changing the service's code +to fetch the password from Secrets Manager itself +rather than getting it passed in from an environment variable. + +## Rotating the ACM Certificate + +ACM issues certs that last for 1 year. They send you an email prior to renewing, but will automatically rotate the cert. + +Since we rely on DNS validation, it is necessary that our validation CNAMEs are present in route53, provided by this module. +The required records MAY not change, but if they have changed behind the scenes, this will catch up our terraform state: + +```bash +# from clean state , no changes to code + +# updates our state file's records of the Domain Validation Options on the cert (DVOs). +terraform apply + +# should show that new DNS records need to be created/updated, matching the DVOs. +terraform plan -out TMP.tfplan +terraform apply TMP.tfplan +``` + +For more info: see [AWS Docs](https://docs.aws.amazon.com/acm/latest/userguide/dns-renewal-validation.html). + +## Development + +When you change the resources in this directory, you must run `terraform apply` in the calling workspace to see changes. + +More info on developing [terraform modules](https://developer.hashicorp.com/terraform/language/modules/develop). diff --git a/infrastructure/terraform/aws/modules/core-services/main.tf b/infrastructure/terraform/aws/modules/core-services/main.tf new file mode 100644 index 0000000000..dbc568ce95 --- /dev/null +++ b/infrastructure/terraform/aws/modules/core-services/main.tf @@ -0,0 +1,91 @@ +# aws terraform provider config + +terraform { + required_version = ">= 0.12.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 4.0" + } + } +} + +# Static secrets +resource "random_password" "api_key" { + length = 32 + special = true + override_special = "-_.~!#$&'()*+,/:;=?@[]" +} + +resource "aws_secretsmanager_secret" "api_key" { + name = "api-key-${var.cluster_info.name}-${var.cluster_info.environment}" +} + +resource "aws_secretsmanager_secret_version" "api_key" { + secret_id = aws_secretsmanager_secret.api_key.id + secret_string = random_password.api_key.result +} + +# generate password and make it accessible through aws secrets manager +resource "random_password" "rds_db_password" { + length = 16 + special = true + override_special = "!#$%&*()-_=+[]{}<>:?" +} + +resource "aws_secretsmanager_secret" "rds_db_password" { + name = "rds-db-password-${var.cluster_info.name}-${var.cluster_info.environment}" +} + +resource "aws_secretsmanager_secret_version" "password" { + secret_id = aws_secretsmanager_secret.rds_db_password.id + secret_string = random_password.rds_db_password.result +} + +# network config +resource "aws_db_subnet_group" "ecs_dbs" { + name = "${var.cluster_info.name}_ecs_db_${var.cluster_info.environment}" + subnet_ids = var.cluster_info.private_subnet_ids + + tags = { + Name = "subnet group for ECS RDS instances" + } +} + +resource "aws_security_group" "ecs_tasks_rds_instances" { + name = "${var.cluster_info.name}-sg-rds-${var.cluster_info.environment}" + vpc_id = var.cluster_info.vpc_id + + ingress { + protocol = "tcp" + from_port = 5432 + to_port = 5432 + security_groups = var.cluster_info.container_security_group_ids + } +} + +# the actual database instance +resource "aws_db_instance" "core_postgres" { + identifier = "${var.cluster_info.name}-core-postgres-${var.cluster_info.environment}" + allocated_storage = 20 + db_name = "${var.cluster_info.name}_${var.cluster_info.environment}_core_postgres" + db_subnet_group_name = aws_db_subnet_group.ecs_dbs.name + engine = "postgres" + engine_version = "14" + instance_class = "db.t3.small" + vpc_security_group_ids = [aws_security_group.ecs_tasks_rds_instances.id] + username = var.cluster_info.name + password = random_password.rds_db_password.result + parameter_group_name = "default.postgres14" + skip_final_snapshot = true + + lifecycle { + ignore_changes = [ + password, + ] + } +} + + +### TODO : add Sentry? other stuff? + diff --git a/infrastructure/terraform/aws/modules/core-services/outputs.tf b/infrastructure/terraform/aws/modules/core-services/outputs.tf new file mode 100644 index 0000000000..f3590935ef --- /dev/null +++ b/infrastructure/terraform/aws/modules/core-services/outputs.tf @@ -0,0 +1,18 @@ +locals { + db_user = aws_db_instance.core_postgres.username + db_name = aws_db_instance.core_postgres.db_name + db_host = aws_db_instance.core_postgres.address + db_sslmode = "require" +} + +output "api_key_secret_id" { + value = aws_secretsmanager_secret.api_key.id +} + +output "rds_db_password_id" { + value = aws_secretsmanager_secret.rds_db_password.id +} + +output "rds_connection_string_sans_password" { + value = "postgresql://${local.db_user}@${local.db_host}:5432/${local.db_name}?sslmode=${local.db_sslmode}" +} diff --git a/infrastructure/terraform/aws/modules/core-services/variables.tf b/infrastructure/terraform/aws/modules/core-services/variables.tf new file mode 100644 index 0000000000..ecd025c2af --- /dev/null +++ b/infrastructure/terraform/aws/modules/core-services/variables.tf @@ -0,0 +1,15 @@ +variable "cluster_info" { + description = "infrastructure values output from v7-cluster" + + type = object({ + region = string + name = string + vpc_id = string + cluster_arn = string + environment = string + private_subnet_ids = list(string) + container_security_group_ids = list(string) + cloudwatch_log_group_name = string + lb_target_group_arn = string + }) +} diff --git a/infrastructure/terraform/aws/modules/ecs-service/README.md b/infrastructure/terraform/aws/modules/ecs-service/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/infrastructure/terraform/aws/modules/ecs-service/main.tf b/infrastructure/terraform/aws/modules/ecs-service/main.tf new file mode 100644 index 0000000000..c2de9f63b1 --- /dev/null +++ b/infrastructure/terraform/aws/modules/ecs-service/main.tf @@ -0,0 +1,62 @@ +module "ecs_service" { + source = "terraform-aws-modules/ecs/aws//modules/service" + name = "${var.cluster_info.name}-${var.service_name}" + + cluster_arn = var.cluster_info.cluster_arn + enable_execute_command = true + + # allow github actions to update the service without confusing TF + ignore_task_definition_changes = true + + cpu = var.resources.cpu + memory = var.resources.memory + desired_count = var.resources.desired_count + # execution_role_arn = aws_iam_role.ecs_task_execution_role.arn + # task_role_arn = aws_iam_role.ecs_task_role.arn + + # TEMPLATE Container definition(s). + container_definitions = { + + "${var.service_name}" = { + essential = true + image = "${var.repository_url}:latest" + port_mappings = [{ + protocol = "tcp" + containerPort = var.configuration.container_port + hostPort = var.configuration.container_port + }] + + environment = var.configuration.environment + + readonly_root_filesystem = false + + log_configuration = { + logDriver = "awslogs", + options = { + awslogs-group = var.cluster_info.cloudwatch_log_group_name, + awslogs-region = var.cluster_info.region, + awslogs-stream-prefix = "ecs" + } + } + # memory_reservation = 100 + } + } + + + load_balancer = { + service = { + target_group_arn = var.cluster_info.lb_target_group_arn + container_name = var.service_name + container_port = var.configuration.container_port + } + } + + subnet_ids = var.cluster_info.private_subnet_ids + security_group_ids = var.cluster_info.container_security_group_ids + assign_public_ip = false + + tags = { + Environment = "${var.cluster_info.name}-${var.cluster_info.environment}" + Project = "Pubpub-v7" + } +} diff --git a/infrastructure/terraform/aws/modules/ecs-service/outputs.tf b/infrastructure/terraform/aws/modules/ecs-service/outputs.tf new file mode 100644 index 0000000000..e69de29bb2 diff --git a/infrastructure/terraform/aws/modules/ecs-service/variables.tf b/infrastructure/terraform/aws/modules/ecs-service/variables.tf new file mode 100644 index 0000000000..a5482c797c --- /dev/null +++ b/infrastructure/terraform/aws/modules/ecs-service/variables.tf @@ -0,0 +1,51 @@ +variable "cluster_info" { + description = "infrastructure values output from v7-cluster" + + type = object({ + region = string + name = string + vpc_id = string + cluster_arn = string + environment = string + private_subnet_ids = list(string) + container_security_group_ids = list(string) + cloudwatch_log_group_name = string + lb_target_group_arn = string + }) +} + +variable "service_name" { + description = "name for this service" +} + +variable "repository_url" { + description = "url to the image repository (excluding tag)" +} + +variable "resources" { + description = "resources available to this container service" + type = object({ + cpu = number + memory = number + desired_count = number + }) + + default = { + cpu = 512 + memory = 1024 + desired_count = 1 + } +} + +variable "configuration" { + description = "Container configuration options" + + type = object({ + container_port = number + + environment = list(object({ + name = string + value = string + })) + }) +} diff --git a/infrastructure/terraform/aws/modules/v7-cluster/ecr.tf b/infrastructure/terraform/aws/modules/v7-cluster/ecr.tf new file mode 100644 index 0000000000..c3d58c2a10 --- /dev/null +++ b/infrastructure/terraform/aws/modules/v7-cluster/ecr.tf @@ -0,0 +1,37 @@ +# ecr repositories for all containers +resource "aws_ecr_repository" "pubpub_v7" { + name = "pubpub-v7" + image_tag_mutability = "MUTABLE" + + image_scanning_configuration { + scan_on_push = false # can set this to true if we want + } +} + +resource "aws_ecr_repository" "pubpub_v7_core" { + name = "pubpub-v7-core" + image_tag_mutability = "MUTABLE" + + image_scanning_configuration { + scan_on_push = false # can set this to true if we want + } +} + +# TODO: integrations may want to be one image alone ...? +resource "aws_ecr_repository" "pubpub_v7_intg_submissions" { + name = "pubpub-v7-integration-submissions" + image_tag_mutability = "MUTABLE" + + image_scanning_configuration { + scan_on_push = false # can set this to true if we want + } +} + +resource "aws_ecr_repository" "pubpub_v7_jobs" { + name = "pubpub-v7-jobs" + image_tag_mutability = "MUTABLE" + + image_scanning_configuration { + scan_on_push = false # can set this to true if we want + } +} diff --git a/infrastructure/terraform/aws/modules/v7-cluster/ecs.tf b/infrastructure/terraform/aws/modules/v7-cluster/ecs.tf new file mode 100644 index 0000000000..69426ef799 --- /dev/null +++ b/infrastructure/terraform/aws/modules/v7-cluster/ecs.tf @@ -0,0 +1,20 @@ +module "ecs_cluster" { + source = "terraform-aws-modules/ecs/aws//modules/cluster" + + cluster_name = "${var.name}-ecs-cluster-${var.environment}" + + cluster_configuration = { + execute_command_configuration = { + logging = "OVERRIDE" + log_configuration = { + cloud_watch_log_group_name = "/aws/ecs/aws-ec2" + } + } + } + + tags = { + Environment = "${var.name}-${var.environment}" + Project = "Pubpub-v7" + } +} + diff --git a/infrastructure/terraform/aws/modules/v7-cluster/main.tf b/infrastructure/terraform/aws/modules/v7-cluster/main.tf new file mode 100644 index 0000000000..302547a61e --- /dev/null +++ b/infrastructure/terraform/aws/modules/v7-cluster/main.tf @@ -0,0 +1,182 @@ +# aws terraform provider config + +terraform { + required_version = ">= 0.12.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 4.0" + } + } +} + +# Network configuration +resource "aws_vpc" "main" { + cidr_block = var.cidr +} + +resource "aws_internet_gateway" "main" { + vpc_id = aws_vpc.main.id +} + +resource "aws_subnet" "private" { + vpc_id = aws_vpc.main.id + cidr_block = element(var.private_subnets, count.index) + availability_zone = element(var.availability_zones, count.index) + count = length(var.private_subnets) +} + +resource "aws_subnet" "public" { + vpc_id = aws_vpc.main.id + cidr_block = element(var.public_subnets, count.index) + availability_zone = element(var.availability_zones, count.index) + count = length(var.public_subnets) + map_public_ip_on_launch = true +} + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.main.id +} + +resource "aws_route" "public" { + route_table_id = aws_route_table.public.id + destination_cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.main.id +} + +resource "aws_route_table_association" "public" { + count = length(var.public_subnets) + subnet_id = element(aws_subnet.public.*.id, count.index) + route_table_id = aws_route_table.public.id +} + +resource "aws_nat_gateway" "main" { + count = length(var.private_subnets) + allocation_id = element(aws_eip.nat.*.id, count.index) + # note that there is one nat gateway per private subnet, + # but the gateway must live in the public subnet + subnet_id = element(aws_subnet.public.*.id, count.index) + depends_on = [aws_internet_gateway.main] +} + +resource "aws_eip" "nat" { + count = length(var.private_subnets) + domain = "vpc" +} + +resource "aws_route_table" "private" { + count = length(var.private_subnets) + vpc_id = aws_vpc.main.id +} + +resource "aws_route" "private" { + count = length(compact(var.private_subnets)) + route_table_id = element(aws_route_table.private.*.id, count.index) + destination_cidr_block = "0.0.0.0/0" + nat_gateway_id = element(aws_nat_gateway.main.*.id, count.index) +} + +resource "aws_route_table_association" "private" { + count = length(var.private_subnets) + subnet_id = element(aws_subnet.private.*.id, count.index) + route_table_id = element(aws_route_table.private.*.id, count.index) +} + +# security groups for the load balancer and task +resource "aws_security_group" "alb" { + name = "${var.name}-sg-alb-${var.environment}" + vpc_id = aws_vpc.main.id + + ingress { + protocol = "tcp" + from_port = 80 + to_port = 80 + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + protocol = "tcp" + from_port = 443 + to_port = 443 + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + protocol = "-1" + from_port = 0 + to_port = 0 + cidr_blocks = ["0.0.0.0/0"] + } +} + +resource "aws_security_group" "ecs_tasks" { + name = "${var.name}-sg-task-${var.environment}" + vpc_id = aws_vpc.main.id + + ingress { + protocol = "tcp" + from_port = var.container_ingress_port + to_port = var.container_ingress_port + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = ["::/0"] + } + + egress { + protocol = "-1" + from_port = 0 + to_port = 0 + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = ["::/0"] + } +} + + +# load balancer +resource "aws_lb" "main" { + name = "${var.name}-lb-${var.environment}" + internal = false + load_balancer_type = "application" + security_groups = [aws_security_group.alb.id] + subnets = aws_subnet.public.*.id + + enable_deletion_protection = false +} + +resource "aws_lb_target_group" "main" { + name = "${var.name}-tg-${var.environment}" + port = 80 + protocol = "HTTP" + vpc_id = aws_vpc.main.id + target_type = "ip" + + # health_check { + # path = var.health_check_path + # interval = "30" + # protocol = "HTTP" + # matcher = "200" + # timeout = "3" + # unhealthy_threshold = "2" + # } +} + +resource "aws_lb_listener" "http" { + load_balancer_arn = aws_lb.main.arn + port = 80 + protocol = "HTTP" + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.main.arn + } +} + + +# logging + +resource "aws_cloudwatch_log_group" "ecs" { + name = "${var.name}-ecs-${var.environment}-container-logs" + + tags = { + Environment = var.environment + } +} diff --git a/infrastructure/terraform/aws/modules/v7-cluster/outputs.tf b/infrastructure/terraform/aws/modules/v7-cluster/outputs.tf new file mode 100644 index 0000000000..80e2e8b1a7 --- /dev/null +++ b/infrastructure/terraform/aws/modules/v7-cluster/outputs.tf @@ -0,0 +1,22 @@ +output "ecr_repository_urls" { + value = { + root = aws_ecr_repository.pubpub_v7.repository_url + core = aws_ecr_repository.pubpub_v7_core.repository_url + intg_submissions = aws_ecr_repository.pubpub_v7_intg_submissions.repository_url + jobs = aws_ecr_repository.pubpub_v7_jobs.repository_url + } +} + +output "cluster_info" { + value = { + region = var.region + name = var.name + vpc_id = aws_vpc.main.id + environment = var.environment + cluster_arn = module.ecs_cluster.arn + private_subnet_ids = aws_subnet.private.*.id + container_security_group_ids = [aws_security_group.ecs_tasks.id] + cloudwatch_log_group_name = aws_cloudwatch_log_group.ecs.name + lb_target_group_arn = aws_lb_target_group.main.arn + } +} diff --git a/infrastructure/terraform/aws/modules/v7-cluster/variables.tf b/infrastructure/terraform/aws/modules/v7-cluster/variables.tf new file mode 100644 index 0000000000..4045e3b8a3 --- /dev/null +++ b/infrastructure/terraform/aws/modules/v7-cluster/variables.tf @@ -0,0 +1,60 @@ +variable "name" { + description = "Familiar name of the stack" + default = "pubpub" +} + +variable "environment" { + description = "Name of the version/layer of the stack" +} + +variable "cidr" { + description = "The CIDR block for the VPC" + default = "10.0.0.0/16" +} + +variable "private_subnets" { + description = "a list of CIDRs for private subnets in the VPC, one for each availability zone" + default = ["10.0.128.0/20", "10.0.144.0/20"] +} + +variable "public_subnets" { + description = "a list of CIDRs for public subnets in the VPC, one for each availability zone" + default = ["10.0.0.0/20", "10.0.16.0/20"] +} + +variable "availability_zones" { + description = "a list of availability zones" +} + +# variable "container_port" { +# description = "The port the containers are listening on" +# default = 5050 +# } +# +# variable "container_environment" { +# description = "Environment variables for the containers" +# default = [] +# } +# +# variable "health_check_path" { +# description = "The path for the health check" +# default = "/v1/debug/health" +# } +# +# variable "hosted_zone_id" { +# description = "The ID of the hosted zone for the domain" +# } +# +# variable "subdomain" { +# description = "Prefix to domain name of hosted zone above, so serve app from" +# } + +variable "region" { + description = "Region for all resources (MUST agree with provider config)" + default = "us-east-1" +} + +variable "container_ingress_port" { + description = "port to allow traffic in private security group" + type = number +} diff --git a/infrastructure/terraform/aws/outputs.tf b/infrastructure/terraform/aws/outputs.tf new file mode 100644 index 0000000000..e69de29bb2 diff --git a/infrastructure/terraform/aws/variables.tf b/infrastructure/terraform/aws/variables.tf new file mode 100644 index 0000000000..be212d1bc1 --- /dev/null +++ b/infrastructure/terraform/aws/variables.tf @@ -0,0 +1,42 @@ +variable "region" { + description = "AWS region shortname" + type = string + default = "us-east-1" +} + +variable "name" { + description = "Proper name for this environment" + type = string +} + +variable "environment" { + description = "Functional name for this environment" + type = string +} + +variable "pubpub_url" { + description = "URL where pubpub will be addressable (include https://)" + type = string +} + +variable "MAILGUN_SMTP_USERNAME" { + description = "SMTP Username for Mailgun service" + type = string +} + +variable "NEXT_PUBLIC_SUPABASE_URL" { + description = "URL to Supabase public address for this install" + type = string +} + +# TODO deprecate this in favor of a Terraformed bucket +variable "ASSETS_BUCKET_NAME" { + description = "Name of the S3 bucket to store assets" + type = string +} + +# TODO: deprecate this in favor of terraformed iam/service roles +variable "ASSETS_UPLOAD_KEY" { + description = "AWS access key ID for uploading to s3" + type = string +} diff --git a/infrastructure/terraform/globals/.terraform.lock.hcl b/infrastructure/terraform/globals/.terraform.lock.hcl new file mode 100644 index 0000000000..ec30c9d73c --- /dev/null +++ b/infrastructure/terraform/globals/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.33.0" + constraints = ">= 2.0.0" + hashes = [ + "h1:kPm7PkwHh6tZ74pUj5C/QRPtauxdnzrEG2yhCJla/4o=", + "zh:10bb683f2a9306e881f51a971ad3b2bb654ac94b54945dd63769876a343b5b04", + "zh:3916406db958d5487ea0c2d2320012d1907c29e6d01bf693560fe05e38ee0601", + "zh:3cb54b76b2f9e30620f3281ab7fb20633b1e4584fc84cc4ecd5752546252e86f", + "zh:513bcfd6971482215c5d64725189f875cbcbd260c6d11f0da4d66321efd93a92", + "zh:545a34427ebe7a950056627e7c980c9ba16318bf086d300eb808ffc41c52b7a8", + "zh:5a44b90faf1c8e8269f389c04bfac25ad4766d26360e7f7ac371be12a442981c", + "zh:64e1ef83162f78538dccad8b035577738851395ba774d6919cb21eb465a21e3a", + "zh:7315c70cb6b7f975471ea6129474639a08c58c071afc95a36cfaa41a13ae7fb9", + "zh:9806faae58938d638b757f54414400be998dddb45edfd4a29c85e827111dc93d", + "zh:997fa2e2db242354d9f772fba7eb17bd6d18d28480291dd93f85a18ca0a67ac2", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f9e076b7e9752971f39eead6eda69df1c5e890c82ba2ca95f56974af7adfe79", + "zh:b1d6af047f96de7f97d38b685654f1aed4356d5060b0e696d87d0270f5d49f75", + "zh:bfb0654b6f34398aeffdf907b744af06733d168db610a2c5747263380f817ac7", + "zh:e25203ee8cedccf60bf450950d533d3c172509bda8af97dbc3bc817d2a503c57", + ] +} diff --git a/infrastructure/terraform/globals/README.md b/infrastructure/terraform/globals/README.md new file mode 100644 index 0000000000..bd137d7a9f --- /dev/null +++ b/infrastructure/terraform/globals/README.md @@ -0,0 +1,15 @@ +# Global terraform state module + +This module should generally be created by an admin, +and not applied or updated by a machine user. + +1. Uncomment the code creating this bucket; comment the backend block +1. terraform init +1. Set the bucket name +1. terraform apply +1. `terraform state rm aws_s3_bucket.terraform_state` +1. comment the bucket definition; uncomment the backend block +1. terraform init ("yes" to copying the state file) +1. destroy local copies of the state file + +This bucket name can now be in your s3.tfbackend files everywhere. diff --git a/infrastructure/terraform/globals/github_actions_iam.tf b/infrastructure/terraform/globals/github_actions_iam.tf new file mode 100644 index 0000000000..116850b7f3 --- /dev/null +++ b/infrastructure/terraform/globals/github_actions_iam.tf @@ -0,0 +1,83 @@ +# iam user for Github Actions + +resource "aws_iam_user" "github_actions" { + name = "github_actions" + path = "/" +} + +resource "aws_iam_access_key" "github_actions" { + user = aws_iam_user.github_actions.name +} + +resource "aws_iam_policy" "ecr" { + name = "ECRAdmin" + policy = jsonencode({ + Version: "2012-10-17", + Statement: [ + { + Sid: "EcrAdmin", + Effect: "Allow", + Action: [ + "ecr:*" + ], + Resource: [ + "*" + ] + } + ] + }) +} + +// iam policy to allow aws ecs update-service +resource "aws_iam_policy" "ecs" { + name = "ECSUpdateService" + policy = jsonencode({ + Version: "2012-10-17", + Statement: [ + { + Sid: "EcsUpdateService", + Effect: "Allow", + Action: [ + "ecs:UpdateService", + "ecs:DescribeServices", + "ecs:DescribeClusters" + ], + Resource: [ + "*" + ] + } + ] + }) +} + +resource "aws_iam_role" "github_actions_role" { + name = "github_actions_role" + + # Terraform's "jsonencode" function converts a + # Terraform expression result to valid JSON syntax. + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = [ + "sts:AssumeRole", + "sts:TagSession", // specifically used by AWS provided GH action modules + ] + Effect = "Allow" + Principal = { + AWS = [aws_iam_user.github_actions.arn] + } + }, + ] + }) +} + +resource "aws_iam_role_policy_attachment" "gha_attach_ecr" { + role = aws_iam_role.github_actions_role.name + policy_arn = aws_iam_policy.ecr.arn +} + +resource "aws_iam_role_policy_attachment" "gha_attach_ecs" { + role = aws_iam_role.github_actions_role.name + policy_arn = aws_iam_policy.ecs.arn +} diff --git a/infrastructure/terraform/globals/main.tf b/infrastructure/terraform/globals/main.tf new file mode 100644 index 0000000000..5c66e9362d --- /dev/null +++ b/infrastructure/terraform/globals/main.tf @@ -0,0 +1,38 @@ +# aws terraform provider config + +terraform { + required_version = ">= 1.5.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 2.0" + } + } + backend "s3" { + bucket = "pubpub-tfstates" + key = "global.tfstate" + region = "us-east-1" + } +} + + +provider "aws" { + region = "us-east-1" +} + +## s3 bucket for terraform state +# +# This resource was created using terraform with these fields. +# However, it is dangerous to manage terraform state files in +# a bucket that is itself Terraform-configured. +# +# it has been removed with `terraform state rm`, but this +# declaration is left here for posterity. +# +# resource "aws_s3_bucket" "terraform_state" { +# bucket = "pubpub-tfstates" +# acl = "private" +# versioning { +# enabled = true +# } +# } diff --git a/infrastructure/terraform/globals/outputs.tf b/infrastructure/terraform/globals/outputs.tf new file mode 100644 index 0000000000..cc28776c7b --- /dev/null +++ b/infrastructure/terraform/globals/outputs.tf @@ -0,0 +1,20 @@ +# if resources are needed in an environment's inputs, +# then you can use terraform_remote_state to get this module's output + +# put these creds in github actions secrets config +output "github_actions_user_credential" { + value = { + id = aws_iam_access_key.github_actions.id + secret = aws_iam_access_key.github_actions.secret + } + + # prevents this secret value from appearing accidentally + # NOTE - it is still saved in the state file. + sensitive = true +} + +# Provide this value to github actions +output "github_actions_role_to_assume_arn" { + value = aws_iam_role.github_actions_role.arn +} + diff --git a/jobs/package.json b/jobs/package.json index e683f20d6f..af91b2e8d3 100644 --- a/jobs/package.json +++ b/jobs/package.json @@ -6,8 +6,12 @@ "start": "tsx index.ts", "dev": "dotenv -e .env.local tsx index.ts" }, + "files": [ + "index.ts" + ], "dependencies": { "@pubpub/sdk": "workspace:*", + "contracts": "workspace:*", "graphile-worker": "^0.15.0", "tsx": "^3.13.0" },