Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 @@ -26,6 +26,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
44 changes: 27 additions & 17 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,40 @@ 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 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"},
}
Example (`dev.yaml`):

```yaml
VPC_CIDR: 10.254.172.0/24
FQDN: dev.mydomain.io
CERTIFICATE_ARN: arn:aws:acm:us-east-1:XXXXXXXXX:certificate/e8093404-7db1-4042-90d0-01eb5bde1ffc
```

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 +309,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
49 changes: 9 additions & 40 deletions app.py
Original file line number Diff line number Diff line change
@@ -1,51 +1,20 @@
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",
"CERTIFICATE_ARN": "arn:aws:acm:us-east-1:XXXXXXXXX:certificate/69b3ba97-b382-4648-8f94-a250b77b4994",
"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 = "edge"

# 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}"
fully_qualified_domain_name = config["FQDN"]
environment_tags = config["TAGS"]
app_version = "edge"

# recursively apply tags to all stack resources
if environment_tags:
Expand All @@ -55,7 +24,7 @@
network_stack = NetworkStack(
scope=cdk_app,
construct_id=f"{stack_name_prefix}-network",
vpc_cidr=environment_variables["VPC_CIDR"],
vpc_cidr=config["VPC_CIDR"],
)

ecs_stack = EcsStack(
Expand Down Expand Up @@ -92,7 +61,7 @@
cluster=ecs_stack.cluster,
props=app_props,
load_balancer=load_balancer_stack.alb,
certificate_arn=environment_variables["CERTIFICATE_ARN"],
certificate_arn=config["CERTIFICATE_ARN"],
health_check_path="/health",
)
app_stack.add_dependency(app_stack)
Expand Down
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
4 changes: 4 additions & 0 deletions config/dev.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
VPC_CIDR: 10.254.172.0/24
FQDN: dev.mydomain.io
CERTIFICATE_ARN: >-
arn:aws:acm:us-east-1:XXXXXXXXX:certificate/e8093404-7db1-4042-90d0-01eb5bde1ffc
4 changes: 4 additions & 0 deletions config/prod.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
VPC_CIDR: 10.254.174.0/24
FQDN: prod.mydomain.io
CERTIFICATE_ARN: >-
arn:aws:acm:us-east-1:XXXXXXXXX:certificate/69b3ba97-b382-4648-8f94-a250b77b4994
4 changes: 4 additions & 0 deletions config/stage.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
VPC_CIDR: 10.254.173.0/24
FQDN: stage.mydomain.io
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.139.0
constructs>=10.0.0,<11.0.0
boto3>=1.34.1
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
6 changes: 5 additions & 1 deletion tests/unit/test_network_stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@
import aws_cdk.assertions as assertions

from src.network_stack import NetworkStack
from src.utils import load_context_config


def test_vpc_created():
# Load configuration from dev environment
config = load_context_config(env_name="dev")

app = core.App()
vpc_cidr = "10.254.192.0/24"
vpc_cidr = config["VPC_CIDR"]
network = NetworkStack(app, "NetworkStack", vpc_cidr)
template = assertions.Template.from_stack(network)
template.has_resource_properties("AWS::EC2::VPC", {"CidrBlock": vpc_cidr})
13 changes: 11 additions & 2 deletions tests/unit/test_service_stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,23 @@
from src.ecs_stack import EcsStack
from src.service_props import ServiceProps, ServiceSecret, ContainerVolume
from src.service_stack import ServiceStack
from src.utils import load_context_config


def test_service_stack_created():
# Load configuration from dev environment
config = load_context_config(env_name="dev")

cdk_app = cdk.App()
vpc_cidr = "10.254.192.0/24"
vpc_cidr = config["VPC_CIDR"]
fully_qualified_domain_name = config["FQDN"]

network_stack = NetworkStack(cdk_app, "NetworkStack", vpc_cidr=vpc_cidr)
ecs_stack = EcsStack(
cdk_app, "EcsStack", vpc=network_stack.vpc, namespace="dev.app.io"
cdk_app,
"EcsStack",
vpc=network_stack.vpc,
namespace=fully_qualified_domain_name,
)

app_props = ServiceProps(
Expand Down
Loading