deploy-stack 0.15.2 β†’ 0.17.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 CHANGED
@@ -48,6 +48,8 @@ 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.
52
+ * **πŸ€– IDE AI Integration:** Automatically generates contextual rules for Cursor, Windsurf, Copilot, and Claude to prevent Terraform hallucinations.
51
53
 
52
54
  ---
53
55
 
@@ -57,6 +59,7 @@ Transitioning from PaaS to AWS involves a few architectural shifts. We've writte
57
59
  * [Managing Secrets & Environment Variables](./docs/guides/secrets-management.md)
58
60
  * [Zero-Trust Database Connections](./docs/guides/database-connections.md)
59
61
  * [Migrating Next.js from Vercel](./docs/migrations/nextjs-vercel-to-aws.md)
62
+ * [Ephemeral PR Previews & AWS Costs](./docs/guides/ephemeral-pr-previews.md)
60
63
 
61
64
  ---
62
65
 
@@ -95,6 +98,9 @@ The interactive wizard will analyze your codebase, detect your framework, estima
95
98
  * **`npx deploy-stack --headless`**
96
99
  Bypasses the interactive wizard for fully programmatic execution. Perfect for CI/CD pipelines, custom scripts, or AI agent integration. Accepts flags like `--framework=static`, `--region=us-east-2`, and `--size=micro`.
97
100
 
101
+ * **`npx deploy-stack sync-ai`**
102
+ Selectively generates architecture rules for AI coding assistants (Cursor, Copilot, Windsurf, Claude). Automatically extracts your AWS Region and Container Port to prevent Terraform hallucinations.
103
+
98
104
  ---
99
105
 
100
106
  ## πŸ“ Generated File Structure
@@ -131,14 +137,16 @@ your-project/
131
137
 
132
138
  ---
133
139
 
134
- ## πŸ€– AI Agent Integration
140
+ ## πŸ€– AI Context Management (Cursor, Copilot, Windsurf, Claude)
141
+
142
+ AI coding assistants are incredible, but they often hallucinate custom Terraform or raw AWS CLI commands that can break your infrastructure state. `deploy-stack` natively intercepts and guides AI agents directly in your IDE by providing strict deployment rules and project-specific context (like your exact AWS Region and Container Port).
135
143
 
136
- Are you using Cursor, Windsurf, or GitHub Copilot? AI coding assistants often hallucinate complex, broken infrastructure code when asked to "deploy to AWS."
144
+ **How it works:**
145
+ * **Quickstart Flow:** The CLI silently auto-detects if you are using AI tools in your repository and safely injects context.
146
+ * **Advanced Flow:** You are explicitly prompted to choose which AI assistants your team uses.
147
+ * **Standalone Command:** You can run `npx deploy-stack sync-ai` at any time to selectively generate these rules later.
137
148
 
138
- To teach your AI to natively use this CLI instead, copy our [Agent Ruleset](./agent-rules.md) into your repository's specific instruction file:
139
- * **Cursor:** Save as `.cursorrules` in your project root.
140
- * **Windsurf:** Save as `.windsurfrules` in your project root.
141
- * **GitHub Copilot:** Save as `.github/copilot-instructions.md`.
149
+ **Safe & Non-Destructive:** We use isolated rule files (like `.cursor/rules/deploy-stack.mdc`) or strictly delimited blocks (``) to ensure your team's existing agent instructions, coding standards, and project prompts are **never overwritten**.
142
150
 
143
151
  ---
144
152
 
@@ -154,11 +162,11 @@ npx deploy-stack --no-telemetry
154
162
 
155
163
  ## πŸ—ΊοΈ Roadmap
156
164
 
157
- ### Current Focus (Phase 6: Migration & Trust Engine)
158
- - [x] **Dry-Run Visualization:** Interactive pre-flight terminal UI with ASCII topology maps and precise, dynamic AWS cost estimation.
159
- - [x] **PaaS Importers:** Auto-parse `vercel.json` and Heroku `Procfile` configurations to map routing rules, web commands, and background workers automatically.
160
- - [x] **Docker Compose to ECS Translator:** Automatically converting a familiar local `docker-compose.yml` into production ECS task definitions.
161
- - [x] **AI Agent Rulesets:** Publishing `.cursorrules` and Copilot instructions that teach AI assistants exactly how to utilize the CLI on the user's behalf.
165
+ ### Current Focus (Phase 7: Team Workflows & Ecosystem Integrations)
166
+ - [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.
167
+ - [x] **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.
168
+ - [ ] **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.
169
+ - [ ] **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
170
 
163
171
  πŸ‘‰ **[See the full project history and future plans in ROADMAP.md](./ROADMAP.md)**
164
172
 
package/bin/cli.js CHANGED
@@ -6,6 +6,7 @@ import { runDoctor } from '../src/commands/doctor.js';
6
6
  import { pushSecrets } from '../src/commands/secrets.js';
7
7
  import { ejectStack } from '../src/commands/eject.js';
8
8
  import { applyStack } from '../src/commands/apply.js';
9
+ import { syncAi } from '../src/commands/sync-ai.js';
9
10
 
10
11
  // 1. Extract the telemetry flag and set the environment variable
11
12
  const rawArgs = process.argv.slice(2);
@@ -40,15 +41,17 @@ const headlessOptions = isHeadless ? {
40
41
  if (args[0] === 'secrets' && args[1] === 'push') {
41
42
  const envFile = args[2] || '.env';
42
43
  const projectName = path.basename(process.cwd());
43
- pushSecrets(envFile, projectName).catch(console.error);
44
+ pushSecrets(envFile, projectName).catch(e => { console.error(e); process.exit(1); });
44
45
  } else if (args[0] === 'apply') {
45
- applyStack({ isDryRun }).catch(console.error);
46
+ applyStack({ isDryRun }).catch(e => { console.error(e); process.exit(1); });
46
47
  } else if (args[0] === 'doctor') {
47
- runDoctor().catch(console.error);
48
+ runDoctor().catch(e => { console.error(e); process.exit(1); });
48
49
  } else if (args[0] === 'destroy') {
49
- destroyStack().catch(console.error);
50
+ destroyStack().catch(e => { console.error(e); process.exit(1); });
50
51
  } else if (args[0] === 'eject') {
51
- ejectStack().catch(console.error);
52
+ ejectStack().catch(e => { console.error(e); process.exit(1); });
53
+ } else if (args[0] === 'sync-ai') {
54
+ syncAi().catch(e => { console.error(e); process.exit(1); });
52
55
  } else {
53
56
  mainStack({ isHeadless, headlessOptions }).catch(e => { console.error(e); process.exit(1); });
54
57
  }
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 (Current)
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
- - [ ] **AI Agent Rulesets:** Publishing `.cursorrules` and Copilot instructions that teach AI assistants exactly how to utilize the CLI on the user's behalf.
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
+ - [x] **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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deploy-stack",
3
- "version": "0.15.2",
3
+ "version": "0.17.0",
4
4
  "description": "Provision production-ready AWS infrastructure and CI/CD pipelines in seconds.",
5
5
  "engines": {
6
6
  "node": ">=18.0.0"
@@ -20,7 +20,8 @@ import { handleExistingFiles } from '../utils/backup.js';
20
20
  import { estimateMonthlyCost } from '../utils/visualizer.js';
21
21
  import { getTargetDirectory, getProjectConfig } from '../utils/prompts.js';
22
22
  import { resolveDjangoWsgi, handleRailsCI } from '../utils/frameworks.js';
23
- import { hasDockerCompose, parseDockerCompose } from '../utils/dockerCompose.js';
23
+ import { parseDockerCompose } from '../utils/dockerCompose.js';
24
+ import { getBaseRules, getCursorRules, injectManagedBlock } from '../utils/ai-rules.js';
24
25
 
25
26
  const pkg = JSON.parse(fsSync.readFileSync(new URL('../../package.json', import.meta.url)));
26
27
  const CLI_VERSION = pkg.version;
@@ -143,7 +144,8 @@ export async function mainStack({ isHeadless = false, headlessOptions = {} } = {
143
144
  DISABLE_DEFAULT_CI: disableDefaultCI,
144
145
  PROCFILE: procfile,
145
146
  VERCEL_RULES: vercelRules,
146
- DOCKER_COMPOSE: dockerCompose
147
+ DOCKER_COMPOSE: dockerCompose,
148
+ ENABLE_PR_PREVIEWS: config.enablePrPreviews
147
149
  });
148
150
 
149
151
  // 8. Telemetry
@@ -169,10 +171,54 @@ export async function mainStack({ isHeadless = false, headlessOptions = {} } = {
169
171
  is_heroku_migration: !!procfile,
170
172
  is_vercel_migration: !!vercelRules,
171
173
  is_docker_compose: !!dockerCompose,
174
+ has_pr_previews: config.enablePrPreviews,
175
+ ai_assistants_configured: config.aiAssistants || [],
172
176
  });
173
177
 
174
178
  s.stop('Infrastructure provisioned successfully!');
175
179
 
180
+ // 8.5 Configure AI Context
181
+ s.start('Configuring AI workspace rules...');
182
+ const aiContext = { region: config.region, port: config.port };
183
+
184
+ if (config.setupType === 'advanced') {
185
+ // --- ADVANCED MODE: Explicitly respect user choices ---
186
+ if (config.aiAssistants.includes('cursor')) {
187
+ const cursorDir = path.join(dirConfig.targetDir, '.cursor', 'rules');
188
+ if (!fsSync.existsSync(cursorDir)) fsSync.mkdirSync(cursorDir, { recursive: true });
189
+ fsSync.writeFileSync(path.join(cursorDir, 'deploy-stack.mdc'), getCursorRules(aiContext));
190
+ }
191
+ if (config.aiAssistants.includes('windsurf')) {
192
+ injectManagedBlock(path.join(dirConfig.targetDir, '.windsurfrules'), getBaseRules(aiContext), false);
193
+ }
194
+ if (config.aiAssistants.includes('claude')) {
195
+ injectManagedBlock(path.join(dirConfig.targetDir, 'CLAUDE.md'), getBaseRules(aiContext), true);
196
+ }
197
+ if (config.aiAssistants.includes('copilot')) {
198
+ const copilotPath = path.join(dirConfig.targetDir, '.github', 'copilot-instructions.md');
199
+ if (!fsSync.existsSync(path.dirname(copilotPath))) fsSync.mkdirSync(path.dirname(copilotPath), { recursive: true });
200
+ injectManagedBlock(copilotPath, getBaseRules(aiContext), true);
201
+ }
202
+ } else {
203
+ // --- QUICKSTART MODE: Silent Auto-Detection ---
204
+ if (fsSync.existsSync(path.join(dirConfig.targetDir, '.cursor'))) {
205
+ const cursorDir = path.join(dirConfig.targetDir, '.cursor', 'rules');
206
+ if (!fsSync.existsSync(cursorDir)) fsSync.mkdirSync(cursorDir, { recursive: true });
207
+ fsSync.writeFileSync(path.join(cursorDir, 'deploy-stack.mdc'), getCursorRules(aiContext));
208
+ }
209
+ if (fsSync.existsSync(path.join(dirConfig.targetDir, '.windsurf')) || fsSync.existsSync(path.join(dirConfig.targetDir, '.windsurfrules'))) {
210
+ injectManagedBlock(path.join(dirConfig.targetDir, '.windsurfrules'), getBaseRules(aiContext), false);
211
+ }
212
+ if (fsSync.existsSync(path.join(dirConfig.targetDir, 'CLAUDE.md'))) {
213
+ injectManagedBlock(path.join(dirConfig.targetDir, 'CLAUDE.md'), getBaseRules(aiContext), true);
214
+ }
215
+ const copilotPath = path.join(dirConfig.targetDir, '.github', 'copilot-instructions.md');
216
+ if (fsSync.existsSync(copilotPath)) {
217
+ injectManagedBlock(copilotPath, getBaseRules(aiContext), true);
218
+ }
219
+ }
220
+ s.stop('AI rules configured successfully!');
221
+
176
222
  // 9. Output
177
223
  let frameworkWarnings = '';
178
224
  if (!(config.framework === 'static' && detectedFramework?.buildDir)) {
@@ -0,0 +1,83 @@
1
+ import fsSync from 'fs';
2
+ import path from 'path';
3
+ import { intro, outro, spinner, log } from '@clack/prompts';
4
+ import color from 'picocolors';
5
+ import { getAiAssistants } from '../utils/prompts.js';
6
+ import { getBaseRules, getCursorRules, injectManagedBlock } from '../utils/ai-rules.js';
7
+ import { trackEvent, flushTelemetry } from '../core/telemetry.js';
8
+
9
+ function getProjectContext(cwd) {
10
+ const context = { region: '', port: '' };
11
+ const mainTfPath = path.join(cwd, 'terraform', 'main.tf');
12
+
13
+ if (fsSync.existsSync(mainTfPath)) {
14
+ const tfContent = fsSync.readFileSync(mainTfPath, 'utf8');
15
+
16
+ // Extract Region
17
+ const regionMatch = tfContent.match(/region\s*=\s*"([^"]+)"/);
18
+ if (regionMatch) context.region = regionMatch[1];
19
+
20
+ // Extract Port
21
+ const portMatch = tfContent.match(/containerPort\s*=\s*(\d+)/);
22
+ if (portMatch) context.port = portMatch[1];
23
+ }
24
+
25
+ return context;
26
+ }
27
+
28
+ export async function syncAi() {
29
+ intro(color.bgCyan(color.black(' deploy-stack sync-ai πŸ€– ')));
30
+
31
+ const assistants = await getAiAssistants();
32
+
33
+ if (!assistants || assistants.length === 0) {
34
+ log.warn('No AI assistants selected. Skipping synchronization.');
35
+ process.exit(0);
36
+ }
37
+
38
+ const s = spinner();
39
+ s.start('Writing AI context rules...');
40
+ const cwd = process.cwd();
41
+
42
+ const context = getProjectContext(cwd);
43
+
44
+ try {
45
+ if (assistants.includes('cursor')) {
46
+ const cursorDir = path.join(cwd, '.cursor', 'rules');
47
+ if (!fsSync.existsSync(cursorDir)) fsSync.mkdirSync(cursorDir, { recursive: true });
48
+ fsSync.writeFileSync(path.join(cursorDir, 'deploy-stack.mdc'), getCursorRules(context));
49
+ }
50
+
51
+ if (assistants.includes('copilot')) {
52
+ const githubDir = path.join(cwd, '.github');
53
+ if (!fsSync.existsSync(githubDir)) fsSync.mkdirSync(githubDir, { recursive: true });
54
+ injectManagedBlock(path.join(githubDir, 'copilot-instructions.md'), getBaseRules(context), true);
55
+ }
56
+
57
+ if (assistants.includes('windsurf')) {
58
+ injectManagedBlock(path.join(cwd, '.windsurfrules'), getBaseRules(context), false);
59
+ }
60
+
61
+ if (assistants.includes('claude')) {
62
+ injectManagedBlock(path.join(cwd, 'CLAUDE.md'), getBaseRules(context), true);
63
+ }
64
+
65
+ trackEvent('sync_ai_executed', {
66
+ assistants_selected: assistants,
67
+ has_cursor: assistants.includes('cursor'),
68
+ has_copilot: assistants.includes('copilot'),
69
+ has_windsurf: assistants.includes('windsurf'),
70
+ has_claude: assistants.includes('claude')
71
+ });
72
+ await flushTelemetry();
73
+
74
+ s.stop('AI context synchronized successfully!');
75
+
76
+ outro(`${color.green('βœ… AI Assistant Rules generated!')}
77
+ Your AI tools now know exactly how to deploy your app without hallucinating Terraform.`);
78
+ } catch (error) {
79
+ s.stop('❌ Failed to write AI context files.');
80
+ console.error(color.red(error.message));
81
+ process.exit(1);
82
+ }
83
+ }
@@ -0,0 +1,54 @@
1
+ import fsSync from 'fs';
2
+
3
+ export const getBaseRules = (context = {}) => `
4
+ # deploy-stack Infrastructure Context
5
+
6
+ This project's AWS infrastructure is managed strictly by the \`deploy-stack\` CLI.
7
+ **DO NOT** generate custom Terraform, AWS CloudFormation, or raw AWS CLI commands to deploy this application or modify the architecture.
8
+
9
+ ### Standard Operating Procedures
10
+
11
+ 1. **Applying Changes:** If the user asks to deploy, provision, or update the infrastructure, instruct them to run:
12
+ \`npx deploy-stack apply\`
13
+
14
+ 2. **Environment Variables:** If the user adds a new environment variable and needs it available in production, instruct them to run:
15
+ \`npx deploy-stack secrets push <path-to-env-file>\`
16
+
17
+ 3. **Teardown:** If the user wants to destroy the AWS environment, instruct them to run:
18
+ \`npx deploy-stack destroy\`
19
+
20
+ 4. **Architecture Details:**
21
+ - **AWS Region:** \`${context.region || 'Unknown (Check terraform/main.tf)'}\`
22
+ - **Container Port:** \`${context.port || 'Unknown'}\`
23
+ - The infrastructure is an AWS ECS Fargate cluster.
24
+ - It uses an Application Load Balancer (ALB).
25
+ - CI/CD is handled securely via GitHub Actions OIDC (no long-lived IAM keys).
26
+ - Preview environments (Ephemeral PRs) are managed via Terraform Workspaces.
27
+ `;
28
+
29
+ export const getCursorRules = (context = {}) => `---
30
+ description: "Rules for deploying the application and managing AWS infrastructure"
31
+ globs: ["terraform/*.tf", ".github/workflows/*.yml", "Dockerfile"]
32
+ ---${getBaseRules(context)}`;
33
+
34
+ export function injectManagedBlock(filePath, content, isMarkdown = true) {
35
+ const beginMarker = isMarkdown ? '<!-- BEGIN DEPLOY-STACK CONTEXT -->' : '# BEGIN DEPLOY-STACK CONTEXT';
36
+ const endMarker = isMarkdown ? '<!-- END DEPLOY-STACK CONTEXT -->' : '# END DEPLOY-STACK CONTEXT';
37
+ const block = `\n${beginMarker}\n${content.trim()}\n${endMarker}\n`;
38
+
39
+ if (fsSync.existsSync(filePath)) {
40
+ let fileContent = fsSync.readFileSync(filePath, 'utf8');
41
+ // Look for the existing block to replace it
42
+ const regex = new RegExp(`\\n?${beginMarker}[\\s\\S]*?${endMarker}\\n?`);
43
+
44
+ if (regex.test(fileContent)) {
45
+ fileContent = fileContent.replace(regex, block); // Replace our old rules
46
+ } else {
47
+ fileContent = fileContent.trim() + '\n' + block; // Append to bottom
48
+ }
49
+ fsSync.writeFileSync(filePath, fileContent);
50
+ } else {
51
+ // File doesn't exist, create it cleanly
52
+ fsSync.writeFileSync(filePath, block.trim() + '\n');
53
+ }
54
+ }
@@ -2,13 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { load } from 'js-yaml';
4
4
 
5
- // 1. Check if a Docker Compose file exists in the target directory.
6
- export function hasDockerCompose(targetDir) {
7
- return fs.existsSync(path.join(targetDir, 'docker-compose.yml')) ||
8
- fs.existsSync(path.join(targetDir, 'docker-compose.yaml'));
9
- }
10
-
11
- // 2. Parse docker-compose.yml and normalize it into an array of services.
5
+ // Parse docker-compose.yml and normalize it into an array of services.
12
6
  export function parseDockerCompose(targetDir) {
13
7
  let composePath = path.join(targetDir, 'docker-compose.yml');
14
8
  if (!fs.existsSync(composePath)) {
@@ -14,6 +14,8 @@ export async function generateTemplates(targetDir, config) {
14
14
 
15
15
  // 2. Define paths
16
16
  const templatesDir = path.join(__dirname, '../../templates');
17
+ const readmeExists = fsSync.existsSync(path.join(targetDir, 'README.md'));
18
+ const readmeDest = readmeExists ? 'DEPLOYMENT.md' : 'README.md';
17
19
  const filesToProcess = [
18
20
  { src: 'terraform/main.tf', dest: 'terraform/main.tf' },
19
21
  { src: 'terraform/network.tf', dest: 'terraform/network.tf' },
@@ -23,9 +25,21 @@ export async function generateTemplates(targetDir, config) {
23
25
  { src: 'terraform/cloudfront.tf', dest: 'terraform/cloudfront.tf' },
24
26
  { src: `docker/${config.finalFramework}.Dockerfile`, dest: 'Dockerfile' },
25
27
  { src: 'github/deploy.yml', dest: '.github/workflows/deploy.yml' },
26
- { src: 'README.md', dest: 'README.md' }
28
+ { src: 'README.md', dest: readmeDest }
27
29
  ];
28
30
 
31
+ // 2.1 Check if README.md exists (append DEPLOYMENT.md note if yes, generate new if no)
32
+ if (readmeExists) {
33
+ const readmeNotice = `\n## πŸš€ Deployment\nAWS Fargate infrastructure is managed via [deploy-stack](./DEPLOYMENT.md).\n`;
34
+ fsSync.appendFileSync(path.join(targetDir, 'README.md'), readmeNotice);
35
+ }
36
+
37
+ // 2.1 Add Ephemeral PR workflows only if opted in
38
+ if (config.ENABLE_PR_PREVIEWS) {
39
+ filesToProcess.push({ src: 'github/preview.yml', dest: '.github/workflows/preview.yml' });
40
+ filesToProcess.push({ src: 'github/teardown.yml', dest: '.github/workflows/teardown.yml' });
41
+ }
42
+
29
43
  // 3. Configure Compute Commands & Environment Variables
30
44
  let secretsArray = [];
31
45
 
@@ -122,7 +136,7 @@ export async function generateTemplates(targetDir, config) {
122
136
 
123
137
  // 4.1. Inject Rails Master Key if applicable
124
138
  if (config.finalFramework === 'rails') {
125
- secretsArray.push(`{ "name": "RAILS_MASTER_KEY", "valueFrom": "\${aws_secretsmanager_secret.app_secrets.arn}:RAILS_MASTER_KEY::" }`);
139
+ secretsArray.push(`{ "name": "RAILS_MASTER_KEY", "valueFrom": "\${local.secret_arn}:RAILS_MASTER_KEY::" }`);
126
140
 
127
141
  initialSecretMap += `,\n RAILS_MASTER_KEY = var.rails_master_key`;
128
142
 
@@ -1,5 +1,5 @@
1
1
  import path from 'path';
2
- import { text, select, confirm, group, cancel, log } from '@clack/prompts';
2
+ import { text, select, multiselect, confirm, group, cancel } from '@clack/prompts';
3
3
  import color from 'picocolors';
4
4
  import { execSync } from 'child_process';
5
5
 
@@ -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,19 @@ export async function getProjectConfig(isHeadless, headlessOptions, targetDir, d
99
100
  needsDatabase = dbChoice;
100
101
  }
101
102
 
103
+ let enablePrPreviews = false;
104
+ let aiAssistants = [];
105
+ if (setupType === 'advanced') {
106
+ const prChoice = await confirm({
107
+ 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')}`,
108
+ initialValue: false,
109
+ });
110
+ if (typeof prChoice === 'symbol') process.exit(0);
111
+ enablePrPreviews = prChoice;
112
+
113
+ aiAssistants = await getAiAssistants();
114
+ }
115
+
102
116
  const project = await group({
103
117
  region: () => select({
104
118
  message: 'Which AWS region do you want to deploy to?',
@@ -151,6 +165,28 @@ export async function getProjectConfig(isHeadless, headlessOptions, targetDir, d
151
165
  desiredCount: project.desiredCount || '1',
152
166
  branch: project.branch || currentGitBranch,
153
167
  needsDatabase,
168
+ enablePrPreviews,
169
+ aiAssistants,
154
170
  setupType
155
171
  };
172
+ }
173
+
174
+ export async function getAiAssistants() {
175
+ const selected = await multiselect({
176
+ message: 'Which AI coding assistants does your team use?',
177
+ options: [
178
+ { value: 'cursor', label: 'Cursor', hint: 'Generates .cursor/rules/deploy-stack.mdc' },
179
+ { value: 'copilot', label: 'GitHub Copilot', hint: 'Generates .github/copilot-instructions.md' },
180
+ { value: 'windsurf', label: 'Windsurf', hint: 'Generates .windsurfrules' },
181
+ { value: 'claude', label: 'Claude Code / CLI Agents', hint: 'Generates CLAUDE.md' }
182
+ ],
183
+ required: false,
184
+ });
185
+
186
+ if (typeof selected === 'symbol') {
187
+ cancel('Operation cancelled.');
188
+ process.exit(0);
189
+ }
190
+
191
+ return selected;
156
192
  }
@@ -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 = "{{PROJECT_NAME}}-db-isolated-${count.index}"
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 = "{{PROJECT_NAME}}-db-subnet-group"
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 = "{{PROJECT_NAME}}-rds-sg"
21
- vpc_id = aws_vpc.main.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 = "{{PROJECT_NAME}}-db"
35
- engine = "postgres"
36
- engine_version = "16"
37
- instance_class = "db.t4g.micro"
38
- allocated_storage = 20
39
- storage_encrypted = true
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 = replace("{{PROJECT_NAME}}", "-", "_")
43
- username = "dbadmin"
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 = 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
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 = "{{PROJECT_NAME}}-rds-secret-policy"
58
- role = aws_iam_role.execution_role.id
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/{{PROJECT_NAME}}"
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 = "{{PROJECT_NAME}}-execution-role"
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 = "{{PROJECT_NAME}}-task-role"
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 = "{{PROJECT_NAME}}-cluster"
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 = "{{PROJECT_NAME}}-task"
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 = "{{PROJECT_NAME}}-container"
82
- image = "${aws_ecr_repository.app.repository_url}:latest"
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 = "${aws_secretsmanager_secret.app_secrets.arn}:${key}::"
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 = "{{SAFE_ALB_NAME}}-alb"
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 = "{{SAFE_ALB_NAME}}-tg"
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 = "{{PROJECT_NAME}}-service"
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 = "{{PROJECT_NAME}}-container"
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 = [aws_secretsmanager_secret.app_secrets.arn]
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 = aws_ecr_repository.app.repository_url
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 = "{{PROJECT_NAME}}-high-5xx-errors"
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 = "{{PROJECT_NAME}}-vpc"
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 = "{{PROJECT_NAME}}-public-subnet-${count.index + 1}"
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 = "{{PROJECT_NAME}}-alb-sg"
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 = "{{PROJECT_NAME}}-ecs-sg"
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 = "https://token.actions.githubusercontent.com"
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
- name = "{{PROJECT_NAME}}-github-actions-role"
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
- role = aws_iam_role.github_actions.name
61
- policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
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
- secret_id = aws_secretsmanager_secret.app_secrets.id
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 = "{{PROJECT_NAME}}-worker-task"
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 = "{{PROJECT_NAME}}-worker-container"
14
- image = "${aws_ecr_repository.app.repository_url}:latest"
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 = "${aws_secretsmanager_secret.app_secrets.arn}:${key}::"
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 = "{{PROJECT_NAME}}-worker-service"
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"