Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .github/workflows/aws-deploy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,4 @@ jobs:
role-session-name: ${{ inputs.role-session-name }}
role-duration-seconds: ${{ inputs.role-duration-seconds }}
- name: CDK deploy
run: cdk deploy --all --debug --concurrency 5 --require-approval never
env:
ENV: ${{ inputs.environment }}
run: cdk deploy --context env=${{ inputs.environment }} --all --debug --concurrency 5 --require-approval never
4 changes: 1 addition & 3 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,4 @@ jobs:
- name: Install dependencies
run: pip install -r requirements.txt -r requirements-dev.txt
- name: Generate cloudformation
env:
ENV: ${{ inputs.environment }}
run: cdk synth --debug --output ./cdk.out
run: cdk synth --context env=${{ inputs.environment }} --debug --output ./cdk.out
40 changes: 21 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ execute the validations by running `pre-commit run --all-files`.
Verify CDK to Cloudformation conversion by running [cdk synth]:

```console
ENV=dev cdk synth
cdk synth --context env=dev
```

The Cloudformation output is saved to the `cdk.out` folder
Expand All @@ -102,30 +102,32 @@ 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.
When running `cdk` commands, you must specify which environment's
configuration to use. This is done by passing a context variable to
CDK, which loads environment-specific parameters.

Set environment variables for each environment in the [app.py](./app.py) file:
Create a configuration file in the [config folder](./config) for each environment
(e.g., `dev.yaml`, `prod.yaml`). Both yaml and json files are supported.
The supported environments are dev, stage, and prod.

```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:
To synthesize or deploy using the `prod` environment configuration:

```console
ENV=prod cdk synth
cdk synth --context env=prod
```

There is also an optional base configuration file that is always loaded
and merged with one of the passed in environment configuration.

The values from the environment-specific configuration file will
override those in the base configuration file if there are conflicts.
For example, if both files define `TAGS`, the value from `dev.yaml`
will take precedence.


> [!NOTE]
> The `VPC_CIDR` must be a unique value within our AWS organization. Check our
> [wiki](https://sagebionetworks.jira.com/wiki/spaces/IT/pages/2850586648/Setup+AWS+VPC)
> for information on how to obtain a unique CIDR
> Ensure that `VPC_CIDR` is unique within your AWS organization.
Refer to our [guidance](https://sagebionetworks.jira.com/wiki/spaces/IT/pages/2850586648/Setup+AWS+VPC) on selecting a unique CIDR block.

## Certificates

Expand Down Expand Up @@ -299,7 +301,7 @@ Deployment requires setting up an [AWS profile](https://docs.aws.amazon.com/cli/
then executing the following command:

```console
AWS_PROFILE=itsandbox-dev AWS_DEFAULT_REGION=us-east-1 ENV=dev cdk deploy --all
AWS_PROFILE=itsandbox-dev AWS_DEFAULT_REGION=us-east-1 cdk deploy --context env=dev --all
```

## Force new deployment
Expand Down
69 changes: 19 additions & 50 deletions app.py
Original file line number Diff line number Diff line change
@@ -1,67 +1,36 @@
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
from src.service_stack import LoadBalancedServiceStack
from src.utils import load_context_config

# 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.mydomain.io",
"TAGS": {"CostCenter": "NO PROGRAM / 000000"},
}
case "stage":
environment_variables = {
"VPC_CIDR": "10.254.173.0/24",
"FQDN": "stage.mydomain.io",
"CERTIFICATE_ARN": "arn:aws:acm:us-east-1:XXXXXXXXXX:certificate/69b3ba97-b382-4648-8f94-a250b77b4994",
"TAGS": {"CostCenter": "NO PROGRAM / 000000"},
}
case "dev":
environment_variables = {
"VPC_CIDR": "10.254.172.0/24",
"FQDN": "dev.mydomain.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"app-{environment}"
fully_qualified_domain_name = environment_variables["FQDN"]
environment_tags = environment_variables["TAGS"]
app_version = "latest"

# Define stacks
cdk_app = cdk.App()
env_name = cdk_app.node.try_get_context("env") or "dev"
config = load_context_config(env_name=env_name)
STACK_NAME_PREFIX = f"app-{env_name}"
FQDN = config["FQDN"]
TAGS = config["TAGS"]
APP_VERSION = "latest"

# recursively apply tags to all stack resources
if environment_tags:
for key, value in environment_tags.items():
if TAGS:
for key, value in 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"],
construct_id=f"{STACK_NAME_PREFIX}-network",
vpc_cidr=config["VPC_CIDR"],
)

ecs_stack = EcsStack(
scope=cdk_app,
construct_id=f"{stack_name_prefix}-ecs",
construct_id=f"{STACK_NAME_PREFIX}-ecs",
vpc=network_stack.vpc,
namespace=fully_qualified_domain_name,
namespace=FQDN,
)

# From AWS docs https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-connect-concepts-deploy.html
Expand All @@ -70,29 +39,29 @@
# 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",
construct_id=f"{STACK_NAME_PREFIX}-load-balancer",
vpc=network_stack.vpc,
)
load_balancer_stack.add_dependency(ecs_stack)

app_props = ServiceProps(
ecs_task_cpu=256,
ecs_task_memory=512,
container_name="my-app",
# can also reference github with 'ghcr.io/sage-bionetworks/my-app:{app_version}'
container_location=f"nginx:{app_version}",
# can also reference github with 'ghcr.io/sage-bionetworks/my-app:{APP_VERSION}'
container_location=f"nginx:{APP_VERSION}",
container_port=80,
container_env_vars={
"APP_VERSION": f"{app_version}",
"APP_VERSION": f"{APP_VERSION}",
},
)
app_stack = LoadBalancedServiceStack(
scope=cdk_app,
construct_id=f"{stack_name_prefix}-app",
construct_id=f"{STACK_NAME_PREFIX}-app",
vpc=network_stack.vpc,
cluster=ecs_stack.cluster,
props=app_props,
load_balancer=load_balancer_stack.alb,
)
app_stack.add_dependency(app_stack)

cdk_app.synth()
2 changes: 2 additions & 0 deletions config/base.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
TAGS:
CostCenter: NO PROGRAM / 000000
6 changes: 6 additions & 0 deletions config/dev.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
VPC_CIDR: 10.254.172.0/24
FQDN: dev.mydomain.io

# Optional
# CERTIFICATE_ARN: >-
# arn:aws:acm:us-east-1:XXXXXXXXX:certificate/e8093404-7db1-4042-90d0-01eb5bde1ffc
6 changes: 6 additions & 0 deletions config/prod.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
VPC_CIDR: 10.254.174.0/24
FQDN: prod.mydomain.io

# Optional
# CERTIFICATE_ARN: >-
# arn:aws:acm:us-east-1:XXXXXXXXX:certificate/69b3ba97-b382-4648-8f94-a250b77b4994
6 changes: 6 additions & 0 deletions config/stage.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
VPC_CIDR: 10.254.173.0/24
FQDN: stage.mydomain.io

# Optional
# CERTIFICATE_ARN: >-
# arn:aws:acm:us-east-1:XXXXXXXXX:certificate/69b3ba97-b382-4648-8f94-a250b77b4994
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
aws-cdk-lib==2.220.0
constructs>=10.0.0,<11.0.0
boto3>=1.40.55
pyyaml>=6.0
69 changes: 69 additions & 0 deletions src/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import json
import yaml
from pathlib import Path
from typing import Any, Dict


def load_context_config(env_name: str, config_dir: str = "config") -> Dict[str, Any]:
"""
Load AWS CDK context configuration from a YAML or JSON file.

Supports:
- .yaml/.yml OR .json (but not both)
- base.yaml merged with environment config
- Only 'dev', 'stage', or 'prod' environments are valid

Raises:
- ValueError if environment is invalid or multiple config files exist
- FileNotFoundError if expected config file not found
"""

# ✅ Validate environment name
VALID_ENVS = {"dev", "stage", "prod"}
if env_name not in VALID_ENVS:
raise ValueError(
f"Invalid environment '{env_name}'. "
f"Must be one of: {', '.join(sorted(VALID_ENVS))}"
)

# Define possible config paths
base_path = Path(config_dir) / "base.yaml"
env_yaml = Path(config_dir) / f"{env_name}.yaml"
env_yml = Path(config_dir) / f"{env_name}.yml"
env_json = Path(config_dir) / f"{env_name}.json"

def read_file(path: Path) -> Dict[str, Any]:
"""Read YAML or JSON file into a dictionary."""
with open(path, "r") as f:
if path.suffix in (".yaml", ".yml"):
return yaml.safe_load(f) or {}
elif path.suffix == ".json":
return json.load(f)
else:
raise ValueError(f"Unsupported config file type: {path.suffix}")

# Load base config (optional)
base_config = {}
if base_path.exists():
base_config = read_file(base_path)

# Detect existing env-specific file(s)
env_files = [p for p in [env_yaml, env_yml, env_json] if p.exists()]

if not env_files:
raise FileNotFoundError(
f"No config file found for environment '{env_name}' "
f"in {config_dir}. Expected one of: {env_yaml}, {env_yml}, {env_json}"
)

if len(env_files) > 1:
raise ValueError(
f"Multiple config files found for environment '{env_name}': {env_files}. "
f"Use only one (.yaml/.yml OR .json)."
)

# Load and merge configs
env_config = read_file(env_files[0])
merged_config = {**base_config, **env_config}

return merged_config
Loading
Loading