specky-sdd 3.5.0 → 3.7.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/.apm/hooks/scripts/ears-validator.sh +56 -17
- package/.apm/prompts/specky-api.prompt.md +1 -1
- package/.apm/prompts/specky-research.prompt.md +2 -2
- package/.apm/skills/research-analyst/SKILL.md +2 -2
- package/.apm/skills/specky-onboarding/SKILL.md +6 -4
- package/CHANGELOG.md +52 -0
- package/README.md +44 -8
- package/SECURITY.md +19 -9
- package/apm.yml +1 -1
- package/config.yml +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/doctor.js +5 -0
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +17 -2
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/index.js +32 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/lib/update-check.d.ts +36 -0
- package/dist/cli/lib/update-check.d.ts.map +1 -0
- package/dist/cli/lib/update-check.js +165 -0
- package/dist/cli/lib/update-check.js.map +1 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +14 -0
- package/dist/config.js.map +1 -1
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -1
- package/dist/services/diagram-generator.d.ts +53 -4
- package/dist/services/diagram-generator.d.ts.map +1 -1
- package/dist/services/diagram-generator.js +565 -178
- package/dist/services/diagram-generator.js.map +1 -1
- package/dist/services/doc-generator.d.ts +44 -0
- package/dist/services/doc-generator.d.ts.map +1 -1
- package/dist/services/doc-generator.js +352 -50
- package/dist/services/doc-generator.js.map +1 -1
- package/dist/services/document-converter.d.ts +10 -7
- package/dist/services/document-converter.d.ts.map +1 -1
- package/dist/services/document-converter.js +113 -38
- package/dist/services/document-converter.js.map +1 -1
- package/dist/services/iac-generator.d.ts +31 -3
- package/dist/services/iac-generator.d.ts.map +1 -1
- package/dist/services/iac-generator.js +509 -41
- package/dist/services/iac-generator.js.map +1 -1
- package/dist/services/pbt-generator.d.ts +12 -0
- package/dist/services/pbt-generator.d.ts.map +1 -1
- package/dist/services/pbt-generator.js +296 -145
- package/dist/services/pbt-generator.js.map +1 -1
- package/dist/services/state-machine.d.ts +15 -1
- package/dist/services/state-machine.d.ts.map +1 -1
- package/dist/services/state-machine.js +21 -3
- package/dist/services/state-machine.js.map +1 -1
- package/dist/services/test-generator.d.ts +8 -0
- package/dist/services/test-generator.d.ts.map +1 -1
- package/dist/services/test-generator.js +134 -33
- package/dist/services/test-generator.js.map +1 -1
- package/dist/services/test-traceability-mapper.d.ts.map +1 -1
- package/dist/services/test-traceability-mapper.js +8 -3
- package/dist/services/test-traceability-mapper.js.map +1 -1
- package/dist/services/work-item-exporter.d.ts +70 -9
- package/dist/services/work-item-exporter.d.ts.map +1 -1
- package/dist/services/work-item-exporter.js +115 -53
- package/dist/services/work-item-exporter.js.map +1 -1
- package/dist/templates/checklist.md +2 -3
- package/dist/templates/cross-analysis.md +2 -4
- package/dist/tools/documentation.d.ts.map +1 -1
- package/dist/tools/documentation.js +9 -3
- package/dist/tools/documentation.js.map +1 -1
- package/dist/tools/environment.d.ts.map +1 -1
- package/dist/tools/environment.js +75 -12
- package/dist/tools/environment.js.map +1 -1
- package/dist/tools/infrastructure.d.ts.map +1 -1
- package/dist/tools/infrastructure.js +7 -39
- package/dist/tools/infrastructure.js.map +1 -1
- package/dist/tools/input.js +4 -4
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/integration.js +4 -4
- package/dist/tools/integration.js.map +1 -1
- package/dist/tools/pipeline.d.ts.map +1 -1
- package/dist/tools/pipeline.js +42 -4
- package/dist/tools/pipeline.js.map +1 -1
- package/dist/tools/quality.d.ts.map +1 -1
- package/dist/tools/quality.js +36 -9
- package/dist/tools/quality.js.map +1 -1
- package/dist/tools/testing.d.ts +10 -0
- package/dist/tools/testing.d.ts.map +1 -1
- package/dist/tools/testing.js +66 -13
- package/dist/tools/testing.js.map +1 -1
- package/dist/tools/transcript.d.ts.map +1 -1
- package/dist/tools/transcript.js +66 -41
- package/dist/tools/transcript.js.map +1 -1
- package/dist/tools/utility.d.ts.map +1 -1
- package/dist/tools/utility.js +22 -12
- package/dist/tools/utility.js.map +1 -1
- package/dist/tools/visualization.d.ts +21 -0
- package/dist/tools/visualization.d.ts.map +1 -1
- package/dist/tools/visualization.js +130 -29
- package/dist/tools/visualization.js.map +1 -1
- package/package.json +5 -2
- package/templates/checklist.md +2 -3
- package/templates/cross-analysis.md +2 -4
|
@@ -1,3 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect the tech stack named in DESIGN.md prose. Returns undefined when the
|
|
3
|
+
* design names no recognizable language/runtime so callers can pick their own
|
|
4
|
+
* fallback (codebase scan result, generic image, TypeScript default, ...).
|
|
5
|
+
*/
|
|
6
|
+
export function detectTechStackFromDesign(designContent) {
|
|
7
|
+
const lower = designContent.toLowerCase();
|
|
8
|
+
if (!lower.trim())
|
|
9
|
+
return undefined;
|
|
10
|
+
if (/\bpython\b/.test(lower)) {
|
|
11
|
+
const framework = /\bdjango\b/.test(lower)
|
|
12
|
+
? "Django"
|
|
13
|
+
: /\bfastapi\b/.test(lower)
|
|
14
|
+
? "FastAPI"
|
|
15
|
+
: /\bflask\b/.test(lower)
|
|
16
|
+
? "Flask"
|
|
17
|
+
: undefined;
|
|
18
|
+
return { language: "Python", framework, runtime: "python3.12" };
|
|
19
|
+
}
|
|
20
|
+
const namesJsRuntime = /\btypescript\b|\bjavascript\b|\bnode(\.js)?\b|\bdeno\b|\bbun\b|\bexpress\b|\bfastify\b|\bnext\.?js\b|\breact\b|\bnpm\b/.test(lower);
|
|
21
|
+
// Bare "go" is common English prose; only treat it as Go when no other
|
|
22
|
+
// language is named (or when "golang" is explicit).
|
|
23
|
+
if (/\bgolang\b/.test(lower) || (/\bgo\b/.test(lower) && !namesJsRuntime && !/\bjava\b/.test(lower))) {
|
|
24
|
+
const framework = /\bgin\b/.test(lower) ? "Gin" : /\becho\b/.test(lower) ? "Echo" : undefined;
|
|
25
|
+
return { language: "Go", framework, runtime: "go1.22" };
|
|
26
|
+
}
|
|
27
|
+
if (/\bjava\b/.test(lower) && !/\bjavascript\b/.test(lower)) {
|
|
28
|
+
const framework = /\bspring\b/.test(lower) ? "Spring Boot" : undefined;
|
|
29
|
+
return { language: "Java", framework, runtime: "java21" };
|
|
30
|
+
}
|
|
31
|
+
if (namesJsRuntime) {
|
|
32
|
+
const framework = /\bnext\.?js\b/.test(lower)
|
|
33
|
+
? "Next.js"
|
|
34
|
+
: /\bexpress\b/.test(lower)
|
|
35
|
+
? "Express"
|
|
36
|
+
: /\bfastify\b/.test(lower)
|
|
37
|
+
? "Fastify"
|
|
38
|
+
: undefined;
|
|
39
|
+
return { language: "TypeScript", framework, runtime: "node22" };
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Detect docker-compose sidecar services from DESIGN.md prose and package.json
|
|
45
|
+
* dependencies (dependencies + devDependencies). Deterministic keyword scan.
|
|
46
|
+
*/
|
|
47
|
+
export function detectServicesFromDesign(designContent, packageJsonContent) {
|
|
48
|
+
const lower = designContent.toLowerCase();
|
|
49
|
+
let deps = {};
|
|
50
|
+
if (packageJsonContent) {
|
|
51
|
+
try {
|
|
52
|
+
const pkg = JSON.parse(packageJsonContent);
|
|
53
|
+
deps = {
|
|
54
|
+
...pkg["dependencies"],
|
|
55
|
+
...pkg["devDependencies"],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Unparseable package.json — fall back to design keywords only
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const hasDep = (name) => name in deps;
|
|
63
|
+
const services = [];
|
|
64
|
+
if (/\bpostgres(ql)?\b/.test(lower) || hasDep("pg") || hasDep("postgres") || hasDep("pg-promise"))
|
|
65
|
+
services.push("postgres");
|
|
66
|
+
if (/\b(mysql|mariadb)\b/.test(lower) || hasDep("mysql") || hasDep("mysql2") || hasDep("mariadb"))
|
|
67
|
+
services.push("mysql");
|
|
68
|
+
if (/\bmongo(db)?\b/.test(lower) || hasDep("mongodb") || hasDep("mongoose"))
|
|
69
|
+
services.push("mongodb");
|
|
70
|
+
if (/\bredis\b/.test(lower) || hasDep("redis") || hasDep("ioredis"))
|
|
71
|
+
services.push("redis");
|
|
72
|
+
if (/\b(rabbitmq|amqp)\b/.test(lower) || hasDep("amqplib"))
|
|
73
|
+
services.push("rabbitmq");
|
|
74
|
+
if (/\bkafka\b/.test(lower) || hasDep("kafkajs"))
|
|
75
|
+
services.push("kafka");
|
|
76
|
+
return services;
|
|
77
|
+
}
|
|
78
|
+
const DEFAULT_SERVICE_FOR_MODULE = {
|
|
79
|
+
networking: "network",
|
|
80
|
+
compute: "container",
|
|
81
|
+
database: "postgres",
|
|
82
|
+
cache: "redis",
|
|
83
|
+
storage: "object",
|
|
84
|
+
messaging: "queue",
|
|
85
|
+
identity: "identity",
|
|
86
|
+
monitoring: "logs",
|
|
87
|
+
};
|
|
88
|
+
/** Compose sidecar templates for the services detectServicesFromDesign can emit. */
|
|
89
|
+
const COMPOSE_SERVICES = {
|
|
90
|
+
postgres: {
|
|
91
|
+
image: "postgres:16-alpine",
|
|
92
|
+
ports: ["5432:5432"],
|
|
93
|
+
environment: ["POSTGRES_USER=app", "POSTGRES_PASSWORD=app", "POSTGRES_DB=app"],
|
|
94
|
+
volume: { name: "postgres-data", mountPath: "/var/lib/postgresql/data" },
|
|
95
|
+
},
|
|
96
|
+
mysql: {
|
|
97
|
+
image: "mysql:8",
|
|
98
|
+
ports: ["3306:3306"],
|
|
99
|
+
environment: ["MYSQL_ROOT_PASSWORD=app", "MYSQL_DATABASE=app"],
|
|
100
|
+
volume: { name: "mysql-data", mountPath: "/var/lib/mysql" },
|
|
101
|
+
},
|
|
102
|
+
mongodb: { image: "mongo:7", ports: ["27017:27017"], volume: { name: "mongo-data", mountPath: "/data/db" } },
|
|
103
|
+
redis: { image: "redis:7-alpine", ports: ["6379:6379"] },
|
|
104
|
+
rabbitmq: { image: "rabbitmq:3-management-alpine", ports: ["5672:5672", "15672:15672"] },
|
|
105
|
+
kafka: { image: "apache/kafka:3.7.0", ports: ["9092:9092"] },
|
|
106
|
+
};
|
|
1
107
|
export class IacGenerator {
|
|
2
108
|
fileManager;
|
|
3
109
|
constructor(fileManager) {
|
|
@@ -9,26 +115,24 @@ export class IacGenerator {
|
|
|
9
115
|
designContent = await this.fileManager.readSpecFile(featureDir, "DESIGN.md");
|
|
10
116
|
}
|
|
11
117
|
catch { /* empty */ }
|
|
12
|
-
const
|
|
118
|
+
const explicitModules = Boolean(modules && modules.length > 0);
|
|
119
|
+
const components = this.detectComponents(designContent, modules);
|
|
13
120
|
const files = [];
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
// variables.tf
|
|
18
|
-
const vars = this.generateVariables(cloud, detectedModules);
|
|
19
|
-
variables.push(...vars);
|
|
121
|
+
const { mainTf, outputs } = this.renderCloud(cloud, components);
|
|
122
|
+
files.push({ path: "terraform/main.tf", content: mainTf, description: "Main Terraform configuration" });
|
|
123
|
+
const vars = this.generateVariables(cloud, components);
|
|
20
124
|
files.push({ path: "terraform/variables.tf", content: this.generateVariablesTf(vars), description: "Terraform variable definitions" });
|
|
21
|
-
|
|
22
|
-
files.push({ path: "terraform/outputs.tf", content: this.generateOutputsTf(detectedModules), description: "Terraform output definitions" });
|
|
23
|
-
// terraform.tfvars.example
|
|
125
|
+
files.push({ path: "terraform/outputs.tf", content: this.generateOutputsTf(outputs), description: "Terraform output definitions" });
|
|
24
126
|
files.push({ path: "terraform/terraform.tfvars.example", content: vars.map(v => `${v.name} = ${v.default || `"<${v.name}>"`}`).join("\n"), description: "Example variable values" });
|
|
25
|
-
const diagram = `flowchart TD\n TF[Terraform
|
|
127
|
+
const diagram = `flowchart TD\n TF[Terraform: ${cloud}]\n${components.map((c, i) => ` TF --> M${i}[${c.module}: ${c.service}]`).join("\n")}`;
|
|
128
|
+
const resourceCount = (mainTf.match(/^resource "/gm) || []).length;
|
|
129
|
+
const componentList = components.map(c => `${c.module} (${c.service})`).join(", ");
|
|
26
130
|
return {
|
|
27
131
|
provider: "terraform",
|
|
28
132
|
files,
|
|
29
133
|
variables: vars,
|
|
30
|
-
explanation: `Generated Terraform for ${cloud} with ${
|
|
31
|
-
next_steps: "
|
|
134
|
+
explanation: `Generated Terraform for ${cloud} with ${resourceCount} resource blocks across ${components.length} modules: ${componentList}. ${explicitModules ? "Modules were requested explicitly." : "Components detected from DESIGN.md."}`,
|
|
135
|
+
next_steps: "Set required variables (see terraform.tfvars.example), then run sdd_validate_iac to validate the configuration via Terraform MCP.",
|
|
32
136
|
diagram,
|
|
33
137
|
};
|
|
34
138
|
}
|
|
@@ -43,16 +147,18 @@ export class IacGenerator {
|
|
|
43
147
|
explanation: `Validation payload ready for ${provider} on ${cloud}. Route to ${routing.mcp_server} MCP.`,
|
|
44
148
|
};
|
|
45
149
|
}
|
|
46
|
-
generateDockerfile(techStack, includeCompose, multiStage) {
|
|
150
|
+
generateDockerfile(techStack, includeCompose, multiStage, services = []) {
|
|
47
151
|
const files = [];
|
|
48
152
|
const lang = techStack.language.toLowerCase();
|
|
153
|
+
const isNode = lang.includes("typescript") || lang.includes("javascript");
|
|
154
|
+
const isPython = lang.includes("python");
|
|
49
155
|
let dockerfile = "";
|
|
50
|
-
if (
|
|
156
|
+
if (isNode) {
|
|
51
157
|
dockerfile = multiStage
|
|
52
158
|
? `FROM node:22-slim AS builder\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci\nCOPY . .\nRUN npm run build\n\nFROM node:22-slim\nWORKDIR /app\nCOPY --from=builder /app/dist ./dist\nCOPY --from=builder /app/node_modules ./node_modules\nCOPY --from=builder /app/package.json ./\nEXPOSE 3000\nCMD ["node", "dist/index.js"]`
|
|
53
159
|
: `FROM node:22-slim\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci\nCOPY . .\nRUN npm run build\nEXPOSE 3000\nCMD ["node", "dist/index.js"]`;
|
|
54
160
|
}
|
|
55
|
-
else if (
|
|
161
|
+
else if (isPython) {
|
|
56
162
|
dockerfile = multiStage
|
|
57
163
|
? `FROM python:3.12-slim AS builder\nWORKDIR /app\nCOPY requirements.txt .\nRUN pip install --no-cache-dir -r requirements.txt\nCOPY . .\n\nFROM python:3.12-slim\nWORKDIR /app\nCOPY --from=builder /app .\nEXPOSE 8000\nCMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0"]`
|
|
58
164
|
: `FROM python:3.12-slim\nWORKDIR /app\nCOPY requirements.txt .\nRUN pip install --no-cache-dir -r requirements.txt\nCOPY . .\nEXPOSE 8000\nCMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0"]`;
|
|
@@ -60,16 +166,19 @@ export class IacGenerator {
|
|
|
60
166
|
else {
|
|
61
167
|
dockerfile = `FROM ubuntu:22.04\nWORKDIR /app\nCOPY . .\nEXPOSE 3000\nCMD ["./start.sh"]`;
|
|
62
168
|
}
|
|
169
|
+
// The generic ubuntu fallback is single-stage regardless of the flag —
|
|
170
|
+
// report what was actually generated, not what was requested.
|
|
171
|
+
const multiStageUsed = multiStage && (isNode || isPython);
|
|
63
172
|
files.push({ path: "Dockerfile", content: dockerfile, description: "Application Dockerfile" });
|
|
64
173
|
if (includeCompose) {
|
|
65
|
-
|
|
66
|
-
files.push({ path: "docker-compose.yml", content: compose, description: "Docker Compose configuration" });
|
|
174
|
+
files.push({ path: "docker-compose.yml", content: this.generateCompose(services), description: "Docker Compose configuration" });
|
|
67
175
|
}
|
|
68
176
|
files.push({ path: ".dockerignore", content: "node_modules\ndist\n.git\n*.md\n.specs\n.env*\ncoverage", description: "Docker ignore file" });
|
|
177
|
+
const serviceNote = includeCompose && services.length > 0 ? ` (services: app, ${services.join(", ")})` : "";
|
|
69
178
|
return {
|
|
70
179
|
type: "docker",
|
|
71
180
|
files,
|
|
72
|
-
explanation: `Generated ${
|
|
181
|
+
explanation: `Generated ${multiStageUsed ? "multi-stage " : ""}Dockerfile for ${techStack.language}${techStack.framework ? ` (${techStack.framework})` : ""}${includeCompose ? ` with docker-compose.yml${serviceNote}` : ""}.`,
|
|
73
182
|
next_steps: "Run docker build to test the image, or use sdd_setup_local_env to create a full dev environment.",
|
|
74
183
|
};
|
|
75
184
|
}
|
|
@@ -78,17 +187,21 @@ export class IacGenerator {
|
|
|
78
187
|
let image = "mcr.microsoft.com/devcontainers/base:ubuntu";
|
|
79
188
|
const devFeatures = {};
|
|
80
189
|
const devExtensions = extensions || [];
|
|
190
|
+
let postCreateCommand;
|
|
81
191
|
if (lang.includes("typescript") || lang.includes("javascript")) {
|
|
82
192
|
image = "mcr.microsoft.com/devcontainers/typescript-node:22";
|
|
83
193
|
devExtensions.push("dbaeumer.vscode-eslint", "esbenp.prettier-vscode");
|
|
194
|
+
postCreateCommand = "npm install";
|
|
84
195
|
}
|
|
85
196
|
else if (lang.includes("python")) {
|
|
86
197
|
image = "mcr.microsoft.com/devcontainers/python:3.12";
|
|
87
198
|
devExtensions.push("ms-python.python", "ms-python.vscode-pylance");
|
|
199
|
+
postCreateCommand = "pip install -r requirements.txt";
|
|
88
200
|
}
|
|
89
201
|
else if (lang.includes("go")) {
|
|
90
202
|
image = "mcr.microsoft.com/devcontainers/go:1.22";
|
|
91
203
|
devExtensions.push("golang.go");
|
|
204
|
+
postCreateCommand = "go mod download";
|
|
92
205
|
}
|
|
93
206
|
if (features) {
|
|
94
207
|
for (const f of features)
|
|
@@ -100,8 +213,11 @@ export class IacGenerator {
|
|
|
100
213
|
features: devFeatures,
|
|
101
214
|
customizations: { vscode: { extensions: devExtensions } },
|
|
102
215
|
forwardPorts: [3000],
|
|
103
|
-
postCreateCommand: lang.includes("python") ? "pip install -r requirements.txt" : "npm install",
|
|
104
216
|
};
|
|
217
|
+
// Only known stacks get a package-manager bootstrap; the generic ubuntu
|
|
218
|
+
// image must not hardcode "npm install".
|
|
219
|
+
if (postCreateCommand)
|
|
220
|
+
config.postCreateCommand = postCreateCommand;
|
|
105
221
|
return {
|
|
106
222
|
type: "devcontainer",
|
|
107
223
|
files: [{ path: ".devcontainer/devcontainer.json", content: JSON.stringify(config, null, 2), description: "Dev container configuration" }],
|
|
@@ -110,28 +226,354 @@ export class IacGenerator {
|
|
|
110
226
|
};
|
|
111
227
|
}
|
|
112
228
|
// ─── Private helpers ───
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
229
|
+
generateCompose(services) {
|
|
230
|
+
// Sanitize service names — they end up as YAML keys.
|
|
231
|
+
const names = services.map(s => s.toLowerCase().replace(/[^a-z0-9_-]/g, "-")).filter(s => s.length > 0);
|
|
232
|
+
const lines = [
|
|
233
|
+
'version: "3.8"',
|
|
234
|
+
"services:",
|
|
235
|
+
" app:",
|
|
236
|
+
" build: .",
|
|
237
|
+
" ports:",
|
|
238
|
+
' - "3000:3000"',
|
|
239
|
+
" environment:",
|
|
240
|
+
" - NODE_ENV=production",
|
|
241
|
+
];
|
|
242
|
+
if (names.length > 0) {
|
|
243
|
+
lines.push(" depends_on:");
|
|
244
|
+
for (const name of names)
|
|
245
|
+
lines.push(` - ${name}`);
|
|
246
|
+
}
|
|
247
|
+
lines.push(" restart: unless-stopped");
|
|
248
|
+
const volumes = [];
|
|
249
|
+
for (const name of names) {
|
|
250
|
+
const svc = COMPOSE_SERVICES[name] ?? { image: `${name}:latest`, ports: [] };
|
|
251
|
+
lines.push(` ${name}:`);
|
|
252
|
+
lines.push(` image: ${svc.image}`);
|
|
253
|
+
if (svc.environment && svc.environment.length > 0) {
|
|
254
|
+
lines.push(" environment:");
|
|
255
|
+
for (const e of svc.environment)
|
|
256
|
+
lines.push(` - ${e}`);
|
|
257
|
+
}
|
|
258
|
+
if (svc.ports.length > 0) {
|
|
259
|
+
lines.push(" ports:");
|
|
260
|
+
for (const p of svc.ports)
|
|
261
|
+
lines.push(` - "${p}"`);
|
|
262
|
+
}
|
|
263
|
+
if (svc.volume) {
|
|
264
|
+
lines.push(" volumes:");
|
|
265
|
+
lines.push(` - ${svc.volume.name}:${svc.volume.mountPath}`);
|
|
266
|
+
volumes.push(svc.volume.name);
|
|
267
|
+
}
|
|
268
|
+
lines.push(" restart: unless-stopped");
|
|
269
|
+
}
|
|
270
|
+
if (volumes.length > 0) {
|
|
271
|
+
lines.push("volumes:");
|
|
272
|
+
for (const v of volumes)
|
|
273
|
+
lines.push(` ${v}:`);
|
|
274
|
+
}
|
|
275
|
+
return lines.join("\n");
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Detect concrete infrastructure components from DESIGN.md. Each component
|
|
279
|
+
* carries its module/category AND the specific service flavor named in the
|
|
280
|
+
* design (postgres vs mysql, container vs kubernetes, ...), so the emitted
|
|
281
|
+
* Terraform contains real resource blocks for what the design describes.
|
|
282
|
+
*/
|
|
283
|
+
detectComponents(designContent, requestedModules) {
|
|
284
|
+
const lower = designContent.toLowerCase();
|
|
285
|
+
const detected = [];
|
|
286
|
+
const add = (module, service) => {
|
|
287
|
+
if (!detected.some(c => c.module === module && c.service === service))
|
|
288
|
+
detected.push({ module, service });
|
|
122
289
|
};
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
290
|
+
if (/\b(vpc|vnet|virtual network|subnet|load balancer|application gateway|front door|firewall)\b/.test(lower))
|
|
291
|
+
add("networking", "network");
|
|
292
|
+
if (/\b(kubernetes|k8s|aks|eks|gke)\b/.test(lower))
|
|
293
|
+
add("compute", "kubernetes");
|
|
294
|
+
else if (/\b(lambda|serverless|azure functions?|cloud functions?)\b/.test(lower))
|
|
295
|
+
add("compute", "serverless");
|
|
296
|
+
else if (/\b(container apps?|containers?|docker|ecs|fargate|cloud run|app service)\b/.test(lower))
|
|
297
|
+
add("compute", "container");
|
|
298
|
+
if (/\bpostgres(ql)?\b/.test(lower))
|
|
299
|
+
add("database", "postgres");
|
|
300
|
+
if (/\b(mysql|mariadb)\b/.test(lower))
|
|
301
|
+
add("database", "mysql");
|
|
302
|
+
if (/\b(cosmos ?db|mongodb|mongo|dynamodb|firestore|documentdb)\b/.test(lower))
|
|
303
|
+
add("database", "nosql");
|
|
304
|
+
if (/\b(sql server|azure sql|mssql)\b/.test(lower))
|
|
305
|
+
add("database", "sqlserver");
|
|
306
|
+
if (/\b(redis|memcached|elasticache|cache)\b/.test(lower))
|
|
307
|
+
add("cache", "redis");
|
|
308
|
+
if (/\b(blob storage|s3|bucket|object storage|storage account|file share|storage)\b/.test(lower))
|
|
309
|
+
add("storage", "object");
|
|
310
|
+
if (/\b(service bus|sqs|sns|pub\/?sub|rabbitmq|kafka|event hubs?|message queue|queue)\b/.test(lower))
|
|
311
|
+
add("messaging", "queue");
|
|
312
|
+
if (/\b(managed identity|iam|rbac|entra|active directory|service account)\b/.test(lower))
|
|
313
|
+
add("identity", "identity");
|
|
314
|
+
if (/\b(monitoring|log analytics|application insights|cloudwatch|observability|alerting|metrics)\b/.test(lower))
|
|
315
|
+
add("monitoring", "logs");
|
|
316
|
+
// Explicit module list: honor it, refining each module with the flavor
|
|
317
|
+
// detected in the design (requested "database" + design says mysql → mysql).
|
|
318
|
+
if (requestedModules && requestedModules.length > 0) {
|
|
319
|
+
const result = [];
|
|
320
|
+
for (const m of requestedModules) {
|
|
321
|
+
const hits = detected.filter(c => c.module === m);
|
|
322
|
+
if (hits.length > 0)
|
|
323
|
+
result.push(...hits);
|
|
324
|
+
else
|
|
325
|
+
result.push({ module: m, service: DEFAULT_SERVICE_FOR_MODULE[m] ?? m });
|
|
326
|
+
}
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
return detected.length > 0 ? detected : [{ module: "compute", service: "container" }, { module: "networking", service: "network" }];
|
|
330
|
+
}
|
|
331
|
+
renderCloud(cloud, components) {
|
|
332
|
+
const providerName = cloud === "azure" ? "azurerm" : cloud === "aws" ? "aws" : "google";
|
|
333
|
+
const providerSource = cloud === "azure" ? "hashicorp/azurerm" : cloud === "aws" ? "hashicorp/aws" : "hashicorp/google";
|
|
334
|
+
const providerBlock = cloud === "azure"
|
|
335
|
+
? 'provider "azurerm" {\n features {}\n}'
|
|
336
|
+
: cloud === "aws"
|
|
337
|
+
? 'provider "aws" {\n region = var.region\n}'
|
|
338
|
+
: 'provider "google" {\n project = var.project_id\n region = var.region\n}';
|
|
339
|
+
const blocks = [];
|
|
340
|
+
const outputs = [];
|
|
341
|
+
if (cloud === "azure") {
|
|
342
|
+
blocks.push(`resource "azurerm_resource_group" "main" {\n name = "\${var.project_name}-\${var.environment}-rg"\n location = var.location\n}`);
|
|
343
|
+
outputs.push({ name: "resource_group_name", description: "Resource group name", value: "azurerm_resource_group.main.name" });
|
|
344
|
+
}
|
|
345
|
+
for (const component of components) {
|
|
346
|
+
const rendered = this.componentResources(cloud, component);
|
|
347
|
+
if (rendered) {
|
|
348
|
+
blocks.push(`# ── Module: ${component.module} (${component.service}) ──\n\n${rendered.hcl}`);
|
|
349
|
+
outputs.push(...rendered.outputs);
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
blocks.push(`# ── Module: ${component.module} ──\n# No ${cloud} template exists for module "${component.module}" — define its resources in a dedicated .tf file.`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const mainTf = `terraform {\n required_version = ">= 1.5"\n required_providers {\n ${providerName} = {\n source = "${providerSource}"\n }\n }\n}\n\n${providerBlock}\n\n${blocks.join("\n\n")}`;
|
|
356
|
+
return { mainTf, outputs };
|
|
357
|
+
}
|
|
358
|
+
componentResources(cloud, c) {
|
|
359
|
+
const key = `${c.module}:${c.service}`;
|
|
360
|
+
if (cloud === "azure")
|
|
361
|
+
return this.azureComponent(key);
|
|
362
|
+
if (cloud === "aws")
|
|
363
|
+
return this.awsComponent(key);
|
|
364
|
+
return this.gcpComponent(key);
|
|
365
|
+
}
|
|
366
|
+
azureComponent(key) {
|
|
367
|
+
switch (key) {
|
|
368
|
+
case "networking:network":
|
|
369
|
+
return {
|
|
370
|
+
hcl: `resource "azurerm_virtual_network" "network" {\n name = "\${var.project_name}-\${var.environment}-vnet"\n address_space = ["10.0.0.0/16"]\n location = azurerm_resource_group.main.location\n resource_group_name = azurerm_resource_group.main.name\n}\n\nresource "azurerm_subnet" "app" {\n name = "app"\n resource_group_name = azurerm_resource_group.main.name\n virtual_network_name = azurerm_virtual_network.network.name\n address_prefixes = ["10.0.1.0/24"]\n}`,
|
|
371
|
+
outputs: [{ name: "network_id", description: "Virtual network ID", value: "azurerm_virtual_network.network.id" }],
|
|
372
|
+
};
|
|
373
|
+
case "compute:container":
|
|
374
|
+
return {
|
|
375
|
+
hcl: `resource "azurerm_container_app_environment" "main" {\n name = "\${var.project_name}-\${var.environment}-cae"\n location = azurerm_resource_group.main.location\n resource_group_name = azurerm_resource_group.main.name\n}\n\nresource "azurerm_container_app" "app" {\n name = "\${var.project_name}-\${var.environment}-app"\n container_app_environment_id = azurerm_container_app_environment.main.id\n resource_group_name = azurerm_resource_group.main.name\n revision_mode = "Single"\n\n template {\n container {\n name = var.project_name\n image = var.container_image\n cpu = 0.25\n memory = "0.5Gi"\n }\n }\n\n ingress {\n external_enabled = true\n target_port = var.container_port\n }\n}`,
|
|
376
|
+
outputs: [{ name: "app_fqdn", description: "Container app FQDN", value: "azurerm_container_app.app.latest_revision_fqdn" }],
|
|
377
|
+
};
|
|
378
|
+
case "compute:kubernetes":
|
|
379
|
+
return {
|
|
380
|
+
hcl: `resource "azurerm_kubernetes_cluster" "k8s" {\n name = "\${var.project_name}-\${var.environment}-aks"\n location = azurerm_resource_group.main.location\n resource_group_name = azurerm_resource_group.main.name\n dns_prefix = var.project_name\n\n default_node_pool {\n name = "default"\n node_count = 1\n vm_size = "Standard_B2s"\n }\n\n identity {\n type = "SystemAssigned"\n }\n}`,
|
|
381
|
+
outputs: [{ name: "kubernetes_cluster_name", description: "AKS cluster name", value: "azurerm_kubernetes_cluster.k8s.name" }],
|
|
382
|
+
};
|
|
383
|
+
case "compute:serverless":
|
|
384
|
+
return {
|
|
385
|
+
hcl: `resource "azurerm_storage_account" "function" {\n name = replace("\${var.project_name}\${var.environment}fn", "-", "")\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n account_tier = "Standard"\n account_replication_type = "LRS"\n}\n\nresource "azurerm_service_plan" "function" {\n name = "\${var.project_name}-\${var.environment}-plan"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n os_type = "Linux"\n sku_name = "Y1"\n}\n\nresource "azurerm_linux_function_app" "app" {\n name = "\${var.project_name}-\${var.environment}-func"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n service_plan_id = azurerm_service_plan.function.id\n storage_account_name = azurerm_storage_account.function.name\n storage_account_access_key = azurerm_storage_account.function.primary_access_key\n\n site_config {}\n}`,
|
|
386
|
+
outputs: [{ name: "function_app_name", description: "Function app name", value: "azurerm_linux_function_app.app.name" }],
|
|
387
|
+
};
|
|
388
|
+
case "database:postgres":
|
|
389
|
+
return {
|
|
390
|
+
hcl: `resource "azurerm_postgresql_flexible_server" "postgres" {\n name = "\${var.project_name}-\${var.environment}-pg"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n version = "16"\n administrator_login = var.db_admin_username\n administrator_password = var.db_admin_password\n storage_mb = 32768\n sku_name = "B_Standard_B1ms"\n}\n\nresource "azurerm_postgresql_flexible_server_database" "app" {\n name = var.project_name\n server_id = azurerm_postgresql_flexible_server.postgres.id\n}`,
|
|
391
|
+
outputs: [{ name: "postgres_fqdn", description: "PostgreSQL server FQDN", value: "azurerm_postgresql_flexible_server.postgres.fqdn" }],
|
|
392
|
+
};
|
|
393
|
+
case "database:mysql":
|
|
394
|
+
return {
|
|
395
|
+
hcl: `resource "azurerm_mysql_flexible_server" "mysql" {\n name = "\${var.project_name}-\${var.environment}-mysql"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n administrator_login = var.db_admin_username\n administrator_password = var.db_admin_password\n sku_name = "B_Standard_B1ms"\n}`,
|
|
396
|
+
outputs: [{ name: "mysql_fqdn", description: "MySQL server FQDN", value: "azurerm_mysql_flexible_server.mysql.fqdn" }],
|
|
397
|
+
};
|
|
398
|
+
case "database:nosql":
|
|
399
|
+
return {
|
|
400
|
+
hcl: `resource "azurerm_cosmosdb_account" "nosql" {\n name = "\${var.project_name}-\${var.environment}-cosmos"\n location = azurerm_resource_group.main.location\n resource_group_name = azurerm_resource_group.main.name\n offer_type = "Standard"\n kind = "GlobalDocumentDB"\n\n consistency_policy {\n consistency_level = "Session"\n }\n\n geo_location {\n location = azurerm_resource_group.main.location\n failover_priority = 0\n }\n}`,
|
|
401
|
+
outputs: [{ name: "nosql_endpoint", description: "Cosmos DB endpoint", value: "azurerm_cosmosdb_account.nosql.endpoint" }],
|
|
402
|
+
};
|
|
403
|
+
case "database:sqlserver":
|
|
404
|
+
return {
|
|
405
|
+
hcl: `resource "azurerm_mssql_server" "mssql" {\n name = "\${var.project_name}-\${var.environment}-sql"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n version = "12.0"\n administrator_login = var.db_admin_username\n administrator_login_password = var.db_admin_password\n}\n\nresource "azurerm_mssql_database" "app" {\n name = var.project_name\n server_id = azurerm_mssql_server.mssql.id\n sku_name = "Basic"\n}`,
|
|
406
|
+
outputs: [{ name: "sqlserver_fqdn", description: "SQL Server FQDN", value: "azurerm_mssql_server.mssql.fully_qualified_domain_name" }],
|
|
407
|
+
};
|
|
408
|
+
case "cache:redis":
|
|
409
|
+
return {
|
|
410
|
+
hcl: `resource "azurerm_redis_cache" "redis" {\n name = "\${var.project_name}-\${var.environment}-redis"\n location = azurerm_resource_group.main.location\n resource_group_name = azurerm_resource_group.main.name\n capacity = 0\n family = "C"\n sku_name = "Basic"\n minimum_tls_version = "1.2"\n}`,
|
|
411
|
+
outputs: [{ name: "redis_hostname", description: "Redis cache hostname", value: "azurerm_redis_cache.redis.hostname" }],
|
|
412
|
+
};
|
|
413
|
+
case "storage:object":
|
|
414
|
+
return {
|
|
415
|
+
hcl: `resource "azurerm_storage_account" "storage" {\n name = replace("\${var.project_name}\${var.environment}st", "-", "")\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n account_tier = "Standard"\n account_replication_type = "LRS"\n}\n\nresource "azurerm_storage_container" "app" {\n name = "app-data"\n storage_account_name = azurerm_storage_account.storage.name\n container_access_type = "private"\n}`,
|
|
416
|
+
outputs: [{ name: "storage_account_id", description: "Storage account ID", value: "azurerm_storage_account.storage.id" }],
|
|
417
|
+
};
|
|
418
|
+
case "messaging:queue":
|
|
419
|
+
return {
|
|
420
|
+
hcl: `resource "azurerm_servicebus_namespace" "queue" {\n name = "\${var.project_name}-\${var.environment}-bus"\n location = azurerm_resource_group.main.location\n resource_group_name = azurerm_resource_group.main.name\n sku = "Basic"\n}\n\nresource "azurerm_servicebus_queue" "app" {\n name = "\${var.project_name}-queue"\n namespace_id = azurerm_servicebus_namespace.queue.id\n}`,
|
|
421
|
+
outputs: [{ name: "servicebus_namespace_id", description: "Service Bus namespace ID", value: "azurerm_servicebus_namespace.queue.id" }],
|
|
422
|
+
};
|
|
423
|
+
case "identity:identity":
|
|
424
|
+
return {
|
|
425
|
+
hcl: `resource "azurerm_user_assigned_identity" "app" {\n name = "\${var.project_name}-\${var.environment}-id"\n location = azurerm_resource_group.main.location\n resource_group_name = azurerm_resource_group.main.name\n}`,
|
|
426
|
+
outputs: [{ name: "identity_client_id", description: "Managed identity client ID", value: "azurerm_user_assigned_identity.app.client_id" }],
|
|
427
|
+
};
|
|
428
|
+
case "monitoring:logs":
|
|
429
|
+
return {
|
|
430
|
+
hcl: `resource "azurerm_log_analytics_workspace" "logs" {\n name = "\${var.project_name}-\${var.environment}-logs"\n location = azurerm_resource_group.main.location\n resource_group_name = azurerm_resource_group.main.name\n sku = "PerGB2018"\n retention_in_days = 30\n}\n\nresource "azurerm_application_insights" "app" {\n name = "\${var.project_name}-\${var.environment}-appi"\n location = azurerm_resource_group.main.location\n resource_group_name = azurerm_resource_group.main.name\n workspace_id = azurerm_log_analytics_workspace.logs.id\n application_type = "web"\n}`,
|
|
431
|
+
outputs: [{ name: "log_analytics_workspace_id", description: "Log Analytics workspace ID", value: "azurerm_log_analytics_workspace.logs.id" }],
|
|
432
|
+
};
|
|
433
|
+
default:
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
awsComponent(key) {
|
|
438
|
+
switch (key) {
|
|
439
|
+
case "networking:network":
|
|
440
|
+
return {
|
|
441
|
+
hcl: `resource "aws_vpc" "network" {\n cidr_block = "10.0.0.0/16"\n enable_dns_support = true\n enable_dns_hostnames = true\n\n tags = {\n Name = "\${var.project_name}-\${var.environment}"\n }\n}\n\nresource "aws_subnet" "app" {\n vpc_id = aws_vpc.network.id\n cidr_block = "10.0.1.0/24"\n\n tags = {\n Name = "\${var.project_name}-\${var.environment}-app"\n }\n}`,
|
|
442
|
+
outputs: [{ name: "vpc_id", description: "VPC ID", value: "aws_vpc.network.id" }],
|
|
443
|
+
};
|
|
444
|
+
case "compute:container":
|
|
445
|
+
return {
|
|
446
|
+
hcl: `resource "aws_ecs_cluster" "app" {\n name = "\${var.project_name}-\${var.environment}"\n}\n\nresource "aws_ecs_task_definition" "app" {\n family = "\${var.project_name}-\${var.environment}"\n requires_compatibilities = ["FARGATE"]\n network_mode = "awsvpc"\n cpu = "256"\n memory = "512"\n\n container_definitions = jsonencode([\n {\n name = var.project_name\n image = var.container_image\n essential = true\n portMappings = [\n {\n containerPort = var.container_port\n }\n ]\n }\n ])\n}`,
|
|
447
|
+
outputs: [{ name: "ecs_cluster_arn", description: "ECS cluster ARN", value: "aws_ecs_cluster.app.arn" }],
|
|
448
|
+
};
|
|
449
|
+
case "compute:kubernetes":
|
|
450
|
+
return {
|
|
451
|
+
hcl: `resource "aws_iam_role" "eks" {\n name = "\${var.project_name}-\${var.environment}-eks"\n\n assume_role_policy = jsonencode({\n Version = "2012-10-17"\n Statement = [\n {\n Action = "sts:AssumeRole"\n Effect = "Allow"\n Principal = { Service = "eks.amazonaws.com" }\n }\n ]\n })\n}\n\nresource "aws_eks_cluster" "k8s" {\n name = "\${var.project_name}-\${var.environment}"\n role_arn = aws_iam_role.eks.arn\n\n vpc_config {\n subnet_ids = var.subnet_ids\n }\n}`,
|
|
452
|
+
outputs: [{ name: "kubernetes_cluster_name", description: "EKS cluster name", value: "aws_eks_cluster.k8s.name" }],
|
|
453
|
+
};
|
|
454
|
+
case "compute:serverless":
|
|
455
|
+
return {
|
|
456
|
+
hcl: `resource "aws_iam_role" "lambda" {\n name = "\${var.project_name}-\${var.environment}-lambda"\n\n assume_role_policy = jsonencode({\n Version = "2012-10-17"\n Statement = [\n {\n Action = "sts:AssumeRole"\n Effect = "Allow"\n Principal = { Service = "lambda.amazonaws.com" }\n }\n ]\n })\n}\n\nresource "aws_lambda_function" "app" {\n function_name = "\${var.project_name}-\${var.environment}"\n role = aws_iam_role.lambda.arn\n package_type = "Image"\n image_uri = var.container_image\n}`,
|
|
457
|
+
outputs: [{ name: "lambda_function_name", description: "Lambda function name", value: "aws_lambda_function.app.function_name" }],
|
|
458
|
+
};
|
|
459
|
+
case "database:postgres":
|
|
460
|
+
return {
|
|
461
|
+
hcl: `resource "aws_db_instance" "postgres" {\n identifier = "\${var.project_name}-\${var.environment}-pg"\n engine = "postgres"\n engine_version = "16"\n instance_class = "db.t3.micro"\n allocated_storage = 20\n db_name = replace(var.project_name, "-", "")\n username = var.db_admin_username\n password = var.db_admin_password\n skip_final_snapshot = true\n}`,
|
|
462
|
+
outputs: [{ name: "postgres_endpoint", description: "PostgreSQL endpoint", value: "aws_db_instance.postgres.endpoint" }],
|
|
463
|
+
};
|
|
464
|
+
case "database:mysql":
|
|
465
|
+
return {
|
|
466
|
+
hcl: `resource "aws_db_instance" "mysql" {\n identifier = "\${var.project_name}-\${var.environment}-mysql"\n engine = "mysql"\n engine_version = "8.0"\n instance_class = "db.t3.micro"\n allocated_storage = 20\n db_name = replace(var.project_name, "-", "")\n username = var.db_admin_username\n password = var.db_admin_password\n skip_final_snapshot = true\n}`,
|
|
467
|
+
outputs: [{ name: "mysql_endpoint", description: "MySQL endpoint", value: "aws_db_instance.mysql.endpoint" }],
|
|
468
|
+
};
|
|
469
|
+
case "database:nosql":
|
|
470
|
+
return {
|
|
471
|
+
hcl: `resource "aws_dynamodb_table" "nosql" {\n name = "\${var.project_name}-\${var.environment}"\n billing_mode = "PAY_PER_REQUEST"\n hash_key = "id"\n\n attribute {\n name = "id"\n type = "S"\n }\n}`,
|
|
472
|
+
outputs: [{ name: "dynamodb_table_name", description: "DynamoDB table name", value: "aws_dynamodb_table.nosql.name" }],
|
|
473
|
+
};
|
|
474
|
+
case "database:sqlserver":
|
|
475
|
+
return {
|
|
476
|
+
hcl: `resource "aws_db_instance" "mssql" {\n identifier = "\${var.project_name}-\${var.environment}-sql"\n engine = "sqlserver-ex"\n instance_class = "db.t3.small"\n allocated_storage = 20\n license_model = "license-included"\n username = var.db_admin_username\n password = var.db_admin_password\n skip_final_snapshot = true\n}`,
|
|
477
|
+
outputs: [{ name: "sqlserver_endpoint", description: "SQL Server endpoint", value: "aws_db_instance.mssql.endpoint" }],
|
|
478
|
+
};
|
|
479
|
+
case "cache:redis":
|
|
480
|
+
return {
|
|
481
|
+
hcl: `resource "aws_elasticache_cluster" "redis" {\n cluster_id = "\${var.project_name}-\${var.environment}-redis"\n engine = "redis"\n node_type = "cache.t3.micro"\n num_cache_nodes = 1\n}`,
|
|
482
|
+
outputs: [{ name: "redis_endpoint", description: "Redis endpoint address", value: "aws_elasticache_cluster.redis.cache_nodes[0].address" }],
|
|
483
|
+
};
|
|
484
|
+
case "storage:object":
|
|
485
|
+
return {
|
|
486
|
+
hcl: `resource "aws_s3_bucket" "storage" {\n bucket = "\${var.project_name}-\${var.environment}-data"\n}\n\nresource "aws_s3_bucket_versioning" "storage" {\n bucket = aws_s3_bucket.storage.id\n\n versioning_configuration {\n status = "Enabled"\n }\n}`,
|
|
487
|
+
outputs: [{ name: "s3_bucket_name", description: "S3 bucket name", value: "aws_s3_bucket.storage.bucket" }],
|
|
488
|
+
};
|
|
489
|
+
case "messaging:queue":
|
|
490
|
+
return {
|
|
491
|
+
hcl: `resource "aws_sqs_queue" "queue" {\n name = "\${var.project_name}-\${var.environment}-queue"\n}`,
|
|
492
|
+
outputs: [{ name: "sqs_queue_url", description: "SQS queue URL", value: "aws_sqs_queue.queue.url" }],
|
|
493
|
+
};
|
|
494
|
+
case "identity:identity":
|
|
495
|
+
return {
|
|
496
|
+
hcl: `resource "aws_iam_role" "app" {\n name = "\${var.project_name}-\${var.environment}-app"\n\n assume_role_policy = jsonencode({\n Version = "2012-10-17"\n Statement = [\n {\n Action = "sts:AssumeRole"\n Effect = "Allow"\n Principal = { Service = "ecs-tasks.amazonaws.com" }\n }\n ]\n })\n}`,
|
|
497
|
+
outputs: [{ name: "app_role_arn", description: "Application IAM role ARN", value: "aws_iam_role.app.arn" }],
|
|
498
|
+
};
|
|
499
|
+
case "monitoring:logs":
|
|
500
|
+
return {
|
|
501
|
+
hcl: `resource "aws_cloudwatch_log_group" "logs" {\n name = "/app/\${var.project_name}-\${var.environment}"\n retention_in_days = 30\n}`,
|
|
502
|
+
outputs: [{ name: "log_group_name", description: "CloudWatch log group name", value: "aws_cloudwatch_log_group.logs.name" }],
|
|
503
|
+
};
|
|
504
|
+
default:
|
|
505
|
+
return null;
|
|
126
506
|
}
|
|
127
|
-
return modules.length > 0 ? modules : ["compute", "networking"];
|
|
128
507
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
508
|
+
gcpComponent(key) {
|
|
509
|
+
switch (key) {
|
|
510
|
+
case "networking:network":
|
|
511
|
+
return {
|
|
512
|
+
hcl: `resource "google_compute_network" "network" {\n name = "\${var.project_name}-\${var.environment}"\n auto_create_subnetworks = false\n}\n\nresource "google_compute_subnetwork" "app" {\n name = "\${var.project_name}-\${var.environment}-app"\n network = google_compute_network.network.id\n ip_cidr_range = "10.0.1.0/24"\n region = var.region\n}`,
|
|
513
|
+
outputs: [{ name: "network_id", description: "VPC network ID", value: "google_compute_network.network.id" }],
|
|
514
|
+
};
|
|
515
|
+
case "compute:container":
|
|
516
|
+
case "compute:serverless":
|
|
517
|
+
// Cloud Run is GCP's serverless container platform — it covers both flavors.
|
|
518
|
+
return {
|
|
519
|
+
hcl: `resource "google_cloud_run_v2_service" "app" {\n name = "\${var.project_name}-\${var.environment}"\n location = var.region\n\n template {\n containers {\n image = var.container_image\n\n ports {\n container_port = var.container_port\n }\n }\n }\n}`,
|
|
520
|
+
outputs: [{ name: "cloud_run_url", description: "Cloud Run service URL", value: "google_cloud_run_v2_service.app.uri" }],
|
|
521
|
+
};
|
|
522
|
+
case "compute:kubernetes":
|
|
523
|
+
return {
|
|
524
|
+
hcl: `resource "google_container_cluster" "k8s" {\n name = "\${var.project_name}-\${var.environment}"\n location = var.region\n enable_autopilot = true\n}`,
|
|
525
|
+
outputs: [{ name: "kubernetes_cluster_name", description: "GKE cluster name", value: "google_container_cluster.k8s.name" }],
|
|
526
|
+
};
|
|
527
|
+
case "database:postgres":
|
|
528
|
+
return {
|
|
529
|
+
hcl: `resource "google_sql_database_instance" "postgres" {\n name = "\${var.project_name}-\${var.environment}-pg"\n database_version = "POSTGRES_16"\n region = var.region\n\n settings {\n tier = "db-f1-micro"\n }\n}\n\nresource "google_sql_user" "postgres" {\n name = var.db_admin_username\n instance = google_sql_database_instance.postgres.name\n password = var.db_admin_password\n}`,
|
|
530
|
+
outputs: [{ name: "postgres_connection_name", description: "Cloud SQL connection name", value: "google_sql_database_instance.postgres.connection_name" }],
|
|
531
|
+
};
|
|
532
|
+
case "database:mysql":
|
|
533
|
+
return {
|
|
534
|
+
hcl: `resource "google_sql_database_instance" "mysql" {\n name = "\${var.project_name}-\${var.environment}-mysql"\n database_version = "MYSQL_8_0"\n region = var.region\n\n settings {\n tier = "db-f1-micro"\n }\n}\n\nresource "google_sql_user" "mysql" {\n name = var.db_admin_username\n instance = google_sql_database_instance.mysql.name\n password = var.db_admin_password\n}`,
|
|
535
|
+
outputs: [{ name: "mysql_connection_name", description: "Cloud SQL connection name", value: "google_sql_database_instance.mysql.connection_name" }],
|
|
536
|
+
};
|
|
537
|
+
case "database:nosql":
|
|
538
|
+
return {
|
|
539
|
+
hcl: `resource "google_firestore_database" "nosql" {\n name = "\${var.project_name}-\${var.environment}"\n location_id = var.region\n type = "FIRESTORE_NATIVE"\n}`,
|
|
540
|
+
outputs: [{ name: "firestore_database_name", description: "Firestore database name", value: "google_firestore_database.nosql.name" }],
|
|
541
|
+
};
|
|
542
|
+
case "database:sqlserver":
|
|
543
|
+
return {
|
|
544
|
+
hcl: `resource "google_sql_database_instance" "mssql" {\n name = "\${var.project_name}-\${var.environment}-sql"\n database_version = "SQLSERVER_2019_EXPRESS"\n region = var.region\n root_password = var.db_admin_password\n\n settings {\n tier = "db-custom-1-3840"\n }\n}`,
|
|
545
|
+
outputs: [{ name: "sqlserver_connection_name", description: "Cloud SQL connection name", value: "google_sql_database_instance.mssql.connection_name" }],
|
|
546
|
+
};
|
|
547
|
+
case "cache:redis":
|
|
548
|
+
return {
|
|
549
|
+
hcl: `resource "google_redis_instance" "redis" {\n name = "\${var.project_name}-\${var.environment}-redis"\n memory_size_gb = 1\n region = var.region\n}`,
|
|
550
|
+
outputs: [{ name: "redis_host", description: "Redis instance host", value: "google_redis_instance.redis.host" }],
|
|
551
|
+
};
|
|
552
|
+
case "storage:object":
|
|
553
|
+
return {
|
|
554
|
+
hcl: `resource "google_storage_bucket" "storage" {\n name = "\${var.project_name}-\${var.environment}-data"\n location = var.region\n uniform_bucket_level_access = true\n}`,
|
|
555
|
+
outputs: [{ name: "bucket_name", description: "Storage bucket name", value: "google_storage_bucket.storage.name" }],
|
|
556
|
+
};
|
|
557
|
+
case "messaging:queue":
|
|
558
|
+
return {
|
|
559
|
+
hcl: `resource "google_pubsub_topic" "queue" {\n name = "\${var.project_name}-\${var.environment}"\n}\n\nresource "google_pubsub_subscription" "app" {\n name = "\${var.project_name}-\${var.environment}-sub"\n topic = google_pubsub_topic.queue.id\n}`,
|
|
560
|
+
outputs: [{ name: "pubsub_topic_id", description: "Pub/Sub topic ID", value: "google_pubsub_topic.queue.id" }],
|
|
561
|
+
};
|
|
562
|
+
case "identity:identity":
|
|
563
|
+
return {
|
|
564
|
+
hcl: `resource "google_service_account" "app" {\n account_id = "\${var.project_name}-app"\n display_name = "\${var.project_name} application"\n}`,
|
|
565
|
+
outputs: [{ name: "service_account_email", description: "Service account email", value: "google_service_account.app.email" }],
|
|
566
|
+
};
|
|
567
|
+
case "monitoring:logs":
|
|
568
|
+
return {
|
|
569
|
+
hcl: `resource "google_logging_metric" "logs" {\n name = "\${var.project_name}-\${var.environment}-errors"\n filter = "severity>=ERROR"\n\n metric_descriptor {\n metric_kind = "DELTA"\n value_type = "INT64"\n }\n}`,
|
|
570
|
+
outputs: [{ name: "logging_metric_name", description: "Log-based metric name", value: "google_logging_metric.logs.name" }],
|
|
571
|
+
};
|
|
572
|
+
default:
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
133
575
|
}
|
|
134
|
-
generateVariables(cloud,
|
|
576
|
+
generateVariables(cloud, components) {
|
|
135
577
|
const vars = [
|
|
136
578
|
{ name: "project_name", type: "string", description: "Project name", required: true },
|
|
137
579
|
{ name: "environment", type: "string", description: "Environment (dev/staging/prod)", default: '"dev"', required: false },
|
|
@@ -140,13 +582,39 @@ export class IacGenerator {
|
|
|
140
582
|
vars.push({ name: "location", type: "string", description: "Azure region", default: '"eastus2"', required: false });
|
|
141
583
|
if (cloud === "aws")
|
|
142
584
|
vars.push({ name: "region", type: "string", description: "AWS region", default: '"us-east-1"', required: false });
|
|
585
|
+
if (cloud === "gcp") {
|
|
586
|
+
vars.push({ name: "project_id", type: "string", description: "GCP project ID", required: true });
|
|
587
|
+
vars.push({ name: "region", type: "string", description: "GCP region", default: '"us-central1"', required: false });
|
|
588
|
+
}
|
|
589
|
+
const needsContainerVars = components.some(c => c.module === "compute" && (c.service === "container" || (c.service === "serverless" && cloud !== "azure")));
|
|
590
|
+
if (needsContainerVars) {
|
|
591
|
+
vars.push({ name: "container_image", type: "string", description: "Container image to deploy", default: '"nginx:latest"', required: false });
|
|
592
|
+
vars.push({ name: "container_port", type: "number", description: "Port the application listens on", default: "3000", required: false });
|
|
593
|
+
}
|
|
594
|
+
const needsDbCredentials = components.some(c => c.module === "database" && ["postgres", "mysql", "sqlserver"].includes(c.service));
|
|
595
|
+
if (needsDbCredentials) {
|
|
596
|
+
vars.push({ name: "db_admin_username", type: "string", description: "Database administrator login", default: '"sddadmin"', required: false });
|
|
597
|
+
vars.push({ name: "db_admin_password", type: "string", description: "Database administrator password (sensitive)", required: true });
|
|
598
|
+
}
|
|
599
|
+
if (cloud === "aws" && components.some(c => c.module === "compute" && c.service === "kubernetes")) {
|
|
600
|
+
vars.push({ name: "subnet_ids", type: "list(string)", description: "Subnet IDs for the EKS cluster", required: true });
|
|
601
|
+
}
|
|
143
602
|
return vars;
|
|
144
603
|
}
|
|
145
604
|
generateVariablesTf(vars) {
|
|
146
|
-
return vars
|
|
605
|
+
return vars
|
|
606
|
+
.map(v => {
|
|
607
|
+
const sensitive = /password|secret/.test(v.name) ? " sensitive = true\n" : "";
|
|
608
|
+
return `variable "${v.name}" {\n type = ${v.type}\n description = "${v.description}"\n${sensitive}${v.default ? ` default = ${v.default}\n` : ""}}`;
|
|
609
|
+
})
|
|
610
|
+
.join("\n\n");
|
|
147
611
|
}
|
|
148
|
-
generateOutputsTf(
|
|
149
|
-
|
|
612
|
+
generateOutputsTf(outputs) {
|
|
613
|
+
if (outputs.length === 0)
|
|
614
|
+
return "# No outputs defined for the detected components.";
|
|
615
|
+
return outputs
|
|
616
|
+
.map(o => `output "${o.name}" {\n description = "${o.description}"\n value = ${o.value}\n}`)
|
|
617
|
+
.join("\n\n");
|
|
150
618
|
}
|
|
151
619
|
}
|
|
152
620
|
//# sourceMappingURL=iac-generator.js.map
|