grada-run 0.0.2 → 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 -7
  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,214 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+ import { intro, outro, spinner, log, cancel, confirm, isCancel } from '@clack/prompts';
4
+ import color from 'picocolors';
5
+ import { renderDryRunPreview, parseTerraformConfig, buildCostTelemetryProps } from '../utils/visualizer.js';
6
+ import { detectFramework } from '../utils/detector.js';
7
+ import { trackEvent, flushTelemetry, trackSuccess, trackFailure } from '../core/telemetry.js';
8
+ import { failCommand } from '../utils/command.js';
9
+ import { normalizeOptions } from '../utils/args.js';
10
+ import { spawnSync } from 'child_process';
11
+ import { provisionStateBucket } from '../utils/aws.js';
12
+ import { runTerraformCommand, getTerraformOutputs } from '../utils/terraform.js';
13
+ import { readSleepState, resolveSleepTarget } from '../utils/sleep-state.js';
14
+ import { resolveProjectName } from '../utils/resolvers.js';
15
+ import { ensureLambdaSeedImage } from '../utils/lambda-ecr.js';
16
+
17
+ export async function applyStack(input = {}) {
18
+ const options = normalizeOptions(input);
19
+ intro(color.bgCyan(color.black(' grada apply ☁️ ')));
20
+
21
+ const targetDir = process.cwd();
22
+ const tfDir = path.join(targetDir, 'terraform');
23
+
24
+ // 1. Verify that the user is inside a project generated by grada
25
+ if (!fs.existsSync(tfDir) || !fs.existsSync(path.join(tfDir, 'main.tf'))) {
26
+ return failCommand({
27
+ print: () => {
28
+ log.error(color.red('✖ No terraform directory found in current path.'));
29
+ log.message('Please run "npx grada-run" first to generate your infrastructure templates.');
30
+ },
31
+ });
32
+ }
33
+
34
+ // 1b. Warn when this environment is asleep: apply resets the app
35
+ // desired count while the database stays stopped, so fresh tasks would
36
+ // crash-loop. Advisory only — the apply still proceeds.
37
+ const sleepState = readSleepState(targetDir);
38
+ const sleepTarget = resolveSleepTarget({}, targetDir);
39
+ if (sleepState[sleepTarget.envKey]) {
40
+ log.warn(color.yellow(`⚠ Environment "${sleepTarget.envKey}" is asleep. Run ${color.green('npx grada-run wake')} first, or this apply will start tasks against a stopped database.`));
41
+ }
42
+
43
+ // 2. Read the actual AWS configuration from disk (CPU, Memory, Region, Database)
44
+ const detectedConfig = parseTerraformConfig(tfDir);
45
+ detectedConfig.projectName = path.basename(process.cwd());
46
+ const costProps = buildCostTelemetryProps(detectedConfig);
47
+
48
+ // 3. Detect the framework name using your existing detector
49
+ const detectedFw = detectFramework(targetDir);
50
+ detectedConfig.framework = detectedFw ? detectedFw.name : 'Container';
51
+
52
+ // 4. Run the visualizer (passing the flag so it knows whether to prompt)
53
+ if (options.isDryRun) {
54
+ await renderDryRunPreview(detectedConfig, true);
55
+ outro(color.green('Dry run complete. No infrastructure was provisioned.'));
56
+ await trackSuccess('infrastructure_dry_run', {
57
+ framework: detectedConfig.framework,
58
+ ...costProps,
59
+ });
60
+ process.exit(0);
61
+ } else if (options.autoApprove) {
62
+ await renderDryRunPreview(detectedConfig, true);
63
+ } else if (!options.autoApprove) {
64
+ const confirmed = await renderDryRunPreview(detectedConfig, false);
65
+ if (!confirmed) {
66
+ cancel('Apply aborted.');
67
+ process.exit(0);
68
+ }
69
+ }
70
+
71
+ const s = spinner();
72
+ const runTerraform = options.runTerraformImpl || runTerraformCommand;
73
+ const runSync = options.spawnSyncImpl || spawnSync;
74
+
75
+ try {
76
+ // 5. Run terraform init
77
+ s.start('Initializing Terraform plugins...');
78
+ await runTerraform(['init', '-upgrade'], tfDir, s, 'Initializing');
79
+ s.stop('Terraform initialized.');
80
+
81
+ // 5b. Lambda Day-0 bootstrap: CreateFunction rejects an empty ECR
82
+ // repository, so ensure the repository exists and seed a minimal
83
+ // placeholder image when :latest hasn't been pushed yet. No-op on
84
+ // reruns once CI owns the tag.
85
+ if (detectedConfig.computeTarget === 'lambda') {
86
+ s.start('Ensuring ECR repository for the Lambda image...');
87
+ await runTerraform(['apply', '-target=aws_ecr_repository.app', '-input=false', '-auto-approve'], tfDir, s, 'Ensuring ECR repository');
88
+ s.stop('ECR repository ready.');
89
+
90
+ s.start('Checking for a deployable container image...');
91
+ const seed = ensureLambdaSeedImage({
92
+ run: runSync,
93
+ repository: `${resolveProjectName({}, targetDir)}-repo`,
94
+ region: detectedConfig.region,
95
+ });
96
+ if (!seed.ok) {
97
+ if (seed.reason === 'cli-missing') {
98
+ throw new Error('AWS CLI is required to bootstrap the Lambda container image on Day 0 (install it from https://aws.amazon.com/cli/, then re-run apply).');
99
+ }
100
+ throw new Error(`Could not bootstrap the Day-0 Lambda container image (${seed.reason}${seed.detail ? `: ${seed.detail}` : ''}). Push any image to ECR and re-run apply.`);
101
+ }
102
+ s.stop(seed.seeded ? 'Day-0 placeholder image seeded.' : 'Container image already present.');
103
+ }
104
+
105
+ // 6. Run terraform apply
106
+ s.start('Provisioning AWS infrastructure (this may take 3–5 minutes)...');
107
+ await runTerraform(['apply', '-auto-approve'], tfDir, s, 'Provisioning');
108
+ s.stop('Cloud resources provisioned.');
109
+
110
+ // 7. Extract and print the outputs
111
+ const outputs = await getTerraformOutputs(tfDir);
112
+
113
+ const cfUrl = outputs.cloudfront_url?.value;
114
+ const albUrl = outputs.alb_direct_url?.value;
115
+ const apiUrl = outputs.api_gateway_url?.value;
116
+
117
+ let finalMessage = color.green('✅ Infrastructure is live!');
118
+ if (cfUrl) finalMessage += `\n 🌍 App URL: ${color.cyan(cfUrl)} ${color.dim('(global CDN — share this link)')}`;
119
+ if (albUrl) finalMessage += `\n 🚦 Direct URL: ${color.gray(albUrl)} ${color.dim('(bypasses the CDN — for debugging)')}`;
120
+ if (apiUrl) finalMessage += `\n 🔌 API URL: ${color.gray(apiUrl)} ${color.dim('(bypasses the CDN — for debugging)')}`;
121
+
122
+ outro(`${finalMessage}\n\n ${color.yellow('Push code to deploy your app and clear the 503 error:')}\n ${color.cyan('git add . && git commit -m "ci: infra" && git push origin main')}`);
123
+
124
+ const actualProjectName = path.basename(process.cwd());
125
+ await trackSuccess('infrastructure_applied', {
126
+ projectName: actualProjectName,
127
+ framework: detectedConfig.framework,
128
+ ...costProps
129
+ });
130
+
131
+ process.exit(0);
132
+
133
+ } catch (error) {
134
+ s.stop(color.red('❌ Terraform apply failed.'));
135
+
136
+ const errString = error.message || "";
137
+
138
+ if (errString.includes('NoSuchBucket') || errString.includes('does not exist')) {
139
+ log.warn('Terraform state bucket is missing (likely deleted manually or via destroy).');
140
+
141
+ trackEvent('recovery_prompted', { type: 'state_bucket_missing' });
142
+
143
+ const shouldRecreate = await confirm({
144
+ message: 'Do you want to automatically recreate the state bucket and resume provisioning?',
145
+ initialValue: true
146
+ });
147
+
148
+ if (isCancel(shouldRecreate) || !shouldRecreate) {
149
+ return failCommand({
150
+ print: () => cancel('Apply aborted. State bucket remains missing.'),
151
+ event: 'recovery_declined',
152
+ telemetry: { type: 'state_bucket_missing' },
153
+ });
154
+ }
155
+
156
+ trackEvent('recovery_accepted', { type: 'state_bucket_missing' });
157
+
158
+ try {
159
+ // 1. Destroy corrupted local cache
160
+ const dotTerraformPath = path.join(tfDir, '.terraform');
161
+ if (fs.existsSync(dotTerraformPath)) {
162
+ fs.rmSync(dotTerraformPath, { recursive: true, force: true });
163
+ }
164
+
165
+ // 2. Recreate bucket
166
+ const actualProjectName = path.basename(process.cwd());
167
+ const targetRegion = detectedConfig.region || process.env.AWS_REGION || 'us-east-2';
168
+
169
+ const recSpinner = spinner();
170
+ recSpinner.start('Recreating S3 state bucket in AWS...');
171
+ await provisionStateBucket(targetRegion, actualProjectName);
172
+ recSpinner.stop('✅ State bucket recreated.');
173
+
174
+ trackEvent('recovery_successful', { type: 'state_bucket_missing' });
175
+
176
+ // 3. Auto-resume the apply flow
177
+ log.message(color.cyan('Resuming infrastructure provisioning...'));
178
+ return applyStack({ ...options, autoApprove: true });
179
+
180
+ } catch (recoveryErr) {
181
+ return failCommand({
182
+ print: () => {
183
+ log.error(color.red('✖ Failed to auto-recover state bucket.'));
184
+ log.message(recoveryErr.message || recoveryErr);
185
+ },
186
+ event: 'recovery_failed',
187
+ telemetry: { type: 'state_bucket_missing', error: recoveryErr.message },
188
+ });
189
+ }
190
+ } else if (errString.includes('EntityAlreadyExists') && errString.includes('token.actions.githubusercontent.com')) {
191
+ log.error(color.red('GitHub OIDC Provider Conflict Detected'));
192
+ log.message('AWS only allows one GitHub Actions provider per account, and you already have one configured.');
193
+ log.message('');
194
+ log.message(`${color.bold('How to fix this:')}`);
195
+ log.message(`1. Open ${color.cyan('terraform/oidc.tf')}`);
196
+ log.message(`2. Change ${color.green('create_oidc_provider = true')} to ${color.green('create_oidc_provider = false')}`);
197
+ log.message(`3. Run ${color.cyan('npx grada-run apply')} again.`);
198
+ } else {
199
+ // Generic fallback for unknown errors
200
+ log.error(color.red(errString));
201
+ log.message(`${color.bold('To debug manually, navigate to your terraform folder:')}`);
202
+ log.message(color.cyan('cd terraform && terraform apply'));
203
+ }
204
+
205
+ const actualProjectName = path.basename(process.cwd());
206
+ await trackFailure('infrastructure_applied', {
207
+ projectName: actualProjectName,
208
+ error_code: error.code || 'UNKNOWN',
209
+ ...costProps
210
+ });
211
+
212
+ return failCommand({ exitCode: 1 });
213
+ }
214
+ }
@@ -0,0 +1,229 @@
1
+ import { RDSClient, CreateDBSnapshotCommand, DescribeDBSnapshotsCommand, CreateDBClusterSnapshotCommand, DescribeDBClusterSnapshotsCommand } from '@aws-sdk/client-rds';
2
+ import color from 'picocolors';
3
+ import { intro, outro, spinner } from '@clack/prompts';
4
+ import { trackSuccess, trackFailure } from '../../core/telemetry.js';
5
+ import { failCommand, failProjectNotInitialized } from '../../utils/command.js';
6
+ import { parseFlags, normalizeOptions, normalizeArgv } from '../../utils/args.js';
7
+ import { handleAuthErrorBranch, resolveClient } from '../../utils/aws.js';
8
+ import { resolveRegion, resolveProjectName, resolveAppName, resolveCwd } from '../../utils/resolvers.js';
9
+ import { findDbTarget, generateSnapshotId, isValidSnapshotId, resolveDbIdentifier, resolveDbClusterIdentifier } from '../../utils/rds.js';
10
+ import { pollUntil, parseTimeoutSeconds } from '../../utils/system.js';
11
+
12
+ export const DEFAULT_BACKUP_TIMEOUT_SECONDS = 900;
13
+ export const DEFAULT_BACKUP_POLL_INTERVAL_MS = 5000;
14
+
15
+ const SNAPSHOT_NOT_FOUND_NAMES = new Set(['DBSnapshotNotFound', 'DBSnapshotNotFoundFault']);
16
+ const CLUSTER_SNAPSHOT_NOT_FOUND_NAMES = new Set(['DBClusterSnapshotNotFound', 'DBClusterSnapshotNotFoundFault']);
17
+
18
+ export function parseDbBackupArgs(argv = []) {
19
+ const args = normalizeArgv(argv);
20
+ if (args[0] === 'db') args.shift();
21
+ if (args[0] === 'backup') args.shift();
22
+ const { options, rest } = parseFlags(args, {
23
+ string: [
24
+ { name: 'id', key: 'snapshotId' },
25
+ { name: 'timeout', key: 'timeout' },
26
+ { name: 'project-name', key: 'projectName' },
27
+ { name: 'region', key: 'region' },
28
+ { name: 'workspace', key: 'workspace' },
29
+ { name: 'db-identifier', key: 'dbIdentifier' },
30
+ ],
31
+ boolean: ['no-wait', 'headless'],
32
+ });
33
+ const positionals = rest.filter((arg) => typeof arg === 'string' && !arg.startsWith('-'));
34
+ if (positionals.length > 0) options.unexpectedPositionals = positionals;
35
+ return options;
36
+ }
37
+
38
+ export async function runDbBackup(input = {}) {
39
+ const options = normalizeOptions(input);
40
+ let cwd;
41
+ let region;
42
+ let projectName;
43
+ let dbIdentifier;
44
+ let dbClusterIdentifier;
45
+ try {
46
+ cwd = resolveCwd(options);
47
+ region = resolveRegion(options, cwd);
48
+ projectName = resolveProjectName(options, cwd);
49
+ const appName = resolveAppName(projectName, options.workspace, cwd);
50
+ dbIdentifier = resolveDbIdentifier({ ...options, projectName }, cwd);
51
+ dbClusterIdentifier = resolveDbClusterIdentifier({ ...options, projectName }, cwd);
52
+ } catch {
53
+ return failProjectNotInitialized({ event: 'db_backup_run' });
54
+ }
55
+ const noWait = options.noWait === true || options.noWait === 'true';
56
+
57
+ intro(color.bgCyan(color.black(' grada db backup 📸 ')));
58
+
59
+ if (Array.isArray(options.unexpectedPositionals) && options.unexpectedPositionals.length > 0) {
60
+ return failCommand({
61
+ message: `\n✖ Unexpected argument "${options.unexpectedPositionals[0]}". Set the snapshot id with --id <snapshot-id>.\n`,
62
+ event: 'db_backup_run',
63
+ telemetry: { projectName },
64
+ errorCode: 'UNEXPECTED_POSITIONAL_ARGS',
65
+ reason: 'unexpected-positional-args',
66
+ resultExtra: { dbIdentifier, region },
67
+ });
68
+ }
69
+
70
+ if (options.snapshotId !== undefined && !isValidSnapshotId(options.snapshotId)) {
71
+ return failCommand({
72
+ message: `\n✖ Invalid --id "${options.snapshotId}". Snapshot ids start with a letter, use letters/digits/hyphens (no "--", no trailing "-").\n`,
73
+ event: 'db_backup_run',
74
+ telemetry: { projectName },
75
+ errorCode: 'INVALID_SNAPSHOT_ID',
76
+ reason: 'invalid-snapshot-id',
77
+ resultExtra: { dbIdentifier, region },
78
+ });
79
+ }
80
+
81
+ const timeoutSeconds = parseTimeoutSeconds(options.timeout, DEFAULT_BACKUP_TIMEOUT_SECONDS);
82
+ if (timeoutSeconds === null) {
83
+ return failCommand({
84
+ message: `\n✖ Invalid --timeout "${options.timeout}". Use a positive number of seconds (default ${DEFAULT_BACKUP_TIMEOUT_SECONDS}).\n`,
85
+ event: 'db_backup_run',
86
+ telemetry: { projectName },
87
+ errorCode: 'INVALID_TIMEOUT',
88
+ reason: 'invalid-timeout',
89
+ resultExtra: { dbIdentifier, region },
90
+ });
91
+ }
92
+
93
+ const rdsClient = resolveClient(options.rdsClient, RDSClient, { region });
94
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_BACKUP_POLL_INTERVAL_MS;
95
+ const timeoutMs = options.timeoutMs ?? timeoutSeconds * 1000;
96
+
97
+ const s = spinner();
98
+ s.start('Finding your database...');
99
+
100
+ try {
101
+ const target = await findDbTarget(rdsClient, { dbIdentifier, dbClusterIdentifier });
102
+ if (!target) {
103
+ s.stop();
104
+ return failCommand({
105
+ print: () => {
106
+ console.log(color.yellow('\n⚠ No database found.'));
107
+ console.log(` No RDS database named ${color.cyan(dbIdentifier)} exists in this environment.`);
108
+ console.log(` Re-run ${color.green('npx grada-run')} and answer "Yes" to the database prompt, then ${color.green('npx grada-run apply')}.\n`);
109
+ },
110
+ event: 'db_backup_run',
111
+ telemetry: { projectName },
112
+ errorCode: 'RDS_INSTANCE_NOT_FOUND',
113
+ reason: 'rds-instance-not-found',
114
+ resultExtra: { dbIdentifier, region },
115
+ });
116
+ }
117
+ const isCluster = target.kind === 'cluster';
118
+
119
+ const snapshotId = (typeof options.snapshotId === 'string' && options.snapshotId.trim())
120
+ ? options.snapshotId.trim()
121
+ : generateSnapshotId(target.id);
122
+
123
+ s.message(`Creating snapshot ${snapshotId}...`);
124
+ const snapshotTags = [
125
+ { Key: 'ManagedBy', Value: 'grada' },
126
+ { Key: 'Project', Value: projectName },
127
+ ];
128
+ if (isCluster) {
129
+ await rdsClient.send(new CreateDBClusterSnapshotCommand({
130
+ DBClusterIdentifier: target.id,
131
+ DBClusterSnapshotIdentifier: snapshotId,
132
+ Tags: snapshotTags,
133
+ }));
134
+ } else {
135
+ await rdsClient.send(new CreateDBSnapshotCommand({
136
+ DBInstanceIdentifier: target.id,
137
+ DBSnapshotIdentifier: snapshotId,
138
+ Tags: snapshotTags,
139
+ }));
140
+ }
141
+
142
+ if (noWait) {
143
+ s.stop(color.green('Snapshot creation started.'));
144
+ console.log(`\n Snapshot ${color.cyan(snapshotId)} is being created (status: creating).`);
145
+ console.log(color.dim(` Restore it later with: npx grada-run db restore ${snapshotId}\n`));
146
+ await trackSuccess('db_backup_run', { projectName, waited: false, db_kind: target.kind });
147
+ outro(color.green('Done.'));
148
+ return { ok: true, snapshotId, status: 'creating' };
149
+ }
150
+
151
+ s.message(`Waiting for snapshot ${snapshotId} to become available...`);
152
+ const outcome = await pollUntil({
153
+ intervalMs: pollIntervalMs,
154
+ timeoutMs,
155
+ onTick: async ({ elapsedMs }) => {
156
+ let snapshots = [];
157
+ try {
158
+ if (isCluster) {
159
+ const resp = await rdsClient.send(
160
+ new DescribeDBClusterSnapshotsCommand({ DBClusterSnapshotIdentifier: snapshotId })
161
+ );
162
+ snapshots = resp.DBClusterSnapshots || [];
163
+ } else {
164
+ const resp = await rdsClient.send(
165
+ new DescribeDBSnapshotsCommand({ DBSnapshotIdentifier: snapshotId })
166
+ );
167
+ snapshots = resp.DBSnapshots || [];
168
+ }
169
+ } catch (error) {
170
+ // The snapshot is not describable for a moment right after
171
+ // creation (eventual consistency), so keep polling.
172
+ const notFoundNames = isCluster ? CLUSTER_SNAPSHOT_NOT_FOUND_NAMES : SNAPSHOT_NOT_FOUND_NAMES;
173
+ if (error && notFoundNames.has(error.name)) {
174
+ s.message(`Waiting for snapshot ${snapshotId}... [${Math.floor(elapsedMs / 1000)}s]`);
175
+ return { done: false };
176
+ }
177
+ throw error;
178
+ }
179
+ const snapshot = snapshots[0] || null;
180
+ if (snapshot && snapshot.Status === 'available') {
181
+ return { done: true, value: snapshot };
182
+ }
183
+ s.message(`Waiting for snapshot ${snapshotId}... [${Math.floor(elapsedMs / 1000)}s]`);
184
+ return { done: false };
185
+ },
186
+ });
187
+
188
+ if (outcome.timedOut) {
189
+ s.stop(color.yellow('Snapshot still creating.'));
190
+ return failCommand({
191
+ print: () => {
192
+ console.log(color.yellow(`\n⚠ Snapshot ${snapshotId} did not become available within ${timeoutSeconds}s.`));
193
+ console.log(' Creation continues in the background — check status in the AWS console or retry with a larger --timeout.');
194
+ console.log(color.dim(` Restore it once available with: npx grada-run db restore ${snapshotId}\n`));
195
+ },
196
+ event: 'db_backup_run',
197
+ telemetry: { projectName, waited: true },
198
+ errorCode: 'SNAPSHOT_TIMEOUT',
199
+ reason: 'snapshot-timeout',
200
+ resultExtra: { dbIdentifier, region, snapshotId },
201
+ });
202
+ }
203
+
204
+ s.stop(color.green('Snapshot available.'));
205
+ console.log(`\n Snapshot ${color.cyan(snapshotId)} is ready.`);
206
+ console.log(color.dim(` Restore it with: npx grada-run db restore ${snapshotId}\n`));
207
+ await trackSuccess('db_backup_run', { projectName, waited: true, db_kind: target.kind });
208
+ outro(color.green('Done.'));
209
+ return { ok: true, snapshotId, status: 'available' };
210
+ } catch (error) {
211
+ await trackFailure('db_backup_run', {
212
+ projectName,
213
+ error_code: error?.name || 'UNKNOWN',
214
+ error_message: error?.message,
215
+ });
216
+ if (handleAuthErrorBranch(error, s, options)) {
217
+ return { ok: false, reason: 'error', dbIdentifier, region };
218
+ }
219
+ try { s.stop(color.red('❌ Db backup failed.')); } catch { /* spinner already stopped */ }
220
+ return failCommand({
221
+ message: `✖ ${error?.message || error}`,
222
+ hint: 'Check your AWS credentials and region, then try again.',
223
+ reason: 'error',
224
+ resultExtra: { dbIdentifier, region },
225
+ });
226
+ }
227
+ }
228
+
229
+ export default runDbBackup;