grada-run 0.0.1 → 0.32.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.
Files changed (231) hide show
  1. package/.github/workflows/deploy-docs.yml +37 -0
  2. package/.github/workflows/iac-validation.yml +303 -0
  3. package/.github/workflows/publish.yml +68 -0
  4. package/.github/workflows/sync-bedrock-models.yml +57 -0
  5. package/.github/workflows/test.yml +43 -0
  6. package/.muserules +31 -0
  7. package/LICENSE +21 -0
  8. package/README.md +193 -3
  9. package/apps/docs/.astro/collections/docs.schema.json +644 -0
  10. package/apps/docs/.astro/content-assets.mjs +4 -0
  11. package/apps/docs/.astro/content-modules.mjs +4 -0
  12. package/apps/docs/.astro/content.d.ts +179 -0
  13. package/apps/docs/.astro/data-store.json +1 -0
  14. package/apps/docs/.astro/dev.json +14 -0
  15. package/apps/docs/.astro/settings.json +5 -0
  16. package/apps/docs/.astro/types.d.ts +2 -0
  17. package/apps/docs/astro.config.mjs +97 -0
  18. package/apps/docs/package.json +17 -0
  19. package/apps/docs/src/content/docs/adrs/0001-s3-native-state-locking.md +37 -0
  20. package/apps/docs/src/content/docs/adrs/0002-eject-mechanism-pure-iac.md +39 -0
  21. package/apps/docs/src/content/docs/adrs/0003-sync-ai-context-strategy.md +48 -0
  22. package/apps/docs/src/content/docs/adrs/0004-iac-driven-diagnostic-context.md +37 -0
  23. package/apps/docs/src/content/docs/adrs/0005-ecs-fargate-alb-runtime-target.md +38 -0
  24. package/apps/docs/src/content/docs/adrs/0006-github-oidc-no-stored-keys.md +37 -0
  25. package/apps/docs/src/content/docs/adrs/0007-framework-detection-with-fallback.md +37 -0
  26. package/apps/docs/src/content/docs/adrs/0008-secrets-names-in-git-values-in-aws.md +37 -0
  27. package/apps/docs/src/content/docs/adrs/0009-regenerate-with-backup-on-rerun.md +37 -0
  28. package/apps/docs/src/content/docs/adrs/0010-advisory-only-security-scans.md +37 -0
  29. package/apps/docs/src/content/docs/cli/add.md +84 -0
  30. package/apps/docs/src/content/docs/cli/apply.md +32 -0
  31. package/apps/docs/src/content/docs/cli/db.md +200 -0
  32. package/apps/docs/src/content/docs/cli/destroy.md +31 -0
  33. package/apps/docs/src/content/docs/cli/diagnose.md +37 -0
  34. package/apps/docs/src/content/docs/cli/doctor.md +28 -0
  35. package/apps/docs/src/content/docs/cli/domain.md +57 -0
  36. package/apps/docs/src/content/docs/cli/drift.md +40 -0
  37. package/apps/docs/src/content/docs/cli/eject.md +29 -0
  38. package/apps/docs/src/content/docs/cli/exec.md +49 -0
  39. package/apps/docs/src/content/docs/cli/gc.md +37 -0
  40. package/apps/docs/src/content/docs/cli/init.md +72 -0
  41. package/apps/docs/src/content/docs/cli/logs.md +39 -0
  42. package/apps/docs/src/content/docs/cli/rollback.md +51 -0
  43. package/apps/docs/src/content/docs/cli/secrets.md +73 -0
  44. package/apps/docs/src/content/docs/cli/sleep.md +53 -0
  45. package/apps/docs/src/content/docs/cli/status.md +34 -0
  46. package/apps/docs/src/content/docs/cli/sync-ai.md +27 -0
  47. package/apps/docs/src/content/docs/guides/architecture.md +87 -0
  48. package/apps/docs/src/content/docs/guides/aws-credentials.md +72 -0
  49. package/apps/docs/src/content/docs/guides/background-workers.md +45 -0
  50. package/apps/docs/src/content/docs/guides/cicd-pipeline.md +64 -0
  51. package/apps/docs/src/content/docs/guides/database-connections.md +64 -0
  52. package/apps/docs/src/content/docs/guides/docker-compose.md +37 -0
  53. package/apps/docs/src/content/docs/guides/dockerfiles.md +46 -0
  54. package/apps/docs/src/content/docs/guides/ephemeral-pr-previews.md +39 -0
  55. package/apps/docs/src/content/docs/guides/examples.md +50 -0
  56. package/apps/docs/src/content/docs/guides/frameworks.md +88 -0
  57. package/apps/docs/src/content/docs/guides/headless.md +75 -0
  58. package/apps/docs/src/content/docs/guides/quickstart.md +52 -0
  59. package/apps/docs/src/content/docs/guides/rerun-init.md +43 -0
  60. package/apps/docs/src/content/docs/guides/secrets-management.md +83 -0
  61. package/apps/docs/src/content/docs/guides/understanding-your-bill.md +63 -0
  62. package/apps/docs/src/content/docs/index.mdx +103 -0
  63. package/apps/docs/src/content/docs/migrations/astro-vercel-to-aws.md +55 -0
  64. package/apps/docs/src/content/docs/migrations/heroku-procfile-to-aws.md +41 -0
  65. package/apps/docs/src/content/docs/migrations/nextjs-vercel-to-aws.md +51 -0
  66. package/apps/docs/src/content/docs/migrations/sveltekit-vercel-to-aws.md +63 -0
  67. package/apps/docs/src/content/docs/roadmap.md +97 -0
  68. package/apps/docs/src/content/docs/testing-strategy.md +32 -0
  69. package/apps/docs/src/content.config.ts +7 -0
  70. package/apps/docs/src/custom.css +14 -0
  71. package/apps/docs/tsconfig.json +6 -0
  72. package/bin/cli.js +140 -0
  73. package/package.json +105 -6
  74. package/scripts/sync-bedrock-models.js +22 -0
  75. package/scripts/test-iac.js +261 -0
  76. package/specs/add-redis-sqs-bedrock.md +128 -0
  77. package/specs/add-storage-dynamodb.md +106 -0
  78. package/specs/bedrock-model-catalog.md +131 -0
  79. package/specs/ci-pipeline.md +17 -0
  80. package/specs/cost-transparency.md +115 -0
  81. package/specs/custom-domains-and-ses.md +153 -0
  82. package/specs/database-suite-expansion.md +151 -0
  83. package/specs/db-connect.md +69 -0
  84. package/specs/db-lifecycle-migrations.md +159 -0
  85. package/specs/dependency-aware-init.md +176 -0
  86. package/specs/deploy-stack-to-grada-run-rebrand.md +45 -0
  87. package/specs/deployment-safety.md +170 -0
  88. package/specs/diagnose.md +16 -0
  89. package/specs/docs-hub.md +16 -0
  90. package/specs/dx-polish.md +46 -0
  91. package/specs/exec.md +25 -0
  92. package/specs/finops-cron-drift.md +161 -0
  93. package/specs/gc.md +26 -0
  94. package/specs/integration-suite.md +16 -0
  95. package/specs/logs.md +32 -0
  96. package/specs/rollback-live-polling.md +40 -0
  97. package/specs/secrets-pull-audit.md +51 -0
  98. package/specs/serverless-lambda-target.md +133 -0
  99. package/specs/status.md +31 -0
  100. package/specs/telemetry-and-spawn-hardening.md +69 -0
  101. package/specs/telemetry-hardening.md +35 -0
  102. package/src/commands/add.js +1111 -0
  103. package/src/commands/apply.js +214 -0
  104. package/src/commands/db/backup.js +229 -0
  105. package/src/commands/db/connect.js +304 -0
  106. package/src/commands/db/enable-vector.js +344 -0
  107. package/src/commands/db/import.js +604 -0
  108. package/src/commands/db/migrate.js +477 -0
  109. package/src/commands/db/restore.js +361 -0
  110. package/src/commands/db.js +87 -0
  111. package/src/commands/destroy.js +217 -0
  112. package/src/commands/diagnose.js +460 -0
  113. package/src/commands/doctor.js +109 -0
  114. package/src/commands/domain.js +685 -0
  115. package/src/commands/drift.js +243 -0
  116. package/src/commands/eject.js +127 -0
  117. package/src/commands/exec.js +222 -0
  118. package/src/commands/gc.js +250 -0
  119. package/src/commands/init.js +649 -0
  120. package/src/commands/logs.js +256 -0
  121. package/src/commands/rollback.js +323 -0
  122. package/src/commands/secrets.js +485 -0
  123. package/src/commands/sleep.js +347 -0
  124. package/src/commands/status.js +309 -0
  125. package/src/commands/sync-ai.js +115 -0
  126. package/src/commands/wake.js +337 -0
  127. package/src/core/parser.js +126 -0
  128. package/src/core/telemetry.js +244 -0
  129. package/src/data/bedrock-models.json +896 -0
  130. package/src/utils/addons.js +126 -0
  131. package/src/utils/ai-rules.js +59 -0
  132. package/src/utils/args.js +91 -0
  133. package/src/utils/aws.js +178 -0
  134. package/src/utils/backup.js +69 -0
  135. package/src/utils/bedrock-catalog.js +511 -0
  136. package/src/utils/capabilities.js +500 -0
  137. package/src/utils/command.js +65 -0
  138. package/src/utils/db-tunnel.js +164 -0
  139. package/src/utils/detector.js +298 -0
  140. package/src/utils/dockerCompose.js +65 -0
  141. package/src/utils/domains.js +73 -0
  142. package/src/utils/ecs-runner.js +289 -0
  143. package/src/utils/ecs.js +92 -0
  144. package/src/utils/frameworks.js +55 -0
  145. package/src/utils/generator.js +527 -0
  146. package/src/utils/hcl.js +426 -0
  147. package/src/utils/lambda-ecr.js +185 -0
  148. package/src/utils/prompts.js +278 -0
  149. package/src/utils/rds.js +131 -0
  150. package/src/utils/resolvers.js +174 -0
  151. package/src/utils/sleep-state.js +140 -0
  152. package/src/utils/sleep-targets.js +139 -0
  153. package/src/utils/system.js +42 -0
  154. package/src/utils/terraform.js +70 -0
  155. package/src/utils/visualizer.js +381 -0
  156. package/src/utils/warnings.js +49 -0
  157. package/templates/README.md +150 -0
  158. package/templates/docker/django.Dockerfile +40 -0
  159. package/templates/docker/go.Dockerfile +23 -0
  160. package/templates/docker/nestjs.Dockerfile +33 -0
  161. package/templates/docker/nextjs.Dockerfile +55 -0
  162. package/templates/docker/node.Dockerfile +24 -0
  163. package/templates/docker/nuxt.Dockerfile +47 -0
  164. package/templates/docker/python.Dockerfile +38 -0
  165. package/templates/docker/rails.Dockerfile +59 -0
  166. package/templates/docker/static.Dockerfile +32 -0
  167. package/templates/docker/svelte.Dockerfile +52 -0
  168. package/templates/github/deploy-lambda.yml +120 -0
  169. package/templates/github/deploy.yml +138 -0
  170. package/templates/github/drift.yml +112 -0
  171. package/templates/github/preview-lambda.yml +86 -0
  172. package/templates/github/preview.yml +69 -0
  173. package/templates/github/teardown.yml +43 -0
  174. package/templates/terraform/addons/bedrock.tf +34 -0
  175. package/templates/terraform/addons/cron-lambda.tf +78 -0
  176. package/templates/terraform/addons/cron.tf +101 -0
  177. package/templates/terraform/addons/dynamodb.tf +73 -0
  178. package/templates/terraform/addons/redis.tf +64 -0
  179. package/templates/terraform/addons/s3.tf +143 -0
  180. package/templates/terraform/addons/ses.tf +73 -0
  181. package/templates/terraform/addons/sqs.tf +67 -0
  182. package/templates/terraform/backend.tf +22 -0
  183. package/templates/terraform/cloudfront-lambda.tf +80 -0
  184. package/templates/terraform/cloudfront.tf +80 -0
  185. package/templates/terraform/database-aurora-postgresql.tf +92 -0
  186. package/templates/terraform/database-mysql.tf +72 -0
  187. package/templates/terraform/database.tf +71 -0
  188. package/templates/terraform/main-lambda.tf +229 -0
  189. package/templates/terraform/main.tf +296 -0
  190. package/templates/terraform/network.tf +95 -0
  191. package/templates/terraform/oidc.tf +64 -0
  192. package/templates/terraform/secrets.tf +31 -0
  193. package/templates/terraform/worker.tf +69 -0
  194. package/tests/__snapshots__/generator.test.js.snap +9633 -0
  195. package/tests/add.test.js +2037 -0
  196. package/tests/ai.test.js +94 -0
  197. package/tests/apply.test.js +488 -0
  198. package/tests/args.test.js +86 -0
  199. package/tests/aws.test.js +244 -0
  200. package/tests/capabilities.test.js +307 -0
  201. package/tests/cli.test.js +29 -0
  202. package/tests/command.test.js +100 -0
  203. package/tests/commands-import.test.js +74 -0
  204. package/tests/db.test.js +2704 -0
  205. package/tests/destroy.test.js +391 -0
  206. package/tests/detector.test.js +79 -0
  207. package/tests/diagnose.test.js +779 -0
  208. package/tests/doctor.test.js +202 -0
  209. package/tests/domain.test.js +899 -0
  210. package/tests/drift.test.js +243 -0
  211. package/tests/ecs.test.js +130 -0
  212. package/tests/eject.test.js +65 -0
  213. package/tests/exec.test.js +380 -0
  214. package/tests/gc.test.js +496 -0
  215. package/tests/generator.test.js +794 -0
  216. package/tests/headless.test.js +562 -0
  217. package/tests/lambda-ecr.test.js +185 -0
  218. package/tests/logs.test.js +447 -0
  219. package/tests/parser.test.js +160 -0
  220. package/tests/rds.test.js +244 -0
  221. package/tests/resolvers.test.js +282 -0
  222. package/tests/rollback.test.js +692 -0
  223. package/tests/secrets.test.js +752 -0
  224. package/tests/sleep-wake.test.js +1016 -0
  225. package/tests/status.test.js +370 -0
  226. package/tests/system.test.js +70 -0
  227. package/tests/telemetry.test.js +520 -0
  228. package/tests/terraform.test.js +84 -0
  229. package/tests/visualizer.test.js +496 -0
  230. package/vitest.config.js +9 -0
  231. package/index.js +0 -2
@@ -0,0 +1,794 @@
1
+ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import { generateTemplates, resolveDocDest, isManagedDoc, MANAGED_DOC_FALLBACK, LEGACY_MANAGED_DOC_FALLBACK, injectLambdaAdapter, convertDatabaseTfForLambda, addRandomProvider } from '../src/utils/generator.js';
5
+
6
+ describe('Infrastructure Generator', () => {
7
+ const testTargetDir = path.join(process.cwd(), 'tests', '.tmp-test-env');
8
+
9
+ beforeAll(async () => {
10
+ await fs.mkdir(testTargetDir, { recursive: true });
11
+ });
12
+
13
+ afterAll(async () => {
14
+ await fs.rm(testTargetDir, { recursive: true, force: true });
15
+ });
16
+
17
+ const matrix = [
18
+ // 1. Backend APIs & Monoliths
19
+ { name: 'Django_Postgres', framework: 'django', needsDb: true, buildDir: '' },
20
+ { name: 'Rails_Postgres', framework: 'rails', needsDb: true, buildDir: '' },
21
+ { name: 'Go_Distroless', framework: 'go', needsDb: false, buildDir: '' },
22
+ { name: 'FastAPI_Python', framework: 'python', needsDb: false, buildDir: '' },
23
+
24
+ // 2. Frontend & Meta-Frameworks
25
+ { name: 'NextJS_Standalone', framework: 'nextjs', needsDb: false, buildDir: '.next/standalone' },
26
+ { name: 'Nuxt_SSR', framework: 'nuxt', needsDb: false, buildDir: '.output/server' },
27
+ { name: 'Vite_Static_SPA', framework: 'static', needsDb: false, buildDir: 'dist' },
28
+ { name: 'SvelteKit_Node', framework: 'svelte', needsDb: false, buildDir: 'build' },
29
+
30
+ // 3. Migration Engines
31
+ {
32
+ name: 'Heroku_Procfile_Migration',
33
+ framework: 'django',
34
+ needsDb: true,
35
+ buildDir: '',
36
+ procfile: { web: ['gunicorn config.wsgi'], worker: ['celery -A config worker'] }
37
+ },
38
+ {
39
+ name: 'Vercel_Edge_Migration',
40
+ framework: 'nextjs',
41
+ needsDb: false,
42
+ buildDir: '.next/standalone',
43
+ vercelRouting: '{"routes": [{"src": "/api/(.*)", "dest": "https://api.example.com/$1"}]}'
44
+ },
45
+ {
46
+ name: 'Docker_Compose_Sidecars',
47
+ framework: 'node',
48
+ needsDb: false,
49
+ buildDir: '',
50
+ dockerCompose: [{ name: 'web', port: 3000 }, { name: 'redis', image: 'redis:alpine' }]
51
+ },
52
+
53
+ // 4. Multi-Engine RDS
54
+ {
55
+ name: 'MySQL_RDS',
56
+ framework: 'node',
57
+ needsDb: true,
58
+ buildDir: '',
59
+ dbEngine: 'mysql'
60
+ },
61
+ {
62
+ name: 'Aurora_Serverless',
63
+ framework: 'node',
64
+ needsDb: true,
65
+ buildDir: '',
66
+ dbEngine: 'aurora-postgresql'
67
+ }
68
+ ];
69
+
70
+ for (const tc of matrix) {
71
+ it(`generates correct infrastructure, CI/CD, and Dockerfile for ${tc.name}`, async () => {
72
+ await fs.rm(testTargetDir, { recursive: true, force: true }).catch(() => { });
73
+ await fs.mkdir(path.join(testTargetDir, '.github', 'workflows'), { recursive: true });
74
+
75
+ const dummyConfig = {
76
+ PROJECT_NAME: `test-${tc.name.toLowerCase()}`,
77
+ REGION: 'us-east-2',
78
+ PORT: '8000',
79
+ CPU: '256',
80
+ MEMORY: '512',
81
+ COMPUTE_TIER: 'Micro',
82
+ ESTIMATED_COST: '30.00',
83
+ STATE_BUCKET: 'test-bucket-123',
84
+ AWS_ACCOUNT_ID: '123456789012',
85
+ HEALTH_CHECK_PATH: '/health',
86
+ DESIRED_COUNT: '1',
87
+ DEPLOY_BRANCH: 'main',
88
+ BUILD_DIR: tc.buildDir,
89
+ finalFramework: tc.framework,
90
+ NEEDS_DATABASE: tc.needsDb,
91
+ // Older cases omit DB_ENGINE entirely, proving the generator
92
+ // defaults to postgres when the key is absent.
93
+ ...(tc.dbEngine ? { DB_ENGINE: tc.dbEngine } : {}),
94
+ DJANGO_WSGI: tc.framework === 'django' ? 'gunicorn config.wsgi' : '',
95
+ DISABLE_DEFAULT_CI: false,
96
+ PROCFILE: tc.procfile || null,
97
+ VERCEL_RULES: tc.vercelRouting ? { routes: [] } : null,
98
+ VERCEL_EDGE_ROUTING: tc.vercelRouting || '',
99
+ DOCKER_COMPOSE: tc.dockerCompose || null,
100
+ ENABLE_PR_PREVIEWS: true,
101
+ TASK_COMMAND: '',
102
+ WORKER_COMMAND: '',
103
+ DB_ENV_VARS: '',
104
+ COMPOSE_WEB_ENV_VARS: '',
105
+ EXTRA_CONTAINERS: '',
106
+ TASK_SECRETS: '',
107
+ INITIAL_SECRET_MAP: '{\n }',
108
+ SAFE_ALB_NAME: `test-alb`,
109
+ };
110
+
111
+ await generateTemplates(testTargetDir, dummyConfig);
112
+
113
+ const mainTfPath = path.join(testTargetDir, 'terraform', 'main.tf');
114
+ const networkTfPath = path.join(testTargetDir, 'terraform', 'network.tf');
115
+ const databaseTfPath = path.join(testTargetDir, 'terraform', 'database.tf');
116
+ const workerTfPath = path.join(testTargetDir, 'terraform', 'worker.tf');
117
+
118
+ const deployYmlPath = path.join(testTargetDir, '.github', 'workflows', 'deploy.yml');
119
+ const previewYmlPath = path.join(testTargetDir, '.github', 'workflows', 'preview.yml');
120
+ const teardownYmlPath = path.join(testTargetDir, '.github', 'workflows', 'teardown.yml');
121
+
122
+ const dockerfilePath = path.join(testTargetDir, 'Dockerfile');
123
+
124
+ // Read contents (falling back to a string if they correctly don't exist)
125
+ const mainTfContent = await fs.readFile(mainTfPath, 'utf-8').catch(() => 'FILE_NOT_FOUND');
126
+ const networkTfContent = await fs.readFile(networkTfPath, 'utf-8').catch(() => 'FILE_NOT_FOUND');
127
+ const databaseTfContent = await fs.readFile(databaseTfPath, 'utf-8').catch(() => 'FILE_NOT_FOUND');
128
+ const workerTfContent = await fs.readFile(workerTfPath, 'utf-8').catch(() => 'FILE_NOT_FOUND');
129
+
130
+ const deployYmlContent = await fs.readFile(deployYmlPath, 'utf-8').catch(() => 'FILE_NOT_FOUND');
131
+ const previewYmlContent = await fs.readFile(previewYmlPath, 'utf-8').catch(() => 'FILE_NOT_FOUND');
132
+ const teardownYmlContent = await fs.readFile(teardownYmlPath, 'utf-8').catch(() => 'FILE_NOT_FOUND');
133
+
134
+ const dockerfileContent = await fs.readFile(dockerfilePath, 'utf-8').catch(() => 'FILE_NOT_FOUND');
135
+
136
+ // ECS deployment circuit breaker safety net (Phase 10, Sprint 1)
137
+ expect(mainTfContent).toContain('deployment_circuit_breaker');
138
+ expect(mainTfContent).toMatch(/enable\s*=\s*true/);
139
+ expect(mainTfContent).toMatch(/rollback\s*=\s*true/);
140
+ // Code-only deploys register revisions outside Terraform; the
141
+ // service must not revert task_definition on the next apply.
142
+ expect(mainTfContent).toContain('ignore_changes = [task_definition]');
143
+
144
+ // Each code push must register a new immutable task revision.
145
+ expect(deployYmlContent).toContain('ECS_TASK_FAMILY');
146
+ expect(deployYmlContent).toContain('register-task-definition');
147
+ expect(deployYmlContent).toContain('--task-definition');
148
+
149
+ // Snapshot everything
150
+ expect(mainTfContent).toMatchSnapshot(`${tc.name} - main.tf`);
151
+ expect(networkTfContent).toMatchSnapshot(`${tc.name} - network.tf`);
152
+ expect(databaseTfContent).toMatchSnapshot(`${tc.name} - database.tf`);
153
+ expect(workerTfContent).toMatchSnapshot(`${tc.name} - worker.tf`);
154
+
155
+ expect(deployYmlContent).toMatchSnapshot(`${tc.name} - deploy.yml`);
156
+ expect(previewYmlContent).toMatchSnapshot(`${tc.name} - preview.yml`);
157
+ expect(teardownYmlContent).toMatchSnapshot(`${tc.name} - teardown.yml`);
158
+
159
+ expect(dockerfileContent).toMatchSnapshot(`${tc.name} - Dockerfile`);
160
+ });
161
+ }
162
+ });
163
+
164
+ describe('worker.tf without a database', () => {
165
+ const noDbWorkerDir = path.join(process.cwd(), 'tests', '.tmp-nodb-worker-env');
166
+
167
+ afterAll(async () => {
168
+ await fs.rm(noDbWorkerDir, { recursive: true, force: true });
169
+ });
170
+
171
+ it('renders no dangling comma and a count-safe image reference', async () => {
172
+ await fs.rm(noDbWorkerDir, { recursive: true, force: true }).catch(() => { });
173
+ await fs.mkdir(path.join(noDbWorkerDir, '.github', 'workflows'), { recursive: true });
174
+
175
+ await generateTemplates(noDbWorkerDir, {
176
+ PROJECT_NAME: 'test-nodb-worker',
177
+ REGION: 'us-east-2',
178
+ PORT: '8000',
179
+ CPU: '256',
180
+ MEMORY: '512',
181
+ COMPUTE_TIER: 'Micro',
182
+ ESTIMATED_COST: '30.00',
183
+ STATE_BUCKET: 'test-bucket-123',
184
+ AWS_ACCOUNT_ID: '123456789012',
185
+ HEALTH_CHECK_PATH: '/health',
186
+ DESIRED_COUNT: '1',
187
+ DEPLOY_BRANCH: 'main',
188
+ BUILD_DIR: '',
189
+ finalFramework: 'node',
190
+ NEEDS_DATABASE: false,
191
+ DISABLE_DEFAULT_CI: false,
192
+ PROCFILE: { web: ['node', 'index.js'], worker: ['npm', 'run', 'worker'] },
193
+ VERCEL_RULES: null,
194
+ VERCEL_EDGE_ROUTING: '',
195
+ DOCKER_COMPOSE: null,
196
+ ENABLE_PR_PREVIEWS: false,
197
+ TASK_COMMAND: '',
198
+ WORKER_COMMAND: '',
199
+ DB_ENV_VARS: '',
200
+ COMPOSE_WEB_ENV_VARS: '',
201
+ EXTRA_CONTAINERS: '',
202
+ TASK_SECRETS: '',
203
+ INITIAL_SECRET_MAP: '{\n }',
204
+ SAFE_ALB_NAME: 'test-alb',
205
+ });
206
+
207
+ const workerTf = await fs.readFile(path.join(noDbWorkerDir, 'terraform', 'worker.tf'), 'utf-8');
208
+ expect(workerTf).toContain('command = ["npm","run","worker"]');
209
+ // Empty {{DB_ENV_VARS}} must not leave a comma before the array close.
210
+ expect(workerTf).not.toMatch(/,\s*\]/);
211
+ // Count-guarded ECR repo: use the shared local like main.tf does.
212
+ expect(workerTf).toContain('${local.ecr_url}');
213
+ expect(workerTf).not.toContain('aws_ecr_repository.app.repository_url');
214
+ });
215
+ });
216
+
217
+ describe('database env vars render as bare HCL references', () => {
218
+ const dbDir = path.join(process.cwd(), 'tests', '.tmp-db-refs-env');
219
+
220
+ afterAll(async () => {
221
+ await fs.rm(dbDir, { recursive: true, force: true });
222
+ });
223
+
224
+ it('emits DB_HOST and DB_NAME without deprecated "${...}" wrappers', async () => {
225
+ await fs.rm(dbDir, { recursive: true, force: true }).catch(() => { });
226
+ await fs.mkdir(path.join(dbDir, '.github', 'workflows'), { recursive: true });
227
+
228
+ await generateTemplates(dbDir, {
229
+ PROJECT_NAME: 'test-db-refs',
230
+ REGION: 'us-east-2',
231
+ PORT: '8000',
232
+ CPU: '256',
233
+ MEMORY: '512',
234
+ COMPUTE_TIER: 'Micro',
235
+ ESTIMATED_COST: '30.00',
236
+ STATE_BUCKET: 'test-bucket-123',
237
+ AWS_ACCOUNT_ID: '123456789012',
238
+ HEALTH_CHECK_PATH: '/health',
239
+ DESIRED_COUNT: '1',
240
+ DEPLOY_BRANCH: 'main',
241
+ BUILD_DIR: '',
242
+ finalFramework: 'node',
243
+ NEEDS_DATABASE: true,
244
+ DISABLE_DEFAULT_CI: false,
245
+ PROCFILE: null,
246
+ VERCEL_RULES: null,
247
+ VERCEL_EDGE_ROUTING: '',
248
+ DOCKER_COMPOSE: null,
249
+ ENABLE_PR_PREVIEWS: false,
250
+ TASK_COMMAND: '',
251
+ WORKER_COMMAND: '',
252
+ DB_ENV_VARS: '',
253
+ COMPOSE_WEB_ENV_VARS: '',
254
+ EXTRA_CONTAINERS: '',
255
+ TASK_SECRETS: '',
256
+ INITIAL_SECRET_MAP: '{\n }',
257
+ SAFE_ALB_NAME: 'test-alb',
258
+ });
259
+
260
+ const mainTf = await fs.readFile(path.join(dbDir, 'terraform', 'main.tf'), 'utf-8');
261
+ expect(mainTf).toContain('{ "name": "DB_HOST", "value": aws_db_instance.postgres.address }');
262
+ expect(mainTf).toContain('{ "name": "DB_NAME", "value": aws_db_instance.postgres.db_name }');
263
+ expect(mainTf).not.toContain('"${aws_db_instance.postgres.address}"');
264
+ expect(mainTf).not.toContain('"${aws_db_instance.postgres.db_name}"');
265
+
266
+ const databaseTf = await fs.readFile(path.join(dbDir, 'terraform', 'database.tf'), 'utf-8');
267
+ expect(databaseTf).toContain('db_name = replace(local.app_name, "-", "_")');
268
+ expect(databaseTf).not.toContain('replace("${local.app_name}"');
269
+ });
270
+ });
271
+
272
+ describe('multi-engine database generation', () => {
273
+ const engineDir = path.join(process.cwd(), 'tests', '.tmp-db-engines-env');
274
+
275
+ afterAll(async () => {
276
+ await fs.rm(engineDir, { recursive: true, force: true });
277
+ });
278
+
279
+ async function generateWithEngine(dbEngine) {
280
+ const dir = path.join(engineDir, String(dbEngine).replace(/[^a-z0-9]+/gi, '-'));
281
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => { });
282
+ await fs.mkdir(path.join(dir, '.github', 'workflows'), { recursive: true });
283
+
284
+ await generateTemplates(dir, {
285
+ PROJECT_NAME: 'test-db-engine',
286
+ REGION: 'us-east-2',
287
+ PORT: '3000',
288
+ CPU: '256',
289
+ MEMORY: '512',
290
+ COMPUTE_TIER: 'Micro',
291
+ ESTIMATED_COST: '30.00',
292
+ STATE_BUCKET: 'test-bucket-123',
293
+ AWS_ACCOUNT_ID: '123456789012',
294
+ HEALTH_CHECK_PATH: '/health',
295
+ DESIRED_COUNT: '1',
296
+ DEPLOY_BRANCH: 'main',
297
+ BUILD_DIR: '',
298
+ finalFramework: 'node',
299
+ NEEDS_DATABASE: true,
300
+ DB_ENGINE: dbEngine,
301
+ DISABLE_DEFAULT_CI: false,
302
+ PROCFILE: null,
303
+ VERCEL_RULES: null,
304
+ VERCEL_EDGE_ROUTING: '',
305
+ DOCKER_COMPOSE: null,
306
+ ENABLE_PR_PREVIEWS: false,
307
+ TASK_COMMAND: '',
308
+ WORKER_COMMAND: '',
309
+ DB_ENV_VARS: '',
310
+ COMPOSE_WEB_ENV_VARS: '',
311
+ EXTRA_CONTAINERS: '',
312
+ TASK_SECRETS: '',
313
+ INITIAL_SECRET_MAP: '{\n }',
314
+ SAFE_ALB_NAME: 'test-alb',
315
+ });
316
+ return {
317
+ mainTf: await fs.readFile(path.join(dir, 'terraform', 'main.tf'), 'utf-8'),
318
+ databaseTf: await fs.readFile(path.join(dir, 'terraform', 'database.tf'), 'utf-8'),
319
+ };
320
+ }
321
+
322
+ it('generates MySQL 8.0 on port 3306 with a DB_ENGINE marker', async () => {
323
+ const { mainTf, databaseTf } = await generateWithEngine('mysql');
324
+ expect(databaseTf).toContain('resource "aws_db_instance" "postgres"');
325
+ expect(databaseTf).toContain('engine = "mysql"');
326
+ expect(databaseTf).toContain('engine_version = "8.0"');
327
+ expect(databaseTf).toContain('from_port = 3306');
328
+ expect(mainTf).toContain('{ "name": "DB_PORT", "value": "3306" }');
329
+ expect(mainTf).toContain('{ "name": "DB_ENGINE", "value": "mysql" }');
330
+ expect(mainTf).toContain('{ "name": "DB_HOST", "value": aws_db_instance.postgres.address }');
331
+ // RDS MySQL db_name is alphanumeric-only: dashes are stripped, not underscored.
332
+ expect(databaseTf).toContain('db_name = replace(local.app_name, "-", "")');
333
+ expect(databaseTf).not.toContain('replace(local.app_name, "-", "_")');
334
+ });
335
+
336
+ it('generates an Aurora Serverless v2 scale-to-zero cluster', async () => {
337
+ const { mainTf, databaseTf } = await generateWithEngine('aurora-postgresql');
338
+ expect(databaseTf).toContain('resource "aws_rds_cluster" "postgres"');
339
+ expect(databaseTf).toContain('resource "aws_rds_cluster_instance" "postgres"');
340
+ expect(databaseTf).toContain('engine = "aurora-postgresql"');
341
+ // engine_version is omitted on the cluster (AWS retires pinned minors like
342
+ // 16.4); the instance inherits it from the cluster instead.
343
+ expect(databaseTf).not.toContain('engine_version = "16.4"');
344
+ expect(databaseTf).toContain('engine_version = aws_rds_cluster.postgres.engine_version');
345
+ expect(databaseTf).toContain('instance_class = "db.serverless"');
346
+ expect(databaseTf).toContain('min_capacity = 0');
347
+ expect(databaseTf).toContain('max_capacity = 2');
348
+ expect(databaseTf).toContain('seconds_until_auto_pause = 300');
349
+ expect(databaseTf).toContain('ignore_changes = [availability_zones]');
350
+ expect(mainTf).toContain('{ "name": "DB_HOST", "value": aws_rds_cluster.postgres.endpoint }');
351
+ expect(mainTf).toContain('{ "name": "DB_NAME", "value": aws_rds_cluster.postgres.database_name }');
352
+ expect(mainTf).toContain('{ "name": "DB_ENGINE", "value": "aurora-postgresql" }');
353
+ expect(mainTf).toContain('aws_rds_cluster.postgres.master_user_secret[0].secret_arn');
354
+ // Aurora database_name is alphanumeric-only: dashes are stripped, not underscored.
355
+ expect(databaseTf).toContain('database_name = replace(local.app_name, "-", "")');
356
+ expect(databaseTf).not.toContain('replace(local.app_name, "-", "_")');
357
+ });
358
+ });
359
+
360
+ describe('Generator gitignore handling of secret_keys.json', () => {
361
+ const gitignoreTargetDir = path.join(process.cwd(), 'tests', '.tmp-gitignore-env');
362
+
363
+ const baseConfig = {
364
+ PROJECT_NAME: 'test-gitignore',
365
+ REGION: 'us-east-2',
366
+ PORT: '8000',
367
+ CPU: '256',
368
+ MEMORY: '512',
369
+ COMPUTE_TIER: 'Micro',
370
+ ESTIMATED_COST: '30.00',
371
+ STATE_BUCKET: 'test-bucket-123',
372
+ AWS_ACCOUNT_ID: '123456789012',
373
+ HEALTH_CHECK_PATH: '/health',
374
+ DESIRED_COUNT: '1',
375
+ DEPLOY_BRANCH: 'main',
376
+ BUILD_DIR: '',
377
+ finalFramework: 'node',
378
+ NEEDS_DATABASE: false,
379
+ DISABLE_DEFAULT_CI: false,
380
+ PROCFILE: null,
381
+ VERCEL_RULES: null,
382
+ VERCEL_EDGE_ROUTING: '',
383
+ DOCKER_COMPOSE: null,
384
+ ENABLE_PR_PREVIEWS: true,
385
+ TASK_COMMAND: '',
386
+ WORKER_COMMAND: '',
387
+ DB_ENV_VARS: '',
388
+ COMPOSE_WEB_ENV_VARS: '',
389
+ EXTRA_CONTAINERS: '',
390
+ TASK_SECRETS: '',
391
+ INITIAL_SECRET_MAP: '{\n }',
392
+ SAFE_ALB_NAME: 'test-alb',
393
+ };
394
+
395
+ afterAll(async () => {
396
+ await fs.rm(gitignoreTargetDir, { recursive: true, force: true });
397
+ });
398
+
399
+ it('does not ignore terraform/secret_keys.json in a fresh .gitignore', async () => {
400
+ await fs.rm(gitignoreTargetDir, { recursive: true, force: true }).catch(() => { });
401
+ await fs.mkdir(path.join(gitignoreTargetDir, '.github', 'workflows'), { recursive: true });
402
+
403
+ await generateTemplates(gitignoreTargetDir, baseConfig);
404
+
405
+ const gitignore = await fs.readFile(path.join(gitignoreTargetDir, '.gitignore'), 'utf-8');
406
+ const activeRules = gitignore.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
407
+ expect(activeRules).not.toContain('terraform/secret_keys.json');
408
+ // Sanity: genuinely sensitive/local files must still be ignored
409
+ expect(gitignore).toContain('terraform/.terraform/');
410
+ expect(gitignore).toContain('.env');
411
+
412
+ const keysFile = await fs.readFile(path.join(gitignoreTargetDir, 'terraform', 'secret_keys.json'), 'utf-8');
413
+ expect(keysFile).toBe('[]');
414
+ });
415
+
416
+ it('does not append a secret_keys.json ignore rule to an existing .gitignore', async () => {
417
+ await fs.rm(gitignoreTargetDir, { recursive: true, force: true }).catch(() => { });
418
+ await fs.mkdir(path.join(gitignoreTargetDir, '.github', 'workflows'), { recursive: true });
419
+ await fs.writeFile(path.join(gitignoreTargetDir, '.gitignore'), 'node_modules/\n');
420
+
421
+ await generateTemplates(gitignoreTargetDir, baseConfig);
422
+
423
+ const gitignore = await fs.readFile(path.join(gitignoreTargetDir, '.gitignore'), 'utf-8');
424
+ expect(gitignore).not.toContain('secret_keys.json');
425
+ expect(gitignore).toContain('terraform/.terraform/');
426
+ });
427
+ });
428
+
429
+ describe('Generator doc ownership & secret_keys preservation', () => {
430
+ const docTargetDir = path.join(process.cwd(), 'tests', '.tmp-doc-ownership-env');
431
+
432
+ const minimalConfig = {
433
+ PROJECT_NAME: 'test-docs',
434
+ REGION: 'us-east-2',
435
+ PORT: '8000',
436
+ CPU: '256',
437
+ MEMORY: '512',
438
+ COMPUTE_TIER: 'Micro',
439
+ ESTIMATED_COST: '30.00',
440
+ STATE_BUCKET: 'test-bucket-123',
441
+ AWS_ACCOUNT_ID: '123456789012',
442
+ HEALTH_CHECK_PATH: '/health',
443
+ DESIRED_COUNT: '1',
444
+ DEPLOY_BRANCH: 'main',
445
+ BUILD_DIR: '',
446
+ finalFramework: 'node',
447
+ NEEDS_DATABASE: false,
448
+ DISABLE_DEFAULT_CI: false,
449
+ PROCFILE: null,
450
+ VERCEL_RULES: null,
451
+ VERCEL_EDGE_ROUTING: '',
452
+ DOCKER_COMPOSE: null,
453
+ ENABLE_PR_PREVIEWS: false,
454
+ TASK_COMMAND: '',
455
+ WORKER_COMMAND: '',
456
+ DB_ENV_VARS: '',
457
+ COMPOSE_WEB_ENV_VARS: '',
458
+ EXTRA_CONTAINERS: '',
459
+ TASK_SECRETS: '',
460
+ INITIAL_SECRET_MAP: '{\n }',
461
+ SAFE_ALB_NAME: 'test-alb',
462
+ };
463
+
464
+ const MANAGED_README = '# test-docs\n\n* **Estimated Monthly Cost:** ~$30.00/month\n';
465
+ const USER_README = '# my cool app\n\nMy own docs.\n';
466
+ const USER_DEPLOYMENT = '# my deploy notes\n\nCustom content.\n';
467
+
468
+ beforeEach(async () => {
469
+ await fs.rm(docTargetDir, { recursive: true, force: true }).catch(() => { });
470
+ await fs.mkdir(docTargetDir, { recursive: true });
471
+ });
472
+
473
+ afterEach(async () => {
474
+ await fs.rm(docTargetDir, { recursive: true, force: true }).catch(() => { });
475
+ });
476
+
477
+ it('preserves previously pushed secret keys instead of resetting to []', async () => {
478
+ await fs.mkdir(path.join(docTargetDir, 'terraform'), { recursive: true });
479
+ await fs.writeFile(path.join(docTargetDir, 'terraform', 'secret_keys.json'), '["API_KEY"]');
480
+ await generateTemplates(docTargetDir, minimalConfig);
481
+ const keysFile = await fs.readFile(path.join(docTargetDir, 'terraform', 'secret_keys.json'), 'utf-8');
482
+ expect(keysFile).toBe('["API_KEY"]');
483
+ });
484
+
485
+ it('creates an empty secret_keys.json when absent', async () => {
486
+ await generateTemplates(docTargetDir, minimalConfig);
487
+ const keysFile = await fs.readFile(path.join(docTargetDir, 'terraform', 'secret_keys.json'), 'utf-8');
488
+ expect(keysFile).toBe('[]');
489
+ });
490
+
491
+ it('resolveDocDest never selects a user-owned file', async () => {
492
+ expect(resolveDocDest(docTargetDir)).toBe('README.md');
493
+
494
+ await fs.writeFile(path.join(docTargetDir, 'README.md'), USER_README);
495
+ expect(resolveDocDest(docTargetDir)).toBe('DEPLOYMENT.md');
496
+
497
+ await fs.writeFile(path.join(docTargetDir, 'DEPLOYMENT.md'), USER_DEPLOYMENT);
498
+ expect(resolveDocDest(docTargetDir)).toBe(MANAGED_DOC_FALLBACK);
499
+
500
+ await fs.writeFile(path.join(docTargetDir, MANAGED_DOC_FALLBACK), USER_DEPLOYMENT);
501
+ expect(resolveDocDest(docTargetDir)).toBe(LEGACY_MANAGED_DOC_FALLBACK);
502
+
503
+ await fs.writeFile(path.join(docTargetDir, LEGACY_MANAGED_DOC_FALLBACK), USER_DEPLOYMENT);
504
+ expect(resolveDocDest(docTargetDir)).toBeNull();
505
+ });
506
+
507
+ it('resolveDocDest prefers updating managed docs in place', async () => {
508
+ await fs.writeFile(path.join(docTargetDir, 'README.md'), MANAGED_README);
509
+ expect(resolveDocDest(docTargetDir)).toBe('README.md');
510
+
511
+ await fs.rm(path.join(docTargetDir, 'README.md'));
512
+ await fs.writeFile(path.join(docTargetDir, 'README.md'), USER_README);
513
+ await fs.writeFile(path.join(docTargetDir, 'DEPLOYMENT.md'), MANAGED_README);
514
+ expect(resolveDocDest(docTargetDir)).toBe('DEPLOYMENT.md');
515
+ });
516
+
517
+ it('writes DEPLOYMENT.md for user READMEs and appends the notice exactly once', async () => {
518
+ await fs.writeFile(path.join(docTargetDir, 'README.md'), USER_README);
519
+ await generateTemplates(docTargetDir, minimalConfig);
520
+ await generateTemplates(docTargetDir, minimalConfig);
521
+
522
+ const readme = await fs.readFile(path.join(docTargetDir, 'README.md'), 'utf-8');
523
+ expect(readme).toContain(USER_README.trim());
524
+ expect(readme.match(/## 🚀 Deployment/g)).toHaveLength(1);
525
+ expect(readme).toContain('./DEPLOYMENT.md');
526
+
527
+ const deployment = await fs.readFile(path.join(docTargetDir, 'DEPLOYMENT.md'), 'utf-8');
528
+ expect(deployment).toContain('Estimated Fixed Monthly Baseline:');
529
+ });
530
+
531
+ it('falls back to GRADA.md when README and DEPLOYMENT are user-owned', async () => {
532
+ await fs.writeFile(path.join(docTargetDir, 'README.md'), USER_README);
533
+ await fs.writeFile(path.join(docTargetDir, 'DEPLOYMENT.md'), USER_DEPLOYMENT);
534
+ await generateTemplates(docTargetDir, minimalConfig);
535
+
536
+ expect(await fs.readFile(path.join(docTargetDir, 'README.md'), 'utf-8')).toContain(USER_README.trim());
537
+ expect(await fs.readFile(path.join(docTargetDir, 'DEPLOYMENT.md'), 'utf-8')).toBe(USER_DEPLOYMENT);
538
+ const fallback = await fs.readFile(path.join(docTargetDir, MANAGED_DOC_FALLBACK), 'utf-8');
539
+ expect(fallback).toContain('Estimated Fixed Monthly Baseline:');
540
+ expect(isManagedDoc(fallback)).toBe(true);
541
+ });
542
+
543
+ it('keeps updating a managed legacy DEPLOY-STACK.md in place', async () => {
544
+ await fs.writeFile(path.join(docTargetDir, 'README.md'), USER_README);
545
+ await fs.writeFile(path.join(docTargetDir, 'DEPLOYMENT.md'), USER_DEPLOYMENT);
546
+ await fs.writeFile(path.join(docTargetDir, MANAGED_DOC_FALLBACK), USER_DEPLOYMENT);
547
+ await fs.writeFile(path.join(docTargetDir, LEGACY_MANAGED_DOC_FALLBACK), MANAGED_README);
548
+ expect(resolveDocDest(docTargetDir)).toBe(LEGACY_MANAGED_DOC_FALLBACK);
549
+ await generateTemplates(docTargetDir, minimalConfig);
550
+
551
+ const legacy = await fs.readFile(path.join(docTargetDir, LEGACY_MANAGED_DOC_FALLBACK), 'utf-8');
552
+ expect(legacy).toContain('Estimated Fixed Monthly Baseline:');
553
+ expect(await fs.readFile(path.join(docTargetDir, MANAGED_DOC_FALLBACK), 'utf-8')).toBe(USER_DEPLOYMENT);
554
+ });
555
+
556
+ it('skips doc generation entirely when every candidate is user-owned', async () => {
557
+ await fs.writeFile(path.join(docTargetDir, 'README.md'), USER_README);
558
+ await fs.writeFile(path.join(docTargetDir, 'DEPLOYMENT.md'), USER_DEPLOYMENT);
559
+ await fs.writeFile(path.join(docTargetDir, MANAGED_DOC_FALLBACK), USER_DEPLOYMENT);
560
+ await fs.writeFile(path.join(docTargetDir, LEGACY_MANAGED_DOC_FALLBACK), USER_DEPLOYMENT);
561
+ await generateTemplates(docTargetDir, minimalConfig);
562
+
563
+ expect(await fs.readFile(path.join(docTargetDir, 'README.md'), 'utf-8')).toBe(USER_README);
564
+ expect(await fs.readFile(path.join(docTargetDir, 'DEPLOYMENT.md'), 'utf-8')).toBe(USER_DEPLOYMENT);
565
+ expect(await fs.readFile(path.join(docTargetDir, LEGACY_MANAGED_DOC_FALLBACK), 'utf-8')).toBe(USER_DEPLOYMENT);
566
+ });
567
+
568
+ it('updates a managed README in place without creating DEPLOYMENT.md', async () => {
569
+ await fs.writeFile(path.join(docTargetDir, 'README.md'), MANAGED_README);
570
+ await generateTemplates(docTargetDir, minimalConfig);
571
+
572
+ const readme = await fs.readFile(path.join(docTargetDir, 'README.md'), 'utf-8');
573
+ expect(readme).toContain('Estimated Fixed Monthly Baseline:');
574
+ expect(readme).toContain('30.00');
575
+ await expect(fs.stat(path.join(docTargetDir, 'DEPLOYMENT.md'))).rejects.toThrow();
576
+ });
577
+ });
578
+ describe('Infrastructure Generator: --target lambda', () => {
579
+ const lambdaTargetDir = path.join(process.cwd(), 'tests', '.tmp-test-env-lambda');
580
+
581
+ beforeAll(async () => {
582
+ await fs.mkdir(lambdaTargetDir, { recursive: true });
583
+ });
584
+
585
+ afterAll(async () => {
586
+ await fs.rm(lambdaTargetDir, { recursive: true, force: true });
587
+ });
588
+
589
+ beforeEach(async () => {
590
+ await fs.rm(lambdaTargetDir, { recursive: true, force: true });
591
+ await fs.mkdir(lambdaTargetDir, { recursive: true });
592
+ });
593
+
594
+ function lambdaConfig(overrides = {}) {
595
+ return {
596
+ PROJECT_NAME: 'test-lambda',
597
+ REGION: 'us-east-2',
598
+ PORT: '3000',
599
+ CPU: '256',
600
+ MEMORY: '512',
601
+ COMPUTE_TIER: 'Micro',
602
+ ESTIMATED_COST: '0.80',
603
+ STATE_BUCKET: 'test-bucket-123',
604
+ AWS_ACCOUNT_ID: '123456789012',
605
+ HEALTH_CHECK_PATH: '/',
606
+ DESIRED_COUNT: '1',
607
+ DEPLOY_BRANCH: 'main',
608
+ BUILD_DIR: '',
609
+ finalFramework: 'node',
610
+ NEEDS_DATABASE: false,
611
+ DB_ENGINE: 'postgres',
612
+ DJANGO_WSGI: '',
613
+ DISABLE_DEFAULT_CI: false,
614
+ PROCFILE: null,
615
+ VERCEL_RULES: null,
616
+ VERCEL_EDGE_ROUTING: '',
617
+ DOCKER_COMPOSE: null,
618
+ ENABLE_PR_PREVIEWS: false,
619
+ TASK_COMMAND: '',
620
+ WORKER_COMMAND: '',
621
+ DB_ENV_VARS: '',
622
+ COMPOSE_WEB_ENV_VARS: '',
623
+ EXTRA_CONTAINERS: '',
624
+ TASK_SECRETS: '',
625
+ INITIAL_SECRET_MAP: '{\n }',
626
+ SAFE_ALB_NAME: 'test-lambda',
627
+ TARGET: 'lambda',
628
+ ...overrides,
629
+ };
630
+ }
631
+
632
+ async function readTf(name) {
633
+ return fs.readFile(path.join(lambdaTargetDir, 'terraform', name), 'utf-8');
634
+ }
635
+
636
+ it('writes the serverless topology with no ECS resources', async () => {
637
+ await generateTemplates(lambdaTargetDir, lambdaConfig());
638
+
639
+ const mainTf = await readTf('main.tf');
640
+ expect(mainTf).toContain('resource "aws_lambda_function" "app"');
641
+ expect(mainTf).toContain('image_uri = "${local.ecr_url}:latest"');
642
+ expect(mainTf).toContain('ignore_changes = [image_uri]');
643
+ expect(mainTf).toContain('resource "aws_apigatewayv2_api" "main"');
644
+ expect(mainTf).toContain('resource "aws_apigatewayv2_integration" "lambda"');
645
+ expect(mainTf).toContain('resource "aws_lambda_permission" "apigw"');
646
+ expect(mainTf).toContain('output "api_gateway_url"');
647
+ expect(mainTf).not.toContain('aws_ecs_service');
648
+ expect(mainTf).not.toContain('aws_lb');
649
+ expect(mainTf).toContain('AWSLambdaVPCAccessExecutionRole');
650
+
651
+ const cloudfrontTf = await readTf('cloudfront.tf');
652
+ expect(cloudfrontTf).toContain('replace(aws_apigatewayv2_api.main.api_endpoint, "https://", "")');
653
+ expect(cloudfrontTf).toContain('origin_protocol_policy = "https-only"');
654
+ expect(cloudfrontTf).not.toContain('aws_lb.main.dns_name');
655
+
656
+ const deployYml = await fs.readFile(path.join(lambdaTargetDir, '.github', 'workflows', 'deploy.yml'), 'utf-8');
657
+ expect(deployYml).toContain('aws lambda update-function-code');
658
+ expect(deployYml).toContain('aws lambda wait function-updated');
659
+ expect(deployYml).toContain('--platform linux/amd64');
660
+ expect(deployYml).not.toContain('Force ECS deployment');
661
+ });
662
+
663
+ it('never provisions worker.tf, even with a Procfile worker process', async () => {
664
+ await generateTemplates(lambdaTargetDir, lambdaConfig({
665
+ PROCFILE: { web: ['node', 'index.js'], worker: ['node', 'worker.js'] },
666
+ }));
667
+ await expect(fs.stat(path.join(lambdaTargetDir, 'terraform', 'worker.tf'))).rejects.toThrow();
668
+ });
669
+
670
+ it('maps Procfile web commands to image_config and omits it when empty', async () => {
671
+ await generateTemplates(lambdaTargetDir, lambdaConfig({
672
+ PROCFILE: { web: ['gunicorn', 'config.wsgi'] },
673
+ }));
674
+ const mainTf = await readTf('main.tf');
675
+ expect(mainTf).toContain('image_config {');
676
+ expect(mainTf).toContain('command = ["sh","-c","gunicorn config.wsgi"]');
677
+
678
+ await fs.rm(lambdaTargetDir, { recursive: true, force: true });
679
+ await generateTemplates(lambdaTargetDir, lambdaConfig());
680
+ expect(await readTf('main.tf')).not.toContain('image_config');
681
+ });
682
+
683
+ it.each(['postgres', 'mysql', 'aurora-postgresql'])('wires VPC + env password for %s without Secrets Manager lookups', async (engine) => {
684
+ await generateTemplates(lambdaTargetDir, lambdaConfig({ NEEDS_DATABASE: true, DB_ENGINE: engine }));
685
+
686
+ const mainTf = await readTf('main.tf');
687
+ expect(mainTf).toContain('vpc_config {');
688
+ expect(mainTf).toContain('DB_PASSWORD = random_password.db_password.result');
689
+ expect(mainTf).toContain('DB_USER = "dbadmin"');
690
+ if (engine !== 'postgres') {
691
+ expect(mainTf).toContain(`DB_ENGINE = "${engine}"`);
692
+ }
693
+
694
+ const databaseTf = await readTf('database.tf');
695
+ expect(databaseTf).toContain('password = random_password.db_password.result');
696
+ expect(databaseTf).toContain('resource "random_password" "db_password"');
697
+ expect(databaseTf).not.toContain('manage_master_user_password');
698
+ expect(databaseTf).not.toContain('rds_secret_access');
699
+
700
+ const backendTf = await readTf('backend.tf');
701
+ expect(backendTf).toContain('hashicorp/random');
702
+ });
703
+
704
+ it('leaves non-database functions outside the VPC', async () => {
705
+ await generateTemplates(lambdaTargetDir, lambdaConfig());
706
+ const mainTf = await readTf('main.tf');
707
+ expect(mainTf).not.toContain('vpc_config');
708
+ await expect(fs.stat(path.join(lambdaTargetDir, 'terraform', 'database.tf'))).rejects.toThrow();
709
+ expect(await readTf('backend.tf')).not.toContain('hashicorp/random');
710
+ });
711
+
712
+ it('injects the Lambda Web Adapter into every framework Dockerfile', async () => {
713
+ for (const framework of ['node', 'nestjs', 'nextjs', 'nuxt', 'svelte', 'python', 'django', 'rails', 'go', 'static']) {
714
+ await fs.rm(lambdaTargetDir, { recursive: true, force: true });
715
+ await generateTemplates(lambdaTargetDir, lambdaConfig({ finalFramework: framework }));
716
+ const dockerfile = await fs.readFile(path.join(lambdaTargetDir, 'Dockerfile'), 'utf-8');
717
+ expect(dockerfile).toContain('COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter');
718
+ expect(dockerfile).toContain('ENV AWS_LWA_PORT=3000');
719
+ // The adapter copy precedes any USER switch (root-owned /opt).
720
+ const copyIdx = dockerfile.indexOf('/lambda-adapter /opt/extensions');
721
+ const userIdx = dockerfile.search(/^\s*USER\s/m);
722
+ if (userIdx !== -1) expect(copyIdx).toBeLessThan(userIdx);
723
+ // Templates that already set PORT keep exactly one such line.
724
+ expect(dockerfile.match(/^\s*ENV\s+PORT=/gm)).toHaveLength(1);
725
+ }
726
+ });
727
+
728
+ it('drops ALB listener rules and sidecars while keeping compose env vars', async () => {
729
+ await generateTemplates(lambdaTargetDir, lambdaConfig({
730
+ VERCEL_RULES: { redirects: [{ source: '/old', destination: '/new', permanent: true }] },
731
+ DOCKER_COMPOSE: [
732
+ { name: 'web', port: 3000, environment: { COMPOSE_KEY: 'compose-val' } },
733
+ { name: 'redis', image: 'redis:alpine', environment: {} },
734
+ ],
735
+ }));
736
+ expect(await readTf('network.tf')).not.toContain('aws_lb_listener_rule');
737
+ const mainTf = await readTf('main.tf');
738
+ expect(mainTf).toContain('COMPOSE_KEY = "compose-val"');
739
+ });
740
+
741
+ it('generates the lambda PR preview workflow when opted in', async () => {
742
+ await generateTemplates(lambdaTargetDir, lambdaConfig({ ENABLE_PR_PREVIEWS: true }));
743
+ const previewYml = await fs.readFile(path.join(lambdaTargetDir, '.github', 'workflows', 'preview.yml'), 'utf-8');
744
+ expect(previewYml).toContain('api_gateway_url');
745
+ expect(previewYml).toContain('aws lambda update-function-code');
746
+ expect(previewYml).not.toContain('alb_direct_url');
747
+ await expect(fs.stat(path.join(lambdaTargetDir, '.github', 'workflows', 'teardown.yml'))).resolves.toBeTruthy();
748
+ });
749
+ });
750
+
751
+ describe('Lambda generator helpers', () => {
752
+ it('injectLambdaAdapter inserts after the last FROM and skips duplicates', () => {
753
+ const multiStage = 'FROM node:22 AS builder\nRUN build\nFROM node:22 AS runner\nUSER node\nCMD ["node"]\n';
754
+ const injected = injectLambdaAdapter(multiStage, '3000');
755
+ expect(injected.indexOf('/lambda-adapter /opt/extensions')).toBeGreaterThan(injected.lastIndexOf('FROM'));
756
+ expect(injected.indexOf('/lambda-adapter /opt/extensions')).toBeLessThan(injected.indexOf('USER node'));
757
+
758
+ const withPort = 'FROM node:22\nENV PORT=3000\nCMD ["node"]\n';
759
+ expect(injectLambdaAdapter(withPort, '3000').match(/^\s*ENV\s+PORT=/gm)).toHaveLength(1);
760
+
761
+ expect(injectLambdaAdapter('no from here', '3000')).toBe('no from here');
762
+ });
763
+
764
+ it('convertDatabaseTfForLambda is a no-op without AWS-managed passwords', () => {
765
+ expect(convertDatabaseTfForLambda('resource "x" "y" {}')).toBe('resource "x" "y" {}');
766
+ expect(convertDatabaseTfForLambda(null)).toBe('');
767
+ });
768
+
769
+ it('convertDatabaseTfForLambda uses master_password for Aurora clusters', async () => {
770
+ const aurora = await fs.readFile(path.join(process.cwd(), 'templates', 'terraform', 'database-aurora-postgresql.tf'), 'utf-8');
771
+ const converted = convertDatabaseTfForLambda(aurora);
772
+ expect(converted).toMatch(/^\s*master_password = random_password\.db_password\.result/m);
773
+ expect(converted).not.toMatch(/^\s*password = random_password/m);
774
+ expect(converted).not.toContain('manage_master_user_password');
775
+ expect(converted).toContain('resource "random_password" "db_password"');
776
+ });
777
+
778
+ it('convertDatabaseTfForLambda keeps password for RDS instances', async () => {
779
+ for (const tpl of ['database.tf', 'database-mysql.tf']) {
780
+ const content = await fs.readFile(path.join(process.cwd(), 'templates', 'terraform', tpl), 'utf-8');
781
+ const converted = convertDatabaseTfForLambda(content);
782
+ expect(converted).toMatch(/^\s*password = random_password\.db_password\.result/m);
783
+ expect(converted).not.toContain('master_password');
784
+ }
785
+ });
786
+
787
+ it('addRandomProvider is idempotent and ignores foreign backends', () => {
788
+ const backend = 'terraform {\n required_providers {\n aws = {}\n tls = {\n source = "hashicorp/tls"\n version = "~> 4.0"\n }\n }\n}\n';
789
+ const once = addRandomProvider(backend);
790
+ expect(once).toContain('hashicorp/random');
791
+ expect(addRandomProvider(once)).toBe(once);
792
+ expect(addRandomProvider('unrelated')).toBe('unrelated');
793
+ });
794
+ });