deploy-stack 0.7.0 → 0.9.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 +33 -10
- package/bin/cli.js +3 -0
- package/package.json +2 -1
- package/src/commands/eject.js +82 -0
- package/src/commands/init.js +44 -4
- package/src/utils/detector.js +23 -3
- package/src/utils/generator.js +28 -44
- package/templates/README.md +11 -1
- package/templates/docker/django.Dockerfile +19 -0
- package/templates/docker/go.Dockerfile +23 -0
- package/templates/docker/nuxt.Dockerfile +29 -0
- package/templates/docker/rails.Dockerfile +21 -0
- package/templates/github/deploy.yml +6 -5
- package/templates/terraform/database.tf +71 -0
- package/templates/terraform/main.tf +38 -6
package/README.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
> The zero-lock-in cloud generator. Eject your containerized web app from expensive PaaS platforms to production-ready, highly available AWS infrastructure in 60 seconds.
|
|
4
4
|
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
[](https://trivy.dev/)
|
|
6
7
|
|
|
7
8
|
<!--  -->
|
|
8
9
|
|
|
@@ -25,15 +26,19 @@ You retain complete ownership of your infrastructure code without relying on bla
|
|
|
25
26
|
## ✨ Features
|
|
26
27
|
|
|
27
28
|
* **Zero Vendor Lock-In:** Generates standard, clean `.tf` files. Modify, expand, or decouple them at any time.
|
|
29
|
+
* **Automated DevSecOps:** Integrated Trivy vulnerability scanning for your infrastructure (IaC) and Docker containers on every GitHub Actions run.
|
|
30
|
+
* **Hardened, Unprivileged Containers:** Built-in Dockerfile generators explicitly drop root privileges and utilize `nginx-unprivileged` for maximum Fargate security compliance.
|
|
31
|
+
* **Cost & Observability Baselines:** Prevents runaway AWS bills with explicit 14-day CloudWatch log retention policies and auto-generates 5XX error alerting.
|
|
32
|
+
* **Safe Overwrite Flow:** Idempotent CLI safely backs up existing configurations to timestamped `.bak` files and updates `.gitignore` to guarantee zero data loss during rapid iteration.
|
|
28
33
|
* **Framework Agnostic:** Tailored container presets for Next.js, Express.js, FastAPI, and **Zero-Config Static Sites** (React, Vue, SvelteKit, Astro, Vite).
|
|
29
34
|
* **Smart Git Integration:** Automatically detects your working branch (`main`, `master`, `develop`) and binds it directly to the generated GitHub Actions pipeline.
|
|
30
35
|
* **Production-Grade Defaults:** Automatically provisions an Amazon ECS Fargate cluster fronted by an Application Load Balancer across multiple availability zones.
|
|
31
36
|
* **Global Edge Acceleration:** Includes an integrated AWS CloudFront CDN distribution with SSL termination and optimized caching.
|
|
32
|
-
* **Keyless, Zero-Secret CI/CD:** Uses AWS IAM OpenID Connect (OIDC) for automated
|
|
33
|
-
* **Remote State with Native S3 Locking:** Automatically creates an encrypted S3 state bucket utilizing modern native S3 concurrency locking.
|
|
37
|
+
* **Keyless, Zero-Secret CI/CD:** Uses AWS IAM OpenID Connect (OIDC) for automated deployments—no long-lived AWS keys stored in GitHub Secrets.
|
|
38
|
+
* **Remote State with Native S3 Locking:** Automatically creates an encrypted S3 state bucket utilizing modern Terraform (1.7+) native S3 concurrency locking.
|
|
34
39
|
* **Built-in Secrets Sync:** Provides a dedicated CLI workflow to securely push local `.env` variables into AWS Secrets Manager and map them directly into containers at runtime.
|
|
35
|
-
* **
|
|
36
|
-
* **
|
|
40
|
+
* **Ecosystem Ready:** Integrated directly with the [deploy-stack GitHub Action](https://github.com/marketplace/actions/deploy-stack-aws-fargate-terraform-deploy) for a secure, boilerplate-free continuous deployment pipeline.
|
|
41
|
+
* **Database Scaffolding:** Automatically provisions fully isolated, zero-trust AWS RDS PostgreSQL databases for backend monoliths.
|
|
37
42
|
* **Safe Teardown:** Completely remove all generated AWS resources and empty S3 state buckets with a single `destroy` command.
|
|
38
43
|
|
|
39
44
|
---
|
|
@@ -134,12 +139,30 @@ npx deploy-stack --no-telemetry
|
|
|
134
139
|
|
|
135
140
|
## 🗺️ Roadmap
|
|
136
141
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
- [
|
|
141
|
-
- [
|
|
142
|
-
- [
|
|
142
|
+
## 🗺️ Roadmap
|
|
143
|
+
|
|
144
|
+
### Phase 1–3: The Core Engine (Completed)
|
|
145
|
+
- [x] **Core MVP:** Interactive CLI, ECS Fargate + ALB generation, CI/CD, and Secrets sync.
|
|
146
|
+
- [x] **Production Readiness:** CloudFront CDN edge distribution, native S3 state locking, and secure OIDC integration.
|
|
147
|
+
- [x] **Smart Experience:** Zero-config framework auto-discovery for static output directories.
|
|
148
|
+
- [x] **Trust & Observability:** DevSecOps Trivy scanning, automated 5XX alarms, 14-day log retention, and safe local overwrite protections.
|
|
149
|
+
|
|
150
|
+
### Phase 4: Trust Anchors & TAM Expansion (v0.9.0 - Current)
|
|
151
|
+
- [x] **Ecosystem Distribution:** Native GitHub Marketplace Action for rapid discovery.
|
|
152
|
+
- [x] **Cost Transparency:** Pre-flight AWS cost estimator injected directly into the CLI wizard.
|
|
153
|
+
- [x] **Zero Vendor Lock-In:** Explicit `npx deploy-stack eject` command to safely strip `ManagedBy` tags and CLI metadata, leaving behind pure IaC.
|
|
154
|
+
- [x] **Heavy Backend Monoliths:** Hardened, unprivileged container adapters for Go, Nuxt.js, Django, and Rails, complete with automated zero-trust RDS PostgreSQL provisioning.
|
|
155
|
+
|
|
156
|
+
### Phase 5: The Activation Engine (Pre-1.0 Growth Loops)
|
|
157
|
+
- [ ] **Ephemeral PR Previews:** Generating live preview URLs on every GitHub Pull Request, turning single-user tests into team-wide advertisements.
|
|
158
|
+
- [ ] **Next.js Image Optimization Handler:** A drop-in AWS Serverless handler (Lambda + CloudFront) to process images affordably, removing a massive Vercel lock-in hurdle.
|
|
159
|
+
- [ ] **GitHub Deployments UI Sync:** Wiring up the native GitHub "Environments" tab for instant visual validation that the CLI succeeded in the background.
|
|
160
|
+
|
|
161
|
+
### Phase 6: Workflow Interception (IDE, AI, & Local Bridges)
|
|
162
|
+
- [ ] **AI Agent Rulesets:** Publishing `.cursorrules` and Copilot instructions that teach AI assistants exactly how to utilize the CLI on the user's behalf.
|
|
163
|
+
- [ ] **Docker Compose to ECS Translator:** Automatically converting a familiar local `docker-compose.yml` into production ECS task definitions.
|
|
164
|
+
- [ ] **Framework Registries:** Submitting official plugins to the Astro Integrations directory and Nuxt Modules to secure passive, sustained discovery.
|
|
165
|
+
- [ ] **Dry-Run Visualization:** Generating a local `docker-compose.yml` mirror and a visual architecture map for local infrastructure validation.
|
|
143
166
|
|
|
144
167
|
---
|
|
145
168
|
|
package/bin/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import { mainStack } from '../src/commands/init.js';
|
|
|
4
4
|
import { destroyStack } from '../src/commands/destroy.js';
|
|
5
5
|
import { runDoctor } from '../src/commands/doctor.js';
|
|
6
6
|
import { pushSecrets } from '../src/commands/secrets.js';
|
|
7
|
+
import { ejectStack } from '../src/commands/eject.js';
|
|
7
8
|
|
|
8
9
|
// 1. Extract the telemetry flag and set the environment variable
|
|
9
10
|
const rawArgs = process.argv.slice(2);
|
|
@@ -25,6 +26,8 @@ if (args[0] === 'secrets' && args[1] === 'push') {
|
|
|
25
26
|
runDoctor().catch(console.error);
|
|
26
27
|
} else if (args[0] === 'destroy') {
|
|
27
28
|
destroyStack().catch(console.error);
|
|
29
|
+
} else if (args[0] === 'eject') {
|
|
30
|
+
ejectStack().catch(console.error);
|
|
28
31
|
} else {
|
|
29
32
|
mainStack().catch(console.error);
|
|
30
33
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "deploy-stack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Provision production-ready AWS infrastructure and CI/CD pipelines in seconds.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
],
|
|
38
38
|
"license": "MIT",
|
|
39
39
|
"dependencies": {
|
|
40
|
+
"@aws-sdk/client-dynamodb": "3.1119.0",
|
|
40
41
|
"@aws-sdk/client-s3": "3.1115.0",
|
|
41
42
|
"@aws-sdk/client-secrets-manager": "3.1112.0",
|
|
42
43
|
"@aws-sdk/client-sts": "3.1115.0",
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { intro, outro, confirm, spinner, cancel } from '@clack/prompts';
|
|
4
|
+
import color from 'picocolors';
|
|
5
|
+
|
|
6
|
+
export async function ejectStack() {
|
|
7
|
+
intro(color.bgRed(color.white(' deploy-stack eject ⏏️ ')));
|
|
8
|
+
|
|
9
|
+
console.log(color.yellow('This will permanently decouple your infrastructure from the deploy-stack CLI.'));
|
|
10
|
+
console.log(color.gray('It removes all ManagedBy tags and tool-specific metadata from your local files.'));
|
|
11
|
+
console.log(color.gray('Your infrastructure will remain fully operational as raw, standalone Terraform.'));
|
|
12
|
+
|
|
13
|
+
const shouldEject = await confirm({
|
|
14
|
+
message: 'Are you sure you want to eject? (This cannot be undone)',
|
|
15
|
+
initialValue: false,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
if (!shouldEject || typeof shouldEject === 'symbol') {
|
|
19
|
+
cancel('Eject cancelled. You are still managed by deploy-stack.');
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const s = spinner();
|
|
24
|
+
s.start('Ejecting deploy-stack metadata...');
|
|
25
|
+
|
|
26
|
+
const targetDir = process.cwd();
|
|
27
|
+
const tfDir = path.join(targetDir, 'terraform');
|
|
28
|
+
const mainTfPath = path.join(tfDir, 'main.tf');
|
|
29
|
+
const gitignorePath = path.join(targetDir, '.gitignore');
|
|
30
|
+
|
|
31
|
+
// 1. Clean main.tf
|
|
32
|
+
if (fs.existsSync(mainTfPath)) {
|
|
33
|
+
let content = fs.readFileSync(mainTfPath, 'utf8');
|
|
34
|
+
|
|
35
|
+
// Remove the top header comment
|
|
36
|
+
content = content.replace(/# deploy-stack generated infrastructure\n?/g, '');
|
|
37
|
+
|
|
38
|
+
// Safely remove the default_tags block
|
|
39
|
+
const defaultTagsRegex = /[ \t]*default_tags\s*\{\s*tags\s*=\s*\{\s*ManagedBy\s*=\s*"deploy-stack"\s*\}\s*\}\n?/g;
|
|
40
|
+
content = content.replace(defaultTagsRegex, '');
|
|
41
|
+
|
|
42
|
+
fs.writeFileSync(mainTfPath, content.trim() + '\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 2. Clean up .gitignore
|
|
46
|
+
if (fs.existsSync(gitignorePath)) {
|
|
47
|
+
let gitignore = fs.readFileSync(gitignorePath, 'utf8');
|
|
48
|
+
// Remove the exact block we injected in backup.js
|
|
49
|
+
gitignore = gitignore.replace(/\n# deploy-stack backups\n\*\.bak\.\*\n/g, '\n');
|
|
50
|
+
fs.writeFileSync(gitignorePath, gitignore.trim() + '\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 3. Purge all local `.bak` files to leave a pristine directory
|
|
54
|
+
const cleanBackups = (dir) => {
|
|
55
|
+
if (!fs.existsSync(dir)) return;
|
|
56
|
+
const files = fs.readdirSync(dir);
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
const fullPath = path.join(dir, file);
|
|
59
|
+
if (file.includes('.bak.')) {
|
|
60
|
+
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
61
|
+
} else if (fs.statSync(fullPath).isDirectory() && file !== 'node_modules' && file !== '.git') {
|
|
62
|
+
cleanBackups(fullPath); // Recursively check subdirectories like .github/workflows
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
cleanBackups(targetDir);
|
|
68
|
+
|
|
69
|
+
s.stop('Ejection complete.');
|
|
70
|
+
|
|
71
|
+
outro(`
|
|
72
|
+
${color.green('✅ Successfully ejected from deploy-stack!')}
|
|
73
|
+
|
|
74
|
+
Your Terraform and CI/CD files are now 100% vanilla.
|
|
75
|
+
|
|
76
|
+
${color.blue('Next Step:')}
|
|
77
|
+
Run ${color.cyan('terraform apply')} inside your terraform/ folder.
|
|
78
|
+
AWS will sync your state and automatically remove the 'ManagedBy' tags from your live cloud resources.
|
|
79
|
+
|
|
80
|
+
${color.magenta('Godspeed! You are now the sole owner of your infrastructure.')}
|
|
81
|
+
`);
|
|
82
|
+
}
|
package/src/commands/init.js
CHANGED
|
@@ -73,7 +73,11 @@ export async function mainStack() {
|
|
|
73
73
|
options: [
|
|
74
74
|
{ value: 'node', label: 'Node.js / Express' },
|
|
75
75
|
{ value: 'nextjs', label: 'Next.js (Standalone)' },
|
|
76
|
+
{ value: 'nuxt', label: 'Nuxt 3 (SSR)' },
|
|
76
77
|
{ value: 'python', label: 'Python FastAPI' },
|
|
78
|
+
{ value: 'django', label: 'Django (Python)' },
|
|
79
|
+
{ value: 'rails', label: 'Ruby on Rails' },
|
|
80
|
+
{ value: 'go', label: 'Go (Golang)' },
|
|
77
81
|
{ value: 'static', label: 'Static Site (Gatsby, React, plain HTML via Nginx)' },
|
|
78
82
|
],
|
|
79
83
|
});
|
|
@@ -88,7 +92,9 @@ export async function mainStack() {
|
|
|
88
92
|
let defaultPort = '3000';
|
|
89
93
|
|
|
90
94
|
if (finalFramework === 'static') defaultPort = '8080';
|
|
91
|
-
if (finalFramework === 'python') defaultPort = '8000';
|
|
95
|
+
if (finalFramework === 'python' || finalFramework === 'django') defaultPort = '8000';
|
|
96
|
+
if (finalFramework === 'rails') defaultPort = '3000';
|
|
97
|
+
if (finalFramework === 'go') defaultPort = '8080';
|
|
92
98
|
|
|
93
99
|
// 2.8 Check current Git branch
|
|
94
100
|
let currentGitBranch = 'main';
|
|
@@ -98,6 +104,23 @@ export async function mainStack() {
|
|
|
98
104
|
// Not a git repo yet, fallback to 'main'
|
|
99
105
|
}
|
|
100
106
|
|
|
107
|
+
// 2.9 Ask for Managed Database (Only for Backend/Fullstack Frameworks)
|
|
108
|
+
let needsDatabase = false;
|
|
109
|
+
const isBackendFramework = ['node', 'nextjs', 'nuxt', 'python', 'django', 'rails', 'go'].includes(finalFramework);
|
|
110
|
+
|
|
111
|
+
if (isBackendFramework) {
|
|
112
|
+
const dbChoice = await confirm({
|
|
113
|
+
message: 'Do you need a managed AWS RDS PostgreSQL database? (Adds ~$14/month or uses AWS Free Tier)',
|
|
114
|
+
initialValue: false,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (typeof dbChoice === 'symbol') {
|
|
118
|
+
cancel('Provisioning cancelled.')
|
|
119
|
+
process.exit(0);
|
|
120
|
+
}
|
|
121
|
+
needsDatabase = dbChoice;
|
|
122
|
+
}
|
|
123
|
+
|
|
101
124
|
// 3. Prompt Configuration Group
|
|
102
125
|
const project = await group(
|
|
103
126
|
{
|
|
@@ -167,15 +190,31 @@ export async function mainStack() {
|
|
|
167
190
|
const cpu = project.size === 'small' ? '512' : '256';
|
|
168
191
|
const memory = project.size === 'small' ? '1024' : '512';
|
|
169
192
|
|
|
193
|
+
const baseCost = project.size === 'small' ? 35 : 25;
|
|
194
|
+
const dbCost = needsDatabase ? 14 : 0;
|
|
195
|
+
const totalCost = baseCost + dbCost;
|
|
196
|
+
|
|
170
197
|
const computeTier = project.size === 'small' ? 'Small (0.5 vCPU, 1GB RAM)' : 'Micro (0.25 vCPU, 512MB RAM)';
|
|
171
|
-
const estimatedCost =
|
|
198
|
+
const estimatedCost = `~$${totalCost}.00 / month${needsDatabase ? ' (Includes Fargate + RDS PostgreSQL)' : ''}`;
|
|
172
199
|
|
|
173
200
|
const healthCheckPath = project.healthCheckPath || '/';
|
|
174
201
|
const desiredCount = project.desiredCount || '1';
|
|
175
202
|
const deployBranch = project.branch || currentGitBranch;
|
|
176
|
-
|
|
177
203
|
const buildDir = detectedFramework?.buildDir || 'dist';
|
|
178
204
|
|
|
205
|
+
// 4.5. The Pre-Flight Cost Estimator
|
|
206
|
+
// We explicitly ask for financial consent to eliminate AWS billing anxiety.
|
|
207
|
+
console.log(''); // Add a blank line for visual pacing
|
|
208
|
+
const costConsent = await confirm({
|
|
209
|
+
message: color.yellow(`⚠️ Pre-Flight Check: This AWS architecture will cost ${estimatedCost}. Proceed with provisioning?`),
|
|
210
|
+
initialValue: true,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
if (!costConsent || typeof costConsent === 'symbol') {
|
|
214
|
+
cancel('Deployment cancelled. No AWS resources were provisioned.');
|
|
215
|
+
process.exit(0);
|
|
216
|
+
}
|
|
217
|
+
|
|
179
218
|
// 5. Safely handle existing files (Backup and auto-prune)
|
|
180
219
|
await handleExistingFiles(targetDir);
|
|
181
220
|
|
|
@@ -213,7 +252,8 @@ export async function mainStack() {
|
|
|
213
252
|
DESIRED_COUNT: desiredCount,
|
|
214
253
|
DEPLOY_BRANCH: deployBranch,
|
|
215
254
|
BUILD_DIR: buildDir,
|
|
216
|
-
finalFramework: finalFramework
|
|
255
|
+
finalFramework: finalFramework,
|
|
256
|
+
NEEDS_DATABASE: needsDatabase
|
|
217
257
|
});
|
|
218
258
|
|
|
219
259
|
// 8. Track the event in telemetry
|
package/src/utils/detector.js
CHANGED
|
@@ -4,6 +4,9 @@ import path from 'path';
|
|
|
4
4
|
export function detectFramework(targetDir) {
|
|
5
5
|
const packageJsonPath = path.join(targetDir, 'package.json');
|
|
6
6
|
const requirementsTxtPath = path.join(targetDir, 'requirements.txt');
|
|
7
|
+
const managePyPath = path.join(targetDir, 'manage.py');
|
|
8
|
+
const goModPath = path.join(targetDir, 'go.mod');
|
|
9
|
+
const gemfilePath = path.join(targetDir, 'Gemfile');
|
|
7
10
|
|
|
8
11
|
// 1. Detect Node.js Frameworks
|
|
9
12
|
if (fsSync.existsSync(packageJsonPath)) {
|
|
@@ -12,15 +15,15 @@ export function detectFramework(targetDir) {
|
|
|
12
15
|
// Merge dependencies and devDependencies to check both
|
|
13
16
|
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
14
17
|
|
|
15
|
-
//
|
|
18
|
+
// Fullstack / API
|
|
16
19
|
if (deps['next']) return { id: 'nextjs', name: 'Next.js' };
|
|
20
|
+
if (deps['nuxt']) return { id: 'nuxt', name: 'Nuxt 3 (SSR)' };
|
|
17
21
|
if (deps['express']) return { id: 'node', name: 'Node.js / Express' };
|
|
18
22
|
|
|
19
23
|
// Static Site Generators & SPAs (with precise build directories)
|
|
20
24
|
if (deps['@sveltejs/kit']) return { id: 'static', name: 'SvelteKit', buildDir: 'build' };
|
|
21
25
|
if (deps['react-scripts']) return { id: 'static', name: 'Create React App', buildDir: 'build' };
|
|
22
26
|
if (deps['gatsby']) return { id: 'static', name: 'Gatsby', buildDir: 'public' };
|
|
23
|
-
if (deps['nuxt']) return { id: 'static', name: 'Nuxt', buildDir: '.output/public' };
|
|
24
27
|
if (deps['astro']) return { id: 'static', name: 'Astro', buildDir: 'dist' };
|
|
25
28
|
if (deps['vite']) return { id: 'static', name: 'Vite', buildDir: 'dist' };
|
|
26
29
|
if (deps['@vue/cli-service']) return { id: 'static', name: 'Vue.js', buildDir: 'dist' };
|
|
@@ -35,11 +38,28 @@ export function detectFramework(targetDir) {
|
|
|
35
38
|
try {
|
|
36
39
|
const reqs = fsSync.readFileSync(requirementsTxtPath, 'utf-8').toLowerCase();
|
|
37
40
|
if (reqs.includes('fastapi')) return { id: 'python', name: 'Python FastAPI' };
|
|
41
|
+
if (reqs.includes('django')) return { id: 'django', name: 'Django' };
|
|
38
42
|
} catch (e) {
|
|
39
43
|
// Silently fail
|
|
40
44
|
}
|
|
41
45
|
}
|
|
42
46
|
|
|
43
|
-
//
|
|
47
|
+
// Fallback Django detection (sometimes requirements.txt is named differently)
|
|
48
|
+
if (fsSync.existsSync(managePyPath)) return { id: 'django', name: 'Django' };
|
|
49
|
+
|
|
50
|
+
// 3. Detect Ruby on Rails
|
|
51
|
+
if (fsSync.existsSync(gemfilePath)) {
|
|
52
|
+
try {
|
|
53
|
+
const gemfile = fsSync.readFileSync(gemfilePath, 'utf-8').toLowerCase();
|
|
54
|
+
if (/gem\s+['"]rails['"]/i.test(gemfile)) { return { id: 'rails', name: 'Ruby on Rails' }; }
|
|
55
|
+
} catch (e) {
|
|
56
|
+
// Silently fail
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 4. Detect Go
|
|
61
|
+
if (fsSync.existsSync(goModPath)) return { id: 'go', name: 'Go' };
|
|
62
|
+
|
|
63
|
+
// 5. Fallback
|
|
44
64
|
return null;
|
|
45
65
|
}
|
package/src/utils/generator.js
CHANGED
|
@@ -26,6 +26,22 @@ export async function generateTemplates(targetDir, config) {
|
|
|
26
26
|
{ src: 'README.md', dest: 'README.md' }
|
|
27
27
|
];
|
|
28
28
|
|
|
29
|
+
if (config.NEEDS_DATABASE) {
|
|
30
|
+
filesToProcess.push({ src: 'terraform/database.tf', dest: 'terraform/database.tf' });
|
|
31
|
+
|
|
32
|
+
config.DB_ENV_VARS = `
|
|
33
|
+
{ "name": "DB_HOST", "value": "\${aws_db_instance.postgres.address}" },
|
|
34
|
+
{ "name": "DB_PORT", "value": "5432" },
|
|
35
|
+
{ "name": "DB_NAME", "value": "\${aws_db_instance.postgres.db_name}" }`;
|
|
36
|
+
|
|
37
|
+
config.DB_SECRETS = `
|
|
38
|
+
{ "name": "DB_USER", "valueFrom": "\${aws_db_instance.postgres.master_user_secret[0].secret_arn}:username::" },
|
|
39
|
+
{ "name": "DB_PASSWORD", "valueFrom": "\${aws_db_instance.postgres.master_user_secret[0].secret_arn}:password::" }`;
|
|
40
|
+
} else {
|
|
41
|
+
config.DB_ENV_VARS = '';
|
|
42
|
+
config.DB_SECRETS = '';
|
|
43
|
+
}
|
|
44
|
+
|
|
29
45
|
// 3. Process standard files
|
|
30
46
|
for (const file of filesToProcess) {
|
|
31
47
|
let content = await fs.readFile(path.join(templatesDir, file.src), 'utf-8');
|
|
@@ -71,49 +87,17 @@ terraform/.terraform.*
|
|
|
71
87
|
Thumbs.db
|
|
72
88
|
`;
|
|
73
89
|
|
|
74
|
-
const
|
|
75
|
-
# Node.js
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
build/
|
|
87
|
-
next-env.d.ts
|
|
88
|
-
`;
|
|
89
|
-
|
|
90
|
-
const pythonIgnore = `
|
|
91
|
-
# Python
|
|
92
|
-
__pycache__/
|
|
93
|
-
*.py[cod]
|
|
94
|
-
*$py.class
|
|
95
|
-
venv/
|
|
96
|
-
env/
|
|
97
|
-
.venv/
|
|
98
|
-
.pytest_cache/
|
|
99
|
-
`;
|
|
100
|
-
|
|
101
|
-
const staticIgnore = `
|
|
102
|
-
# Static Sites
|
|
103
|
-
node_modules/
|
|
104
|
-
dist/
|
|
105
|
-
build/
|
|
106
|
-
out/
|
|
107
|
-
.output/
|
|
108
|
-
.cache/
|
|
109
|
-
public/
|
|
110
|
-
`;
|
|
111
|
-
|
|
112
|
-
let frameworkIgnore = '';
|
|
113
|
-
if (framework === 'node') frameworkIgnore = nodeIgnore;
|
|
114
|
-
if (framework === 'nextjs') frameworkIgnore = nextjsIgnore;
|
|
115
|
-
if (framework === 'python') frameworkIgnore = pythonIgnore;
|
|
116
|
-
if (framework === 'static') frameworkIgnore = staticIgnore;
|
|
117
|
-
|
|
90
|
+
const presets = {
|
|
91
|
+
node: '\n# Node.js\nnode_modules/\nnpm-debug.log\nyarn-error.log\n',
|
|
92
|
+
nextjs: '\n# Next.js\nnode_modules/\n.next/\nout/\nbuild/\nnext-env.d.ts\n',
|
|
93
|
+
nuxt: '\n# Nuxt 3\nnode_modules/\n.nuxt/\n.output/\ndist/\n',
|
|
94
|
+
python: '\n# Python\n__pycache__/\n*.py[cod]\n*$py.class\nvenv/\nenv/\n.venv/\n.pytest_cache/\n',
|
|
95
|
+
django: '\n# Django\n*.log\n*.pot\n*.pyc\n__pycache__/\ndb.sqlite3\nmedia/\n',
|
|
96
|
+
go: '\n# Go\n/bin/\n/pkg/\n/obj/\n*.exe\n*.exe~*\n*.dll\n*.so\n*.dylib\n',
|
|
97
|
+
rails: '\n# Rails\n/*.log\n/tmp/\n/log/\n/public/system/\n/public/assets/\n/vendor/bundle/\n',
|
|
98
|
+
static: '\n# Static Sites\nnode_modules/\ndist/\nbuild/\nout/\n.output/\n.cache/\npublic/\n'
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const frameworkIgnore = presets[framework] || '';
|
|
118
102
|
return (baseIgnore + frameworkIgnore).trim();
|
|
119
103
|
}
|
package/templates/README.md
CHANGED
|
@@ -29,7 +29,7 @@ This infrastructure provisions a highly available Application Load Balancer (ALB
|
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
3. **Automated CI/CD (Keyless via OIDC):**
|
|
32
|
-
Push this repository to GitHub. Your deployment pipeline uses AWS IAM OpenID Connect (OIDC) to authenticate securely with temporary credentials—**no long-lived AWS secret keys are required in GitHub Secrets**. Every push to `{{DEPLOY_BRANCH}}` will automatically
|
|
32
|
+
Push this repository to GitHub. Your deployment pipeline uses the official [deploy-stack GitHub Action](https://github.com/marketplace/actions/deploy-stack-aws-fargate-terraform-deploy) and AWS IAM OpenID Connect (OIDC) to authenticate securely with temporary credentials—**no long-lived AWS secret keys are required in GitHub Secrets**. Every push to `{{DEPLOY_BRANCH}}` will automatically run your infrastructure changes, build your container, and deploy your application.
|
|
33
33
|
|
|
34
34
|
### ⚠️ Troubleshooting: OIDC Provider Already Exists
|
|
35
35
|
AWS only permits one GitHub Actions OIDC provider per AWS account. If `terraform apply` fails with an `EntityAlreadyExists` error regarding the OIDC provider, it indicates GitHub Actions was previously configured in this account.
|
|
@@ -98,6 +98,16 @@ If you are deploying a static site, your application is served via a highly opti
|
|
|
98
98
|
* **Zero-Config Build:** The CLI automatically detected your framework's output folder (`dist`, `build`, etc.) and pre-configured your Dockerfile.
|
|
99
99
|
* **Health Checks:** You do not need to configure a custom `/health` route. Nginx will automatically return a `200 OK` when AWS pings the root `/` index page.
|
|
100
100
|
|
|
101
|
+
### 5. Database Connections (Backend Frameworks Only)
|
|
102
|
+
If you opted to include a managed AWS RDS PostgreSQL database, the infrastructure automatically creates the database in isolated private subnets and injects the credentials into your container's environment variables.
|
|
103
|
+
|
|
104
|
+
Ensure your backend framework (Rails, Django, Go, etc.) is configured to consume these variables at runtime:
|
|
105
|
+
* `DB_HOST`: The AWS RDS endpoint URL
|
|
106
|
+
* `DB_PORT`: `5432`
|
|
107
|
+
* `DB_NAME`: The auto-formatted database name
|
|
108
|
+
* `DB_USER`: The auto-generated master username
|
|
109
|
+
* `DB_PASSWORD`: The securely injected master password (sourced from AWS Secrets Manager)
|
|
110
|
+
|
|
101
111
|
## 🛡️ Security Scanning
|
|
102
112
|
|
|
103
113
|
This pipeline includes automated DevSecOps guardrails using [Trivy](https://trivy.dev/).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
FROM python:3.11-slim
|
|
2
|
+
WORKDIR /app
|
|
3
|
+
|
|
4
|
+
RUN apt-get update && apt-get install -y --no-install-recommends gcc libpq-dev && rm -rf /var/lib/apt/lists/*
|
|
5
|
+
|
|
6
|
+
# Create unprivileged user
|
|
7
|
+
RUN groupadd -g 1001 appgroup && \
|
|
8
|
+
useradd -u 1001 -g appgroup -s /bin/sh -m appuser
|
|
9
|
+
|
|
10
|
+
COPY requirements.txt .
|
|
11
|
+
RUN pip install --no-cache-dir -r requirements.txt gunicorn
|
|
12
|
+
|
|
13
|
+
# Copy code with explicit ownership
|
|
14
|
+
COPY --chown=appuser:appgroup . .
|
|
15
|
+
|
|
16
|
+
USER appuser
|
|
17
|
+
EXPOSE {{PORT}}
|
|
18
|
+
|
|
19
|
+
CMD ["gunicorn", "--bind", "0.0.0.0:{{PORT}}", "--workers", "3", "your_project.wsgi:application"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Stage 1: Compile
|
|
2
|
+
FROM golang:1.22-alpine AS builder
|
|
3
|
+
WORKDIR /app
|
|
4
|
+
COPY go.mod go.sum* ./
|
|
5
|
+
RUN go mod download
|
|
6
|
+
COPY . .
|
|
7
|
+
RUN CGO_ENABLED=0 GOOS=linux go build -o /main .
|
|
8
|
+
|
|
9
|
+
# Stage 2: Production runner
|
|
10
|
+
FROM alpine:3.19
|
|
11
|
+
WORKDIR /app
|
|
12
|
+
|
|
13
|
+
# Create unprivileged user
|
|
14
|
+
RUN addgroup -g 1001 -S appgroup && \
|
|
15
|
+
adduser -S appuser -u 1001 -G appgroup
|
|
16
|
+
|
|
17
|
+
# Copy compiled binary with explicit ownership
|
|
18
|
+
COPY --from=builder --chown=appuser:appgroup /main /main
|
|
19
|
+
|
|
20
|
+
USER appuser
|
|
21
|
+
EXPOSE {{PORT}}
|
|
22
|
+
|
|
23
|
+
CMD ["/main"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Stage 1: Build the Nuxt Nitro output
|
|
2
|
+
FROM node:22-alpine AS builder
|
|
3
|
+
WORKDIR /app
|
|
4
|
+
|
|
5
|
+
COPY package.json package-lock.json* ./
|
|
6
|
+
RUN npm ci
|
|
7
|
+
|
|
8
|
+
COPY . .
|
|
9
|
+
RUN npm run build
|
|
10
|
+
|
|
11
|
+
# Stage 2: Production runner
|
|
12
|
+
FROM node:22-alpine AS runner
|
|
13
|
+
WORKDIR /app
|
|
14
|
+
|
|
15
|
+
ENV NODE_ENV=production
|
|
16
|
+
ENV PORT={{PORT}}
|
|
17
|
+
ENV HOSTNAME="0.0.0.0"
|
|
18
|
+
|
|
19
|
+
# Create an unprivileged user and group
|
|
20
|
+
RUN addgroup -g 1001 -S nodejs && \
|
|
21
|
+
adduser -S nuxtjs -u 1001 -G nodejs
|
|
22
|
+
|
|
23
|
+
# Copy the standalone output and assign ownership
|
|
24
|
+
COPY --from=builder --chown=nuxtjs:nodejs /app/.output ./.output
|
|
25
|
+
|
|
26
|
+
USER nuxtjs
|
|
27
|
+
EXPOSE {{PORT}}
|
|
28
|
+
|
|
29
|
+
CMD ["node", ".output/server/index.mjs"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
FROM ruby:3.2-slim
|
|
2
|
+
WORKDIR /app
|
|
3
|
+
|
|
4
|
+
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs && rm -rf /var/lib/apt/lists/*
|
|
5
|
+
|
|
6
|
+
# Create unprivileged user
|
|
7
|
+
RUN groupadd -g 1001 appgroup && \
|
|
8
|
+
useradd -u 1001 -g appgroup -s /bin/sh -m appuser
|
|
9
|
+
|
|
10
|
+
COPY Gemfile Gemfile.lock ./
|
|
11
|
+
RUN bundle install --without development test
|
|
12
|
+
|
|
13
|
+
# Copy code with explicit ownership
|
|
14
|
+
COPY --chown=appuser:appgroup . .
|
|
15
|
+
|
|
16
|
+
RUN SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompile
|
|
17
|
+
|
|
18
|
+
USER appuser
|
|
19
|
+
EXPOSE {{PORT}}
|
|
20
|
+
|
|
21
|
+
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
|
|
@@ -48,18 +48,19 @@ jobs:
|
|
|
48
48
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
|
49
49
|
fi
|
|
50
50
|
|
|
51
|
-
# --- 2.
|
|
52
|
-
- name:
|
|
53
|
-
uses:
|
|
51
|
+
# --- 2. INFRASTRUCTURE SYNC (The New Marketplace Action) ---
|
|
52
|
+
- name: Apply Infrastructure Changes
|
|
53
|
+
uses: anton-codes-iac/deploy-stack-action@v1
|
|
54
54
|
with:
|
|
55
|
-
role-to-assume: arn:aws:iam::{{AWS_ACCOUNT_ID}}:role/{{PROJECT_NAME}}-github-actions-role
|
|
56
55
|
aws-region: ${{ env.AWS_REGION }}
|
|
56
|
+
role-to-assume: arn:aws:iam::{{AWS_ACCOUNT_ID}}:role/{{PROJECT_NAME}}-github-actions-role
|
|
57
|
+
terraform-dir: 'terraform'
|
|
57
58
|
|
|
59
|
+
# --- 3. BUILD & PUSH ---
|
|
58
60
|
- name: Login to Amazon ECR
|
|
59
61
|
id: login-ecr
|
|
60
62
|
uses: aws-actions/amazon-ecr-login@v2
|
|
61
63
|
|
|
62
|
-
# --- 3. BUILD ---
|
|
63
64
|
- name: Build Docker image
|
|
64
65
|
id: build-image
|
|
65
66
|
env:
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# 1. Isolated Subnets (No Internet Gateway routing)
|
|
2
|
+
resource "aws_subnet" "db_isolated" {
|
|
3
|
+
count = 2
|
|
4
|
+
vpc_id = aws_vpc.main.id
|
|
5
|
+
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index + 10)
|
|
6
|
+
availability_zone = data.aws_availability_zones.available.names[count.index]
|
|
7
|
+
|
|
8
|
+
tags = {
|
|
9
|
+
Name = "{{PROJECT_NAME}}-db-isolated-${count.index}"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
resource "aws_db_subnet_group" "main" {
|
|
14
|
+
name = "{{PROJECT_NAME}}-db-subnet-group"
|
|
15
|
+
subnet_ids = aws_subnet.db_isolated[*].id
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# 2. Database Security Group
|
|
19
|
+
resource "aws_security_group" "rds" {
|
|
20
|
+
name = "{{PROJECT_NAME}}-rds-sg"
|
|
21
|
+
vpc_id = aws_vpc.main.id
|
|
22
|
+
|
|
23
|
+
# ONLY allow inbound traffic from the ECS Fargate tasks
|
|
24
|
+
ingress {
|
|
25
|
+
from_port = 5432
|
|
26
|
+
to_port = 5432
|
|
27
|
+
protocol = "tcp"
|
|
28
|
+
security_groups = [aws_security_group.ecs_tasks.id]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# 3. The PostgreSQL Instance
|
|
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
|
+
|
|
41
|
+
# Clean up dashes for the database name (e.g. my-project -> my_project)
|
|
42
|
+
db_name = replace("{{PROJECT_NAME}}", "-", "_")
|
|
43
|
+
username = "dbadmin"
|
|
44
|
+
|
|
45
|
+
# AWS automatically creates and manages the secret in Secrets Manager!
|
|
46
|
+
manage_master_user_password = true
|
|
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
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# 4. IAM Permission for RDS Master Password Secret
|
|
56
|
+
resource "aws_iam_role_policy" "rds_secret_access" {
|
|
57
|
+
name = "{{PROJECT_NAME}}-rds-secret-policy"
|
|
58
|
+
role = aws_iam_role.execution_role.id
|
|
59
|
+
policy = jsonencode({
|
|
60
|
+
Version = "2012-10-17"
|
|
61
|
+
Statement = [
|
|
62
|
+
{
|
|
63
|
+
Effect = "Allow"
|
|
64
|
+
Action = ["secretsmanager:GetSecretValue"]
|
|
65
|
+
Resource = [
|
|
66
|
+
aws_db_instance.postgres.master_user_secret[0].secret_arn
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
})
|
|
71
|
+
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# deploy-stack generated infrastructure
|
|
2
2
|
provider "aws" {
|
|
3
3
|
region = "{{REGION}}"
|
|
4
|
+
|
|
5
|
+
default_tags {
|
|
6
|
+
tags = {
|
|
7
|
+
ManagedBy = "deploy-stack"
|
|
8
|
+
}
|
|
9
|
+
}
|
|
4
10
|
}
|
|
5
11
|
|
|
6
12
|
locals {
|
|
@@ -76,14 +82,23 @@ resource "aws_ecs_task_definition" "app" {
|
|
|
76
82
|
image = "${aws_ecr_repository.app.repository_url}:latest"
|
|
77
83
|
essential = true
|
|
78
84
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
for key in local.secret_keys : {
|
|
82
|
-
name = key
|
|
83
|
-
valueFrom = "${aws_secretsmanager_secret.app_secrets.arn}:${key}::"
|
|
84
|
-
}
|
|
85
|
+
environment = [
|
|
86
|
+
{{DB_ENV_VARS}}
|
|
85
87
|
]
|
|
86
88
|
|
|
89
|
+
# Dynamically map every secret key found in the local JSON file
|
|
90
|
+
secrets = concat(
|
|
91
|
+
[
|
|
92
|
+
for key in local.secret_keys : {
|
|
93
|
+
name = key
|
|
94
|
+
valueFrom = "${aws_secretsmanager_secret.app_secrets.arn}:${key}::"
|
|
95
|
+
}
|
|
96
|
+
],
|
|
97
|
+
[
|
|
98
|
+
{{DB_SECRETS}}
|
|
99
|
+
]
|
|
100
|
+
)
|
|
101
|
+
|
|
87
102
|
portMappings = [
|
|
88
103
|
{
|
|
89
104
|
containerPort = {{PORT}}
|
|
@@ -191,4 +206,21 @@ output "website_url" {
|
|
|
191
206
|
output "ecr_repository_url" {
|
|
192
207
|
description = "The URL of the ECR repository"
|
|
193
208
|
value = aws_ecr_repository.app.repository_url
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
# --- CloudWatch Alarms ---
|
|
212
|
+
resource "aws_cloudwatch_metric_alarm" "alb_5xx_errors" {
|
|
213
|
+
alarm_name = "{{PROJECT_NAME}}-high-5xx-errors"
|
|
214
|
+
comparison_operator = "GreaterThanThreshold"
|
|
215
|
+
evaluation_periods = "2"
|
|
216
|
+
metric_name = "HTTPCode_Target_5XX_Count"
|
|
217
|
+
namespace = "AWS/ApplicationELB"
|
|
218
|
+
period = "60"
|
|
219
|
+
statistic = "Sum"
|
|
220
|
+
threshold = "10"
|
|
221
|
+
alarm_description = "Triggers if the ALB receives more than 10 5XX errors in 2 minutes."
|
|
222
|
+
|
|
223
|
+
dimensions = {
|
|
224
|
+
LoadBalancer = aws_lb.main.arn_suffix
|
|
225
|
+
}
|
|
194
226
|
}
|