deploy-stack 0.12.3 → 0.13.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 +3 -1
- package/package.json +1 -1
- package/src/commands/apply.js +1 -1
- package/src/commands/destroy.js +1 -1
- package/src/commands/init.js +39 -13
- package/src/utils/aws.js +1 -1
- package/src/utils/detector.js +32 -0
- package/src/utils/generator.js +31 -6
- package/src/utils/visualizer.js +17 -9
- package/templates/docker/django.Dockerfile +3 -0
- package/templates/terraform/main.tf +4 -2
- package/templates/terraform/worker.tf +62 -0
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ You retain complete ownership of your infrastructure code without relying on bla
|
|
|
30
30
|
**🚀 Zero-Config Deployments**
|
|
31
31
|
* **Framework Agnostic:** Tailored container presets for Next.js, Express.js, FastAPI, Go, Django, Rails, Nuxt 3, and Static Sites (React, Vue, SvelteKit, Astro).
|
|
32
32
|
* **Smart Discovery:** Automatically detects build output directories and generates highly optimized, multi-stage Dockerfiles.
|
|
33
|
+
* **PaaS Migration Engine:** Natively parses Heroku `Procfile` configurations to automatically translate web and background worker processes (like Celery or Sidekiq) into multi-container AWS Fargate architectures.
|
|
33
34
|
* **Database Scaffolding:** Automatically provisions fully isolated, zero-trust AWS RDS PostgreSQL databases for backend monoliths.
|
|
34
35
|
|
|
35
36
|
**🛡️ DevSecOps & Security**
|
|
@@ -83,7 +84,7 @@ The interactive wizard will analyze your codebase, detect your framework, estima
|
|
|
83
84
|
Strips all `deploy-stack` metadata and management tags from your project, leaving behind pure, standard Terraform and GitHub Actions files. You retain 100% ownership.
|
|
84
85
|
|
|
85
86
|
* **`npx deploy-stack --headless`**
|
|
86
|
-
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-
|
|
87
|
+
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`.
|
|
87
88
|
|
|
88
89
|
---
|
|
89
90
|
|
|
@@ -123,6 +124,7 @@ your-project/
|
|
|
123
124
|
* **[Ruby on Rails](https://github.com/anton-codes-iac/deploy-stack-rails-example):** A production Rails 7+ setup featuring an auto-provisioned PostgreSQL database and secure `.auto.tfvars` Master Key injection.
|
|
124
125
|
* **[Nuxt 3 (SSR)](https://github.com/anton-codes-iac/deploy-stack-nuxt-example):** Demonstrates a fully server-side rendered Nuxt application using Nitro's optimized Node output.
|
|
125
126
|
* **[Django / Python](https://github.com/anton-codes-iac/deploy-stack-django-example):** A secure Gunicorn/WSGI implementation with PostgreSQL and unprivileged container adapters.
|
|
127
|
+
* **[Heroku to AWS Migration (Django)](https://github.com/anton-codes-iac/deploy-stack-heroku-django-example):** A classic Heroku-style monolith migrated via the Procfile Importer, demonstrating a multi-container Web and Celery Worker architecture deployed from a single codebase.
|
|
126
128
|
* **[Go / Fiber](https://github.com/anton-codes-iac/deploy-stack-go-example):** A distroless, compiled Go binary deployment demonstrating ultra-low memory footprints and instant boot times.
|
|
127
129
|
---
|
|
128
130
|
|
package/package.json
CHANGED
package/src/commands/apply.js
CHANGED
|
@@ -111,7 +111,7 @@ export async function applyStack(options = {}) {
|
|
|
111
111
|
// 6. Run terraform apply
|
|
112
112
|
s.start('Provisioning AWS infrastructure (this may take 3–5 minutes)...');
|
|
113
113
|
await runTerraformCommand(['apply', '-auto-approve'], tfDir, s, 'Provisioning');
|
|
114
|
-
s.stop('
|
|
114
|
+
s.stop('Cloud resources provisioned.');
|
|
115
115
|
|
|
116
116
|
// 7. Extract and print the outputs
|
|
117
117
|
const outputs = await getTerraformOutputs(tfDir);
|
package/src/commands/destroy.js
CHANGED
|
@@ -77,7 +77,7 @@ export async function destroyStack() {
|
|
|
77
77
|
const regionMatch = backendContent.match(/region\s*=\s*"([^"]+)"/);
|
|
78
78
|
|
|
79
79
|
const bucketName = bucketMatch ? bucketMatch[1] : null;
|
|
80
|
-
const region = regionMatch ? regionMatch[1] : 'us-east-
|
|
80
|
+
const region = regionMatch ? regionMatch[1] : 'us-east-2';
|
|
81
81
|
|
|
82
82
|
// 2. Execute Terraform Destroy
|
|
83
83
|
s.start('Destroying AWS compute resources (this takes a few minutes)...');
|
package/src/commands/init.js
CHANGED
|
@@ -6,7 +6,7 @@ import color from 'picocolors';
|
|
|
6
6
|
import { execSync } from 'child_process';
|
|
7
7
|
|
|
8
8
|
import { checkDependency } from '../utils/system.js';
|
|
9
|
-
import { detectFramework } from '../utils/detector.js';
|
|
9
|
+
import { detectFramework, parseProcfile } from '../utils/detector.js';
|
|
10
10
|
import { trackEvent, flushTelemetry } from '../core/telemetry.js';
|
|
11
11
|
import { getFrameworkWarning } from '../utils/warnings.js';
|
|
12
12
|
import { provisionStateBucket } from '../utils/aws.js';
|
|
@@ -35,6 +35,7 @@ export async function mainStack({ isHeadless = false, headlessOptions = {} } = {
|
|
|
35
35
|
let disableDefaultCI = false;
|
|
36
36
|
let project = {};
|
|
37
37
|
let currentGitBranch = 'main';
|
|
38
|
+
let procfile = null;
|
|
38
39
|
|
|
39
40
|
if (isHeadless) {
|
|
40
41
|
// --- HEADLESS MODE ---
|
|
@@ -43,10 +44,11 @@ export async function mainStack({ isHeadless = false, headlessOptions = {} } = {
|
|
|
43
44
|
targetDir = projectName === '.' ? process.cwd() : path.join(process.cwd(), projectName);
|
|
44
45
|
|
|
45
46
|
detectedFramework = detectFramework(targetDir);
|
|
47
|
+
procfile = parseProcfile(targetDir);
|
|
46
48
|
finalFramework = getFlag('framework', detectedFramework ? detectedFramework.id : 'static');
|
|
47
49
|
|
|
48
50
|
project = {
|
|
49
|
-
region: getFlag('region', 'us-east-
|
|
51
|
+
region: getFlag('region', 'us-east-2'),
|
|
50
52
|
port: getFlag('port', finalFramework === 'static' ? '8080' : '3000'),
|
|
51
53
|
size: getFlag('size', 'micro'),
|
|
52
54
|
healthCheckPath: getFlag('healthCheckPath', '/'),
|
|
@@ -85,7 +87,13 @@ export async function mainStack({ isHeadless = false, headlessOptions = {} } = {
|
|
|
85
87
|
log.success(`Auto-detected framework: ${detectedFramework.name}`);
|
|
86
88
|
}
|
|
87
89
|
|
|
88
|
-
// 2.6
|
|
90
|
+
// 2.6 Run the Procfile Parser
|
|
91
|
+
procfile = parseProcfile(targetDir);
|
|
92
|
+
if (procfile && procfile.web) {
|
|
93
|
+
log.success(`Auto-detected Procfile (web command: ${procfile.web.join(' ')})`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 2.7 Resolve the framework
|
|
89
97
|
finalFramework = detectedFramework ? detectedFramework.id : null;
|
|
90
98
|
|
|
91
99
|
if (!finalFramework) {
|
|
@@ -109,15 +117,32 @@ export async function mainStack({ isHeadless = false, headlessOptions = {} } = {
|
|
|
109
117
|
}
|
|
110
118
|
}
|
|
111
119
|
|
|
112
|
-
//
|
|
120
|
+
// 2.8 Check if framework is Django and resolve wsgi.py path
|
|
113
121
|
djangoWsgi = 'core.wsgi';
|
|
114
122
|
if (finalFramework === 'django') {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
let extractedWsgi = null;
|
|
124
|
+
|
|
125
|
+
// Check if Procfile already specifies the WSGI module
|
|
126
|
+
if (procfile && procfile.web) {
|
|
127
|
+
const webCommand = procfile.web.join(' ');
|
|
128
|
+
// Matches patterns like "gunicorn my_app.wsgi" or "my_app.wsgi:application"
|
|
129
|
+
const wsgiMatch = webCommand.match(/([a-zA-Z0-9_]+)\.wsgi/);
|
|
130
|
+
if (wsgiMatch) {
|
|
131
|
+
extractedWsgi = `${wsgiMatch[1]}.wsgi`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (extractedWsgi) {
|
|
136
|
+
djangoWsgi = extractedWsgi;
|
|
137
|
+
log.success(`Auto-detected Django WSGI from Procfile: ${color.cyan(djangoWsgi)}`);
|
|
138
|
+
} else {
|
|
139
|
+
djangoWsgi = await text({
|
|
140
|
+
message: 'What is the Python module path to your Django wsgi.py?',
|
|
141
|
+
placeholder: 'core.wsgi',
|
|
142
|
+
initialValue: 'core.wsgi',
|
|
143
|
+
});
|
|
144
|
+
if (typeof djangoWsgi === 'symbol') process.exit(0);
|
|
145
|
+
}
|
|
121
146
|
}
|
|
122
147
|
|
|
123
148
|
// 3. Prompt for Setup Mode
|
|
@@ -309,7 +334,8 @@ export async function mainStack({ isHeadless = false, headlessOptions = {} } = {
|
|
|
309
334
|
finalFramework: finalFramework,
|
|
310
335
|
NEEDS_DATABASE: needsDatabase,
|
|
311
336
|
DJANGO_WSGI: djangoWsgi,
|
|
312
|
-
DISABLE_DEFAULT_CI: disableDefaultCI
|
|
337
|
+
DISABLE_DEFAULT_CI: disableDefaultCI,
|
|
338
|
+
PROCFILE: procfile
|
|
313
339
|
});
|
|
314
340
|
|
|
315
341
|
// 11. Track the event in telemetry
|
|
@@ -336,8 +362,8 @@ export async function mainStack({ isHeadless = false, headlessOptions = {} } = {
|
|
|
336
362
|
|
|
337
363
|
const needsCd = projectName && projectName !== '.';
|
|
338
364
|
const applyStep = needsCd
|
|
339
|
-
? `cd ${projectName} && npx deploy-stack apply
|
|
340
|
-
: 'npx deploy-stack apply';
|
|
365
|
+
? `cd ${projectName} && npx --yes deploy-stack apply`
|
|
366
|
+
: 'npx --yes deploy-stack apply';
|
|
341
367
|
|
|
342
368
|
const gitInstructions = isGitInitialized
|
|
343
369
|
? `git add . && git commit -m "chore: add AWS infrastructure and CI/CD" && git push`
|
package/src/utils/aws.js
CHANGED
|
@@ -21,7 +21,7 @@ export async function provisionStateBucket(region, projectName) {
|
|
|
21
21
|
try {
|
|
22
22
|
await s3Client.send(new CreateBucketCommand({
|
|
23
23
|
Bucket: stateBucketName,
|
|
24
|
-
CreateBucketConfiguration: region === 'us-east-
|
|
24
|
+
CreateBucketConfiguration: region === 'us-east-2' ? undefined : { LocationConstraint: region }
|
|
25
25
|
}));
|
|
26
26
|
|
|
27
27
|
await s3Client.send(new PutBucketTaggingCommand({
|
package/src/utils/detector.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fsSync from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
|
|
4
|
+
// Detects the framework based on the presence of framework-specific files.
|
|
4
5
|
export function detectFramework(targetDir) {
|
|
5
6
|
const packageJsonPath = path.join(targetDir, 'package.json');
|
|
6
7
|
const requirementsTxtPath = path.join(targetDir, 'requirements.txt');
|
|
@@ -62,4 +63,35 @@ export function detectFramework(targetDir) {
|
|
|
62
63
|
|
|
63
64
|
// 5. Fallback
|
|
64
65
|
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Parses a Heroku/Render Procfile and formats the commands for Terraform ECS.
|
|
69
|
+
export function parseProcfile(targetDir) {
|
|
70
|
+
const procfilePath = path.join(targetDir, 'Procfile');
|
|
71
|
+
|
|
72
|
+
if (!fsSync.existsSync(procfilePath)) return null;
|
|
73
|
+
|
|
74
|
+
const content = fsSync.readFileSync(procfilePath, 'utf-8');
|
|
75
|
+
const processes = {};
|
|
76
|
+
|
|
77
|
+
// Match lines like "web: gunicorn myapp.wsgi"
|
|
78
|
+
const lines = content.split('\n');
|
|
79
|
+
const procRegex = /^([A-Za-z0-9_-]+):\s*(.+)$/;
|
|
80
|
+
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
const match = line.trim().match(procRegex);
|
|
83
|
+
if (match) {
|
|
84
|
+
const type = match[1].toLowerCase();
|
|
85
|
+
const rawCommand = match[2].trim();
|
|
86
|
+
|
|
87
|
+
// Terraform requires the command as a JSON array of strings
|
|
88
|
+
// This splits by spaces but respects single and double quotes
|
|
89
|
+
const commandArray = rawCommand.match(/[^\s"']+|"([^"]*)"|'([^']*)'/g)
|
|
90
|
+
.map(str => str.replace(/^["']|["']$/g, '')); // Strip the quotes
|
|
91
|
+
|
|
92
|
+
processes[type] = commandArray;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return Object.keys(processes).length > 0 ? processes : null;
|
|
65
97
|
}
|
package/src/utils/generator.js
CHANGED
|
@@ -26,8 +26,28 @@ export async function generateTemplates(targetDir, config) {
|
|
|
26
26
|
{ src: 'README.md', dest: 'README.md' }
|
|
27
27
|
];
|
|
28
28
|
|
|
29
|
+
// 3. Configure Compute Commands & Environment Variables
|
|
29
30
|
let secretsArray = [];
|
|
30
31
|
|
|
32
|
+
// 3.1. Format the ECS Task Command (Heroku Procfile Support)
|
|
33
|
+
// If a Procfile exists and has a 'web' process, override the Docker CMD
|
|
34
|
+
if (config.PROCFILE && config.PROCFILE.web) {
|
|
35
|
+
// e.g., ["gunicorn", "myproject.wsgi"] -> JSON string for Terraform
|
|
36
|
+
config.TASK_COMMAND = `command = ${JSON.stringify(config.PROCFILE.web)}`;
|
|
37
|
+
} else {
|
|
38
|
+
// Fallback to the default Dockerfile CMD
|
|
39
|
+
config.TASK_COMMAND = '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (config.PROCFILE && config.PROCFILE.worker) {
|
|
43
|
+
config.WORKER_COMMAND = `command = ${JSON.stringify(config.PROCFILE.worker)}`;
|
|
44
|
+
// Dynamically add worker.tf to the generation list
|
|
45
|
+
filesToProcess.push({ src: 'terraform/worker.tf', dest: 'terraform/worker.tf' });
|
|
46
|
+
} else {
|
|
47
|
+
config.WORKER_COMMAND = '';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 3.2. Inject Managed Database Variables
|
|
31
51
|
if (config.NEEDS_DATABASE) {
|
|
32
52
|
filesToProcess.push({ src: 'terraform/database.tf', dest: 'terraform/database.tf' });
|
|
33
53
|
|
|
@@ -42,10 +62,11 @@ export async function generateTemplates(targetDir, config) {
|
|
|
42
62
|
config.DB_ENV_VARS = '';
|
|
43
63
|
}
|
|
44
64
|
|
|
65
|
+
// 4. Configure AWS Secrets Manager Integration
|
|
45
66
|
// Build the initial HCL map for AWS Secrets Manager
|
|
46
67
|
let initialSecretMap = `{\n EXAMPLE_API_KEY = "replace_me_in_aws_console"`;
|
|
47
68
|
|
|
48
|
-
// Inject Rails Master Key if applicable
|
|
69
|
+
// 4.1. Inject Rails Master Key if applicable
|
|
49
70
|
if (config.finalFramework === 'rails') {
|
|
50
71
|
secretsArray.push(`{ "name": "RAILS_MASTER_KEY", "valueFrom": "\${aws_secretsmanager_secret.app_secrets.arn}:RAILS_MASTER_KEY::" }`);
|
|
51
72
|
|
|
@@ -64,7 +85,11 @@ export async function generateTemplates(targetDir, config) {
|
|
|
64
85
|
config.TASK_SECRETS = secretsArray.join(',\n ');
|
|
65
86
|
config.INITIAL_SECRET_MAP = initialSecretMap;
|
|
66
87
|
|
|
67
|
-
|
|
88
|
+
config.SAFE_ALB_NAME = config.PROJECT_NAME.length > 27
|
|
89
|
+
? config.PROJECT_NAME.substring(0, 27).replace(/-$/, '') // Remove trailing hyphens
|
|
90
|
+
: config.PROJECT_NAME;
|
|
91
|
+
|
|
92
|
+
// 5. Process standard files
|
|
68
93
|
for (const file of filesToProcess) {
|
|
69
94
|
let content = await fs.readFile(path.join(templatesDir, file.src), 'utf-8');
|
|
70
95
|
|
|
@@ -76,10 +101,10 @@ export async function generateTemplates(targetDir, config) {
|
|
|
76
101
|
await fs.writeFile(path.join(targetDir, file.dest), content);
|
|
77
102
|
}
|
|
78
103
|
|
|
79
|
-
//
|
|
104
|
+
// 6. Create empty secrets file
|
|
80
105
|
await fs.writeFile(path.join(targetDir, 'terraform', 'secret_keys.json'), "[]");
|
|
81
106
|
|
|
82
|
-
//
|
|
107
|
+
// 7. Handle .gitignore dynamically based on framework
|
|
83
108
|
const targetGitignore = path.join(targetDir, '.gitignore');
|
|
84
109
|
|
|
85
110
|
if (!fsSync.existsSync(targetGitignore)) {
|
|
@@ -92,7 +117,7 @@ export async function generateTemplates(targetDir, config) {
|
|
|
92
117
|
}
|
|
93
118
|
}
|
|
94
119
|
|
|
95
|
-
//
|
|
120
|
+
// 8. Create .dockerignore to keep images lean and secure
|
|
96
121
|
const dockerignorePath = path.join(targetDir, '.dockerignore');
|
|
97
122
|
const appendDockerIgnore = '\n# Infrastructure (deploy-stack)\nterraform/\n**/.terraform/\n**/.terraform.*\n**/*.tfstate*\n.env\n';
|
|
98
123
|
|
|
@@ -146,7 +171,7 @@ Thumbs.db
|
|
|
146
171
|
return (baseIgnore + frameworkIgnore).trim();
|
|
147
172
|
}
|
|
148
173
|
|
|
149
|
-
//
|
|
174
|
+
// 9. Framework-specific cleanup
|
|
150
175
|
// Disable default workflows that crash in isolated CI environments
|
|
151
176
|
if (config.finalFramework === 'rails') {
|
|
152
177
|
const railsCiPath = path.join(targetDir, '.github', 'workflows', 'ci.yml');
|
package/src/utils/visualizer.js
CHANGED
|
@@ -3,7 +3,7 @@ import pc from 'picocolors';
|
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
|
|
6
|
-
// Cost benchmarks for AWS us-east-
|
|
6
|
+
// Cost benchmarks for AWS us-east-2 baseline (Fargate + ALB)
|
|
7
7
|
const PRICING_TABLE = {
|
|
8
8
|
fargate: {
|
|
9
9
|
cpuPerHour: 0.04048, // per vCPU hour
|
|
@@ -22,7 +22,7 @@ const PRICING_TABLE = {
|
|
|
22
22
|
// 1. Parse the local terraform files to extract the actual configuration
|
|
23
23
|
export function parseTerraformConfig(tfDir) {
|
|
24
24
|
const tfvarsPath = path.join(tfDir, 'terraform.tfvars');
|
|
25
|
-
let region = 'us-east-
|
|
25
|
+
let region = 'us-east-2';
|
|
26
26
|
let cpu = 256;
|
|
27
27
|
let memory = 512;
|
|
28
28
|
let framework = 'Application';
|
|
@@ -43,18 +43,22 @@ export function parseTerraformConfig(tfDir) {
|
|
|
43
43
|
|
|
44
44
|
// Check if database files exist
|
|
45
45
|
const hasDb = fs.existsSync(path.join(tfDir, 'rds.tf')) || fs.existsSync(path.join(tfDir, 'database.tf'));
|
|
46
|
+
const hasWorker = fs.existsSync(path.join(tfDir, 'worker.tf'));
|
|
46
47
|
|
|
47
|
-
return { framework, region, cpu, memory, hasDb };
|
|
48
|
+
return { framework, region, cpu, memory, hasDb, hasWorker };
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
// 2. Calculate itemized monthly costs based on task definition settings
|
|
51
|
-
export function estimateMonthlyCost({ cpu = 256, memory = 512, hasDb = false }) {
|
|
52
|
+
export function estimateMonthlyCost({ cpu = 256, memory = 512, hasDb = false, hasWorker = false }) {
|
|
52
53
|
const vCpu = cpu / 1024;
|
|
53
54
|
const memGb = memory / 1024;
|
|
54
55
|
const hoursInMonth = 730;
|
|
55
56
|
|
|
57
|
+
// If a worker service exists, we are running a second identical Fargate task
|
|
58
|
+
const taskMultiplier = hasWorker ? 2 : 1;
|
|
59
|
+
|
|
56
60
|
const fargateCost = ((vCpu * PRICING_TABLE.fargate.cpuPerHour) +
|
|
57
|
-
(memGb * PRICING_TABLE.fargate.memoryPerHour)) * hoursInMonth;
|
|
61
|
+
(memGb * PRICING_TABLE.fargate.memoryPerHour)) * hoursInMonth * taskMultiplier;
|
|
58
62
|
const albCost = (PRICING_TABLE.alb.basePerHour + PRICING_TABLE.alb.lcuPerHour) * hoursInMonth;
|
|
59
63
|
const dbCost = hasDb ? (PRICING_TABLE.rds.microPerHour * hoursInMonth) + PRICING_TABLE.rds.storagePerMonth : 0;
|
|
60
64
|
|
|
@@ -70,17 +74,21 @@ export function estimateMonthlyCost({ cpu = 256, memory = 512, hasDb = false })
|
|
|
70
74
|
|
|
71
75
|
// 3. Render the terminal architecture visualization and requests confirmation
|
|
72
76
|
export async function renderDryRunPreview(config, isDryRunFlag = false) {
|
|
73
|
-
const { framework = 'Node.js', region = 'us-east-
|
|
74
|
-
|
|
77
|
+
const { framework = 'Node.js', region = 'us-east-2', cpu = 256, memory = 512, hasDb = false, hasWorker = false } = config;
|
|
78
|
+
|
|
79
|
+
// Fixed the duplicate hasWorker argument
|
|
80
|
+
const cost = estimateMonthlyCost({ cpu, memory, hasDb, hasWorker });
|
|
75
81
|
|
|
76
82
|
const hourlyRate = (cost.totalMonthly / 730).toFixed(3); // 730 hours in a month
|
|
77
83
|
|
|
84
|
+
// Flattened the tree to eliminate nesting and vertical bloat
|
|
78
85
|
const treeOutput = [
|
|
79
86
|
`${pc.bold('Topology')} (${pc.cyan(region)}):`,
|
|
80
87
|
` ${pc.gray('├──')} 🌐 ${pc.bold('ALB')} (Public Entry & Health: ${pc.green('200 OK')})`,
|
|
81
88
|
` ${pc.gray('├──')} 🔒 ${pc.bold('IAM OIDC')} (GitHub Auth) & 🐳 ${pc.bold('ECR')} (Registry)`,
|
|
82
|
-
` ${pc.gray('
|
|
83
|
-
|
|
89
|
+
hasDb ? ` ${pc.gray('├──')} 🐘 ${pc.yellow('Amazon RDS')} (PostgreSQL managed instance)` : '',
|
|
90
|
+
` ${pc.gray(hasWorker ? '├──' : '└──')} 📦 ${pc.bold('ECS Web Service')} 🟢 ${pc.green(framework)} [${cpu} CPU / ${memory} MB]`,
|
|
91
|
+
hasWorker ? ` ${pc.gray('└──')} 📦 ${pc.bold('ECS Worker Service')} 🔄 Background Tasks [${cpu} CPU / ${memory} MB]` : '',
|
|
84
92
|
'',
|
|
85
93
|
`${pc.bold('Est. Monthly Cost:')} ${pc.green(pc.bold(`~$${cost.totalMonthly}`))} ${pc.dim(`(ALB: $${cost.albMonthly}, Fargate: $${cost.fargateMonthly}${hasDb ? `, RDS: $${cost.dbMonthly}` : ''})`)}`,
|
|
86
94
|
` ${pc.dim(`* Hourly billing: ~$${hourlyRate}/hr. Destroy anytime with "npx deploy-stack destroy --yes"`)}`
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
FROM python:3.12-alpine
|
|
2
2
|
|
|
3
|
+
# --- DevSecOps Patch: Upgrade Alpine system packages to clear OS-level CVEs ---
|
|
4
|
+
RUN apk upgrade --no-cache
|
|
5
|
+
|
|
3
6
|
# Prevent Python from writing .pyc files and buffer stdout for cleaner logs
|
|
4
7
|
ENV PYTHONDONTWRITEBYTECODE=1
|
|
5
8
|
ENV PYTHONUNBUFFERED=1
|
|
@@ -99,6 +99,8 @@ resource "aws_ecs_task_definition" "app" {
|
|
|
99
99
|
]
|
|
100
100
|
)
|
|
101
101
|
|
|
102
|
+
{{TASK_COMMAND}}
|
|
103
|
+
|
|
102
104
|
portMappings = [
|
|
103
105
|
{
|
|
104
106
|
containerPort = {{PORT}}
|
|
@@ -122,7 +124,7 @@ resource "aws_ecs_task_definition" "app" {
|
|
|
122
124
|
# --- Application Load Balancer ---
|
|
123
125
|
# trivy:ignore:AVD-AWS-0053 - This ALB is intended to be publicly facing behind CloudFront
|
|
124
126
|
resource "aws_lb" "main" {
|
|
125
|
-
name = "{{
|
|
127
|
+
name = "{{SAFE_ALB_NAME}}-alb"
|
|
126
128
|
load_balancer_type = "application"
|
|
127
129
|
security_groups = [aws_security_group.alb.id]
|
|
128
130
|
subnets = aws_subnet.public[*].id
|
|
@@ -130,7 +132,7 @@ resource "aws_lb" "main" {
|
|
|
130
132
|
}
|
|
131
133
|
|
|
132
134
|
resource "aws_lb_target_group" "app" {
|
|
133
|
-
name = "{{
|
|
135
|
+
name = "{{SAFE_ALB_NAME}}-tg"
|
|
134
136
|
port = {{PORT}}
|
|
135
137
|
protocol = "HTTP"
|
|
136
138
|
vpc_id = aws_vpc.main.id
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# --- Worker ECS Task Definition ---
|
|
2
|
+
resource "aws_ecs_task_definition" "worker" {
|
|
3
|
+
family = "{{PROJECT_NAME}}-worker-task"
|
|
4
|
+
network_mode = "awsvpc"
|
|
5
|
+
requires_compatibilities = ["FARGATE"]
|
|
6
|
+
cpu = "{{CPU}}"
|
|
7
|
+
memory = "{{MEMORY}}"
|
|
8
|
+
execution_role_arn = aws_iam_role.execution_role.arn
|
|
9
|
+
task_role_arn = aws_iam_role.task_role.arn
|
|
10
|
+
|
|
11
|
+
container_definitions = jsonencode([
|
|
12
|
+
{
|
|
13
|
+
name = "{{PROJECT_NAME}}-worker-container"
|
|
14
|
+
image = "${aws_ecr_repository.app.repository_url}:latest"
|
|
15
|
+
essential = true
|
|
16
|
+
|
|
17
|
+
environment = [
|
|
18
|
+
{ "name": "NODE_ENV", "value": "production" },
|
|
19
|
+
{{DB_ENV_VARS}}
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
secrets = concat(
|
|
23
|
+
[
|
|
24
|
+
for key in local.secret_keys : {
|
|
25
|
+
name = key
|
|
26
|
+
valueFrom = "${aws_secretsmanager_secret.app_secrets.arn}:${key}::"
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
[
|
|
30
|
+
{{TASK_SECRETS}}
|
|
31
|
+
]
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
{{WORKER_COMMAND}}
|
|
35
|
+
|
|
36
|
+
logConfiguration = {
|
|
37
|
+
logDriver = "awslogs"
|
|
38
|
+
options = {
|
|
39
|
+
"awslogs-group" = aws_cloudwatch_log_group.app_logs.name
|
|
40
|
+
"awslogs-region" = "{{REGION}}"
|
|
41
|
+
"awslogs-stream-prefix" = "worker" # Isolates worker logs from web logs
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
])
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# --- Worker ECS Service ---
|
|
49
|
+
# Notice there is NO load_balancer block. This service is strictly private.
|
|
50
|
+
resource "aws_ecs_service" "worker" {
|
|
51
|
+
name = "{{PROJECT_NAME}}-worker-service"
|
|
52
|
+
cluster = aws_ecs_cluster.main.id
|
|
53
|
+
task_definition = aws_ecs_task_definition.worker.arn
|
|
54
|
+
launch_type = "FARGATE"
|
|
55
|
+
desired_count = 1
|
|
56
|
+
|
|
57
|
+
network_configuration {
|
|
58
|
+
subnets = aws_subnet.public[*].id
|
|
59
|
+
security_groups = [aws_security_group.ecs_tasks.id]
|
|
60
|
+
assign_public_ip = true
|
|
61
|
+
}
|
|
62
|
+
}
|