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,160 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseCliArgs } from '../src/core/parser.js';
3
+
4
+ describe('CLI Argument Parser', () => {
5
+ it('safely extracts telemetry variations and prevents positional hijacking', () => {
6
+ // Simulating: npx deploy-stack secrets push .env --no-telemetry=true
7
+ const args = ['secrets', 'push', '.env', '--no-telemetry=true'];
8
+ const result = parseCliArgs(args);
9
+
10
+ expect(result.hasNoTelemetry).toBe(true);
11
+ expect(result.positionalArgs).toEqual(['secrets', 'push', '.env']);
12
+ expect(result.baseCommand).toBe('secrets push');
13
+ });
14
+
15
+ it('correctly parses headless boolean flags without assignments', () => {
16
+ // Simulating: npx deploy-stack --headless --needsDatabase
17
+ const args = ['--headless', '--needsDatabase'];
18
+ const result = parseCliArgs(args);
19
+
20
+ expect(result.isHeadless).toBe(true);
21
+ expect(result.headlessOptions.needsDatabase).toBe(true);
22
+ });
23
+
24
+ it('correctly maps headless assignment flags', () => {
25
+ // Simulating: npx deploy-stack --headless --framework=django --region=us-east-2
26
+ const args = ['--headless', '--framework=django', '--region=us-east-2'];
27
+ const result = parseCliArgs(args);
28
+
29
+ expect(result.headlessOptions.framework).toBe('django');
30
+ expect(result.headlessOptions.region).toBe('us-east-2');
31
+ });
32
+
33
+ it('survives non-array and mixed-element inputs without throwing', () => {
34
+ for (const bad of [null, undefined, 'string', 42, true, { port: 'string' }]) {
35
+ const result = parseCliArgs(bad);
36
+ expect(result.positionalArgs).toEqual([]);
37
+ expect(result.baseCommand).toBe('init');
38
+ expect(result.isHeadless).toBe(false);
39
+ }
40
+ const mixed = parseCliArgs(['--headless', 42, null, 'status']);
41
+ expect(mixed.isHeadless).toBe(true);
42
+ expect(mixed.positionalArgs).toEqual([42, null, 'status']);
43
+ });
44
+
45
+ it('extracts isPreconfigured and always populates initOptions', () => {
46
+ const headless = parseCliArgs(['--headless', '--preconfigured']);
47
+ expect(headless.isPreconfigured).toBe(true);
48
+ expect(headless.initOptions).toEqual({
49
+ with: [],
50
+ model: null,
51
+ domain: null,
52
+ zoneId: null,
53
+ fromEmail: null,
54
+ dbEngine: null,
55
+ target: null,
56
+ setupCiMigrate: false,
57
+ setupCiDrift: false,
58
+ });
59
+
60
+ const interactive = parseCliArgs(['init', '--with', 'db:redis']);
61
+ expect(interactive.isPreconfigured).toBe(false);
62
+ expect(interactive.initOptions.with).toEqual(['db:redis']);
63
+ });
64
+
65
+ it('accumulates repeatable and comma-separated --with with dedupe', () => {
66
+ const result = parseCliArgs([
67
+ '--headless',
68
+ '--with', 'db:redis, queue:sqs',
69
+ '--with=db:redis',
70
+ '--with=ai:bedrock,',
71
+ '--with', '--headless',
72
+ ]);
73
+ expect(result.initOptions.with).toEqual(['db:redis', 'queue:sqs', 'ai:bedrock']);
74
+ });
75
+
76
+ it('parses addon value flags in space and equals forms', () => {
77
+ const spaced = parseCliArgs(['--headless', '--model', 'claude-x', '--domain', 'example.com']);
78
+ expect(spaced.initOptions.model).toBe('claude-x');
79
+ expect(spaced.initOptions.domain).toBe('example.com');
80
+
81
+ const joined = parseCliArgs(['--zone-id=Z1', '--from-email=hi@example.com', '--setup-ci-migrate']);
82
+ expect(joined.initOptions.zoneId).toBe('Z1');
83
+ expect(joined.initOptions.fromEmail).toBe('hi@example.com');
84
+ expect(joined.initOptions.setupCiMigrate).toBe(true);
85
+ expect(parseCliArgs(['--setup-ci-drift']).initOptions.setupCiDrift).toBe(true);
86
+ });
87
+
88
+ it('treats valueless addon flags as absent', () => {
89
+ const result = parseCliArgs(['--headless', '--model', '--domain=', '--with']);
90
+ expect(result.initOptions.model).toBeNull();
91
+ expect(result.initOptions.domain).toBeNull();
92
+ expect(result.initOptions.with).toEqual([]);
93
+ });
94
+
95
+ it('parses --db-engine in space and equals forms', () => {
96
+ const spaced = parseCliArgs(['--headless', '--db-engine', 'mysql']);
97
+ expect(spaced.initOptions.dbEngine).toBe('mysql');
98
+
99
+ const joined = parseCliArgs(['--db-engine=aurora-postgresql']);
100
+ expect(joined.initOptions.dbEngine).toBe('aurora-postgresql');
101
+
102
+ const absent = parseCliArgs(['--headless']);
103
+ expect(absent.initOptions.dbEngine).toBeNull();
104
+ });
105
+
106
+ it('parses --target in space and equals forms', () => {
107
+ const spaced = parseCliArgs(['--headless', '--target', 'lambda']);
108
+ expect(spaced.initOptions.target).toBe('lambda');
109
+
110
+ const joined = parseCliArgs(['--target=ecs']);
111
+ expect(joined.initOptions.target).toBe('ecs');
112
+
113
+ const absent = parseCliArgs(['--headless']);
114
+ expect(absent.initOptions.target).toBeNull();
115
+ });
116
+
117
+ it('treats a valueless --target as absent', () => {
118
+ expect(parseCliArgs(['--headless', '--target']).initOptions.target).toBeNull();
119
+ expect(parseCliArgs(['--target=']).initOptions.target).toBeNull();
120
+ });
121
+
122
+ it('parses headless value flags in space and equals forms', () => {
123
+ const spaced = parseCliArgs(['--headless', '--target', 'lambda', '--framework', 'node', '--region', 'eu-west-1']);
124
+ expect(spaced.headlessOptions.framework).toBe('node');
125
+ expect(spaced.headlessOptions.region).toBe('eu-west-1');
126
+ expect(spaced.initOptions.target).toBe('lambda');
127
+
128
+ const joined = parseCliArgs(['--headless', '--framework=django', '--port=3000']);
129
+ expect(joined.headlessOptions.framework).toBe('django');
130
+ expect(joined.headlessOptions.port).toBe('3000');
131
+
132
+ const missing = parseCliArgs(['--headless']);
133
+ expect(missing.headlessOptions.framework).toBeNull();
134
+ expect(missing.headlessOptions.needsDatabase).toBeNull();
135
+ });
136
+
137
+ it('preserves bare-boolean headless flags without consuming neighbors', () => {
138
+ const bare = parseCliArgs(['--headless', '--needsDatabase', '--framework', 'node']);
139
+ expect(bare.headlessOptions.needsDatabase).toBe(true);
140
+ expect(bare.headlessOptions.framework).toBe('node');
141
+
142
+ expect(parseCliArgs(['--headless', '--needsDatabase=true']).headlessOptions.needsDatabase).toBe(true);
143
+ expect(parseCliArgs(['--headless', '--enablePrPreviews=false']).headlessOptions.enablePrPreviews).toBe(false);
144
+ });
145
+
146
+ it('filters consumed flag values out of positionalArgs and baseCommand', () => {
147
+ const init = parseCliArgs(['--headless', '--target', 'lambda', '--framework', 'node']);
148
+ expect(init.positionalArgs).toEqual([]);
149
+ expect(init.baseCommand).toBe('init');
150
+
151
+ const routed = parseCliArgs(['status', '--region', 'us-east-1']);
152
+ expect(routed.positionalArgs).toEqual(['status']);
153
+ expect(routed.baseCommand).toBe('status');
154
+
155
+ // Genuine positionals (mixed with flags) are untouched.
156
+ const secrets = parseCliArgs(['secrets', 'push', '.env', '--region', 'us-east-2']);
157
+ expect(secrets.positionalArgs).toEqual(['secrets', 'push', '.env']);
158
+ expect(secrets.baseCommand).toBe('secrets push');
159
+ });
160
+ });
@@ -0,0 +1,244 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import {
6
+ findDbInstance,
7
+ findDbCluster,
8
+ findDbTarget,
9
+ generateSnapshotId,
10
+ isValidSnapshotId,
11
+ resolveDbIdentifier,
12
+ resolveDbClusterIdentifier,
13
+ } from '../src/utils/rds.js';
14
+
15
+ const { MockDescribeDBInstancesCommand, MockDescribeDBClustersCommand } = vi.hoisted(() => {
16
+ const cmd = () => vi.fn(function (input) { Object.assign(this, input); });
17
+ return {
18
+ MockDescribeDBInstancesCommand: cmd(),
19
+ MockDescribeDBClustersCommand: cmd(),
20
+ };
21
+ });
22
+
23
+ vi.mock('@aws-sdk/client-rds', () => ({
24
+ RDSClient: vi.fn(function () { this.send = vi.fn(); }),
25
+ DescribeDBInstancesCommand: MockDescribeDBInstancesCommand,
26
+ DescribeDBClustersCommand: MockDescribeDBClustersCommand,
27
+ }));
28
+
29
+ function mockClient(handler) {
30
+ return { send: vi.fn(handler) };
31
+ }
32
+
33
+ describe('findDbInstance', () => {
34
+ it('returns the instance for an exact identifier match', async () => {
35
+ const instance = { DBInstanceIdentifier: 'myapp-db' };
36
+ const client = mockClient(() => Promise.resolve({ DBInstances: [instance] }));
37
+ await expect(findDbInstance(client, 'myapp-db')).resolves.toBe(instance);
38
+ const input = client.send.mock.calls[0][0];
39
+ expect(input.DBInstanceIdentifier).toBe('myapp-db');
40
+ });
41
+
42
+ it('returns null when no instances are returned', async () => {
43
+ const client = mockClient(() => Promise.resolve({ DBInstances: [] }));
44
+ await expect(findDbInstance(client, 'myapp-db')).resolves.toBeNull();
45
+ });
46
+
47
+ it.each(['DBInstanceNotFound', 'DBInstanceNotFoundFault'])('returns null on %s', async (name) => {
48
+ const err = new Error('missing');
49
+ err.name = name;
50
+ const client = mockClient(() => Promise.reject(err));
51
+ await expect(findDbInstance(client, 'myapp-db')).resolves.toBeNull();
52
+ });
53
+
54
+ it('rethrows unexpected errors', async () => {
55
+ const err = new Error('boom');
56
+ err.name = 'AccessDenied';
57
+ const client = mockClient(() => Promise.reject(err));
58
+ await expect(findDbInstance(client, 'myapp-db')).rejects.toBe(err);
59
+ });
60
+ });
61
+
62
+ describe('generateSnapshotId', () => {
63
+ it('formats the UTC timestamp suffix', () => {
64
+ const now = new Date(Date.UTC(2026, 4, 9, 7, 8, 9));
65
+ expect(generateSnapshotId('myapp-db', now)).toBe('myapp-db-manual-20260509-070809');
66
+ });
67
+
68
+ it('lowercases the identifier', () => {
69
+ const now = new Date(Date.UTC(2026, 0, 1, 0, 0, 0));
70
+ expect(generateSnapshotId('MyApp-DB', now)).toBe('myapp-db-manual-20260101-000000');
71
+ });
72
+
73
+ it('produces a valid snapshot id by default', () => {
74
+ expect(isValidSnapshotId(generateSnapshotId('myapp-db'))).toBe(true);
75
+ });
76
+ });
77
+
78
+ describe('isValidSnapshotId', () => {
79
+ it.each([
80
+ 'a',
81
+ 'snap1',
82
+ 'myapp-db-manual-20260509-070809',
83
+ 'A-valid-ID-123',
84
+ 'x'.repeat(255),
85
+ ])('accepts %s', (id) => {
86
+ expect(isValidSnapshotId(id)).toBe(true);
87
+ });
88
+
89
+ it.each([
90
+ '',
91
+ '1abc',
92
+ '-abc',
93
+ 'has space',
94
+ 'has_underscore',
95
+ 'has--double-hyphen',
96
+ 'trailing-',
97
+ 'x'.repeat(256),
98
+ undefined,
99
+ null,
100
+ 123,
101
+ ])('rejects %s', (id) => {
102
+ expect(isValidSnapshotId(id)).toBe(false);
103
+ });
104
+ });
105
+
106
+ describe('resolveDbIdentifier', () => {
107
+ it('prefers an explicit --db-identifier override', () => {
108
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rds-id-'));
109
+ expect(resolveDbIdentifier({ projectName: 'myapp', dbIdentifier: ' custom-db ' }, dir)).toBe('custom-db');
110
+ });
111
+
112
+ it('resolves the project default with workspace suffixes', () => {
113
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rds-id-'));
114
+ expect(resolveDbIdentifier({ projectName: 'myapp' }, dir)).toBe('myapp-db');
115
+ expect(resolveDbIdentifier({ projectName: 'myapp', workspace: 'pr-1' }, dir)).toBe('myapp-pr-1-db');
116
+ });
117
+
118
+ it('auto-detects the workspace from .terraform/environment', () => {
119
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rds-id-'));
120
+ fs.mkdirSync(path.join(dir, '.terraform'), { recursive: true });
121
+ fs.writeFileSync(path.join(dir, '.terraform', 'environment'), 'pr-42\n');
122
+ expect(resolveDbIdentifier({ projectName: 'myapp' }, dir)).toBe('myapp-pr-42-db');
123
+ });
124
+
125
+ it('survives synthetic fuzzer inputs', () => {
126
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rds-id-'));
127
+ for (const bad of [null, undefined, 'string', 42, true, { port: 'string' }]) {
128
+ expect(resolveDbIdentifier(bad, dir)).toBe(resolveDbIdentifier({}, dir));
129
+ }
130
+ expect(resolveDbIdentifier({ projectName: 'myapp' }, 42)).toBe(resolveDbIdentifier({ projectName: 'myapp' }));
131
+ expect(resolveDbIdentifier({ projectName: 'myapp', cwd: 42 }, dir)).toBe('myapp-db');
132
+ });
133
+ });
134
+
135
+ describe('resolveDbClusterIdentifier', () => {
136
+ it('prefers an explicit --db-identifier override', () => {
137
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rds-id-'));
138
+ expect(resolveDbClusterIdentifier({ projectName: 'myapp', dbIdentifier: ' custom-db ' }, dir)).toBe('custom-db');
139
+ });
140
+
141
+ it('resolves the cluster default with workspace suffixes', () => {
142
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rds-id-'));
143
+ expect(resolveDbClusterIdentifier({ projectName: 'myapp' }, dir)).toBe('myapp-db-cluster');
144
+ expect(resolveDbClusterIdentifier({ projectName: 'myapp', workspace: 'pr-1' }, dir)).toBe('myapp-pr-1-db-cluster');
145
+ });
146
+ });
147
+
148
+ describe('findDbCluster', () => {
149
+ it('returns the cluster for an exact identifier match', async () => {
150
+ const cluster = { DBClusterIdentifier: 'myapp-db-cluster' };
151
+ const client = mockClient(() => Promise.resolve({ DBClusters: [cluster] }));
152
+ await expect(findDbCluster(client, 'myapp-db-cluster')).resolves.toBe(cluster);
153
+ const input = client.send.mock.calls[0][0];
154
+ expect(input.DBClusterIdentifier).toBe('myapp-db-cluster');
155
+ });
156
+
157
+ it.each(['DBClusterNotFound', 'DBClusterNotFoundFault'])('returns null on %s', async (name) => {
158
+ const err = new Error('missing');
159
+ err.name = name;
160
+ const client = mockClient(() => Promise.reject(err));
161
+ await expect(findDbCluster(client, 'myapp-db-cluster')).resolves.toBeNull();
162
+ });
163
+
164
+ it('rethrows unexpected errors', async () => {
165
+ const err = new Error('boom');
166
+ err.name = 'AccessDenied';
167
+ const client = mockClient(() => Promise.reject(err));
168
+ await expect(findDbCluster(client, 'myapp-db-cluster')).rejects.toBe(err);
169
+ });
170
+ });
171
+
172
+ describe('findDbTarget', () => {
173
+ const instance = {
174
+ DBInstanceIdentifier: 'myapp-db',
175
+ Engine: 'postgres',
176
+ Status: 'available',
177
+ Endpoint: { Address: 'myapp-db.abc.us-east-2.rds.amazonaws.com', Port: 5432 },
178
+ DBName: 'myapp',
179
+ MasterUserSecret: { SecretArn: 'arn:secret' },
180
+ };
181
+ const cluster = {
182
+ DBClusterIdentifier: 'myapp-db-cluster',
183
+ Engine: 'aurora-postgresql',
184
+ Status: 'available',
185
+ Endpoint: 'myapp-db-cluster.xyz.us-east-2.rds.amazonaws.com',
186
+ Port: 5432,
187
+ DatabaseName: 'myapp',
188
+ MasterUserSecret: { SecretArn: 'arn:secret' },
189
+ };
190
+
191
+ it('resolves an instance with a single API call', async () => {
192
+ const client = mockClient(() => Promise.resolve({ DBInstances: [instance] }));
193
+ const target = await findDbTarget(client, { dbIdentifier: 'myapp-db', dbClusterIdentifier: 'myapp-db-cluster' });
194
+ expect(target).toMatchObject({
195
+ kind: 'instance',
196
+ id: 'myapp-db',
197
+ engine: 'postgres',
198
+ status: 'available',
199
+ endpoint: 'myapp-db.abc.us-east-2.rds.amazonaws.com',
200
+ port: '5432',
201
+ dbName: 'myapp',
202
+ masterSecretArn: 'arn:secret',
203
+ });
204
+ expect(target.raw).toBe(instance);
205
+ expect(client.send).toHaveBeenCalledTimes(1);
206
+ });
207
+
208
+ it('falls back to the cluster identifier on instance miss', async () => {
209
+ const notFound = new Error('missing');
210
+ notFound.name = 'DBInstanceNotFound';
211
+ const client = mockClient((cmd) => {
212
+ if (cmd instanceof MockDescribeDBInstancesCommand) return Promise.reject(notFound);
213
+ return Promise.resolve({ DBClusters: [cluster] });
214
+ });
215
+ const target = await findDbTarget(client, { dbIdentifier: 'myapp-db', dbClusterIdentifier: 'myapp-db-cluster' });
216
+ expect(target).toMatchObject({
217
+ kind: 'cluster',
218
+ id: 'myapp-db-cluster',
219
+ engine: 'aurora-postgresql',
220
+ endpoint: 'myapp-db-cluster.xyz.us-east-2.rds.amazonaws.com',
221
+ port: '5432',
222
+ dbName: 'myapp',
223
+ masterSecretArn: 'arn:secret',
224
+ });
225
+ expect(target.raw).toBe(cluster);
226
+ expect(client.send.mock.calls[1][0].DBClusterIdentifier).toBe('myapp-db-cluster');
227
+ });
228
+
229
+ it('returns null when neither kind exists', async () => {
230
+ const client = mockClient((cmd) => {
231
+ const err = new Error('missing');
232
+ err.name = cmd instanceof MockDescribeDBInstancesCommand ? 'DBInstanceNotFound' : 'DBClusterNotFound';
233
+ return Promise.reject(err);
234
+ });
235
+ await expect(findDbTarget(client, { dbIdentifier: 'myapp-db', dbClusterIdentifier: 'myapp-db-cluster' })).resolves.toBeNull();
236
+ });
237
+
238
+ it('defaults the port by engine when the endpoint omits it', async () => {
239
+ const mysql = { ...instance, Engine: 'mysql', Endpoint: { Address: 'host' } };
240
+ const client = mockClient(() => Promise.resolve({ DBInstances: [mysql] }));
241
+ const target = await findDbTarget(client, { dbIdentifier: 'myapp-db' });
242
+ expect(target.port).toBe('3306');
243
+ });
244
+ });