deploy-stack 0.15.2 β 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/docs/ROADMAP.md +9 -2
- package/docs/guides/ephemeral-pr-previews.md +32 -0
- package/package.json +1 -1
- package/src/commands/init.js +3 -1
- package/src/utils/generator.js +7 -1
- package/src/utils/prompts.js +12 -0
- package/templates/github/preview.yml +69 -0
- package/templates/github/teardown.yml +43 -0
- package/templates/terraform/database.tf +23 -23
- package/templates/terraform/main.tf +33 -16
- package/templates/terraform/network.tf +4 -4
- package/templates/terraform/oidc.tf +13 -11
- package/templates/terraform/secrets.tf +17 -2
- package/templates/terraform/worker.tf +5 -5
package/README.md
CHANGED
|
@@ -48,6 +48,7 @@ You retain complete ownership of your infrastructure code without relying on bla
|
|
|
48
48
|
* **Zero Vendor Lock-In:** Generates standard, readable Terraform (`.tf`) files. You own the infrastructure.
|
|
49
49
|
* **Native S3 State Locking:** Automatically creates an encrypted S3 state bucket utilizing modern Terraform concurrency locking.
|
|
50
50
|
* **Safe Iteration:** Idempotent CLI safely backs up existing configurations to `.bak` files to guarantee zero data loss.
|
|
51
|
+
* **Ephemeral PR Previews (Opt-In):** Automatically spins up completely isolated AWS Fargate environments for every Pull Request and posts the live preview URL to GitHub, accelerating team code reviews.
|
|
51
52
|
|
|
52
53
|
---
|
|
53
54
|
|
|
@@ -57,6 +58,7 @@ Transitioning from PaaS to AWS involves a few architectural shifts. We've writte
|
|
|
57
58
|
* [Managing Secrets & Environment Variables](./docs/guides/secrets-management.md)
|
|
58
59
|
* [Zero-Trust Database Connections](./docs/guides/database-connections.md)
|
|
59
60
|
* [Migrating Next.js from Vercel](./docs/migrations/nextjs-vercel-to-aws.md)
|
|
61
|
+
* [Ephemeral PR Previews & AWS Costs](./docs/guides/ephemeral-pr-previews.md)
|
|
60
62
|
|
|
61
63
|
---
|
|
62
64
|
|
|
@@ -154,11 +156,11 @@ npx deploy-stack --no-telemetry
|
|
|
154
156
|
|
|
155
157
|
## πΊοΈ Roadmap
|
|
156
158
|
|
|
157
|
-
### Current Focus (Phase
|
|
158
|
-
- [x] **
|
|
159
|
-
- [
|
|
160
|
-
- [
|
|
161
|
-
- [
|
|
159
|
+
### Current Focus (Phase 7: Team Workflows & Ecosystem Integrations)
|
|
160
|
+
- [x] **Ephemeral PR Previews:** Generate GitHub Actions workflows that spin up temporary ECS Fargate tasks and post live preview URLs directly in pull request comments to streamline team code reviews.
|
|
161
|
+
- [ ] **AI Context Synchronization:** Implement `deploy-stack sync-ai` to automatically generate `.cursorrules` and AI context files, ensuring coding assistants generate accurate deployment commands tailored to the project.
|
|
162
|
+
- [ ] **Native Ecosystem Integrations:** Publish seamless, push-button plugins across major frameworks. Targets include a `svelte-adapter-deploy-stack`, an official `create-next-app` AWS template, a `vite-plugin-deploy-stack`, a NestJS schematic, and a Django Cookiecutter template.
|
|
163
|
+
- [ ] **Automated Troubleshooting:** Build `deploy-stack diagnose` (alias: `wtf`) to automatically analyze and troubleshoot common day-2 AWS operational issues (e.g., Fargate OOM kills, ALB 502s) directly from the terminal.
|
|
162
164
|
|
|
163
165
|
π **[See the full project history and future plans in ROADMAP.md](./ROADMAP.md)**
|
|
164
166
|
|
package/docs/ROADMAP.md
CHANGED
|
@@ -16,8 +16,15 @@
|
|
|
16
16
|
- [x] **Local Execution Wrapper:** Native `deploy-stack apply` command with terminal-optimized streaming to eliminate Terraform context switching.
|
|
17
17
|
- [x] **Ecosystem Integrations:** Official plugins published to the Astro Integrations directory (`astro-deploy-stack`) and Nuxt module registry (`nuxt-deploy-stack`).
|
|
18
18
|
|
|
19
|
-
### Phase 6: Migration & Trust Engine (
|
|
19
|
+
### Phase 6: Migration & Trust Engine (Completed)
|
|
20
20
|
- [x] **Dry-Run Visualization:** Interactive pre-flight terminal UI with ASCII topology maps and precise, dynamic AWS cost estimation.
|
|
21
21
|
- [x] **PaaS Importers:** Auto-parse `vercel.json` and Heroku `Procfile` configurations to map routing rules, web commands, and background workers automatically.
|
|
22
22
|
- [x] **Docker Compose to ECS Translator:** Automatically converting a familiar local `docker-compose.yml` into production ECS task definitions.
|
|
23
|
-
- [
|
|
23
|
+
- [x] **AI Agent Rulesets:** Publishing `.cursorrules` and Copilot instructions that teach AI assistants exactly how to utilize the CLI on the user's behalf.
|
|
24
|
+
|
|
25
|
+
### Phase 7: Team Workflows & Ecosystem Integrations (Current)
|
|
26
|
+
*Focus: Enhance collaborative development and expand native support across major framework ecosystems.*
|
|
27
|
+
- [x] **Ephemeral PR Previews:** Generate GitHub Actions workflows that spin up temporary ECS Fargate tasks and post live URLs directly in PR comments to streamline team code reviews.
|
|
28
|
+
- [ ] **AI Context Synchronization:** Implement `deploy-stack sync-ai` to automatically generate `.cursorrules` and AI context files, ensuring IDE assistants understand the infrastructure.
|
|
29
|
+
- [ ] **Native Ecosystem Integrations:** Publish push-button plugins across major frameworks (e.g., `svelte-adapter-deploy-stack`, `create-next-app` AWS template, `vite-plugin-deploy-stack`, NestJS deployment schematic, Django Cookiecutter).
|
|
30
|
+
- [ ] **Automated Troubleshooting:** Build `deploy-stack diagnose` to auto-diagnose common day-2 AWS failures (e.g., Fargate OOM kills, ALB 502s) to establish immediate technical credibility and simplify maintenance.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# π Ephemeral PR Previews
|
|
2
|
+
|
|
3
|
+
When enabled, `deploy-stack` automatically configures your GitHub Actions pipeline to spin up isolated, temporary AWS environments every time a developer opens a Pull Request.
|
|
4
|
+
|
|
5
|
+
A bot will comment on the PR with a live URL (e.g., `http://pr-123-your-app...`), allowing your team to test features, UI changes, and API updates before merging to `main`. When the PR is closed or merged, the environment is automatically destroyed.
|
|
6
|
+
|
|
7
|
+
### ποΈ How it Works
|
|
8
|
+
|
|
9
|
+
Under the hood, `deploy-stack` utilizes **Terraform Workspaces**.
|
|
10
|
+
|
|
11
|
+
When a PR is opened, Terraform creates a new workspace (e.g., `pr-12`). It provisions a completely isolated Application Load Balancer and ECS Fargate Task using the exact same infrastructure definitions as your production environment, ensuring 100% parity.
|
|
12
|
+
|
|
13
|
+
To save time and simplify architecture, PR environments **share** your production AWS Secrets Manager vault and ECR Image Repository.
|
|
14
|
+
|
|
15
|
+
### βοΈ The Rule of Thumb: Should I enable this?
|
|
16
|
+
|
|
17
|
+
**β
Enable PR Previews if:**
|
|
18
|
+
* You are working on a team of 2+ developers and require visual QA or UX sign-off before merging code.
|
|
19
|
+
* You are building a frontend application or full-stack monolith where seeing the live UI is critical.
|
|
20
|
+
|
|
21
|
+
**β Do NOT enable PR Previews if:**
|
|
22
|
+
* You are a solo developer (you can just test locally).
|
|
23
|
+
* You have a massive volume of PRs (e.g., automated Dependabot updates). Spinning up an AWS Load Balancer takes ~3 minutes, which will slow down rapid automated merges.
|
|
24
|
+
|
|
25
|
+
### π° AWS Cost Implications
|
|
26
|
+
|
|
27
|
+
Because PR previews provision a real Application Load Balancer (ALB) and ECS Fargate compute tasks, **they are not free.**
|
|
28
|
+
|
|
29
|
+
* **Compute:** You are charged standard AWS Fargate rates per minute while the PR environment is running.
|
|
30
|
+
* **Load Balancing:** AWS charges ~$16/month per active Load Balancer. If a PR is open for 2 days, you pay the prorated ALB cost for those 48 hours (~$1.00).
|
|
31
|
+
|
|
32
|
+
To keep costs low, ensure your team closes or merges Pull Requests promptly so the `teardown.yml` workflow can destroy the resources and stop the billing clock!
|
package/package.json
CHANGED
package/src/commands/init.js
CHANGED
|
@@ -143,7 +143,8 @@ export async function mainStack({ isHeadless = false, headlessOptions = {} } = {
|
|
|
143
143
|
DISABLE_DEFAULT_CI: disableDefaultCI,
|
|
144
144
|
PROCFILE: procfile,
|
|
145
145
|
VERCEL_RULES: vercelRules,
|
|
146
|
-
DOCKER_COMPOSE: dockerCompose
|
|
146
|
+
DOCKER_COMPOSE: dockerCompose,
|
|
147
|
+
ENABLE_PR_PREVIEWS: config.enablePrPreviews
|
|
147
148
|
});
|
|
148
149
|
|
|
149
150
|
// 8. Telemetry
|
|
@@ -169,6 +170,7 @@ export async function mainStack({ isHeadless = false, headlessOptions = {} } = {
|
|
|
169
170
|
is_heroku_migration: !!procfile,
|
|
170
171
|
is_vercel_migration: !!vercelRules,
|
|
171
172
|
is_docker_compose: !!dockerCompose,
|
|
173
|
+
has_pr_previews: config.enablePrPreviews,
|
|
172
174
|
});
|
|
173
175
|
|
|
174
176
|
s.stop('Infrastructure provisioned successfully!');
|
package/src/utils/generator.js
CHANGED
|
@@ -26,6 +26,12 @@ export async function generateTemplates(targetDir, config) {
|
|
|
26
26
|
{ src: 'README.md', dest: 'README.md' }
|
|
27
27
|
];
|
|
28
28
|
|
|
29
|
+
// 2.1 Add Ephemeral PR workflows only if opted in
|
|
30
|
+
if (config.ENABLE_PR_PREVIEWS) {
|
|
31
|
+
filesToProcess.push({ src: 'github/preview.yml', dest: '.github/workflows/preview.yml' });
|
|
32
|
+
filesToProcess.push({ src: 'github/teardown.yml', dest: '.github/workflows/teardown.yml' });
|
|
33
|
+
}
|
|
34
|
+
|
|
29
35
|
// 3. Configure Compute Commands & Environment Variables
|
|
30
36
|
let secretsArray = [];
|
|
31
37
|
|
|
@@ -122,7 +128,7 @@ export async function generateTemplates(targetDir, config) {
|
|
|
122
128
|
|
|
123
129
|
// 4.1. Inject Rails Master Key if applicable
|
|
124
130
|
if (config.finalFramework === 'rails') {
|
|
125
|
-
secretsArray.push(`{ "name": "RAILS_MASTER_KEY", "valueFrom": "\${
|
|
131
|
+
secretsArray.push(`{ "name": "RAILS_MASTER_KEY", "valueFrom": "\${local.secret_arn}:RAILS_MASTER_KEY::" }`);
|
|
126
132
|
|
|
127
133
|
initialSecretMap += `,\n RAILS_MASTER_KEY = var.rails_master_key`;
|
|
128
134
|
|
package/src/utils/prompts.js
CHANGED
|
@@ -46,6 +46,7 @@ export async function getProjectConfig(isHeadless, headlessOptions, targetDir, d
|
|
|
46
46
|
desiredCount: headlessOptions.desiredCount || '1',
|
|
47
47
|
branch: headlessOptions.branch || 'main',
|
|
48
48
|
needsDatabase: false,
|
|
49
|
+
enablePrPreviews: headlessOptions.enablePrPreviews || false,
|
|
49
50
|
setupType: 'headless'
|
|
50
51
|
};
|
|
51
52
|
}
|
|
@@ -99,6 +100,16 @@ export async function getProjectConfig(isHeadless, headlessOptions, targetDir, d
|
|
|
99
100
|
needsDatabase = dbChoice;
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
let enablePrPreviews = false;
|
|
104
|
+
if (setupType === 'advanced') {
|
|
105
|
+
const prChoice = await confirm({
|
|
106
|
+
message: `Enable Ephemeral PR Previews? (Spins up isolated, temporary AWS environments for PRs)\n ${color.gray('π Learn more: https://github.com/anton-codes-iac/deploy-stack/blob/main/docs/guides/ephemeral-pr-previews.md')}`,
|
|
107
|
+
initialValue: false,
|
|
108
|
+
});
|
|
109
|
+
if (typeof prChoice === 'symbol') process.exit(0);
|
|
110
|
+
enablePrPreviews = prChoice;
|
|
111
|
+
}
|
|
112
|
+
|
|
102
113
|
const project = await group({
|
|
103
114
|
region: () => select({
|
|
104
115
|
message: 'Which AWS region do you want to deploy to?',
|
|
@@ -151,6 +162,7 @@ export async function getProjectConfig(isHeadless, headlessOptions, targetDir, d
|
|
|
151
162
|
desiredCount: project.desiredCount || '1',
|
|
152
163
|
branch: project.branch || currentGitBranch,
|
|
153
164
|
needsDatabase,
|
|
165
|
+
enablePrPreviews,
|
|
154
166
|
setupType
|
|
155
167
|
};
|
|
156
168
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
name: Ephemeral PR Preview
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
types: [opened, synchronize, reopened]
|
|
6
|
+
|
|
7
|
+
env:
|
|
8
|
+
AWS_REGION: {{REGION}}
|
|
9
|
+
ECR_REPOSITORY: {{PROJECT_NAME}}-repo
|
|
10
|
+
WORKSPACE: pr-${{ github.event.pull_request.number }}
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
id-token: write
|
|
14
|
+
contents: read
|
|
15
|
+
pull-requests: write # Required to post the comment
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
deploy-preview:
|
|
19
|
+
name: Provision Preview Environment
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
|
|
22
|
+
steps:
|
|
23
|
+
- name: Checkout Code
|
|
24
|
+
uses: actions/checkout@v4
|
|
25
|
+
|
|
26
|
+
- name: Configure AWS credentials
|
|
27
|
+
uses: aws-actions/configure-aws-credentials@v4
|
|
28
|
+
with:
|
|
29
|
+
role-to-assume: arn:aws:iam::{{AWS_ACCOUNT_ID}}:role/{{PROJECT_NAME}}-github-actions-role
|
|
30
|
+
aws-region: ${{ env.AWS_REGION }}
|
|
31
|
+
|
|
32
|
+
- name: Login to Amazon ECR
|
|
33
|
+
id: login-ecr
|
|
34
|
+
uses: aws-actions/amazon-ecr-login@v2
|
|
35
|
+
|
|
36
|
+
- name: Build and Push Docker image
|
|
37
|
+
env:
|
|
38
|
+
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
|
|
39
|
+
run: |
|
|
40
|
+
# We tag the image specifically for this PR workspace
|
|
41
|
+
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:${{ env.WORKSPACE }} .
|
|
42
|
+
docker push $ECR_REGISTRY/$ECR_REPOSITORY:${{ env.WORKSPACE }}
|
|
43
|
+
|
|
44
|
+
- name: Setup Terraform
|
|
45
|
+
uses: hashicorp/setup-terraform@v3
|
|
46
|
+
|
|
47
|
+
- name: Terraform Init & Apply
|
|
48
|
+
id: tf-apply
|
|
49
|
+
working-directory: ./terraform
|
|
50
|
+
run: |
|
|
51
|
+
terraform init
|
|
52
|
+
# Create or select the workspace specific to this PR
|
|
53
|
+
terraform workspace select -or-create ${{ env.WORKSPACE }}
|
|
54
|
+
terraform apply -auto-approve
|
|
55
|
+
|
|
56
|
+
# Extract the ALB URL to post to GitHub
|
|
57
|
+
ALB_URL=$(terraform output -raw alb_direct_url)
|
|
58
|
+
echo "PREVIEW_URL=$ALB_URL" >> $GITHUB_ENV
|
|
59
|
+
|
|
60
|
+
- name: Comment PR
|
|
61
|
+
uses: actions/github-script@v7
|
|
62
|
+
with:
|
|
63
|
+
script: |
|
|
64
|
+
github.rest.issues.createComment({
|
|
65
|
+
issue_number: context.issue.number,
|
|
66
|
+
owner: context.repo.owner,
|
|
67
|
+
repo: context.repo.repo,
|
|
68
|
+
body: `π **Preview Environment Deployed!**\n\nYour ephemeral AWS Fargate environment is live at:\n[${process.env.PREVIEW_URL}](${process.env.PREVIEW_URL})\n\n_This environment will be automatically destroyed when the PR is merged or closed._\n\n---\n<div align="right"><sup>βοΈ Generated by <a href="https://github.com/anton-codes-iac/deploy-stack">deploy-stack</a></sup></div>`
|
|
69
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
name: Destroy PR Preview
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
types: [closed]
|
|
6
|
+
|
|
7
|
+
env:
|
|
8
|
+
AWS_REGION: {{REGION}}
|
|
9
|
+
WORKSPACE: pr-${{ github.event.pull_request.number }}
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
destroy-preview:
|
|
17
|
+
name: Teardown Preview Environment
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
|
|
20
|
+
steps:
|
|
21
|
+
- name: Checkout Code
|
|
22
|
+
uses: actions/checkout@v4
|
|
23
|
+
|
|
24
|
+
- name: Configure AWS credentials
|
|
25
|
+
uses: aws-actions/configure-aws-credentials@v4
|
|
26
|
+
with:
|
|
27
|
+
role-to-assume: arn:aws:iam::{{AWS_ACCOUNT_ID}}:role/{{PROJECT_NAME}}-github-actions-role
|
|
28
|
+
aws-region: ${{ env.AWS_REGION }}
|
|
29
|
+
|
|
30
|
+
- name: Setup Terraform
|
|
31
|
+
uses: hashicorp/setup-terraform@v3
|
|
32
|
+
|
|
33
|
+
- name: Terraform Init & Destroy
|
|
34
|
+
working-directory: ./terraform
|
|
35
|
+
run: |
|
|
36
|
+
terraform init
|
|
37
|
+
# Select the PR workspace
|
|
38
|
+
terraform workspace select ${{ env.WORKSPACE }}
|
|
39
|
+
# Destroy all resources in this workspace safely
|
|
40
|
+
terraform destroy -auto-approve
|
|
41
|
+
# Switch back to default and delete the PR workspace
|
|
42
|
+
terraform workspace select default
|
|
43
|
+
terraform workspace delete ${{ env.WORKSPACE }}
|
|
@@ -6,19 +6,19 @@ resource "aws_subnet" "db_isolated" {
|
|
|
6
6
|
availability_zone = data.aws_availability_zones.available.names[count.index]
|
|
7
7
|
|
|
8
8
|
tags = {
|
|
9
|
-
Name = "{
|
|
9
|
+
Name = "${local.app_name}-db-isolated-${count.index}"
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
resource "aws_db_subnet_group" "main" {
|
|
14
|
-
name = "{
|
|
14
|
+
name = "${local.app_name}-db-subnet-group"
|
|
15
15
|
subnet_ids = aws_subnet.db_isolated[*].id
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
# 2. Database Security Group
|
|
19
19
|
resource "aws_security_group" "rds" {
|
|
20
|
-
name
|
|
21
|
-
vpc_id
|
|
20
|
+
name = "${local.app_name}-rds-sg"
|
|
21
|
+
vpc_id = aws_vpc.main.id
|
|
22
22
|
|
|
23
23
|
# ONLY allow inbound traffic from the ECS Fargate tasks
|
|
24
24
|
ingress {
|
|
@@ -31,31 +31,31 @@ resource "aws_security_group" "rds" {
|
|
|
31
31
|
|
|
32
32
|
# 3. The PostgreSQL Instance
|
|
33
33
|
resource "aws_db_instance" "postgres" {
|
|
34
|
-
identifier
|
|
35
|
-
engine
|
|
36
|
-
engine_version
|
|
37
|
-
instance_class
|
|
38
|
-
allocated_storage
|
|
39
|
-
storage_encrypted
|
|
40
|
-
|
|
34
|
+
identifier = "${local.app_name}-db"
|
|
35
|
+
engine = "postgres"
|
|
36
|
+
engine_version = "16"
|
|
37
|
+
instance_class = "db.t4g.micro"
|
|
38
|
+
allocated_storage = 20
|
|
39
|
+
storage_encrypted = true
|
|
40
|
+
|
|
41
41
|
# Clean up dashes for the database name (e.g. my-project -> my_project)
|
|
42
|
-
db_name
|
|
43
|
-
username
|
|
44
|
-
|
|
42
|
+
db_name = replace("${local.app_name}", "-", "_")
|
|
43
|
+
username = "dbadmin"
|
|
44
|
+
|
|
45
45
|
# AWS automatically creates and manages the secret in Secrets Manager!
|
|
46
|
-
manage_master_user_password = true
|
|
46
|
+
manage_master_user_password = true
|
|
47
47
|
|
|
48
|
-
db_subnet_group_name
|
|
49
|
-
vpc_security_group_ids
|
|
50
|
-
|
|
51
|
-
skip_final_snapshot
|
|
52
|
-
publicly_accessible
|
|
48
|
+
db_subnet_group_name = aws_db_subnet_group.main.name
|
|
49
|
+
vpc_security_group_ids = [aws_security_group.rds.id]
|
|
50
|
+
|
|
51
|
+
skip_final_snapshot = true
|
|
52
|
+
publicly_accessible = false
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
# 4. IAM Permission for RDS Master Password Secret
|
|
56
56
|
resource "aws_iam_role_policy" "rds_secret_access" {
|
|
57
|
-
name
|
|
58
|
-
role
|
|
57
|
+
name = "${local.app_name}-rds-secret-policy"
|
|
58
|
+
role = aws_iam_role.execution_role.id
|
|
59
59
|
policy = jsonencode({
|
|
60
60
|
Version = "2012-10-17"
|
|
61
61
|
Statement = [
|
|
@@ -68,4 +68,4 @@ resource "aws_iam_role_policy" "rds_secret_access" {
|
|
|
68
68
|
}
|
|
69
69
|
]
|
|
70
70
|
})
|
|
71
|
-
}
|
|
71
|
+
}
|
|
@@ -12,16 +12,23 @@ provider "aws" {
|
|
|
12
12
|
locals {
|
|
13
13
|
# Safely read the keys generated by the CLI, default to empty list if file doesn't exist
|
|
14
14
|
secret_keys = fileexists("${path.module}/secret_keys.json") ? jsondecode(file("${path.module}/secret_keys.json")) : []
|
|
15
|
+
|
|
16
|
+
# Workspace suffix for PR preview environments (e.g., "-pr-123"). Empty for production.
|
|
17
|
+
env_suffix = terraform.workspace == "default" ? "" : "-${terraform.workspace}"
|
|
18
|
+
|
|
19
|
+
# Dynamic naming variable to prevent collisions
|
|
20
|
+
app_name = "{{PROJECT_NAME}}${local.env_suffix}"
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
# --- CloudWatch Logs ---
|
|
18
24
|
resource "aws_cloudwatch_log_group" "app_logs" {
|
|
19
|
-
name = "/ecs
|
|
25
|
+
name = "/ecs/${local.app_name}"
|
|
20
26
|
retention_in_days = 14
|
|
21
27
|
}
|
|
22
28
|
|
|
23
|
-
# --- ECR Repository ---
|
|
29
|
+
# --- ECR Repository (Shared across workspaces) ---
|
|
24
30
|
resource "aws_ecr_repository" "app" {
|
|
31
|
+
count = terraform.workspace == "default" ? 1 : 0
|
|
25
32
|
name = "{{PROJECT_NAME}}-repo"
|
|
26
33
|
# trivy:ignore:AVD-AWS-0031 - Mutable tags allow the CI/CD pipeline to reuse the 'latest' tag for simplified deployments
|
|
27
34
|
image_tag_mutability = "MUTABLE"
|
|
@@ -32,6 +39,16 @@ resource "aws_ecr_repository" "app" {
|
|
|
32
39
|
}
|
|
33
40
|
}
|
|
34
41
|
|
|
42
|
+
# In PR workspaces, fetch the existing production repository
|
|
43
|
+
data "aws_ecr_repository" "existing_app" {
|
|
44
|
+
count = terraform.workspace != "default" ? 1 : 0
|
|
45
|
+
name = "{{PROJECT_NAME}}-repo"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
locals {
|
|
49
|
+
ecr_url = terraform.workspace == "default" ? aws_ecr_repository.app[0].repository_url : data.aws_ecr_repository.existing_app[0].repository_url
|
|
50
|
+
}
|
|
51
|
+
|
|
35
52
|
# --- IAM: Execution Role ---
|
|
36
53
|
# Allows the underlying AWS Fargate agent to pull images and push logs
|
|
37
54
|
data "aws_iam_policy_document" "ecs_trust" {
|
|
@@ -45,7 +62,7 @@ data "aws_iam_policy_document" "ecs_trust" {
|
|
|
45
62
|
}
|
|
46
63
|
|
|
47
64
|
resource "aws_iam_role" "execution_role" {
|
|
48
|
-
name = "{
|
|
65
|
+
name = "${local.app_name}-execution-role"
|
|
49
66
|
assume_role_policy = data.aws_iam_policy_document.ecs_trust.json
|
|
50
67
|
}
|
|
51
68
|
|
|
@@ -57,18 +74,18 @@ resource "aws_iam_role_policy_attachment" "execution_role_policy" {
|
|
|
57
74
|
# --- IAM: Task Role ---
|
|
58
75
|
# Allows your application code running INSIDE the container to access AWS services
|
|
59
76
|
resource "aws_iam_role" "task_role" {
|
|
60
|
-
name = "{
|
|
77
|
+
name = "${local.app_name}-task-role"
|
|
61
78
|
assume_role_policy = data.aws_iam_policy_document.ecs_trust.json
|
|
62
79
|
}
|
|
63
80
|
|
|
64
81
|
# --- ECS Cluster ---
|
|
65
82
|
resource "aws_ecs_cluster" "main" {
|
|
66
|
-
name = "{
|
|
83
|
+
name = "${local.app_name}-cluster"
|
|
67
84
|
}
|
|
68
85
|
|
|
69
86
|
# --- ECS Task Definition ---
|
|
70
87
|
resource "aws_ecs_task_definition" "app" {
|
|
71
|
-
family = "{
|
|
88
|
+
family = "${local.app_name}-task"
|
|
72
89
|
network_mode = "awsvpc"
|
|
73
90
|
requires_compatibilities = ["FARGATE"]
|
|
74
91
|
cpu = "{{CPU}}"
|
|
@@ -78,8 +95,8 @@ resource "aws_ecs_task_definition" "app" {
|
|
|
78
95
|
|
|
79
96
|
container_definitions = jsonencode([
|
|
80
97
|
{
|
|
81
|
-
name = "{
|
|
82
|
-
image = "${
|
|
98
|
+
name = "${local.app_name}-container"
|
|
99
|
+
image = "${local.ecr_url}:${terraform.workspace == "default" ? "latest" : terraform.workspace}"
|
|
83
100
|
essential = true
|
|
84
101
|
|
|
85
102
|
environment = [
|
|
@@ -92,7 +109,7 @@ resource "aws_ecs_task_definition" "app" {
|
|
|
92
109
|
[
|
|
93
110
|
for key in local.secret_keys : {
|
|
94
111
|
name = key
|
|
95
|
-
valueFrom = "${
|
|
112
|
+
valueFrom = "${local.secret_arn}:${key}::"
|
|
96
113
|
}
|
|
97
114
|
],
|
|
98
115
|
[
|
|
@@ -125,7 +142,7 @@ resource "aws_ecs_task_definition" "app" {
|
|
|
125
142
|
# --- Application Load Balancer ---
|
|
126
143
|
# trivy:ignore:AVD-AWS-0053 - This ALB is intended to be publicly facing behind CloudFront
|
|
127
144
|
resource "aws_lb" "main" {
|
|
128
|
-
name = "{
|
|
145
|
+
name = substr("${local.app_name}-alb", 0, 32)
|
|
129
146
|
load_balancer_type = "application"
|
|
130
147
|
security_groups = [aws_security_group.alb.id]
|
|
131
148
|
subnets = aws_subnet.public[*].id
|
|
@@ -133,7 +150,7 @@ resource "aws_lb" "main" {
|
|
|
133
150
|
}
|
|
134
151
|
|
|
135
152
|
resource "aws_lb_target_group" "app" {
|
|
136
|
-
name = "{
|
|
153
|
+
name = substr("${local.app_name}-tg", 0, 32)
|
|
137
154
|
port = {{PORT}}
|
|
138
155
|
protocol = "HTTP"
|
|
139
156
|
vpc_id = aws_vpc.main.id
|
|
@@ -163,7 +180,7 @@ resource "aws_lb_listener" "http" {
|
|
|
163
180
|
|
|
164
181
|
# --- ECS Service ---
|
|
165
182
|
resource "aws_ecs_service" "app" {
|
|
166
|
-
name = "{
|
|
183
|
+
name = "${local.app_name}-service"
|
|
167
184
|
cluster = aws_ecs_cluster.main.id
|
|
168
185
|
task_definition = aws_ecs_task_definition.app.arn
|
|
169
186
|
launch_type = "FARGATE"
|
|
@@ -177,7 +194,7 @@ resource "aws_ecs_service" "app" {
|
|
|
177
194
|
|
|
178
195
|
load_balancer {
|
|
179
196
|
target_group_arn = aws_lb_target_group.app.arn
|
|
180
|
-
container_name = "{
|
|
197
|
+
container_name = "${local.app_name}-container"
|
|
181
198
|
container_port = {{PORT}}
|
|
182
199
|
}
|
|
183
200
|
|
|
@@ -194,7 +211,7 @@ resource "aws_iam_role_policy" "secrets_policy" {
|
|
|
194
211
|
{
|
|
195
212
|
Effect = "Allow"
|
|
196
213
|
Action = ["secretsmanager:GetSecretValue"]
|
|
197
|
-
Resource = [
|
|
214
|
+
Resource = [local.secret_arn]
|
|
198
215
|
}
|
|
199
216
|
]
|
|
200
217
|
})
|
|
@@ -208,12 +225,12 @@ output "alb_direct_url" {
|
|
|
208
225
|
|
|
209
226
|
output "ecr_repository_url" {
|
|
210
227
|
description = "The URL of the ECR repository"
|
|
211
|
-
value =
|
|
228
|
+
value = local.ecr_url
|
|
212
229
|
}
|
|
213
230
|
|
|
214
231
|
# --- CloudWatch Alarms ---
|
|
215
232
|
resource "aws_cloudwatch_metric_alarm" "alb_5xx_errors" {
|
|
216
|
-
alarm_name = "{
|
|
233
|
+
alarm_name = "${local.app_name}-high-5xx-errors"
|
|
217
234
|
comparison_operator = "GreaterThanThreshold"
|
|
218
235
|
evaluation_periods = "2"
|
|
219
236
|
metric_name = "HTTPCode_Target_5XX_Count"
|
|
@@ -5,7 +5,7 @@ resource "aws_vpc" "main" {
|
|
|
5
5
|
enable_dns_support = true
|
|
6
6
|
|
|
7
7
|
tags = {
|
|
8
|
-
Name = "{
|
|
8
|
+
Name = "${local.app_name}-vpc"
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
|
|
@@ -28,7 +28,7 @@ resource "aws_subnet" "public" {
|
|
|
28
28
|
map_public_ip_on_launch = true
|
|
29
29
|
|
|
30
30
|
tags = {
|
|
31
|
-
Name = "{
|
|
31
|
+
Name = "${local.app_name}-public-subnet-${count.index + 1}"
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -50,7 +50,7 @@ resource "aws_route_table_association" "public" {
|
|
|
50
50
|
# --- Security Groups ---
|
|
51
51
|
# ALB SG: Allow public internet access on HTTP
|
|
52
52
|
resource "aws_security_group" "alb" {
|
|
53
|
-
name = "{
|
|
53
|
+
name = "${local.app_name}-alb-sg"
|
|
54
54
|
description = "Allow inbound HTTP to ALB"
|
|
55
55
|
vpc_id = aws_vpc.main.id
|
|
56
56
|
|
|
@@ -72,7 +72,7 @@ resource "aws_security_group" "alb" {
|
|
|
72
72
|
|
|
73
73
|
# ECS SG: Allow traffic ONLY from the ALB on the app's specific port
|
|
74
74
|
resource "aws_security_group" "ecs_tasks" {
|
|
75
|
-
name = "{
|
|
75
|
+
name = "${local.app_name}-ecs-sg"
|
|
76
76
|
description = "Allow inbound access from the ALB only"
|
|
77
77
|
vpc_id = aws_vpc.main.id
|
|
78
78
|
|
|
@@ -9,9 +9,9 @@ variable "create_oidc_provider" {
|
|
|
9
9
|
default = true
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
# Register GitHub as an Identity Provider in AWS
|
|
12
|
+
# Register GitHub as an Identity Provider in AWS (Production Only)
|
|
13
13
|
resource "aws_iam_openid_connect_provider" "github" {
|
|
14
|
-
count = var.create_oidc_provider ? 1 : 0
|
|
14
|
+
count = (var.create_oidc_provider && terraform.workspace == "default") ? 1 : 0
|
|
15
15
|
url = "https://token.actions.githubusercontent.com"
|
|
16
16
|
client_id_list = ["sts.amazonaws.com"]
|
|
17
17
|
thumbprint_list = [data.tls_certificate.github.certificates[0].sha1_fingerprint]
|
|
@@ -19,18 +19,19 @@ resource "aws_iam_openid_connect_provider" "github" {
|
|
|
19
19
|
|
|
20
20
|
# Reference the Provider (whether newly created or already existing)
|
|
21
21
|
data "aws_iam_openid_connect_provider" "github_existing" {
|
|
22
|
-
count = var.create_oidc_provider ? 0 : 1
|
|
23
|
-
url
|
|
22
|
+
count = (var.create_oidc_provider && terraform.workspace == "default") ? 0 : 1
|
|
23
|
+
url = "https://token.actions.githubusercontent.com"
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
# Dynamically pick the ARN of the provider based on the variable
|
|
27
27
|
locals {
|
|
28
|
-
github_provider_arn = var.create_oidc_provider ? aws_iam_openid_connect_provider.github[0].arn : data.aws_iam_openid_connect_provider.github_existing[0].arn
|
|
28
|
+
github_provider_arn = (var.create_oidc_provider && terraform.workspace == "default") ? aws_iam_openid_connect_provider.github[0].arn : data.aws_iam_openid_connect_provider.github_existing[0].arn
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
# Create the IAM Role that GitHub Actions will assume
|
|
32
32
|
resource "aws_iam_role" "github_actions" {
|
|
33
|
-
|
|
33
|
+
count = terraform.workspace == "default" ? 1 : 0
|
|
34
|
+
name = "{{PROJECT_NAME}}-github-actions-role"
|
|
34
35
|
|
|
35
36
|
assume_role_policy = jsonencode({
|
|
36
37
|
Version = "2012-10-17"
|
|
@@ -44,10 +45,10 @@ resource "aws_iam_role" "github_actions" {
|
|
|
44
45
|
Condition = {
|
|
45
46
|
StringLike = {
|
|
46
47
|
# β οΈ SECURITY: Update "repo:*" to "repo:your-github-username/your-repo-name:*" in production
|
|
47
|
-
"token.actions.githubusercontent.com:sub" : "repo:*"
|
|
48
|
+
"token.actions.githubusercontent.com:sub" : "repo:*"
|
|
48
49
|
}
|
|
49
50
|
StringEquals = {
|
|
50
|
-
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
|
|
51
|
+
"token.actions.githubusercontent.com:aud" : "sts.amazonaws.com"
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
}
|
|
@@ -57,6 +58,7 @@ resource "aws_iam_role" "github_actions" {
|
|
|
57
58
|
|
|
58
59
|
# Grant the CI/CD pipeline permissions to provision infrastructure
|
|
59
60
|
resource "aws_iam_role_policy_attachment" "github_actions_admin" {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
count = terraform.workspace == "default" ? 1 : 0
|
|
62
|
+
role = aws_iam_role.github_actions[0].name
|
|
63
|
+
policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
|
|
64
|
+
}
|
|
@@ -1,10 +1,24 @@
|
|
|
1
|
-
# --- AWS Secrets Manager ---
|
|
1
|
+
# --- Shared AWS Secrets Manager ---
|
|
2
|
+
|
|
3
|
+
# 1. Only create the Secret Vault in the default (production) workspace
|
|
2
4
|
resource "aws_secretsmanager_secret" "app_secrets" {
|
|
5
|
+
count = terraform.workspace == "default" ? 1 : 0
|
|
3
6
|
name = "{{PROJECT_NAME}}-secrets"
|
|
4
7
|
description = "Environment variables for {{PROJECT_NAME}}"
|
|
5
8
|
recovery_window_in_days = 0 # Allows instant deletion for dev/POC environments
|
|
6
9
|
}
|
|
7
10
|
|
|
11
|
+
# 2. Fetch the existing Secret Vault when running in a PR workspace
|
|
12
|
+
data "aws_secretsmanager_secret" "existing_secrets" {
|
|
13
|
+
count = terraform.workspace != "default" ? 1 : 0
|
|
14
|
+
name = "{{PROJECT_NAME}}-secrets"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
# 3. Export a single local variable that works in both environments
|
|
18
|
+
locals {
|
|
19
|
+
secret_arn = terraform.workspace == "default" ? aws_secretsmanager_secret.app_secrets[0].arn : data.aws_secretsmanager_secret.existing_secrets[0].arn
|
|
20
|
+
}
|
|
21
|
+
|
|
8
22
|
# Fallback dummy key for CI/CD environments where the real key isn't present
|
|
9
23
|
variable "rails_master_key" {
|
|
10
24
|
type = string
|
|
@@ -13,7 +27,8 @@ variable "rails_master_key" {
|
|
|
13
27
|
|
|
14
28
|
# Initial placeholder secret so the ECS task doesn't fail on first boot
|
|
15
29
|
resource "aws_secretsmanager_secret_version" "app_secrets_initial" {
|
|
16
|
-
|
|
30
|
+
count = terraform.workspace == "default" ? 1 : 0
|
|
31
|
+
secret_id = aws_secretsmanager_secret.app_secrets[0].id
|
|
17
32
|
secret_string = jsonencode({{INITIAL_SECRET_MAP}})
|
|
18
33
|
|
|
19
34
|
lifecycle {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# --- Worker ECS Task Definition ---
|
|
2
2
|
resource "aws_ecs_task_definition" "worker" {
|
|
3
|
-
family = "{
|
|
3
|
+
family = "${local.app_name}-worker-task"
|
|
4
4
|
network_mode = "awsvpc"
|
|
5
5
|
requires_compatibilities = ["FARGATE"]
|
|
6
6
|
cpu = "{{CPU}}"
|
|
@@ -10,8 +10,8 @@ resource "aws_ecs_task_definition" "worker" {
|
|
|
10
10
|
|
|
11
11
|
container_definitions = jsonencode([
|
|
12
12
|
{
|
|
13
|
-
name = "{
|
|
14
|
-
image = "${aws_ecr_repository.app.repository_url}
|
|
13
|
+
name = "${local.app_name}-worker-container"
|
|
14
|
+
image = "${aws_ecr_repository.app.repository_url}:${terraform.workspace == "default" ? "latest" : terraform.workspace}"
|
|
15
15
|
essential = true
|
|
16
16
|
|
|
17
17
|
environment = [
|
|
@@ -23,7 +23,7 @@ resource "aws_ecs_task_definition" "worker" {
|
|
|
23
23
|
[
|
|
24
24
|
for key in local.secret_keys : {
|
|
25
25
|
name = key
|
|
26
|
-
valueFrom = "${
|
|
26
|
+
valueFrom = "${local.secret_arn}:${key}::"
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
29
|
[
|
|
@@ -48,7 +48,7 @@ resource "aws_ecs_task_definition" "worker" {
|
|
|
48
48
|
# --- Worker ECS Service ---
|
|
49
49
|
# Notice there is NO load_balancer block. This service is strictly private.
|
|
50
50
|
resource "aws_ecs_service" "worker" {
|
|
51
|
-
name = "{
|
|
51
|
+
name = "${local.app_name}-worker-service"
|
|
52
52
|
cluster = aws_ecs_cluster.main.id
|
|
53
53
|
task_definition = aws_ecs_task_definition.worker.arn
|
|
54
54
|
launch_type = "FARGATE"
|