From 294ee2ba9114fbf5d1227be0d30f1bdd6cb613fb Mon Sep 17 00:00:00 2001 From: Khai Do <3697686+zaro0508@users.noreply.github.com> Date: Wed, 20 Nov 2024 07:04:21 -0800 Subject: [PATCH] [IT-3995] Setup containerized infra for Agora app (#1) We are basically using Sage-Bionetworks-IT/schematic-infra-v2[1] as a template for this repo and updating files to fit agora app. * Update files for Agora deployment to ECS * Update to unused VPC CIDRs * Update certificate ARNs * Add ContainerVolume object for attaching volumes to containers * Add test for Service stack [1] https://github.com/Sage-Bionetworks-IT/schematic-infra-v2 --- .devcontainer/devcontainer.json | 15 ++ .flake8 | 16 ++ .github/CODEOWNERS | 1 + .github/PULL_REQUEST_TEMPLATE.md | 7 + .github/workflows/aws-deploy.yaml | 53 ++++++ .github/workflows/check.yml | 34 ++++ .github/workflows/deploy-dev.yaml | 18 ++ .github/workflows/deploy-prod.yaml | 18 ++ .github/workflows/deploy-stage.yaml | 18 ++ .gitignore | 121 ++++-------- .pre-commit-config.yaml | 42 ++++ .yamllint | 27 +++ README.md | 286 +++++++++++++++++++++++++++- app.py | 213 +++++++++++++++++++++ cdk.json | 66 +++++++ docs/acm-certificate.png | Bin 0 -> 54219 bytes requirements-dev.txt | 2 + requirements.txt | 3 + src/__init__.py | 0 src/ecs_stack.py | 34 ++++ src/load_balancer_stack.py | 24 +++ src/network_stack.py | 21 ++ src/service_props.py | 108 +++++++++++ src/service_stack.py | 248 ++++++++++++++++++++++++ tests/__init__.py | 0 tests/unit/__init__.py | 0 tests/unit/test_network_stack.py | 12 ++ tests/unit/test_service_stack.py | 56 ++++++ tools/setup.sh | 17 ++ 29 files changed, 1376 insertions(+), 84 deletions(-) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .flake8 create mode 100644 .github/CODEOWNERS create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/aws-deploy.yaml create mode 100644 .github/workflows/check.yml create mode 100644 .github/workflows/deploy-dev.yaml create mode 100644 .github/workflows/deploy-prod.yaml create mode 100644 .github/workflows/deploy-stage.yaml create mode 100644 .pre-commit-config.yaml create mode 100644 .yamllint create mode 100644 app.py create mode 100644 cdk.json create mode 100644 docs/acm-certificate.png create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/ecs_stack.py create mode 100644 src/load_balancer_stack.py create mode 100644 src/network_stack.py create mode 100644 src/service_props.py create mode 100644 src/service_stack.py create mode 100644 tests/__init__.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_network_stack.py create mode 100644 tests/unit/test_service_stack.py create mode 100755 tools/setup.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..3746fd1 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,15 @@ +{ + "name": "AWS CDK & Python Development Environment", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-22.04", + "features": { + "ghcr.io/devcontainers/features/node:1.5.0": { + "version": "22.6.0" + }, + "ghcr.io/devcontainers/features/python:1.6.3": { + "version": "3.12.0" + }, + "ghcr.io/devcontainers/features/aws-cli:1": {} + }, + "postCreateCommand": "./tools/setup.sh", + "shutdownAction": "stopContainer" +} diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..605fd1b --- /dev/null +++ b/.flake8 @@ -0,0 +1,16 @@ +[flake8] +exclude = + .git, + __pycache__, + build, + dist, + .tox, + venv, + .venv, + .pytest_cache +max-complexity = 12 +#per-file-ignores = +# docs/_api/conf.py: E265 +# integration-tests/steps/*: E501,F811,F403,F405 +extend-ignore = E203 +max-line-length = 120 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..3ff6571 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @Sage-Bionetworks-IT/sagebio-it @Sage-Bionetworks-IT/infra-oversight-committee diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..ffe15eb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ +DELETE THIS TEMPLATE BEFORE SUBMITTING + +PR Checklist: +[ ] Clearly explain your change with a descriptive commit message + +[ ] Setup pre-commit and run the validators (info in README.md) + To validate files run: `pre-commit run --all-files` diff --git a/.github/workflows/aws-deploy.yaml b/.github/workflows/aws-deploy.yaml new file mode 100644 index 0000000..08334ca --- /dev/null +++ b/.github/workflows/aws-deploy.yaml @@ -0,0 +1,53 @@ +# reusable template for deployments to AWS accounts +name: aws-deploy + +# Ensures that only one deploy task per branch/environment will run at a time. +concurrency: + group: ${{ inputs.environment }} + cancel-in-progress: false + +on: + workflow_call: + inputs: + aws-region: + type: string + default: us-east-1 + role-to-assume: + required: true + type: string + role-session-name: + required: true + type: string + role-duration-seconds: + type: number + default: 3600 + environment: + required: true + type: string + +jobs: + deploy: + permissions: + id-token: write + contents: read + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + - name: Install AWS CLI + run: sudo snap install aws-cli --classic + - name: Install AWS CDK CLI + run: npm install -g aws-cdk + - name: Install python dependencies + run: pip install -r requirements.txt -r requirements-dev.txt + - name: Assume AWS Role + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-region: ${{ inputs.aws-region }} + role-to-assume: ${{ inputs.role-to-assume }} + role-session-name: ${{ inputs.role-session-name }} + role-duration-seconds: ${{ inputs.role-duration-seconds }} + - name: CDK deploy + run: cdk deploy --all --concurrency 5 --require-approval never + env: + ENV: ${{ inputs.environment }} diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..8b08013 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,34 @@ +name: check + +on: + pull_request: + branches: ['*'] + push: + branches: ['*'] + +jobs: + unit-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + - name: Install dependencies + run: pip install -r requirements.txt -r requirements-dev.txt + - name: Run unit tests + run: python -m pytest tests/ -s -v + synth: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + - name: Install dependencies + run: pip install -r requirements.txt -r requirements-dev.txt + - name: Generate cloudformation + uses: youyo/aws-cdk-github-actions@v2 + env: + ENV: dev + with: + cdk_subcommand: 'synth' + actions_comment: false + debug_log: true + cdk_args: '--output ./cdk.out' diff --git a/.github/workflows/deploy-dev.yaml b/.github/workflows/deploy-dev.yaml new file mode 100644 index 0000000..7016982 --- /dev/null +++ b/.github/workflows/deploy-dev.yaml @@ -0,0 +1,18 @@ +name: deploy-dev + +on: + workflow_run: + workflows: + - check + types: + - completed + branches: + - dev + +jobs: + aws-deploy: + uses: "./.github/workflows/aws-deploy.yaml" + with: + role-to-assume: "arn:aws:iam::631692904429:role/sagebase-github-oidc-sage-bionetworks-it-schematic-infra-v2" + role-session-name: ${{ github.repository_owner }}-${{ github.event.repository.name }}-${{ github.run_id }} + environment: dev diff --git a/.github/workflows/deploy-prod.yaml b/.github/workflows/deploy-prod.yaml new file mode 100644 index 0000000..14a38aa --- /dev/null +++ b/.github/workflows/deploy-prod.yaml @@ -0,0 +1,18 @@ +name: deploy-prod + +on: + workflow_run: + workflows: + - check + types: + - completed + branches: + - prod + +jobs: + aws-deploy: + uses: "./.github/workflows/aws-deploy.yaml" + with: + role-to-assume: "arn:aws:iam::878654265857:role/sagebase-github-oidc-sage-bionetworks-it-schematic-infra-v2" + role-session-name: ${{ github.repository_owner }}-${{ github.event.repository.name }}-${{ github.run_id }} + environment: prod diff --git a/.github/workflows/deploy-stage.yaml b/.github/workflows/deploy-stage.yaml new file mode 100644 index 0000000..97b8069 --- /dev/null +++ b/.github/workflows/deploy-stage.yaml @@ -0,0 +1,18 @@ +name: deploy-stage + +on: + workflow_run: + workflows: + - check + types: + - completed + branches: + - stage + +jobs: + aws-deploy: + uses: "./.github/workflows/aws-deploy.yaml" + with: + role-to-assume: "arn:aws:iam::878654265857:role/sagebase-github-oidc-sage-bionetworks-it-schematic-infra-v2" + role-session-name: ${{ github.repository_owner }}-${{ github.event.repository.name }}-${{ github.run_id }} + environment: stage diff --git a/.gitignore b/.gitignore index 82f9275..465847b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,30 +1,14 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST +*.swp +package-lock.json +__pycache__ +.pytest_cache +.venv +*.egg-info + +# CDK asset staging directory +.cdk.staging +cdk.out + # PyInstaller # Usually these files are written by a python script from a template @@ -39,17 +23,14 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ -.nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover -*.py,cover .hypothesis/ .pytest_cache/ -cover/ # Translations *.mo @@ -59,7 +40,6 @@ cover/ *.log local_settings.py db.sqlite3 -db.sqlite3-journal # Flask stuff: instance/ @@ -72,51 +52,16 @@ instance/ docs/_build/ # PyBuilder -.pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints -# IPython -profile_default/ -ipython_config.py - # pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version +.python-version -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff +# celery beat schedule file celerybeat-schedule -celerybeat.pid # SageMath parsed files *.sage.py @@ -142,21 +87,33 @@ venv.bak/ # mypy .mypy_cache/ -.dmypy.json -dmypy.json -# Pyre type checker -.pyre/ +.idea/ +git-crypt.key + +# Elastic Beanstalk Files +.elasticbeanstalk/* +!.elasticbeanstalk/*.cfg.yml +!.elasticbeanstalk/*.global.yml -# pytype static type analyzer -.pytype/ +# sceptre remote templates +templates/remote/ + +# lambda artifacts +lambdas/*.zip + +# MAC Crap +.DS_Store + +# temp files +temp/ + +# pipenv +Pipfile* -# Cython debug symbols -cython_debug/ +# npm +node_modules/ -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +# sceptre +sceptre/**/templates/remote/ +.dump/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..32b7a9f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,42 @@ +default_language_version: + python: python3 + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: end-of-file-fixer + - id: mixed-line-ending + - id: trailing-whitespace + - repo: https://github.com/PyCQA/flake8 + rev: 7.1.1 + hooks: + - id: flake8 + - repo: https://github.com/adrienverge/yamllint + rev: v1.35.1 + hooks: + - id: yamllint + - repo: https://github.com/awslabs/cfn-python-lint + rev: v1.19.0 + hooks: + - id: cfn-python-lint + args: + - "-i=E1001" + exclude: | + (?x)( + ^.venv/| + ^tests/| + ^docker/| + ^temp/| + ^.github/| + ^.pre-commit-config.yaml + ) + - repo: https://github.com/psf/black + rev: 24.10.0 + hooks: + - id: black + - repo: https://github.com/sirosen/check-jsonschema + rev: 0.29.4 + hooks: + - id: check-github-workflows + - id: check-github-actions diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..b83bc42 --- /dev/null +++ b/.yamllint @@ -0,0 +1,27 @@ +--- + +extends: default + +rules: + braces: + level: warning + max-spaces-inside: 1 + brackets: + level: warning + max-spaces-inside: 1 + commas: + level: warning + comments: disable + comments-indentation: disable + document-start: disable + empty-lines: + level: warning + hyphens: + level: warning + indentation: + level: warning + indent-sequences: consistent + line-length: disable + truthy: disable + new-line-at-end-of-file: + level: warning diff --git a/README.md b/README.md index de746e1..a186287 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,284 @@ -# agora-infra-v3 -Project to deploy Agora to AWS + +# AWS CDK app + +AWS CDK app for deploying Agora. + +# Prerequisites + +AWS CDK projects require some bootstrapping before synthesis or deployment. +Please review the [bootstapping documentation](https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html#getting_started_bootstrap) +before development. + +# Dev Container + +This repository provides a [dev container](https://containers.dev/) that includes all the tools +required to develop this AWS CDK app. + +## Opening the project inside its dev container + +With VS Code: + +1. Clone this repo +2. File > Open Folder... +3. A prompt should invite you to open the project inside the dev container. If not, open VS Code + Command Palette and select "Dev Containers: Open Folder in Container..." + +With GitHub Codespaces: + +1. From the main page of this repository, click on the button "Code" > Codespaces > Click on the + button "Create codespace" + +That's it! You are now inside the dev container and have access to all the development tools. + +# Development + +All the development tools are provided when developing inside the dev container +(see above). These tools include Python, AWS CLI, AWS CDK CLI, etc. These tools +also include a Python virtual environment where all the Python packages needed +are already installed. + +If you decide the develop outside of the dev container, some of the development +tools can be installed by running: + +```console +./tools/setup.sh +``` + +Development requires the activation of the Python virtual environment: + +``` +$ source .venv/bin/activate +``` + +At this point you can now synthesize the CloudFormation template for this code. + +``` +$ cdk synth +``` + +To add additional dependencies, for example other CDK libraries, just add +them to your `setup.py` file and rerun the `pip install -r requirements.txt` +command. + +## Useful commands + + * `cdk ls` list all stacks in the app + * `cdk synth` emits the synthesized CloudFormation template + * `cdk deploy` deploy this stack to your default AWS account/region + * `cdk diff` compare deployed stack with current state + * `cdk docs` open CDK documentation + + +# Testing + +## Static Analysis + +As a pre-deployment step we syntactically validate the CDK json, yaml and +python files with [pre-commit](https://pre-commit.com). + +Please install pre-commit, once installed the file validations will +automatically run on every commit. Alternatively you can manually +execute the validations by running `pre-commit run --all-files`. + +Verify CDK to Cloudformation conversion by running [cdk synth]: + +```console +ENV=dev cdk synth +``` + +The Cloudformation output is saved to the `cdk.out` folder + +## Unit Tests + +Tests are available in the tests folder. Execute the following to run tests: + +``` +python -m pytest tests/ -s -v +``` + + +# Environments + +An `ENV` environment variable must be set when running the `cdk` command tell the +CDK which environment's variables to use when synthesising or deploying the stacks. + +Set environment variables for each environment in the [app.py](./app.py) file: + +```python +environment_variables = { + "VPC_CIDR": "10.254.192.0/24", + "FQDN": "dev.app.io", + "CERTIFICATE_ARN": "arn:aws:acm:us-east-1:XXXXXXXXXXX:certificate/0e9682f6-3ffa-46fb-9671-b6349f5164d6", + "TAGS": {"CostCenter": "NO PROGRAM / 000000"}, +} +``` + +For example, synthesis with the `prod` environment variables: + +```console +ENV=prod cdk synth +``` + +# Certificates + +Certificates to set up HTTPS connections should be created manually in AWS certificate manager. +This is not automated due to AWS requiring manual verification of the domain ownership. +Once created take the ARN of the certificate and set that ARN in environment_variables. + +![ACM certificate](docs/acm-certificate.png) + +# Secrets + +Secrets can be manually created in the +[AWS Secrets Manager](https://docs.aws.amazon.com/secretsmanager/latest/userguide/create_secret.html). +When naming your secret make sure that the secret does not end in a pattern that matches +`-??????`, this will cause issues with how AWS CDK looks up secrets. + +To pass secrets to a container set the secrets manager `container_secrets` +when creating a `ServiceProp` object. You'll be creating a list of `ServiceSecret` objects: +```python +from src.service_props import ServiceProps, ServiceSecret + +app_service_props = ServiceProps( + container_name="app", + container_port=443, + container_memory=1024, + container_location="ghcr.io/sage-bionetworks/app:v1.0", + container_secrets=[ + ServiceSecret( + secret_name="app/dev/DATABASE", + environment_key="NAME_OF_ENVIRONMENT_VARIABLE_SET_FOR_CONTAINER", + ), + ServiceSecret( + secret_name="app/dev/PASSWORD", + environment_key="SINGLE_VALUE_SECRET", + ) + ] +) +``` + +For example, the KVs for `app/dev/DATABASE` could be: +```json +{ + "DATABASE_USER": "maria", + "DATABASE_PASSWORD": "password" +} +``` + +And the value for `app/dev/PASSWORD` could be: `password` + +In the application (Python) code the secrets may be loaded into a dict using code like: + +```python +import json +import os + +all_secrets_dict = json.loads(os.environ["NAME_OF_ENVIRONMENT_VARIABLE_SET_FOR_CONTAINER"]) +``` + +In the case of a single value you may load the value like: + +```python +import os + +my_secret = os.environ.get("SINGLE_VALUE_SECRET", None) +``` + + +> [!NOTE] +> Retrieving secrets requires access to the AWS Secrets Manager + +# Deployment + +## Bootstrap + +There are a few items that need to be manually bootstrapped before deploying the application. + +* Add secrets to the AWS Secrets Manager +* Create an [ACM certificate for the application](#Certificates) using the AWS Certificates Manager +* Update environment_variables in [app.py](app.py) with variable specific to each environment. +* Update references to the docker images in [app.py](app.py) + (i.e. `ghcr.io/sage-bionetworks/app-xxx:`) +* (Optional) Update the `ServiceProps` objects in [app.py](app.py) with parameters specific to + each container. + +## Login with the AWS CLI + +> [!NOTE] +> This and the following sections assume that you are working in the AWS account +> `org-sagebase-itsandbox` with the role `Developer` and that you are deploying +> to the `us-east-1` region. If this assumption is correct, you should be able +> to simply copy-paste the following commands, otherwise adapting the +> configuration should be straightforward. + +Create the config file if it doesn't exist yet. + +```console +mkdir ~/.aws && touch ~/.aws/config +``` + +As a Developer working in Sage IT Sandbox AWS account, add the following profile to the config file. + +```ini +[profile itsandbox-dev] +sso_start_url = https://d-906769aa66.awsapps.com/start +sso_region = us-east-1 +sso_account_id = XXXXXXXXX +sso_role_name = Developer +``` + +Login with the AWS CLI: + +```console +aws --profile itsandbox-dev sso login +``` + + +## Deploy + +Deployment requires setting up an [AWS profile](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-quickstart.html) +then executing the following command: + +```console +AWS_PROFILE=itsandbox-dev AWS_DEFAULT_REGION=us-east-1 ENV=dev cdk deploy --all +``` + +## Force new deployment + +```console +AWS_PROFILE=itsandbox-dev AWS_DEFAULT_REGION=us-east-1 aws ecs update-service \ + --cluster \ + --service \ + --force-new-deployment +``` + +# Execute a command from a container running on ECS + +Once a container has been deployed successfully it is accessible for debugging using the +[ECS execute-command](https://docs.aws.amazon.com/cli/latest/reference/ecs/execute-command.html) + +Example to get an interactive shell run into a container: + +```console +AWS_PROFILE=itsandbox-dev AWS_DEFAULT_REGION=us-east-1 aws ecs execute-command \ + --cluster AppEcs-ClusterEB0386A7-BygXkQgSvdjY \ + --task a2916461f65747f390fd3e29f1b387d8 \ + --container app-mariadb \ + --command "/bin/sh" --interactive +``` + + +# CI Workflow + +This repo has been set up to use Github Actions CI to continuously deploy the application. + +The workflow for continuous integration: + +* Create PR from the git dev branch +* PR is reviewed and approved +* PR is merged +* CI deploys changes to the dev environment (dev.app.io) in the AWS dev account. +* Changes are promoted (or merged) to the git stage branch. +* CI deploys changes to the staging environment (stage.app.io) in the AWS prod account. +* Changes are promoted (or merged) to the git prod branch. +* CI deploys changes to the prod environment (prod.app.io) in the AWS prod account. diff --git a/app.py b/app.py new file mode 100644 index 0000000..ffd8e91 --- /dev/null +++ b/app.py @@ -0,0 +1,213 @@ +from os import environ + +import aws_cdk as cdk + +from src.ecs_stack import EcsStack +from src.load_balancer_stack import LoadBalancerStack +from src.network_stack import NetworkStack +from src.service_props import ServiceProps, ContainerVolume +from src.service_stack import LoadBalancedServiceStack, ServiceStack + +# get the environment and set environment specific variables +VALID_ENVIRONMENTS = ["dev", "stage", "prod"] +environment = environ.get("ENV") +match environment: + case "prod": + environment_variables = { + "VPC_CIDR": "10.254.174.0/24", + "FQDN": "prod.agora.io", + "CERTIFICATE_ARN": "arn:aws:acm:us-east-1:681175625864:certificate/69b3ba97-b382-4648-8f94-a250b77b4994", + "TAGS": {"CostCenter": "NO PROGRAM / 000000"}, + } + case "stage": + environment_variables = { + "VPC_CIDR": "10.254.173.0/24", + "FQDN": "stage.agora.io", + "CERTIFICATE_ARN": "arn:aws:acm:us-east-1:681175625864:certificate/69b3ba97-b382-4648-8f94-a250b77b4994", + "TAGS": {"CostCenter": "NO PROGRAM / 000000"}, + } + case "dev": + environment_variables = { + "VPC_CIDR": "10.254.172.0/24", + "FQDN": "dev.agora.io", + "CERTIFICATE_ARN": "arn:aws:acm:us-east-1:607346494281:certificate/e8093404-7db1-4042-90d0-01eb5bde1ffc", + "TAGS": {"CostCenter": "NO PROGRAM / 000000"}, + } + case _: + valid_envs_str = ",".join(VALID_ENVIRONMENTS) + raise SystemExit( + f"Must set environment variable `ENV` to one of {valid_envs_str}. Currently set to {environment}." + ) + +stack_name_prefix = f"agora-{environment}" +fully_qualified_domain_name = environment_variables["FQDN"] +environment_tags = environment_variables["TAGS"] +agora_version = "edge" + +# Define stacks +cdk_app = cdk.App() + +# recursively apply tags to all stack resources +if environment_tags: + for key, value in environment_tags.items(): + cdk.Tags.of(cdk_app).add(key, value) + +network_stack = NetworkStack( + scope=cdk_app, + construct_id=f"{stack_name_prefix}-network", + vpc_cidr=environment_variables["VPC_CIDR"], +) + +ecs_stack = EcsStack( + scope=cdk_app, + construct_id=f"{stack_name_prefix}-ecs", + vpc=network_stack.vpc, + namespace=fully_qualified_domain_name, +) + +# From AWS docs https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-connect-concepts-deploy.html +# The public discovery and reachability should be created last by AWS CloudFormation, including the frontend +# client service. The services need to be created in this order to prevent an time period when the frontend +# client service is running and available the public, but a backend isn't. +load_balancer_stack = LoadBalancerStack( + scope=cdk_app, + construct_id=f"{stack_name_prefix}-load-balancer", + vpc=network_stack.vpc, +) + +api_docs_props = ServiceProps( + container_name="agora-api-docs", + container_location=f"ghcr.io/sage-bionetworks/agora-api-docs:{agora_version}", + container_port=8010, + container_memory=200, + container_env_vars={"PORT": "8010"}, +) +api_docs_stack = ServiceStack( + scope=cdk_app, + construct_id=f"{stack_name_prefix}-api-docs", + vpc=network_stack.vpc, + cluster=ecs_stack.cluster, + props=api_docs_props, +) + +mongo_props = ServiceProps( + container_name="agora-mongo", + container_location=f"ghcr.io/sage-bionetworks/agora-mongo:{agora_version}", + container_port=27017, + container_memory=500, + container_env_vars={ + "MONGO_INITDB_ROOT_USERNAME": "root", + "MONGO_INITDB_ROOT_PASSWORD": "changeme", + "MONGO_INITDB_DATABASE": "agora", + }, + container_volumes=[ + ContainerVolume( + path="/data/db", + size=30, + ) + ], +) +mongo_stack = ServiceStack( + scope=cdk_app, + construct_id=f"{stack_name_prefix}-mongo", + vpc=network_stack.vpc, + cluster=ecs_stack.cluster, + props=mongo_props, +) + +# It is probably not appropriate host this container in ECS +# data_props = ServiceProps( +# container_name="agora-data", +# container_location=f"ghcr.io/sage-bionetworks/agora-data:{agora_version}", +# container_port=9999, # Not used +# container_memory=2048, +# ) +# data_stack = ServiceStack( +# scope=cdk_app, +# construct_id=f"{stack_name_prefix}-data", +# vpc=network_stack.vpc, +# cluster=ecs_stack.cluster, +# props=data_props, +# container_env_vars={ +# "DB_USER": "root", +# "DB_PASS": "changeme", +# "DB_NAME": "agora", +# "DB_PORT": "27017", +# "DB_HOST": "agora-mongo", +# "DATA_FILE": "syn13363290", +# "DATA_VERSION": "68", +# "TEAM_IMAGES_ID": "syn12861877", +# "SYNAPSE_AUTH_TOKEN": "agora-service-user-pat-here", +# }, +# ) +# data_stack.add_dependency(mongo_stack) + +api_props = ServiceProps( + container_name="agora-api", + container_location=f"ghcr.io/sage-bionetworks/agora-api:{agora_version}", + container_port=3333, + container_memory=1024, + container_env_vars={ + "MONGODB_URI": "mongodb://root:changeme@agora-mongo:27017/agora?authSource=admin", + "NODE_ENV": "development", + }, +) +api_stack = ServiceStack( + scope=cdk_app, + construct_id=f"{stack_name_prefix}-api", + vpc=network_stack.vpc, + cluster=ecs_stack.cluster, + props=api_props, +) +api_stack.add_dependency(mongo_stack) + +app_props = ServiceProps( + container_name="agora-app", + container_location=f"ghcr.io/sage-bionetworks/agora-app:{agora_version}", + container_port=4200, + container_memory=200, + container_env_vars={ + "API_DOCS_URL": f"http://{fully_qualified_domain_name}/api-docs", + "APP_VERSION": f"{agora_version}", + "CSR_API_URL": f"http://{fully_qualified_domain_name}/api/v1", + "SSR_API_URL": "http://agora-api:3333/v1", + }, +) +app_stack = ServiceStack( + scope=cdk_app, + construct_id=f"{stack_name_prefix}-app", + vpc=network_stack.vpc, + cluster=ecs_stack.cluster, + props=app_props, +) +app_stack.add_dependency(api_stack) + +apex_props = ServiceProps( + container_name="agora-apex", + container_location=f"ghcr.io/sage-bionetworks/agora-apex:{agora_version}", + container_port=80, + container_memory=200, + container_env_vars={ + "API_DOCS_HOST": "agora-api-docs", + "API_DOCS_PORT": "8010", + "API_HOST": "agora-api", + "API_PORT": "3333", + "APP_HOST": "agora-app", + "APP_PORT": "4200", + }, +) +apex_stack = LoadBalancedServiceStack( + scope=cdk_app, + construct_id=f"{stack_name_prefix}-apex", + vpc=network_stack.vpc, + cluster=ecs_stack.cluster, + props=apex_props, + load_balancer=load_balancer_stack.alb, + certificate_arn=environment_variables["CERTIFICATE_ARN"], + health_check_path="/health", +) +apex_stack.add_dependency(app_stack) +apex_stack.add_dependency(api_docs_stack) +apex_stack.add_dependency(api_stack) + +cdk_app.synth() diff --git a/cdk.json b/cdk.json new file mode 100644 index 0000000..e86570a --- /dev/null +++ b/cdk.json @@ -0,0 +1,66 @@ +{ + "app": "python3 app.py", + "watch": { + "include": [ + "**" + ], + "exclude": [ + "README.md", + "cdk*.json", + "requirements*.txt", + "**/__init__.py", + "**/__pycache__", + "tests" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": [ + "aws", + "aws-cn" + ], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, + "@aws-cdk/aws-iam:standardizedServicePrincipals": true, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, + "@aws-cdk/aws-route53-patters:useCertificate": true, + "@aws-cdk/customresources:installLatestAwsSdkDefault": false, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, + "@aws-cdk/aws-redshift:columnId": true, + "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, + "@aws-cdk/aws-kms:aliasNameRef": true, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, + "@aws-cdk/aws-efs:denyAnonymousAccess": true, + "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, + "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, + "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, + "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, + "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, + "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, + "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true, + "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true, + "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true, + "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true, + "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true, + "@aws-cdk/aws-eks:nodegroupNameAttribute": true + } +} diff --git a/docs/acm-certificate.png b/docs/acm-certificate.png new file mode 100644 index 0000000000000000000000000000000000000000..343557f2699bf8fda7fa3d9501b687a9e9e9acc9 GIT binary patch literal 54219 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

<=J$N-|TVzD? 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 z zm3{tWi@wrsy==+n1~I#(ziwAz^Id5=yEV>8q?@-K?=J(BPv2I1h*L0-L5;Ul@>}LB zn0Kq%EuIFG9`1l}C(vtzC@LBV6Gz(*6WlaPomig5KF-kXiI+b;BxS(qi55u& zZHTm_A{p7RNI6R*fM!!c6}x13=RmEX&Ys*vr`cB~-e7o3Krtd&t5OOp55Gbsku5=l zrhPYeGcfAQdL_fO)K|eXY$DSb(jTO6s?8@+@AxZ@0&4IBtHzbVrc@FymhR*!R+am5 zk4CLuTKoI2q4n!+Q}u^%?rG}a5%B}tiiui>X}=M&$A(!Co=|wPXjG|xmQC8)-jxg? zVKTu&B0^>OaX4V~&?rSDrNUu5x&Aw^?MmTc8gNyC4piSV&aRwJ*ZHsXosNIV0Mb`# z-RIP9V?g1DJ4*tu*=Os9X~NUJ9enz{Cw&E1loS_hW2Nrf2$yU(+Fee9Iy%C2Huiog zxc<7M7BsNwD&Z+bQj2y9AxCN&NxA)qDvK*^8aHg`XMApRC6P?>b26$Bw)P5qTeOX- zy%^i$?5}qPYI=JmN(@I830PIBK^|&k5aAU^a)CQa+I)a?G_z9g=0d>y!l#6U5~Fch zynE;BZl=SEvaw+&WPA?aJT6^nr4~B+(hS&6T5AZ{;&a5iVtLfuOsAh&FP@7QXa^A9 zP2@2NL7&2H_InDNrDSyWUVX42b)k39S5KY0L(O9rKTzyMZ$h|Oqp&H4Td$`rW1 zQ-WAqk-pB3!XnjZ8a3}%i7xh|ujq;sn8fV4T@ywXo zt3GH3RUeHQ#3ECB9`x3d@Ggl2t+q(9S8N1O0ipTK22SRSB((>pFqqYEQ##w5MFIIO z^gbc|{@Ua)fz{v_#fbBhJ!78nvdu7nUl@%)qQaTlB1QEBBU|yp|=NA097quZ~nEyeAWw=uM@Xk&en{&>GIa{{;1jTNC!edVWbp zsMm4WD~b1t+4*SegE+?0%13z+-bD9hwOpgx_BOQCeQiX%%J%Zv1A`vjSj-3#&iZhq zGyB!)1s)shhv{Tf7>4`a-wiL{@?8k$jy_Ifj<@pxX*X74C)^6^`4R0P1g z*}%zXbx@j;xTq<$$;C29F)4%ruIU?KX=!6ls4na_Nk~L_3}NgS+Ih>5?-P>8?Ju>f z&5ELwe1SbpmrariBxL(?27}SbMkx0Y%W;ZwSZ%&1?vn<+%V=GWEgwKTDEhKy%YiUM z)YHLy@-*1G|NKWYkKOt}nfr0G4q)H;4FR@765D(+ZQ|+j8 zJ=5EZHp2t_YBmV}fA;o!6ZSVBjF_jHA)6@Lsqi5C)1{jRxymW`l(8 zsJ-A7zvsZ0lc=)pGc|$!WH(pm&azFu_diVM77OPuj3-vq*r-0*EH6cM`w}8sL^H?f z&Yq7RR+hdWSv|GEUWE7>?s>ZM(WzHhMhNQI^HFf!lTCcP)j9ks?~6fXV}U@mv`^sR zejWJNr$Xap9ORldLObu4G3HcJw*5cZC)IU?YoM@&h`^{9Y>z3P(AKY})RR!XN4U`m zI9oQTifl8Bd6WQ9&-)8@K-a;Z$99 z+e4LnQ=|K@D#8Mjg_H`S3N1vx%?7h-)xFPEEtTj5l}B-)m1PYO)E}DoKgJYmm_hNo zQO<1lC9;gcm-}uL{t>Qr%Sve7L2HH8T*o;|(Bl4Ty`B%6Or6VOpo>E7ml<;2_5Ps) z0%rAGW( ziE(=5cQwtdcCbvcXviU{=|6RO;e>%IKBJe!ZdH7KaYTIK4(}$XdYS%B{=kC#M3lG) zLs|z_`wiG6_|D(uaXI$C6!LI84nA>5U2MczEwmm?k!TSN=f9UbR#issvIpSTOHD}t zZaMC{WM@y8n`LNjhX`FTz`NM3mjkFjRGj+?JBn598R#dH@6xaq#AZEmWxyMKF>1h} zw;6vY;=T^*edQ675!BQh!#0M)q~Xt|Iq7> zP(gVOv}PMP-(IG*XCKV~B^9y_!j2a%c^t6nb+o!)a@ZiGVek{K2g6#ho5R!EAa1rz ziBZl?{+=Qd>5Vg<a!Nb{xf^iw~KyIUOu7ixZ2bQv~zw^4UM< z=jVTva>NaZBH>&o!X<3;KZ3o=7rm25y31qI=^LCd7!*S(f|y4$hWOH^8OQ)YmP6K& zxPyyh^F&)mIL*o9xeGTQbXzDtRahRyax|n69SCrObi3ol#qs*sKh7|)=efYfM9x98 zz*eb|GpsA1VM(_@;&WOlS1yWqHLp1&6+WDOt#!0sZ z3$82Eb)=RGd#$BjlhfTMiZc<&-~w^u0UeTNz?(87EGUGt#d{K)p% zN~&Lfn>*xyK4en-@_DMlqWea;UWhBGi-*pcF$XFHlGlE6sd5WauF{{$$Xlu z{-gyoFUc1Fxa|g1g05r&_7j-96R);eZaE!#`Z3PYlkAj%j*OKSm%vz3m%Cgg;H0}O zQs4UK*2a7`;n@O2+HY7CSMXnn9=X$8PQjDv7)%{uLwti^lS$^1)#79!FJ{u)_+xCQ+~oLM?Ckll909o^ag_o@7U1ImU<9k z4OP5ETHUG7XJpQ$P=m;b9W!DgI!xCgt&#CoxQC)YPv>JQPu%&^&=0Yyp7b|^%I_fc zkF{6gJb+}2L$oHVw48A{GiAxkYMGwg#1^*aghFux11VF@# z7EC)Cmd(@460uDhUo@`evcRZC3l(#fdk!_{b)gfpSYj)iXG8?a)-_rodbV6%w{R9h zj(+e80@)dvx^kJVXVk)ee|NV`sCY15YY!3|`$qnbQ)89gIyKln6LiJ-P~|>U=}WIN zs}->=F}|pEs=iz6J!DlcSZ*<$o$9<2=6;4wjgJ%0XZ9UAfD8~5{ZviW4(ng2K37^3 z{?IXGI`vJPtPkef(S-J}Yx^XowIFCw3;{-WG=!*kGqY6Zsq(B@Rv?1KVp3lZ{u z3!2Wp69b`Uk1y7m2|;Aho2lmky3`zRQd-ZSFV{3-q&zahlleNu4%ulw%Il`=LBxM| zo^MZV{zo?O3Rk8b@_skqSvwcZ4fE zZ?-}A;wD2vo~biw*Vl`pcQC)NR#$wBAW3MO*t*vAgfYMnwF3t=LZY^fz?+m{_xIJ1wEwqsFVE-YLMxgo=PM`@;F05Y5?Mj8LI1AJZwKz9Ao`9 z;=fQ@Yl=kK3#O{7&_FH{3w_XQ zbwKSMPi$;2T7gM5#TZ1?hU!M{3(-h~P|A=Len+XMI^0(<2JIeFH-FKZW4)nJSthyL zH$iVb(T%$9nG=(tk|L*McDXVgKYK>EBAgEgUZi@a%14q|9mHms(;JD1`Za#Vt;$*a6i^wUm+3i{;ctreOmM#Uc|#mBRu zFcp6WqNTOB9C`F|Dc=gIkfu*0FFStf&>h88+J?}4Z*G!cQM>v$TY+;6=SksmZ^~*> zQnh{dhTbTM}If&RHb2w)tv_YY2p)|)u zhhRSt^(i+UHLq#S>2~4k#gRXUvS{Y|UqUwzoV8qE->S)vo~*EvHy+H-5t8DdxHX#% zyWNbViiwRUYFz8Ql)E>*+^>>l9eQ=aJUvukvScRP51Z$7XZ>K`Ry~yUDbwKAFycG_ zMON-U@u%*D9TU}$y(`=i$5@6aGLt`sX8FEIer*4Pck;63T$<@NUH~M(RjFBBuFKJT z`R7@a1jOs&G~tvtv)pHg=(HUNJ=pS%KXxY$>MdzQ?@JIpJwO^*`Elk(C@m_}jJWTO z#*4FT9VMPOO>r@?D8=j*yR5#RINnLJar`}kn*eMWll!4WxxtciWrn}{xOOz;_WU&WnTq@=W+bsn4-C*Yxf9X zlH1oXH>1yl?6u_WGg&l+Q}MH@pG$tgy-ea)dB7BO5pd}%-UEBrNE$3chPj{;+9&#` z-QX4ykMd*2fSr&O-h*<1Faz;|H{_g4Jvt)URyqY?Yg+EG*OlLx)iR%!M*KUXiRij@0>7m zuz5wfy=e8fafK3!4>`I2h(hxN97bi*LoX1*qhbI}>D(shfoh<&&98(wwAX`v(QNT; z5mA?n_f3qPPy!I1u>7QHJ^)7odVr%y_MQJ>iDF&>tyQWRW817oqmCpyeUH<`Q_yV@ zIa#$UsU-LHH$294-%z5wAjPBy)Sc+qCA&47dmZ6vyY=MtYZJPi{gthlsBaK}RQB?Y z)QDfn!)ar9?jL~GVlg0~{aT19@OBL_8 zIz=Rj*mBf0?;t*<`98fPMYXxyYpKdo=ql`02|ZS)E)d<}h0wFcT0ex6NzOyP>K*>qRRUwU2gqIc5mr;YQ) z&=Z_@L8l*UFFBd3e8W!OiU*Bk3j33f)OWg3ZIE%Dhc@1OUmHGzw%6;hIhN*%`tD{T zOL$<7G!|33c3AUI%KV)Xa_>TF7lJ+ayce47bwNDKINPy7fpr0AL1AjlAAKyx2KDP$ zy|+GO%5?*5pj{d|>g#v=Fh<1|D_PzE>4B!K(^s!6LL3>-_SptC$fuyn8ZZhI1Dz;% zZN<)5w&K8Gtbxq>MKP&K2<<3-mEcSe*lIp5qB$5j%p>h;dOm7*mX4bO*_P!g?5cBjk;%?exa0u4 z8A&nwt>eZ1dUyGi*ZKF}Yk{LMVq>U>v@>Hch-=_{;kBPY)_~qb94fHJNQg4f&XA6JjcuBr zm^@pAa}#z$$BNmXtxL&^ivJ{`z-0BVp!XI5aB(bS&fny#sbTj%YkENl^-_&%wZtJ0 zB6Lf?suYy$(TF0(LsMpj(BGOl)Vshr^Ainpc z#QVf*J;Ba!S{4N>nFYh^DBGbRnNc^X6ouTO1Yr*Gr21+?MJd-R__I0w{^!wP3Fl1)~DYkyR#L*=CRo-XvwrR|*?5}nH&to+`F8xue z^?T#>x1zRPNvOJD=JVTaR>n6j>voEnw-O39ys4Wzv74t!Pmz$x$2i{}k13@E)ff$Ml)|t!?F+A?3R!F=j`k9Z&|LPb4D>TZ%EAzNc#q#@( z%eLeB*pV9xw9%5KodPy=0@HkJb+5fjrM0uy`3k;nv{%e_Vq;?OTm}eTax$?~h z1LfUL=kc@I+pg2MqmNjguyJ((&a(W}*52XU;uzt+Fkjq9bgepz>fNXuKR<0R8 zv@sTCPrsIPpFrxn?g}C7 z;09au?r~m1_vxZ8Y$6nTFsJBM0{TvKxRNjz?LVYE3~n?% zntuG0P)~`{ot!X_I0r}=a1zmGeu_hX{zFE3AgjlCY~!nFl-K1F|1(7XCgzTV0E<`8 z&hbBDL0^Cz57L1xqIKy%Naz_hcxXsd%k#g9q`wbDff!Mp82AtW zRIDI#BC=j7QyU0t)w$gN^yFsuTG|%X6JN|Q1Ba^W`bc)|sZcLKp7U_Of7r4e>2}$K zqEZ8m<99xMPVy*=erK+2a1W_C?=t(vi!H)SBZ|Gh&+k76$0;72PEacKwE+_bI9H^$ zcY0s~XKxldwZwpH@_ycU2|k3S^NV5uoz~KQ!+J)=FXm+EKYAY;^hi}x@U7Va>!evs z?c2qa!_qvb*9AMJt}a)XUhjBQ!OFN4S$BboKDIAtzp%mq+M8z3N=3X1Bw%6zUA*^7 zW3foNjFNyaDy=<5(3KIu|KY&88EYede;#mQCr(?{v_skb!k{tN8!Z~j@j%hD1BwN& zP2g+Vcd}R~j1RE*``Z5JpfRN3tP=GK={elV2rQLhpkd93(r=EvsE{Q0Xb~^pZxqd_ zM#;^7p-=bpr=Nw~9kmmB+X6YPL@4FZSu#B-a;5(kwTDp&-J$UGC}DO!%f+`h7TkND{xtdd?qX+}kwVVvh^?#j>~YY-`;~SK{ZY(Zg2z7K=Xq?FG5Cvd zeEzbRJ;NK<<1?QCH4Eq>!359UT4vLH8ZalG;Z(|qM=b{V7R{1#ccfgt$p?de^`R5G z$@&EF-=k?_lM6;NDi}Xql)96?%r=?Z{J^;71s~=a2F-UWD3zdVPJvw?@YL$I1}fa! zf1EqSBN<}4`y?+n`WfbW*IHmr{i4m>nz=E{MZB%Y)}ra+U`1= zwmd>bcw@8F6=ODT|MUrd-+5Wt=@4tqt4F|V5#F1^BW_OSg6_ToW2g@6YT;LRvgojyU)Svca! z?V%}#Lx4zzAmbxPq&4bHoE^DE0Mv)aZ7%&b)5|vhikJ|Ujm>`dE0#>TD;AKE7WueH z$7JP^sI4||u|^&5%#SI2v5&_hPFrqx$oFoiJwe*JQ98B7USB`#eQZev7mxhE;2b-#nB& zo5+2qX;QknwtP^f`bEdK>#6koz3$By2lAP6CZj~h7010o#n5jLDs8{*IXN}doB$gS zKinwxH+~NeAa4KV(b9x_v^vQ|h&cY^Gg4aU2-iIy36EbMWWt8qcTInpqTU_1+nXso zra@}4Ix^QtZ&Mmv@Yxvy&LLHhNK1!;v2bLKwL!T-#Ma4WKA|hNNO15-)u;!wQXWgA zzTMs#T|g~FJgb>1#=$m9MXpwQsR^hO)BLM#yBggGd`#VmGx2RmW)?9_T}>g{5igKc**5IMVKSi~Nm6 zb2G*cn&!_m$(U^Uw(<6@sakrV&kBoXiT?&15Yd%3gikw{rXA5lB=x7q)^asqQ`lr& zqN?S_tboQWDewvQPN5G2O*cC}Ha6R`BhI;PZLC(oVze%rq^Tx;i`;@g=~6I|m81BX zxM23wF6WUq0ftiJ45!acytm6MMf28kBM3Y?VjAUJ0vWI40*lQ$oerGxHEJj79Cg}d z1}vBLWfD3-_wLuUcB^9=PM2fjJxD)&eNre5S;h;uU!KP~mE_$?;@Cd&vlm*W6qdco_OPbaMrP#*h$050gKcnch2X3j z^&1#LL7rse?cu%7#8NOm^VKWAv@U-8FfLg%-yU4t5Ad7ZBQot?h{aKk7V-e&z+r3U zZ&7(TK)27$RC&HS^PkD`pf3i!vl~4P2<`f*Z|t0Mg2_keFhpDrK{`> z9a>$P&lK@TF-9r%?%u~I4}M|jzm&pjABA);(J?@UmQj(pS3Ks$5`6P>C!yH~D>DX- zlPB<3)7FGzl0zbojRX%A7FO5LS$sOp_0od7W~x0T zC9I%6n{C8gyQUcUv8?mP>@sB533gq_6-n@di{F?G=W*L@Q_r`(XXlsi^naVdKPD0DfBs*nJEH1@e@}GMVO?Zzw+| zFlFiO3{;I)td{=6Y5YiZQvs35EUv0MD@#~9eV(dPx?00SRce|Z&q3u zz{+No)RIauFwCiRUDrzDA|8mNKQrGN1?U5?(B7?# zn+|AAcaZK2fX+qC!810`GhWx#s=0ddBL#J612_zt#9)o@|pC zy1}1hN+^dr;Ze3!X}l`)+Uw;6Rg$vzB3MNE0?-#i&l0$Tx6TIH9wEUa6#o75j?NRu z+}yliI&(307>#XlHm#j*)MHFA(EqV^y)U_ro%S{p(pkrO2wH4^kU?i#$fk|RXSi2) zh5Zwq>OhvDR4f1Mt~@M*CJ(B&7Y|CXZS-;H&hvduW=@B`w{JVu9i2llaTGuK79N=^ z1($QmCXSS49lD*n9?q_cPg-r)kd6_6Qg+Cy)dQP zn(iuan^klB3@-#c+#xe(H`b|UuG;8>YXn1XE#F)^N^)Oozt;W2vT+|nkF#i{BN&8j z)1@7u7tU#vZHWQTruoYhD`BsBorLmirv<6{rX@F0nGN1`m9HPuV^IO8;n$;cfX*Ey z?>C-7aP9RGM!D+87FSlw*YG&riZyn|fmJ#&soD0w7HLXPxtNA}j;s`O($NS1{TsMA z`wi&Xz)dt|%LQCZzg=}CAh4XVj2V^q{jVUt$cSdQ*a!}xc$l8W-+tT9gO~Xt9^{(C z71+5be^h9~H-O9^>&-{zkl#P=X#*!Hr>fdq-?04lO~6aUSWt!CK+?1NUmrH(MVN<< zOYP0SS*{4z9m=H$M#0sBeX!y#t8uA$`d{Ay83hqEK`6@!Fsl*u#k`M8Ef%)&GfkS- zZ<~*g=AU^`{au4IhNkp@?*pwNGolTrx4cKNC~()s89Zap7=ms>NXSYYTxIdypjQTN1Z1t<^HJUg%M@Zz}nv(E)b-+4LX~na|{3WjtrSiA@&Osg%EFU&?r= z>mi;%f&)m4R4H>Q<+rr~C8M=;ej?9?j_fzswfy&=(-#iAheNvrlL#VEfNM{sqw`C& z*;J~eK=w**@0G^Mvg#_|n^^T~x8@DMsMdSa3r1fb0qKEU#*<8P>w_`7XUC3+!~*7l z%o=syS^cgR``5ZwFzi$AA;j#$c%QtF`Y0X6@I~}su`_0AN0J(7(eb>6)f7Le-#__W zwtVURtY2?<%e60ETVr%@7y*q!F!6jW?Q1>*8Bhd|*68^)ApIR6_s^4kY>S4B)`)RV z7ep8>F0I(1b#`lQ5(b0P0dw8nK@a6^r_IxDcvj|Utw9RFDb&|EO|u=fYIk206c*4w z`1AhFe4xV7MYh2FDr)1qZ=~1!*=(t@o{HAFtPAh{D={sLmSECy+SgfOzzmLwro&&Q z3^RUlrL|e(VqO^h?vK-leZzy`*+5xN!45s9Su$jG?)8X$36ft3*d5c$c2;2}&`B@7 zmmu~UAg7!D7Am?e*?Wl109M5 zNL|%E+_w^ZQSWph)h~6MEgjWq@qU28feG!P9cfK3u->T#%DBEQ8!WPtTfje zoHN@qWaaihgy->a2;kJ$srhrBKEjuaxvk4r-y?TPdNyLGUvBVz*2!4v#cy$VKKH-A z&U4C+uGw14S&CY$BzI$`m#Jch*3#$+gYoj2va@u-e1FcQjS0dK0LSjV9zS{rOiDcN zI}y5^7-Ij0zWth-XP#Ue&vZYh`kwwsrR{)1g(@}Q{2ZBCS{SoKmPCTfW_yHUG=E)KST6Cr6)uXi1EF8YxSjn*TMha@9CbGIBOfgSW8jh)fu(#qqCg~=X73u zXgh~hy+jO{<5}Uu9>7!5KT~}Uyg{~2$1&Q64BOR+V%t^gJ5jBHcfvtK&Cc9WxXN%Z zz8ru>@$NuH2aG5Zm5zI#X@RtLZ77@NTg1I6(7@+=nHk@s(1U0eHO2d?>0$6)^kl1g3Aes1>Z(4^u{O^>vyN>btjbzzZDB* zrIWhdf5D=E;|&0$@a{ba0ZNsr0D+L{heNP7m`o)sTFLy@pl^xRdegOW?{72%!U~5n z&Oiub9FTT{Mm8@QNkwjIV|>DJ2152zY$9e(lR;cSqalV5gFLwV3ZLJ>1;V0!Xe@cE zSCqgJ({~O4815$a5?yX;o5?Dyztn{8Ab|7!`lmYC_(GjX$GH}?O8d?Dg&wu1m*+O6 zw9k(#pU3kne2>my&cZhxSh0FlWlE>=)x4IRg3~GYJqf2`D37z(Un)X)hFV{y98-G; z89sP4iTM|>SNknK>h>hQl=D&c!y@KYM=9NkEl0s1=!`F2w zDb&DuaM-)ByvxDi2qAY(5Cxz^%xhY)q*ZR80g}Z;1hQ~)V0bZ{FB#A&&07 zU6#63@QcrV0yP#q!@M;fyNd|z5IoR@IP-xL@38D^Fpx_={rWi=o|_nPUtVH$4Twi)8buft=laSr6aLJ`sazv(lQ-;?~OjtR>jd^1d-EJ#jH8nsT{9DL<}&eHI0UA66~R4p++{rl*gZ-#_7ZlEGO6BBtIPUE{~@;%)O zp5CQWVfl@3G7Go_j4o>&_nt;Fgxhg2=G7pb<2!5%dRCFJ z3cOt~6o4zLa^EUIm++l2M z&c8Ho{~1+)r>c1UbPtsO@NQgSgF}0k?2U#m0nhe%sNWMlR%$%58bV67FwL%$u0vr1 z%~8xw*VIf0H8Uaw9EAVMFsM3dKK<#d+gzTu*C{V|nO=~7-)ttU{b^X^1)X@{!eN~| zm#FW#!`<19N*g#&u7QKGoa##V{HaYsnGiaVgwb*A1)UwPW{k^_aQ|4R8Q^iPDj_M( zz1gMaceuHbt1^M!%iya``4JH5s`WcMl2Sl7_6$f?ACcK4ro{7f|5F+KT5}-z$f6aM zm5#DdS8T*sY-hNi|5QI?_K1P{hn#07A;t@pJ?9IXMqt>Amn5FVgQ^vMw-F{H7zrey z3v?WKC06~IIo=L1I-Wjf8NKr~Vy@Us1B{lhs18yln)j)MQ5sNkOr!v&SG8}dpG?RAZL{eJ)N z#D2Ro|9{-EPeLp4RH9EuEVXren&_<0dNdp_P7iM0Y5(y1y>hf>%B{t5y=XozaK@nKo@5oqJZ2ma#5GjUn5e9<@W F{tu45A&~$8 literal 0 HcmV?d00001 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..1f2bf77 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +pre-commit~=3.8.0 +pytest==6.2.5 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..92c10ed --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +aws-cdk-lib==2.139.0 +constructs>=10.0.0,<11.0.0 +boto3>=1.34.1 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ecs_stack.py b/src/ecs_stack.py new file mode 100644 index 0000000..8ac821f --- /dev/null +++ b/src/ecs_stack.py @@ -0,0 +1,34 @@ +import aws_cdk as cdk + +from aws_cdk import ( + aws_ecs as ecs, + aws_ec2 as ec2, +) + +from constructs import Construct + + +class EcsStack(cdk.Stack): + """ + ECS cluster + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + vpc: ec2.Vpc, + namespace: str, + **kwargs + ) -> None: + super().__init__(scope, construct_id, **kwargs) + + self.cluster = ecs.Cluster( + self, + "Cluster", + vpc=vpc, + default_cloud_map_namespace=ecs.CloudMapNamespaceOptions( + name=namespace, + use_for_service_connect=True, + ), + ) diff --git a/src/load_balancer_stack.py b/src/load_balancer_stack.py new file mode 100644 index 0000000..b9061ad --- /dev/null +++ b/src/load_balancer_stack.py @@ -0,0 +1,24 @@ +import aws_cdk as cdk + +from aws_cdk import ( + aws_ec2 as ec2, + aws_elasticloadbalancingv2 as elbv2, +) + +from constructs import Construct + + +class LoadBalancerStack(cdk.Stack): + """ + API Gateway to allow access to ECS app from the internet + """ + + def __init__( + self, scope: Construct, construct_id: str, vpc: ec2.Vpc, **kwargs + ) -> None: + super().__init__(scope, construct_id, **kwargs) + + self.alb = elbv2.ApplicationLoadBalancer( + self, "AppLoadBalancer", vpc=vpc, internet_facing=True + ) + cdk.CfnOutput(self, "dns", value=self.alb.load_balancer_dns_name) diff --git a/src/network_stack.py b/src/network_stack.py new file mode 100644 index 0000000..3ad19a5 --- /dev/null +++ b/src/network_stack.py @@ -0,0 +1,21 @@ +import aws_cdk as cdk + +from aws_cdk import aws_ec2 as ec2 + +from constructs import Construct + + +class NetworkStack(cdk.Stack): + """ + Network for applications + """ + + def __init__(self, scope: Construct, construct_id: str, vpc_cidr, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + + # ------------------- + # create a VPC + # ------------------- + self.vpc = ec2.Vpc( + self, "Vpc", max_azs=2, ip_addresses=ec2.IpAddresses.cidr(vpc_cidr) + ) diff --git a/src/service_props.py b/src/service_props.py new file mode 100644 index 0000000..496fe8d --- /dev/null +++ b/src/service_props.py @@ -0,0 +1,108 @@ +from dataclasses import dataclass +from typing import List, Optional, Sequence + +from aws_cdk import aws_ecs as ecs + +CONTAINER_LOCATION_PATH_ID = "path://" + + +@dataclass +class ServiceSecret: + """ + Holds onto configuration for the secrets to be used in the container. + + Attributes: + secret_name: The name of the secret as stored in the AWS Secrets Manager. + environment_key: The name of the environment variable to be set within the container. + """ + + secret_name: str + """The name of the secret as stored in the AWS Secrets Manager.""" + + environment_key: str + """The name of the environment variable to be set within the container.""" + + +@dataclass +class ContainerVolume: + """ + Holds onto configuration for a volume used in the container. + + Attributes: + path: The path on the container to mount the host volume at. + size: The size of the volume in GiB. + read_only: Container has read-only access to the volume, set to `false` for write access. + """ + + path: str + """The path on the container to mount the host volume at.""" + + size: int = 15 + """The size of the volume in GiB.""" + + read_only: bool = False + """Container has read-only access to the volume, set to `false` for write access.""" + + +class ServiceProps: + """ + ECS service properties + + container_name: the name of the container + container_location: + supports "path://" for building container from local (i.e. path://docker/MyContainer) + supports docker registry references (i.e. ghcr.io/sage-bionetworks/app:latest) + container_port: the container application port + container_memory: the container application memory + container_env_vars: a json dictionary of environment variables to pass into the container + i.e. {"EnvA": "EnvValueA", "EnvB": "EnvValueB"} + container_secrets: List of `ServiceSecret` resources to pull from AWS secrets manager + container_volumes: List of `ContainerVolume` resources to mount into the container + auto_scale_min_capacity: the fargate auto scaling minimum capacity + auto_scale_max_capacity: the fargate auto scaling maximum capacity + container_command: Optional commands to run during the container startup + container_healthcheck: Optional health check configuration for the container + """ + + def __init__( + self, + container_name: str, + container_location: str, + container_port: int, + container_memory: int = 512, + container_env_vars: dict = None, + container_secrets: List[ServiceSecret] = None, + container_volumes: List[ContainerVolume] = None, + auto_scale_min_capacity: int = 1, + auto_scale_max_capacity: int = 1, + container_command: Optional[Sequence[str]] = None, + container_healthcheck: Optional[ecs.HealthCheck] = None, + ) -> None: + self.container_name = container_name + self.container_port = container_port + self.container_memory = container_memory + if CONTAINER_LOCATION_PATH_ID in container_location: + container_location = container_location.removeprefix( + CONTAINER_LOCATION_PATH_ID + ) + self.container_location = container_location + + if container_env_vars is None: + self.container_env_vars = {} + else: + self.container_env_vars = container_env_vars + + if container_secrets is None: + self.container_secrets = [] + else: + self.container_secrets = container_secrets + + if container_volumes is None: + self.container_volumes = [] + else: + self.container_volumes = container_volumes + + self.auto_scale_min_capacity = auto_scale_min_capacity + self.auto_scale_max_capacity = auto_scale_max_capacity + self.container_command = container_command + self.container_healthcheck = container_healthcheck diff --git a/src/service_stack.py b/src/service_stack.py new file mode 100644 index 0000000..26775a3 --- /dev/null +++ b/src/service_stack.py @@ -0,0 +1,248 @@ +import aws_cdk as cdk +from aws_cdk import Duration as duration +from aws_cdk import aws_certificatemanager as acm +from aws_cdk import aws_ec2 as ec2 +from aws_cdk import aws_ecs as ecs +from aws_cdk import aws_elasticloadbalancingv2 as elbv2 +from aws_cdk import aws_iam as iam +from aws_cdk import aws_logs as logs +from aws_cdk import aws_secretsmanager as sm +from aws_cdk import Size as size +from constructs import Construct + +from src.service_props import ServiceProps + +ALB_HTTP_LISTENER_PORT = 80 +ALB_HTTPS_LISTENER_PORT = 443 + + +class ServiceStack(cdk.Stack): + """ + ECS Service stack + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + vpc: ec2.Vpc, + cluster: ecs.Cluster, + props: ServiceProps, + **kwargs, + ) -> None: + super().__init__(scope, construct_id, **kwargs) + + # allow containers default task access and s3 bucket access + task_role = iam.Role( + self, + "TaskRole", + assumed_by=iam.ServicePrincipal("ecs-tasks.amazonaws.com"), + managed_policies=[ + iam.ManagedPolicy.from_aws_managed_policy_name("AmazonS3FullAccess"), + ], + ) + task_role.add_to_policy( + iam.PolicyStatement( + actions=[ + "logs:CreateLogStream", + "logs:DescribeLogGroups", + "logs:DescribeLogStreams", + "logs:PutLogEvents", + "ssmmessages:CreateControlChannel", + "ssmmessages:CreateDataChannel", + "ssmmessages:OpenControlChannel", + "ssmmessages:OpenDataChannel", + ], + resources=["*"], + effect=iam.Effect.ALLOW, + ) + ) + + # ECS task with fargate + self.task_definition = ecs.FargateTaskDefinition( + self, + "TaskDef", + cpu=1024, + memory_limit_mib=4096, + task_role=task_role, + ) + + image = ecs.ContainerImage.from_registry(props.container_location) + if "path://" in props.container_location: # build container from source + location = props.container_location.removeprefix("path://") + image = ecs.ContainerImage.from_asset(location) + + def _get_secret(scope: Construct, id: str, name: str) -> sm.Secret: + """Get a secret from the AWS secrets manager""" + isecret = sm.Secret.from_secret_name_v2(scope, id, name) + return ecs.Secret.from_secrets_manager(isecret) + + secrets = {} + for secret in props.container_secrets: + secrets[secret.environment_key] = _get_secret( + self, f"sm-secrets-{secret.environment_key}", secret.secret_name + ) + + self.container = self.task_definition.add_container( + props.container_name, + image=image, + memory_limit_mib=props.container_memory, + environment=props.container_env_vars, + secrets=secrets, + port_mappings=[ + ecs.PortMapping( + name=props.container_name, + container_port=props.container_port, + protocol=ecs.Protocol.TCP, + ) + ], + logging=ecs.LogDrivers.aws_logs( + stream_prefix=f"{construct_id}", + log_retention=logs.RetentionDays.FOUR_MONTHS, + ), + command=props.container_command, + health_check=props.container_healthcheck, + ) + + self.security_group = ec2.SecurityGroup(self, "SecurityGroup", vpc=vpc) + self.security_group.add_ingress_rule( + peer=ec2.Peer.ipv4("0.0.0.0/0"), + connection=ec2.Port.tcp(props.container_port), + ) + + # attach ECS task to ECS cluster + self.service = ecs.FargateService( + self, + "Service", + cluster=cluster, + task_definition=self.task_definition, + enable_execute_command=True, + circuit_breaker=ecs.DeploymentCircuitBreaker(enable=True, rollback=True), + security_groups=([self.security_group]), + service_connect_configuration=ecs.ServiceConnectProps( + log_driver=ecs.LogDrivers.aws_logs(stream_prefix=f"{construct_id}"), + services=[ + ecs.ServiceConnectService( + port_mapping_name=props.container_name, + port=props.container_port, + dns_name=props.container_name, + ) + ], + ), + ) + + # Setup AutoScaling policy + scaling = self.service.auto_scale_task_count( + min_capacity=props.auto_scale_min_capacity, + max_capacity=props.auto_scale_max_capacity, + ) + scaling.scale_on_cpu_utilization( + "CpuScaling", + target_utilization_percent=50, + ) + scaling.scale_on_memory_utilization( + "MemoryScaling", + target_utilization_percent=50, + ) + + # mount volumes + for container_volume in props.container_volumes: + service_volume = ecs.ServiceManagedVolume( + self, + "ContainerVolume", + name=props.container_name, + managed_ebs_volume=ecs.ServiceManagedEBSVolumeConfiguration( + size=size.gibibytes(container_volume.size), + volume_type=ec2.EbsDeviceVolumeType.GP3, + ), + ) + + self.task_definition.add_volume( + name=props.container_name, configured_at_launch=True + ) + self.service.add_volume(service_volume) + + service_volume.mount_in( + self.container, + container_path=container_volume.path, + read_only=container_volume.read_only, + ) + + +class LoadBalancedServiceStack(ServiceStack): + """ + A special stack to create an ECS service fronted by a load balancer. This allows us to split up + the ECS services and the load balancer into separate stacks. It makes maintaining the stacks + easier. Unfortunately, due to the way AWS works, setting up a load balancer and ECS service + in different stacks may cause cyclic references. + https://docs.aws.amazon.com/cdk/api/v2/python/aws_cdk.aws_ecs/README.html#using-a-load-balancer-from-a-different-stack + + To work around this problem we use the "Split at listener" option from + https://github.com/aws-samples/aws-cdk-examples + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + vpc: ec2.Vpc, + cluster: ecs.Cluster, + props: ServiceProps, + load_balancer: elbv2.ApplicationLoadBalancer, + certificate_arn: str, + health_check_path: str = "/", + health_check_interval: int = 1, # max is 5 + **kwargs, + ) -> None: + super().__init__(scope, construct_id, vpc, cluster, props, **kwargs) + + # ------------------- + # ACM Certificate for HTTPS + # ------------------- + self.cert = acm.Certificate.from_certificate_arn( + self, "Cert", certificate_arn=certificate_arn + ) + + # ------------------------------- + # Setup https + # ------------------------------- + https_listener = elbv2.ApplicationListener( + self, + "HttpsListener", + load_balancer=load_balancer, + port=ALB_HTTPS_LISTENER_PORT, + open=True, + protocol=elbv2.ApplicationProtocol.HTTPS, + certificates=[self.cert], + ) + + https_listener.add_targets( + "HttpsTarget", + port=props.container_port, + protocol=elbv2.ApplicationProtocol.HTTP, + targets=[self.service], + health_check=elbv2.HealthCheck( + path=health_check_path, interval=duration.minutes(health_check_interval) + ), + ) + + # ------------------------------- + # redirect http to https + # ------------------------------- + http_listener = elbv2.ApplicationListener( + self, + "HttpListener", + load_balancer=load_balancer, + port=ALB_HTTP_LISTENER_PORT, + open=True, + protocol=elbv2.ApplicationProtocol.HTTP, + ) + + http_listener.add_action( + "HttpRedirect", + action=elbv2.ListenerAction.redirect( + port=str(ALB_HTTPS_LISTENER_PORT), + protocol=(elbv2.ApplicationProtocol.HTTPS).value, + permanent=True, + ), + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_network_stack.py b/tests/unit/test_network_stack.py new file mode 100644 index 0000000..ba6ea66 --- /dev/null +++ b/tests/unit/test_network_stack.py @@ -0,0 +1,12 @@ +import aws_cdk as core +import aws_cdk.assertions as assertions + +from src.network_stack import NetworkStack + + +def test_vpc_created(): + app = core.App() + vpc_cidr = "10.254.192.0/24" + network = NetworkStack(app, "NetworkStack", vpc_cidr) + template = assertions.Template.from_stack(network) + template.has_resource_properties("AWS::EC2::VPC", {"CidrBlock": vpc_cidr}) diff --git a/tests/unit/test_service_stack.py b/tests/unit/test_service_stack.py new file mode 100644 index 0000000..1cbbf63 --- /dev/null +++ b/tests/unit/test_service_stack.py @@ -0,0 +1,56 @@ +import aws_cdk as cdk +import aws_cdk.assertions as assertions + +from src.network_stack import NetworkStack +from src.ecs_stack import EcsStack +from src.service_props import ServiceProps, ServiceSecret, ContainerVolume +from src.service_stack import ServiceStack + + +def test_service_stack_created(): + cdk_app = cdk.App() + vpc_cidr = "10.254.192.0/24" + network_stack = NetworkStack(cdk_app, "NetworkStack", vpc_cidr=vpc_cidr) + ecs_stack = EcsStack( + cdk_app, "EcsStack", vpc=network_stack.vpc, namespace="dev.app.io" + ) + + app_props = ServiceProps( + container_name="app", + container_location="ghcr.io/sage-bionetworks/app:1.0", + container_port=8010, + container_memory=200, + container_secrets=[ + ServiceSecret( + secret_name="/app/secret", + environment_key="APP_SECRET", + ) + ], + container_volumes=[ContainerVolume(path="/work")], + container_command=["test"], + container_healthcheck=cdk.aws_ecs.HealthCheck(command=["CMD", "/healthcheck"]), + ) + app_stack = ServiceStack( + scope=cdk_app, + construct_id="app", + vpc=network_stack.vpc, + cluster=ecs_stack.cluster, + props=app_props, + ) + + template = assertions.Template.from_stack(app_stack) + template.has_resource_properties( + "AWS::ECS::TaskDefinition", + { + "ContainerDefinitions": [ + { + "Image": "ghcr.io/sage-bionetworks/app:1.0", + "Memory": 200, + "MountPoints": [{"ContainerPath": "/work"}], + "Secrets": [{"Name": "APP_SECRET"}], + "Command": ["test"], + "HealthCheck": {"Command": ["CMD", "/healthcheck"]}, + } + ] + }, + ) diff --git a/tools/setup.sh b/tools/setup.sh new file mode 100755 index 0000000..f92cf9b --- /dev/null +++ b/tools/setup.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +# Safer bash scripts +set -euxo pipefail + +# Install Node.js dependencies +npm install -g aws-cdk@2.151.0 + +# Install Python dependencies +python -m venv .venv +source .venv/bin/activate +pip install --upgrade pip +pip install -r requirements.txt -r requirements-dev.txt + +# Install git hooks +git config --global --add safe.directory "$PWD" +pre-commit install --install-hooks