Skip to content

Commit 8c6053e

Browse files
Give the CMS an AWS home: Fargate service, ALB rule, migrate task
infra/cms.ts adds the cms service alongside api and frontend: one small Fargate task (~20 admin users), a host rule for cms[.dev].districtr.org on the shared ALB, an ECR repo, and a separate cms-migrate task definition the deploy workflow runs as a one-off before rolling the service. Secrets (Django key, the RS256 pair, Resend) flow config -> SSM SecureString -> task 'secrets', with the exec role scoped to exactly those parameter ARNs; S3 uses the task role, not static keys. Health checks hit /healthz, which the CMS answers before host validation because the ALB probes by task IP. Auth0 config stays in place here — this PR only adds the CMS, it does not cut over to it, so the stack keeps working for the current frontend and backend. deploy-cms.yml mirrors the api workflow: immutable tags (a failed run is retryable), migrations as a one-off task whose exit code gates the rollout, log tailing, and a rollout check that fails red if the ECS circuit breaker rolled back. BEFORE MERGING: the four new secrets (djangoSecretKey, jwtSigningKey, jwtVerifyingKey, authSecret) must be set on BOTH stacks or every pulumi-running workflow breaks — config.ts requireSecret's them, so 'pulumi preview' fails until they exist. Generate the pair with 'manage.py generate_jwt_keys'. Also: adding cmsDomain to the ACM cert's SANs REPLACES the certificate — read the prod preview diff before running it.
1 parent 81b6872 commit 8c6053e

11 files changed

Lines changed: 482 additions & 19 deletions

File tree

.github/workflows/deploy-cms.yml

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
name: AWS Deploy CMS (Pulumi)
2+
on:
3+
push:
4+
branches:
5+
- main
6+
- dev
7+
paths:
8+
- "cms/**"
9+
- ".github/workflows/deploy-cms.yml"
10+
workflow_dispatch:
11+
12+
permissions:
13+
id-token: write
14+
contents: read
15+
16+
env:
17+
AWS_REGION: ${{ vars.AWS_REGION || 'us-east-2' }}
18+
PULUMI_BACKEND_URL: s3://districtr-v2-pulumi-state?region=${{ vars.AWS_REGION || 'us-east-2' }}
19+
# Maintenance-mode flag read by infra/config.ts. Must be set in every
20+
# workflow that runs `pulumi up`, or an unrelated deploy flips it back.
21+
UNDER_CONSTRUCTION: ${{ vars.UNDER_CONSTRUCTION || 'false' }}
22+
23+
concurrency:
24+
group: aws-deploy-cms-${{ github.ref_name }}
25+
cancel-in-progress: false
26+
27+
jobs:
28+
deploy:
29+
name: Deploy CMS
30+
runs-on: ubuntu-latest
31+
if: >-
32+
(github.ref_name == 'dev' && (github.event_name == 'workflow_dispatch' || vars.AWS_DEPLOY_DEV == 'true')) ||
33+
(github.ref_name == 'main' && (github.event_name == 'workflow_dispatch' || vars.AWS_DEPLOY_PROD == 'true'))
34+
steps:
35+
- uses: actions/checkout@v4
36+
- name: Set stack
37+
id: cfg
38+
run: |
39+
if [ "${{ github.ref_name }}" = "dev" ]; then
40+
echo "stack=dev" >> "$GITHUB_OUTPUT"
41+
else
42+
echo "stack=prod" >> "$GITHUB_OUTPUT"
43+
fi
44+
- uses: aws-actions/configure-aws-credentials@v4
45+
with:
46+
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
47+
aws-region: ${{ env.AWS_REGION }}
48+
role-duration-seconds: 7200
49+
- uses: aws-actions/amazon-ecr-login@v2
50+
id: ecr
51+
# Skip if the tag exists: tags are immutable, and a rebuild produces a
52+
# different digest — without this guard a failed run is unretryable.
53+
- name: Build and push image
54+
id: image
55+
run: |
56+
set -euo pipefail
57+
REPO="districtr-${{ steps.cfg.outputs.stack }}-cms"
58+
IMAGE="${{ steps.ecr.outputs.registry }}/${REPO}:${{ github.sha }}"
59+
if aws ecr describe-images --repository-name "$REPO" \
60+
--image-ids imageTag="${{ github.sha }}" >/dev/null 2>&1; then
61+
echo "Image already pushed for this sha; skipping build"
62+
else
63+
docker build -t "$IMAGE" cms
64+
docker push "$IMAGE"
65+
fi
66+
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
67+
- name: Install Pulumi
68+
uses: pulumi/actions@v7
69+
with:
70+
# Exact pin (not the default ^3 range) so a Pulumi release can't
71+
# silently change deploys; keep ~in sync with infra/package.json.
72+
pulumi-version: 3.242.0
73+
# Fly release_command equivalent: bootstrap_schema + Django migrate as a
74+
# one-off task with the new image. If it fails, the service is left
75+
# untouched on the old image.
76+
- name: Run database migrations
77+
working-directory: infra
78+
run: |
79+
set -euo pipefail
80+
STACK="${{ steps.cfg.outputs.stack }}"
81+
pulumi stack select "$STACK"
82+
CLUSTER=$(pulumi stack output clusterName)
83+
SUBNETS=$(pulumi stack output publicSubnetIds --json | jq -r 'join(",")')
84+
SG=$(pulumi stack output cmsSecurityGroupId)
85+
86+
NEW_DEF=$(aws ecs describe-task-definition \
87+
--task-definition "districtr-${STACK}-cms-migrate" \
88+
--query taskDefinition |
89+
jq --arg IMAGE "${{ steps.image.outputs.image }}" \
90+
'.containerDefinitions[0].image = $IMAGE
91+
| del(.taskDefinitionArn, .revision, .status, .requiresAttributes,
92+
.compatibilities, .registeredAt, .registeredBy)')
93+
TASK_DEF_ARN=$(aws ecs register-task-definition --cli-input-json "$NEW_DEF" \
94+
--query taskDefinition.taskDefinitionArn --output text)
95+
96+
TASK_ARN=$(aws ecs run-task \
97+
--cluster "$CLUSTER" \
98+
--launch-type FARGATE \
99+
--task-definition "$TASK_DEF_ARN" \
100+
--network-configuration "awsvpcConfiguration={subnets=[$SUBNETS],securityGroups=[$SG],assignPublicIp=ENABLED}" \
101+
--query 'tasks[0].taskArn' --output text)
102+
if [ -z "$TASK_ARN" ] || [ "$TASK_ARN" = "None" ]; then
103+
echo "::error::Failed to place migration task — check ECS capacity and IAM permissions"
104+
exit 1
105+
fi
106+
echo "Migration task: $TASK_ARN"
107+
108+
DEADLINE=$((SECONDS + 1800))
109+
STATUS=""
110+
while [ "$SECONDS" -lt "$DEADLINE" ]; do
111+
STATUS=$(aws ecs describe-tasks --cluster "$CLUSTER" --tasks "$TASK_ARN" \
112+
--query 'tasks[0].lastStatus' --output text)
113+
[ "$STATUS" = "STOPPED" ] && break
114+
sleep 15
115+
done
116+
if [ "$STATUS" != "STOPPED" ]; then
117+
echo "::error::Migration task still running after 30 minutes — investigate before redeploying"
118+
exit 1
119+
fi
120+
121+
echo "--- migration logs ---"
122+
aws logs tail "/districtr/${STACK}/cms-migrate" --since 35m || true
123+
124+
EXIT_CODE=$(aws ecs describe-tasks --cluster "$CLUSTER" --tasks "$TASK_ARN" \
125+
--query 'tasks[0].containers[0].exitCode' --output text)
126+
if [ "$EXIT_CODE" != "0" ]; then
127+
STOP_REASON=$(aws ecs describe-tasks --cluster "$CLUSTER" --tasks "$TASK_ARN" \
128+
--query 'tasks[0].stoppedReason' --output text)
129+
echo "::error::Migration failed (exit=$EXIT_CODE, reason=$STOP_REASON) — service not updated"
130+
exit 1
131+
fi
132+
- name: Update service
133+
working-directory: infra
134+
run: |
135+
set -euo pipefail
136+
STACK="${{ steps.cfg.outputs.stack }}"
137+
aws ssm put-parameter \
138+
--name "/districtr/${STACK}/meta/cms-image-tag" \
139+
--type String --value "${{ github.sha }}" --overwrite
140+
npm ci
141+
pulumi stack select "$STACK"
142+
# Retry: another workflow's `pulumi up` may hold the state lock.
143+
UPDATED=0
144+
for attempt in 1 2 3; do
145+
if pulumi up --yes --diff; then UPDATED=1; break; fi
146+
echo "pulumi up failed (attempt $attempt); retrying in 60s in case of state-lock contention"
147+
sleep 60
148+
done
149+
[ "$UPDATED" = "1" ] || exit 1
150+
# `pulumi up` returns when the deployment is created, not when it
151+
# succeeds. Wait for stability and fail red if the circuit breaker
152+
# rolled back to the previous image.
153+
- name: Verify rollout
154+
working-directory: infra
155+
run: |
156+
set -euo pipefail
157+
STACK="${{ steps.cfg.outputs.stack }}"
158+
pulumi stack select "$STACK"
159+
CLUSTER=$(pulumi stack output clusterName)
160+
for attempt in 1 2 3; do
161+
if aws ecs wait services-stable --cluster "$CLUSTER" --services cms; then break; fi
162+
if [ "$attempt" = 3 ]; then
163+
echo "::error::cms service did not stabilize"
164+
exit 1
165+
fi
166+
done
167+
RUNNING_TD=$(aws ecs describe-services --cluster "$CLUSTER" --services cms \
168+
--query 'services[0].deployments[?status==`PRIMARY`].taskDefinition | [0]' --output text)
169+
RUNNING_IMAGE=$(aws ecs describe-task-definition --task-definition "$RUNNING_TD" \
170+
--query 'taskDefinition.containerDefinitions[0].image' --output text)
171+
if [ "$RUNNING_IMAGE" != "${{ steps.image.outputs.image }}" ]; then
172+
echo "::error::Deployment rolled back — service is running $RUNNING_IMAGE"
173+
exit 1
174+
fi
175+
echo "Service is stable on ${RUNNING_IMAGE}"

infra/Pulumi.dev.yaml

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,22 @@ config:
1919
districtr-infra:auth0Domain: districtr-qa.us.auth0.com
2020
districtr-infra:auth0ApiAudience: https://districtr-v2-api-dev.fly.dev
2121
districtr-infra:auth0Issuer: https://districtr-qa.us.auth0.com
22-
districtr-infra:alarmEmails:
23-
- dhalpern@uchicago.edu
24-
- gfang@uchicago.edu
25-
- peter@mggg.org
26-
districtr-infra:secretKey:
27-
secure: v1:JPIYNwHinzVBFMeH:7SXhe/J8zN6jZb/LKisf6a9LKf3AANZZt3Ecu3H8mBgPX+rExXAOJZXlXi4gAerH1CU3t7dgCHlrW82LS2UQNFekHDWUTLat6qStQlz6YH0=
2822
districtr-infra:auth0SessionSecret:
2923
secure: v1:preJBiBl2u/24nkE:d9j76lvR+mP2fmQiEsQlhjdkJYVIxHBq84DK/t5Z1Ov1o/5yL9q6BhbxOmfcU+fpH0iamE2m+lmlED+/ScpIlU0uqcenjzTYqkVwwhhW4oY=
3024
districtr-infra:auth0ClientId:
3125
secure: v1:7vhEocXV2uTLSiF2:DjBuL4gu+lDxU6zaqs4ufPD4Qw==
3226
districtr-infra:auth0ClientSecret:
3327
secure: v1:E1jfy2eSCCa+cVlj:Y4LvQdhJ9JBYuu3X8w1O4Af51A==
28+
districtr-infra:cmsDomain: cms.dev.districtr.org
29+
# Tokens minted before the AWS cutover carry the Fly audience; keep it as
30+
# the `aud` until cutover, then remove to default to https://{apiDomain}/.
31+
districtr-infra:jwtAudience: https://districtr-v2-api-dev.fly.dev/
32+
districtr-infra:alarmEmails:
33+
- dhalpern@uchicago.edu
34+
- gfang@uchicago.edu
35+
- peter@mggg.org
36+
districtr-infra:secretKey:
37+
secure: v1:JPIYNwHinzVBFMeH:7SXhe/J8zN6jZb/LKisf6a9LKf3AANZZt3Ecu3H8mBgPX+rExXAOJZXlXi4gAerH1CU3t7dgCHlrW82LS2UQNFekHDWUTLat6qStQlz6YH0=
3438
districtr-infra:openaiApiKey:
3539
secure: v1:y4Oj/4fDyHpHgIOF:CidsAQ1ZGfcAW4rl4DP90FY65wTJSWyu8d3J/uUq3QjTEv+fPM58bfGn2In/lozPGyWqMjOV+3Fv78gLu2l3u/NVc+caS1LQ283Q4WIdXta93AEbYEFu3hUTZRA9Vyh51dVHdQjAkAOjEYsUM1U47ihcOnUS8uj2YWQ08KZfuODYtu+EGUb0IZ/UVSokcD/cs0GttBosLx0dTV1S13MOBESNopRdZhD0xViFBDVHKn0HiUix3B23
3640
districtr-infra:turnstileSecretKey:

infra/Pulumi.prod.yaml

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,21 +24,26 @@ config:
2424
districtr-infra:s3BucketName:
2525
secure: v1:MS8AzWKQiTmtjfkB:oz6SV4+k43qsx7zvx/cdwVO4Be+fhIJd1wMaghY7OLAHbWwTV3gzHrTg
2626
districtr-infra:cdnUrl: https://tilesets1.cdn.districtr.org
27+
# Auth0 callback base and metadataBase — so until this flipped, pages served
2728
districtr-infra:auth0Domain: districtr.us.auth0.com
2829
districtr-infra:auth0ApiAudience: https://districtr-v2-api.fly.dev
2930
districtr-infra:auth0Issuer: https://districtr.us.auth0.com/
30-
districtr-infra:alarmEmails:
31-
- dhalpern@uchicago.edu
32-
- gfang@uchicago.edu
33-
- peter@mggg.org
34-
districtr-infra:secretKey:
35-
secure: v1:dHM/3HqOvfLfoMgx:48kANUBIY2+yLOYo3BgQLly0YEOXYmw6K45Y9BabOph74VwoWV+oZ/mcvHcuD9GY1xz/vpwZcFJJQRxVwfH9sDeY+39I9ap/8xPDpQARDMs=
3631
districtr-infra:auth0SessionSecret:
3732
secure: v1:sIjiNW5eL0fvKI77:9GKfsZZNr4UiRuZt5h9fEcKyaAycpBzv9zdzHMk0+nPU5dx9Cw/bd0Iy8LcYNlp53LVeqjkTe8tWg0KsEGL+mLgz8rDnLi2KyqY1e4BLmCs=
3833
districtr-infra:auth0ClientId:
3934
secure: v1:sTNQgv6rkb5Fl6K4:mdBTYkKR2NHdFRQIoQBKaUyNO146gc+UdVSYLs/udffQ5TtG8FXhdd24Hxd47rsd
4035
districtr-infra:auth0ClientSecret:
4136
secure: v1:D06+V3SzmnWG9i7t:nqfvgfjP8VuSGg3xRO8VVyP7kV9y6tt/tAPe7oVWuhGM+ZfCBTy17oJy15EX+jVa0jLLlZHvCC41I0QAZxK2SwQRHQY1HwJpf8MJLDxgy7g=
37+
districtr-infra:cmsDomain: cms.districtr.org
38+
# Tokens minted before the AWS cutover carry the Fly audience; keep it as
39+
# the `aud` until cutover, then remove to default to https://{apiDomain}/.
40+
districtr-infra:jwtAudience: https://districtr-v2-api.fly.dev/
41+
districtr-infra:alarmEmails:
42+
- dhalpern@uchicago.edu
43+
- gfang@uchicago.edu
44+
- peter@mggg.org
45+
districtr-infra:secretKey:
46+
secure: v1:dHM/3HqOvfLfoMgx:48kANUBIY2+yLOYo3BgQLly0YEOXYmw6K45Y9BabOph74VwoWV+oZ/mcvHcuD9GY1xz/vpwZcFJJQRxVwfH9sDeY+39I9ap/8xPDpQARDMs=
4247
districtr-infra:openaiApiKey:
4348
secure: v1:iUv/8za+O1Hy6I1+:c0m3/SPlOxsRUVVr6IVdRsYsMRNQuQx4/HmxyajNrOIG+vMDT1AxrHc0NC4WaqaqQ4WTu9MwTxSGmTe4ljV7vGf9WHkG62Up+/Qqr4Zv952ZDmEekBjIi7mxMY4KBUvLIv1L3dGwvp//+nJsVLZDMB/C/vQdPZ2znZ2y6X9RB1yyl0cjgUwgLsXeYRFqBxy8lKCxL6ZTZGt3fUW3h3goFDZ2p432PUPkZDUv2K92PY6KPGpzNg19
4449
districtr-infra:turnstileSecretKey:

infra/alb.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,28 @@ export function createAlb(network: Network) {
9595
},
9696
});
9797

98+
const cmsTargetGroup = new aws.lb.TargetGroup(`${name}-cms-tg`, {
99+
name: `${name}-cms`,
100+
vpcId: network.vpc.id,
101+
port: 8080,
102+
protocol: "HTTP",
103+
targetType: "ip",
104+
deregistrationDelay: 30,
105+
healthCheck: {
106+
// /healthz is answered by middleware before host validation (the ALB
107+
// probes by task IP, which ALLOWED_HOSTS rejects) and — like the
108+
// backend's — is DB-free so a DB blip doesn't cycle tasks.
109+
path: "/healthz",
110+
matcher: "200",
111+
interval: 30,
112+
healthyThreshold: 2,
113+
unhealthyThreshold: 3,
114+
},
115+
});
116+
98117
const certificate = new aws.acm.Certificate(`${name}-cert`, {
99118
domainName: config.appDomain,
100-
subjectAlternativeNames: [config.apiDomain, ...config.extraDomains],
119+
subjectAlternativeNames: [config.apiDomain, config.cmsDomain, ...config.extraDomains],
101120
validationMethod: "DNS",
102121
});
103122

@@ -151,11 +170,19 @@ export function createAlb(network: Network) {
151170
actions: [{type: "forward", targetGroupArn: backendTargetGroup.arn}],
152171
});
153172

173+
new aws.lb.ListenerRule(`${name}-cms-host`, {
174+
listenerArn: httpsListener.arn,
175+
priority: 20,
176+
conditions: [{hostHeader: {values: [config.cmsDomain]}}],
177+
actions: [{type: "forward", targetGroupArn: cmsTargetGroup.arn}],
178+
});
179+
154180
return {
155181
alb,
156182
accessLogsBucket,
157183
backendTargetGroup,
158184
frontendTargetGroup,
185+
cmsTargetGroup,
159186
certificate,
160187
httpsListener,
161188
};

infra/cluster.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export function createCluster() {
2323
const logGroups = {
2424
backend: logGroup("backend"),
2525
frontend: logGroup("frontend"),
26+
cms: logGroup("cms"),
27+
cmsMigrate: logGroup("cms-migrate"),
2628
migrate: logGroup("migrate"),
2729
graphCheck: logGroup("graph-check"),
2830
};

0 commit comments

Comments
 (0)