deploy-stack 0.12.3 → 0.14.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 +4 -2
- package/package.json +1 -1
- package/release-notes.md +14 -0
- package/src/commands/apply.js +19 -1
- package/src/commands/destroy.js +14 -3
- package/src/commands/doctor.js +6 -0
- package/src/commands/eject.js +7 -0
- package/src/commands/init.js +98 -277
- package/src/commands/secrets.js +15 -0
- package/src/core/telemetry.js +1 -0
- package/src/utils/aws.js +1 -1
- package/src/utils/detector.js +127 -0
- package/src/utils/frameworks.js +55 -0
- package/src/utils/generator.js +71 -6
- package/src/utils/prompts.js +156 -0
- package/src/utils/visualizer.js +17 -9
- package/templates/docker/django.Dockerfile +3 -0
- package/templates/terraform/main.tf +4 -2
- package/templates/terraform/network.tf +3 -1
- package/templates/terraform/worker.tf +62 -0
package/src/commands/secrets.js
CHANGED
|
@@ -4,6 +4,7 @@ import fs from 'fs/promises';
|
|
|
4
4
|
import { spinner } from '@clack/prompts';
|
|
5
5
|
import color from 'picocolors';
|
|
6
6
|
import path from 'path';
|
|
7
|
+
import { trackEvent, flushTelemetry } from '../core/telemetry.js';
|
|
7
8
|
|
|
8
9
|
export async function pushSecrets(envFilePath, projectName) {
|
|
9
10
|
const s = spinner();
|
|
@@ -42,7 +43,21 @@ export async function pushSecrets(envFilePath, projectName) {
|
|
|
42
43
|
console.log(color.cyan(`\nUpdated ${keysFilePath}`));
|
|
43
44
|
console.log(color.green('Commit this file and push to GitHub to trigger a deployment with your new variables.'));
|
|
44
45
|
|
|
46
|
+
trackEvent('secrets_pushed', {
|
|
47
|
+
projectName,
|
|
48
|
+
secret_count: Object.keys(parsedSecrets).length,
|
|
49
|
+
success: true
|
|
50
|
+
});
|
|
51
|
+
await flushTelemetry();
|
|
52
|
+
|
|
45
53
|
} catch (error) {
|
|
46
54
|
s.stop(`❌ Failed to push secrets: ${error.message}`);
|
|
55
|
+
|
|
56
|
+
trackEvent('secrets_pushed', {
|
|
57
|
+
projectName,
|
|
58
|
+
success: false,
|
|
59
|
+
error_code: error.name || 'UNKNOWN'
|
|
60
|
+
});
|
|
61
|
+
await flushTelemetry();
|
|
47
62
|
}
|
|
48
63
|
}
|
package/src/core/telemetry.js
CHANGED
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,130 @@ 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;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Parses a vercel.json file to extract routing and edge rules
|
|
100
|
+
export function parseVercelConfig(targetDir) {
|
|
101
|
+
const vercelConfigPath = path.join(targetDir, 'vercel.json');
|
|
102
|
+
if (!fsSync.existsSync(vercelConfigPath)) return null;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const content = fsSync.readFileSync(vercelConfigPath, 'utf-8');
|
|
106
|
+
const vercelJson = JSON.parse(content);
|
|
107
|
+
|
|
108
|
+
// We only care about network-level edge rules that AWS needs to handle
|
|
109
|
+
const rules = {
|
|
110
|
+
redirects: vercelJson.redirects || null,
|
|
111
|
+
headers: vercelJson.headers || null,
|
|
112
|
+
rewrites: vercelJson.rewrites || null
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// If it's just an empty vercel.json, return null
|
|
116
|
+
if (!rules.redirects && !rules.headers && !rules.rewrites) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return rules;
|
|
121
|
+
} catch (e) {
|
|
122
|
+
// Silently fail on malformed JSON
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Checks if Next.js is configured for 'standalone' output
|
|
128
|
+
export function analyzeNextConfig(targetDir) {
|
|
129
|
+
const extensions = ['js', 'mjs', 'cjs', 'ts'];
|
|
130
|
+
let configPath = null;
|
|
131
|
+
let configContent = '';
|
|
132
|
+
|
|
133
|
+
for (const ext of extensions) {
|
|
134
|
+
const tempPath = path.join(targetDir, `next.config.${ext}`);
|
|
135
|
+
if (fsSync.existsSync(tempPath)) {
|
|
136
|
+
configPath = tempPath;
|
|
137
|
+
configContent = fsSync.readFileSync(tempPath, 'utf-8');
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!configPath) return { hasConfig: false, isStandalone: false };
|
|
143
|
+
|
|
144
|
+
// Regex looks for output: 'standalone' or output: "standalone" (handling spacing)
|
|
145
|
+
const isStandalone = /output\s*:\s*['"`]standalone['"`]/.test(configContent);
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
hasConfig: true,
|
|
149
|
+
isStandalone: isStandalone,
|
|
150
|
+
configPath: configPath
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Checks if SvelteKit is locked into Vercel
|
|
155
|
+
export function analyzeSvelteConfig(targetDir) {
|
|
156
|
+
const configPath = path.join(targetDir, 'svelte.config.js');
|
|
157
|
+
if (!fsSync.existsSync(configPath)) return { hasConfig: false, adapter: 'unknown' };
|
|
158
|
+
|
|
159
|
+
const content = fsSync.readFileSync(configPath, 'utf-8');
|
|
160
|
+
|
|
161
|
+
let adapter = 'unknown';
|
|
162
|
+
if (content.includes('@sveltejs/adapter-vercel')) adapter = 'vercel';
|
|
163
|
+
else if (content.includes('@sveltejs/adapter-node')) adapter = 'node';
|
|
164
|
+
else if (content.includes('@sveltejs/adapter-static')) adapter = 'static';
|
|
165
|
+
else if (content.includes('@sveltejs/adapter-auto')) adapter = 'auto'; // Vercel's default
|
|
166
|
+
|
|
167
|
+
return { hasConfig: true, adapter };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Checks if Astro is locked into Vercel
|
|
171
|
+
export function analyzeAstroConfig(targetDir) {
|
|
172
|
+
const extensions = ['mjs', 'js', 'ts', 'cjs'];
|
|
173
|
+
let configPath = null;
|
|
174
|
+
let content = '';
|
|
175
|
+
|
|
176
|
+
for (const ext of extensions) {
|
|
177
|
+
const tempPath = path.join(targetDir, `astro.config.${ext}`);
|
|
178
|
+
if (fsSync.existsSync(tempPath)) {
|
|
179
|
+
configPath = tempPath;
|
|
180
|
+
content = fsSync.readFileSync(tempPath, 'utf-8');
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!configPath) return { hasConfig: false, adapter: 'unknown' };
|
|
186
|
+
|
|
187
|
+
let adapter = 'unknown';
|
|
188
|
+
if (content.includes('@astrojs/vercel')) adapter = 'vercel';
|
|
189
|
+
else if (content.includes('@astrojs/node')) adapter = 'node';
|
|
190
|
+
|
|
191
|
+
return { hasConfig: true, adapter };
|
|
65
192
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fsSync from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { text, confirm, cancel, log } from '@clack/prompts';
|
|
4
|
+
import color from 'picocolors';
|
|
5
|
+
|
|
6
|
+
export async function resolveDjangoWsgi(targetDir, procfile, framework, isHeadless) {
|
|
7
|
+
if (framework !== 'django') return 'core.wsgi';
|
|
8
|
+
|
|
9
|
+
let extractedWsgi = null;
|
|
10
|
+
if (procfile && procfile.web) {
|
|
11
|
+
const webCommand = procfile.web.join(' ');
|
|
12
|
+
const wsgiMatch = webCommand.match(/([a-zA-Z0-9_]+)\.wsgi/);
|
|
13
|
+
if (wsgiMatch) extractedWsgi = `${wsgiMatch[1]}.wsgi`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (extractedWsgi) {
|
|
17
|
+
if (!isHeadless) log.success(`Auto-detected Django WSGI from Procfile: ${color.cyan(extractedWsgi)}`);
|
|
18
|
+
return extractedWsgi;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (isHeadless) return 'core.wsgi';
|
|
22
|
+
|
|
23
|
+
const djangoWsgi = await text({
|
|
24
|
+
message: 'What is the Python module path to your Django wsgi.py?',
|
|
25
|
+
placeholder: 'core.wsgi',
|
|
26
|
+
initialValue: 'core.wsgi',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (typeof djangoWsgi === 'symbol') process.exit(0);
|
|
30
|
+
return djangoWsgi;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function handleRailsCI(targetDir, framework, isHeadless) {
|
|
34
|
+
if (framework !== 'rails') return false;
|
|
35
|
+
|
|
36
|
+
const ciPath = path.join(targetDir, '.github', 'workflows', 'ci.yml');
|
|
37
|
+
const dependabotPath = path.join(targetDir, '.github', 'dependabot.yml');
|
|
38
|
+
|
|
39
|
+
if (fsSync.existsSync(ciPath) || fsSync.existsSync(dependabotPath)) {
|
|
40
|
+
if (isHeadless) return true;
|
|
41
|
+
|
|
42
|
+
console.log('');
|
|
43
|
+
const disable = await confirm({
|
|
44
|
+
message: color.yellow('We detected default Rails GitHub Actions (ci.yml, dependabot.yml) that usually crash in isolated CI environments without a database. Would you like deploy-stack to safely disable them by renaming them to .bak?'),
|
|
45
|
+
initialValue: true,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (typeof disable === 'symbol') {
|
|
49
|
+
cancel('Provisioning cancelled.');
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
return disable;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
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,51 @@ 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
|
+
// 4.5. Translate Vercel Rules to AWS ALB Listener Rules
|
|
93
|
+
let vercelTerraformRules = '';
|
|
94
|
+
|
|
95
|
+
if (config.VERCEL_RULES && config.VERCEL_RULES.redirects) {
|
|
96
|
+
config.VERCEL_RULES.redirects.forEach((rule, index) => {
|
|
97
|
+
// Vercel uses 'permanent: true' for 301, false for 302
|
|
98
|
+
const statusCode = rule.permanent === false ? "HTTP_302" : "HTTP_301";
|
|
99
|
+
|
|
100
|
+
// Map Vercel's source path to AWS ALB Path Pattern
|
|
101
|
+
let sourcePath = rule.source;
|
|
102
|
+
// Vercel sometimes uses regex syntax like '/blog/(.*)'. ALB uses '/blog/*'
|
|
103
|
+
sourcePath = sourcePath.replace(/\(\.\*\)/g, '*');
|
|
104
|
+
|
|
105
|
+
vercelTerraformRules += `
|
|
106
|
+
# Auto-generated from vercel.json redirect
|
|
107
|
+
resource "aws_lb_listener_rule" "vercel_redirect_${index}" {
|
|
108
|
+
listener_arn = aws_lb_listener.http.arn
|
|
109
|
+
priority = ${100 + index} # Start at 100 to avoid conflicts
|
|
110
|
+
|
|
111
|
+
action {
|
|
112
|
+
type = "redirect"
|
|
113
|
+
redirect {
|
|
114
|
+
status_code = "${statusCode}"
|
|
115
|
+
path = "${rule.destination}"
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
condition {
|
|
120
|
+
path_pattern {
|
|
121
|
+
values = ["${sourcePath}"]
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
`;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Assign the compiled HCL to the config object so the template engine can inject it
|
|
130
|
+
config.VERCEL_EDGE_ROUTING = vercelTerraformRules;
|
|
131
|
+
|
|
132
|
+
// 5. Process standard files
|
|
68
133
|
for (const file of filesToProcess) {
|
|
69
134
|
let content = await fs.readFile(path.join(templatesDir, file.src), 'utf-8');
|
|
70
135
|
|
|
@@ -76,10 +141,10 @@ export async function generateTemplates(targetDir, config) {
|
|
|
76
141
|
await fs.writeFile(path.join(targetDir, file.dest), content);
|
|
77
142
|
}
|
|
78
143
|
|
|
79
|
-
//
|
|
144
|
+
// 6. Create empty secrets file
|
|
80
145
|
await fs.writeFile(path.join(targetDir, 'terraform', 'secret_keys.json'), "[]");
|
|
81
146
|
|
|
82
|
-
//
|
|
147
|
+
// 7. Handle .gitignore dynamically based on framework
|
|
83
148
|
const targetGitignore = path.join(targetDir, '.gitignore');
|
|
84
149
|
|
|
85
150
|
if (!fsSync.existsSync(targetGitignore)) {
|
|
@@ -92,7 +157,7 @@ export async function generateTemplates(targetDir, config) {
|
|
|
92
157
|
}
|
|
93
158
|
}
|
|
94
159
|
|
|
95
|
-
//
|
|
160
|
+
// 8. Create .dockerignore to keep images lean and secure
|
|
96
161
|
const dockerignorePath = path.join(targetDir, '.dockerignore');
|
|
97
162
|
const appendDockerIgnore = '\n# Infrastructure (deploy-stack)\nterraform/\n**/.terraform/\n**/.terraform.*\n**/*.tfstate*\n.env\n';
|
|
98
163
|
|
|
@@ -146,7 +211,7 @@ Thumbs.db
|
|
|
146
211
|
return (baseIgnore + frameworkIgnore).trim();
|
|
147
212
|
}
|
|
148
213
|
|
|
149
|
-
//
|
|
214
|
+
// 9. Framework-specific cleanup
|
|
150
215
|
// Disable default workflows that crash in isolated CI environments
|
|
151
216
|
if (config.finalFramework === 'rails') {
|
|
152
217
|
const railsCiPath = path.join(targetDir, '.github', 'workflows', 'ci.yml');
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { text, select, confirm, group, cancel, log } from '@clack/prompts';
|
|
3
|
+
import color from 'picocolors';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
|
|
6
|
+
export async function getTargetDirectory(isHeadless, headlessOptions) {
|
|
7
|
+
if (isHeadless) {
|
|
8
|
+
const projectName = headlessOptions.dir || '.';
|
|
9
|
+
return {
|
|
10
|
+
projectName,
|
|
11
|
+
actualProjectName: projectName === '.' ? path.basename(process.cwd()) : projectName,
|
|
12
|
+
targetDir: projectName === '.' ? process.cwd() : path.join(process.cwd(), projectName)
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const projectName = await text({
|
|
17
|
+
message: 'Where should we generate the infrastructure? (Type "." for current directory)',
|
|
18
|
+
placeholder: '.',
|
|
19
|
+
initialValue: '.',
|
|
20
|
+
validate: (value) => {
|
|
21
|
+
if (!value) return 'Please enter a name or directory.';
|
|
22
|
+
if (value !== '.' && value.includes(' ')) return 'Name cannot contain spaces.';
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (typeof projectName === 'symbol') {
|
|
27
|
+
cancel('Operation cancelled.');
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
projectName,
|
|
33
|
+
actualProjectName: projectName === '.' ? path.basename(process.cwd()) : projectName,
|
|
34
|
+
targetDir: projectName === '.' ? process.cwd() : path.join(process.cwd(), projectName)
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function getProjectConfig(isHeadless, headlessOptions, targetDir, detectedFramework) {
|
|
39
|
+
if (isHeadless) {
|
|
40
|
+
return {
|
|
41
|
+
framework: headlessOptions.framework || (detectedFramework ? detectedFramework.id : 'static'),
|
|
42
|
+
region: headlessOptions.region || 'us-east-2',
|
|
43
|
+
port: headlessOptions.port || (headlessOptions.framework === 'static' ? '8080' : '3000'),
|
|
44
|
+
size: headlessOptions.size || 'micro',
|
|
45
|
+
healthCheckPath: headlessOptions.healthCheckPath || '/',
|
|
46
|
+
desiredCount: headlessOptions.desiredCount || '1',
|
|
47
|
+
branch: headlessOptions.branch || 'main',
|
|
48
|
+
needsDatabase: false,
|
|
49
|
+
setupType: 'headless'
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let finalFramework = detectedFramework ? detectedFramework.id : null;
|
|
54
|
+
|
|
55
|
+
if (!finalFramework) {
|
|
56
|
+
finalFramework = await select({
|
|
57
|
+
message: 'Which framework preset should we configure?',
|
|
58
|
+
options: [
|
|
59
|
+
{ value: 'node', label: 'Node.js / Express' },
|
|
60
|
+
{ value: 'nextjs', label: 'Next.js (Standalone)' },
|
|
61
|
+
{ value: 'nuxt', label: 'Nuxt 3 (SSR)' },
|
|
62
|
+
{ value: 'python', label: 'Python FastAPI' },
|
|
63
|
+
{ value: 'django', label: 'Django (Python)' },
|
|
64
|
+
{ value: 'rails', label: 'Ruby on Rails' },
|
|
65
|
+
{ value: 'go', label: 'Go (Golang)' },
|
|
66
|
+
{ value: 'static', label: 'Static Site (Gatsby, React, plain HTML via Nginx)' },
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
if (typeof finalFramework === 'symbol') process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const setupType = await select({
|
|
73
|
+
message: 'Choose your setup mode:',
|
|
74
|
+
options: [
|
|
75
|
+
{ value: 'quick', label: '⚡ Quickstart (Recommended)', hint: 'Production defaults, minimal prompts' },
|
|
76
|
+
{ value: 'advanced', label: '🛠️ Advanced Configuration', hint: 'Customize health checks, task count, branch, etc.' },
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
if (typeof setupType === 'symbol') process.exit(0);
|
|
80
|
+
|
|
81
|
+
let defaultPort = '3000';
|
|
82
|
+
if (finalFramework === 'static' || finalFramework === 'go') defaultPort = '8080';
|
|
83
|
+
if (finalFramework === 'python' || finalFramework === 'django') defaultPort = '8000';
|
|
84
|
+
|
|
85
|
+
let currentGitBranch = 'main';
|
|
86
|
+
try {
|
|
87
|
+
currentGitBranch = execSync('git symbolic-ref --short HEAD', { cwd: targetDir, stdio: 'pipe' }).toString().trim();
|
|
88
|
+
} catch (e) { }
|
|
89
|
+
|
|
90
|
+
let needsDatabase = false;
|
|
91
|
+
const isBackendFramework = ['node', 'nextjs', 'nuxt', 'python', 'django', 'rails', 'go'].includes(finalFramework);
|
|
92
|
+
|
|
93
|
+
if (isBackendFramework) {
|
|
94
|
+
const dbChoice = await confirm({
|
|
95
|
+
message: 'Do you need a managed AWS RDS PostgreSQL database? (Adds ~$14/month or uses AWS Free Tier)',
|
|
96
|
+
initialValue: false,
|
|
97
|
+
});
|
|
98
|
+
if (typeof dbChoice === 'symbol') process.exit(0);
|
|
99
|
+
needsDatabase = dbChoice;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const project = await group({
|
|
103
|
+
region: () => select({
|
|
104
|
+
message: 'Which AWS region do you want to deploy to?',
|
|
105
|
+
options: [
|
|
106
|
+
{ value: 'us-east-1', label: 'us-east-1 (N. Virginia)' },
|
|
107
|
+
{ value: 'us-east-2', label: 'us-east-2 (Ohio)' },
|
|
108
|
+
{ value: 'eu-west-1', label: 'eu-west-1 (Ireland)' },
|
|
109
|
+
{ value: 'eu-central-1', label: 'EU (Frankfurt)' },
|
|
110
|
+
{ value: 'ap-southeast-2', label: 'Asia Pacific (Sydney)' },
|
|
111
|
+
],
|
|
112
|
+
}),
|
|
113
|
+
port: () => text({
|
|
114
|
+
message: 'What port does your container expose?',
|
|
115
|
+
placeholder: defaultPort,
|
|
116
|
+
defaultValue: defaultPort,
|
|
117
|
+
}),
|
|
118
|
+
size: () => select({
|
|
119
|
+
message: 'Select your Fargate compute size:',
|
|
120
|
+
options: [
|
|
121
|
+
{ value: 'micro', label: 'Micro (0.25 vCPU, 512MB RAM) - Best for POCs' },
|
|
122
|
+
{ value: 'small', label: 'Small (0.5 vCPU, 1GB RAM) - Best for small Projects' },
|
|
123
|
+
],
|
|
124
|
+
}),
|
|
125
|
+
healthCheckPath: () => setupType === 'quick' ? undefined : text({
|
|
126
|
+
message: 'ALB Health Check Path:',
|
|
127
|
+
placeholder: '/',
|
|
128
|
+
defaultValue: '/',
|
|
129
|
+
}),
|
|
130
|
+
desiredCount: () => setupType === 'quick' ? undefined : select({
|
|
131
|
+
message: 'How many container replicas (tasks) should run?',
|
|
132
|
+
options: [
|
|
133
|
+
{ value: '1', label: '1 Task (Single instance - lowest cost)' },
|
|
134
|
+
{ value: '2', label: '2 Tasks (High Availability across AZs)' },
|
|
135
|
+
],
|
|
136
|
+
defaultValue: '1',
|
|
137
|
+
}),
|
|
138
|
+
branch: () => setupType === 'quick' ? undefined : text({
|
|
139
|
+
message: 'Primary Git deployment branch for CI/CD:',
|
|
140
|
+
placeholder: currentGitBranch,
|
|
141
|
+
defaultValue: currentGitBranch,
|
|
142
|
+
}),
|
|
143
|
+
}, { onCancel: () => process.exit(0) });
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
framework: finalFramework,
|
|
147
|
+
region: project.region,
|
|
148
|
+
port: project.port,
|
|
149
|
+
size: project.size,
|
|
150
|
+
healthCheckPath: project.healthCheckPath || '/',
|
|
151
|
+
desiredCount: project.desiredCount || '1',
|
|
152
|
+
branch: project.branch || currentGitBranch,
|
|
153
|
+
needsDatabase,
|
|
154
|
+
setupType
|
|
155
|
+
};
|
|
156
|
+
}
|
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
|