@grada-run/grada 0.0.1 → 0.33.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 (242) hide show
  1. package/.github/workflows/deploy-docs.yml +37 -0
  2. package/.github/workflows/e2e.yml +73 -0
  3. package/.github/workflows/iac-validation.yml +303 -0
  4. package/.github/workflows/publish.yml +68 -0
  5. package/.github/workflows/sync-bedrock-models.yml +57 -0
  6. package/.github/workflows/test.yml +43 -0
  7. package/.muserules +31 -0
  8. package/LICENSE +21 -0
  9. package/README.md +193 -3
  10. package/apps/docs/.astro/collections/docs.schema.json +644 -0
  11. package/apps/docs/.astro/content-assets.mjs +4 -0
  12. package/apps/docs/.astro/content-modules.mjs +4 -0
  13. package/apps/docs/.astro/content.d.ts +179 -0
  14. package/apps/docs/.astro/data-store.json +1 -0
  15. package/apps/docs/.astro/dev.json +14 -0
  16. package/apps/docs/.astro/settings.json +5 -0
  17. package/apps/docs/.astro/types.d.ts +2 -0
  18. package/apps/docs/astro.config.mjs +97 -0
  19. package/apps/docs/package.json +17 -0
  20. package/apps/docs/src/content/docs/adrs/0001-s3-native-state-locking.md +37 -0
  21. package/apps/docs/src/content/docs/adrs/0002-eject-mechanism-pure-iac.md +39 -0
  22. package/apps/docs/src/content/docs/adrs/0003-sync-ai-context-strategy.md +48 -0
  23. package/apps/docs/src/content/docs/adrs/0004-iac-driven-diagnostic-context.md +37 -0
  24. package/apps/docs/src/content/docs/adrs/0005-ecs-fargate-alb-runtime-target.md +38 -0
  25. package/apps/docs/src/content/docs/adrs/0006-github-oidc-no-stored-keys.md +37 -0
  26. package/apps/docs/src/content/docs/adrs/0007-framework-detection-with-fallback.md +37 -0
  27. package/apps/docs/src/content/docs/adrs/0008-secrets-names-in-git-values-in-aws.md +37 -0
  28. package/apps/docs/src/content/docs/adrs/0009-regenerate-with-backup-on-rerun.md +37 -0
  29. package/apps/docs/src/content/docs/adrs/0010-advisory-only-security-scans.md +37 -0
  30. package/apps/docs/src/content/docs/cli/add.md +85 -0
  31. package/apps/docs/src/content/docs/cli/apply.md +35 -0
  32. package/apps/docs/src/content/docs/cli/db.md +200 -0
  33. package/apps/docs/src/content/docs/cli/destroy.md +35 -0
  34. package/apps/docs/src/content/docs/cli/diagnose.md +37 -0
  35. package/apps/docs/src/content/docs/cli/doctor.md +28 -0
  36. package/apps/docs/src/content/docs/cli/domain.md +57 -0
  37. package/apps/docs/src/content/docs/cli/drift.md +40 -0
  38. package/apps/docs/src/content/docs/cli/eject.md +33 -0
  39. package/apps/docs/src/content/docs/cli/exec.md +49 -0
  40. package/apps/docs/src/content/docs/cli/gc.md +37 -0
  41. package/apps/docs/src/content/docs/cli/init.md +72 -0
  42. package/apps/docs/src/content/docs/cli/logs.md +39 -0
  43. package/apps/docs/src/content/docs/cli/rollback.md +51 -0
  44. package/apps/docs/src/content/docs/cli/secrets.md +73 -0
  45. package/apps/docs/src/content/docs/cli/sleep.md +53 -0
  46. package/apps/docs/src/content/docs/cli/status.md +34 -0
  47. package/apps/docs/src/content/docs/cli/sync-ai.md +27 -0
  48. package/apps/docs/src/content/docs/guides/architecture.md +87 -0
  49. package/apps/docs/src/content/docs/guides/aws-credentials.md +72 -0
  50. package/apps/docs/src/content/docs/guides/background-workers.md +45 -0
  51. package/apps/docs/src/content/docs/guides/cicd-pipeline.md +64 -0
  52. package/apps/docs/src/content/docs/guides/database-connections.md +64 -0
  53. package/apps/docs/src/content/docs/guides/docker-compose.md +37 -0
  54. package/apps/docs/src/content/docs/guides/dockerfiles.md +46 -0
  55. package/apps/docs/src/content/docs/guides/ephemeral-pr-previews.md +39 -0
  56. package/apps/docs/src/content/docs/guides/examples.md +50 -0
  57. package/apps/docs/src/content/docs/guides/frameworks.md +88 -0
  58. package/apps/docs/src/content/docs/guides/headless.md +87 -0
  59. package/apps/docs/src/content/docs/guides/quickstart.md +52 -0
  60. package/apps/docs/src/content/docs/guides/rerun-init.md +43 -0
  61. package/apps/docs/src/content/docs/guides/secrets-management.md +83 -0
  62. package/apps/docs/src/content/docs/guides/understanding-your-bill.md +63 -0
  63. package/apps/docs/src/content/docs/index.mdx +103 -0
  64. package/apps/docs/src/content/docs/migrations/astro-vercel-to-aws.md +55 -0
  65. package/apps/docs/src/content/docs/migrations/heroku-procfile-to-aws.md +41 -0
  66. package/apps/docs/src/content/docs/migrations/nextjs-vercel-to-aws.md +51 -0
  67. package/apps/docs/src/content/docs/migrations/sveltekit-vercel-to-aws.md +63 -0
  68. package/apps/docs/src/content/docs/roadmap.md +99 -0
  69. package/apps/docs/src/content/docs/testing-strategy.md +37 -0
  70. package/apps/docs/src/content.config.ts +7 -0
  71. package/apps/docs/src/custom.css +14 -0
  72. package/apps/docs/tsconfig.json +6 -0
  73. package/bin/cli.js +140 -0
  74. package/package.json +107 -7
  75. package/scripts/sync-bedrock-models.js +22 -0
  76. package/scripts/test-iac.js +261 -0
  77. package/specs/add-redis-sqs-bedrock.md +128 -0
  78. package/specs/add-storage-dynamodb.md +106 -0
  79. package/specs/bedrock-model-catalog.md +131 -0
  80. package/specs/ci-pipeline.md +17 -0
  81. package/specs/cost-transparency.md +115 -0
  82. package/specs/custom-domains-and-ses.md +153 -0
  83. package/specs/database-suite-expansion.md +151 -0
  84. package/specs/db-connect.md +69 -0
  85. package/specs/db-lifecycle-migrations.md +159 -0
  86. package/specs/dependency-aware-init.md +176 -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/e2e-testing.md +50 -0
  92. package/specs/exec.md +25 -0
  93. package/specs/finops-cron-drift.md +161 -0
  94. package/specs/gc.md +26 -0
  95. package/specs/integration-suite.md +16 -0
  96. package/specs/logs.md +32 -0
  97. package/specs/rollback-live-polling.md +40 -0
  98. package/specs/secrets-pull-audit.md +51 -0
  99. package/specs/serverless-lambda-target.md +133 -0
  100. package/specs/status.md +31 -0
  101. package/specs/telemetry-and-spawn-hardening.md +69 -0
  102. package/specs/telemetry-hardening.md +35 -0
  103. package/specs/test-suite-deduplication.md +42 -0
  104. package/src/commands/add.js +1111 -0
  105. package/src/commands/apply.js +214 -0
  106. package/src/commands/db/backup.js +229 -0
  107. package/src/commands/db/connect.js +304 -0
  108. package/src/commands/db/enable-vector.js +344 -0
  109. package/src/commands/db/import.js +604 -0
  110. package/src/commands/db/migrate.js +477 -0
  111. package/src/commands/db/restore.js +361 -0
  112. package/src/commands/db.js +87 -0
  113. package/src/commands/destroy.js +217 -0
  114. package/src/commands/diagnose.js +460 -0
  115. package/src/commands/doctor.js +109 -0
  116. package/src/commands/domain.js +685 -0
  117. package/src/commands/drift.js +243 -0
  118. package/src/commands/eject.js +130 -0
  119. package/src/commands/exec.js +222 -0
  120. package/src/commands/gc.js +250 -0
  121. package/src/commands/init.js +649 -0
  122. package/src/commands/logs.js +256 -0
  123. package/src/commands/rollback.js +323 -0
  124. package/src/commands/secrets.js +485 -0
  125. package/src/commands/sleep.js +347 -0
  126. package/src/commands/status.js +309 -0
  127. package/src/commands/sync-ai.js +115 -0
  128. package/src/commands/wake.js +337 -0
  129. package/src/core/parser.js +128 -0
  130. package/src/core/telemetry.js +244 -0
  131. package/src/data/bedrock-models.json +896 -0
  132. package/src/utils/addons.js +126 -0
  133. package/src/utils/ai-rules.js +59 -0
  134. package/src/utils/args.js +91 -0
  135. package/src/utils/aws.js +178 -0
  136. package/src/utils/backup.js +69 -0
  137. package/src/utils/bedrock-catalog.js +511 -0
  138. package/src/utils/capabilities.js +500 -0
  139. package/src/utils/command.js +78 -0
  140. package/src/utils/db-tunnel.js +164 -0
  141. package/src/utils/detector.js +298 -0
  142. package/src/utils/dockerCompose.js +65 -0
  143. package/src/utils/domains.js +73 -0
  144. package/src/utils/ecs-runner.js +289 -0
  145. package/src/utils/ecs.js +92 -0
  146. package/src/utils/frameworks.js +55 -0
  147. package/src/utils/generator.js +527 -0
  148. package/src/utils/hcl.js +426 -0
  149. package/src/utils/lambda-ecr.js +185 -0
  150. package/src/utils/prompts.js +278 -0
  151. package/src/utils/rds.js +131 -0
  152. package/src/utils/resolvers.js +174 -0
  153. package/src/utils/sleep-state.js +140 -0
  154. package/src/utils/sleep-targets.js +139 -0
  155. package/src/utils/system.js +42 -0
  156. package/src/utils/terraform.js +70 -0
  157. package/src/utils/visualizer.js +381 -0
  158. package/src/utils/warnings.js +49 -0
  159. package/templates/README.md +150 -0
  160. package/templates/docker/django.Dockerfile +40 -0
  161. package/templates/docker/go.Dockerfile +23 -0
  162. package/templates/docker/nestjs.Dockerfile +33 -0
  163. package/templates/docker/nextjs.Dockerfile +55 -0
  164. package/templates/docker/node.Dockerfile +24 -0
  165. package/templates/docker/nuxt.Dockerfile +47 -0
  166. package/templates/docker/python.Dockerfile +38 -0
  167. package/templates/docker/rails.Dockerfile +59 -0
  168. package/templates/docker/static.Dockerfile +32 -0
  169. package/templates/docker/svelte.Dockerfile +52 -0
  170. package/templates/github/deploy-lambda.yml +120 -0
  171. package/templates/github/deploy.yml +138 -0
  172. package/templates/github/drift.yml +112 -0
  173. package/templates/github/preview-lambda.yml +86 -0
  174. package/templates/github/preview.yml +69 -0
  175. package/templates/github/teardown.yml +43 -0
  176. package/templates/terraform/addons/bedrock.tf +34 -0
  177. package/templates/terraform/addons/cron-lambda.tf +78 -0
  178. package/templates/terraform/addons/cron.tf +101 -0
  179. package/templates/terraform/addons/dynamodb.tf +73 -0
  180. package/templates/terraform/addons/redis.tf +64 -0
  181. package/templates/terraform/addons/s3.tf +143 -0
  182. package/templates/terraform/addons/ses.tf +73 -0
  183. package/templates/terraform/addons/sqs.tf +67 -0
  184. package/templates/terraform/backend.tf +22 -0
  185. package/templates/terraform/cloudfront-lambda.tf +80 -0
  186. package/templates/terraform/cloudfront.tf +80 -0
  187. package/templates/terraform/database-aurora-postgresql.tf +92 -0
  188. package/templates/terraform/database-mysql.tf +72 -0
  189. package/templates/terraform/database.tf +71 -0
  190. package/templates/terraform/main-lambda.tf +229 -0
  191. package/templates/terraform/main.tf +296 -0
  192. package/templates/terraform/network.tf +95 -0
  193. package/templates/terraform/oidc.tf +64 -0
  194. package/templates/terraform/secrets.tf +31 -0
  195. package/templates/terraform/worker.tf +69 -0
  196. package/tests/__snapshots__/generator.test.js.snap +9633 -0
  197. package/tests/add.test.js +2018 -0
  198. package/tests/ai.test.js +91 -0
  199. package/tests/apply.test.js +446 -0
  200. package/tests/args.test.js +86 -0
  201. package/tests/aws.test.js +244 -0
  202. package/tests/capabilities.test.js +304 -0
  203. package/tests/cli.test.js +29 -0
  204. package/tests/command.test.js +99 -0
  205. package/tests/commands-import.test.js +74 -0
  206. package/tests/db.test.js +2673 -0
  207. package/tests/destroy.test.js +365 -0
  208. package/tests/detector.test.js +79 -0
  209. package/tests/diagnose.test.js +765 -0
  210. package/tests/doctor.test.js +195 -0
  211. package/tests/domain.test.js +882 -0
  212. package/tests/drift.test.js +228 -0
  213. package/tests/e2e/helpers.js +122 -0
  214. package/tests/e2e/tier0.e2e.test.js +142 -0
  215. package/tests/e2e/tier1.live.e2e.test.js +118 -0
  216. package/tests/ecs.test.js +130 -0
  217. package/tests/eject.test.js +65 -0
  218. package/tests/exec.test.js +366 -0
  219. package/tests/gc.test.js +466 -0
  220. package/tests/generator.test.js +794 -0
  221. package/tests/headless.test.js +491 -0
  222. package/tests/helpers/clack.js +103 -0
  223. package/tests/helpers/console.js +41 -0
  224. package/tests/helpers/telemetry.js +37 -0
  225. package/tests/helpers/tmpdir.js +36 -0
  226. package/tests/lambda-ecr.test.js +185 -0
  227. package/tests/logs.test.js +436 -0
  228. package/tests/parser.test.js +160 -0
  229. package/tests/rds.test.js +244 -0
  230. package/tests/resolvers.test.js +279 -0
  231. package/tests/rollback.test.js +670 -0
  232. package/tests/secrets.test.js +729 -0
  233. package/tests/sleep-wake.test.js +998 -0
  234. package/tests/status.test.js +356 -0
  235. package/tests/system.test.js +70 -0
  236. package/tests/telemetry.test.js +520 -0
  237. package/tests/terraform.test.js +84 -0
  238. package/tests/visualizer.test.js +480 -0
  239. package/vitest.config.js +10 -0
  240. package/vitest.e2e.tier0.config.js +8 -0
  241. package/vitest.e2e.tier1.config.js +8 -0
  242. package/index.js +0 -2
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import {
3
+ SESSION_MANAGER_PLUGIN_URL,
4
+ resolveContainer,
5
+ hasSessionManagerPlugin,
6
+ fetchActiveService,
7
+ pickRuntimeContainer,
8
+ printAwsCliGuidance,
9
+ printSessionManagerGuidance,
10
+ printNoTasksGuidance,
11
+ } from '../src/utils/ecs.js';
12
+
13
+ afterEach(() => {
14
+ vi.restoreAllMocks();
15
+ delete process.env.ECS_CONTAINER;
16
+ });
17
+
18
+ function capturedOutput(fn) {
19
+ const lines = [];
20
+ vi.spyOn(console, 'log').mockImplementation((...args) => {
21
+ lines.push(args.join(' '));
22
+ });
23
+ fn();
24
+ return lines.join('\n');
25
+ }
26
+
27
+ describe('ecs shared module', () => {
28
+ it('exposes the Session Manager plugin install URL', () => {
29
+ expect(SESSION_MANAGER_PLUGIN_URL).toContain('session-manager-working-with-install-plugin.html');
30
+ });
31
+
32
+ it('resolveContainer prefers explicit flags, then ECS_CONTAINER, then project default', () => {
33
+ expect(resolveContainer({ projectName: 'myapp' }, '/tmp')).toBe('myapp-container');
34
+ expect(resolveContainer({ projectName: 'myapp', container: ' web ' }, '/tmp')).toBe('web');
35
+ expect(resolveContainer({ projectName: 'myapp', containerName: 'api' }, '/tmp')).toBe('api');
36
+ process.env.ECS_CONTAINER = 'from-env';
37
+ expect(resolveContainer({ projectName: 'myapp' }, '/tmp')).toBe('from-env');
38
+ });
39
+
40
+ it('hasSessionManagerPlugin reports ENOENT and spawn failures as missing', () => {
41
+ const enoent = new Error('not found');
42
+ enoent.code = 'ENOENT';
43
+ expect(hasSessionManagerPlugin({ spawnSyncImpl: () => { throw new Error('boom'); } })).toBe(false);
44
+ expect(hasSessionManagerPlugin({ spawnSyncImpl: () => ({ error: enoent }) })).toBe(false);
45
+ expect(hasSessionManagerPlugin({ spawnSyncImpl: () => ({}) })).toBe(true);
46
+ });
47
+
48
+ it('printAwsCliGuidance defaults to exec wording', () => {
49
+ const out = capturedOutput(() => printAwsCliGuidance());
50
+ expect(out).toContain('exec');
51
+ expect(out).toContain('to open a shell in your container.');
52
+ expect(out).toContain(SESSION_MANAGER_PLUGIN_URL);
53
+ });
54
+
55
+ it('printAwsCliGuidance accepts per-command wording', () => {
56
+ const out = capturedOutput(() => printAwsCliGuidance({
57
+ commandName: 'db connect',
58
+ purpose: 'to open a secure tunnel to your database',
59
+ }));
60
+ expect(out).toContain('db connect');
61
+ expect(out).toContain('to open a secure tunnel to your database.');
62
+ });
63
+
64
+ it('printSessionManagerGuidance defaults to exec wording', () => {
65
+ const out = capturedOutput(() => printSessionManagerGuidance());
66
+ expect(out).toContain('exec');
67
+ expect(out).toContain('to securely tunnel into your container.');
68
+ });
69
+
70
+ it('printSessionManagerGuidance accepts per-command wording', () => {
71
+ const out = capturedOutput(() => printSessionManagerGuidance({
72
+ commandName: 'db connect',
73
+ purpose: 'to securely tunnel to your database',
74
+ }));
75
+ expect(out).toContain('db connect');
76
+ expect(out).toContain('to securely tunnel to your database.');
77
+ });
78
+
79
+ it('printNoTasksGuidance names the service, cluster, and reason', () => {
80
+ const out = capturedOutput(() => printNoTasksGuidance('svc', 'clu'));
81
+ expect(out).toContain('svc');
82
+ expect(out).toContain('clu');
83
+ expect(out).toContain('to open an interactive shell.');
84
+ const dbOut = capturedOutput(() => printNoTasksGuidance('svc', 'clu', 'to act as a jump host for the tunnel'));
85
+ expect(dbOut).toContain('to act as a jump host for the tunnel.');
86
+ });
87
+
88
+ it('fetchActiveService returns the service only when ACTIVE', async () => {
89
+ const active = { serviceName: 's', status: 'ACTIVE' };
90
+ const seen = [];
91
+ const client = {
92
+ send: vi.fn((cmd) => {
93
+ seen.push(cmd.input);
94
+ return Promise.resolve({ services: [active] });
95
+ }),
96
+ };
97
+ await expect(fetchActiveService(client, 'c', 's')).resolves.toBe(active);
98
+ expect(seen[0]).toEqual({ cluster: 'c', services: ['s'] });
99
+
100
+ const inactive = { send: vi.fn(() => Promise.resolve({ services: [{ status: 'INACTIVE' }] })) };
101
+ await expect(fetchActiveService(inactive, 'c', 's')).resolves.toBeNull();
102
+ const missing = { send: vi.fn(() => Promise.resolve({ services: [] })) };
103
+ await expect(fetchActiveService(missing, 'c', 's')).resolves.toBeNull();
104
+ });
105
+
106
+ it('pickRuntimeContainer prefers the expected name, then RUNNING, then first', () => {
107
+ const task = {
108
+ containers: [
109
+ { name: 'redis', lastStatus: 'RUNNING', runtimeId: 'rt-redis' },
110
+ { name: 'myapp-container', lastStatus: 'RUNNING', runtimeId: 'rt-app' },
111
+ ],
112
+ };
113
+ expect(pickRuntimeContainer(task, 'myapp-container').runtimeId).toBe('rt-app');
114
+ expect(pickRuntimeContainer(task, 'missing').runtimeId).toBe('rt-redis');
115
+ expect(pickRuntimeContainer({ containers: [{ name: 'only' }] }, 'missing').name).toBe('only');
116
+ expect(pickRuntimeContainer({ containers: [] }, 'missing')).toBeNull();
117
+ });
118
+
119
+ it('pickRuntimeContainer also reads task-definition containerDefinitions', () => {
120
+ const taskDef = { containerDefinitions: [{ name: 'a' }, { name: 'b' }] };
121
+ expect(pickRuntimeContainer(taskDef, 'b').name).toBe('b');
122
+ expect(pickRuntimeContainer(taskDef, 'missing').name).toBe('a');
123
+ expect(pickRuntimeContainer({}, 'missing')).toBeNull();
124
+ });
125
+
126
+ it.each([null, undefined, 'string', 42, true, { port: 'string' }])('resolveContainer(%s) behaves like {}', (bad) => {
127
+ expect(resolveContainer(bad)).toBe(resolveContainer({}));
128
+ expect(resolveContainer(bad, 42)).toBe(resolveContainer({}));
129
+ });
130
+ });
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { stripTerraformMetadata, stripGitignoreMetadata } from '../src/commands/eject.js';
3
+
4
+ describe('eject metadata stripping', () => {
5
+ it('strips grada headers and tag blocks', () => {
6
+ const content = '# grada generated infrastructure\n'
7
+ + 'provider "aws" {\n'
8
+ + ' region = "us-east-2"\n\n'
9
+ + ' default_tags {\n'
10
+ + ' tags = {\n'
11
+ + ' ManagedBy = "grada"\n'
12
+ + ' }\n'
13
+ + ' }\n'
14
+ + '}\n';
15
+ const cleaned = stripTerraformMetadata(content);
16
+ expect(cleaned).not.toContain('grada generated infrastructure');
17
+ expect(cleaned).not.toContain('ManagedBy');
18
+ expect(cleaned).toContain('provider "aws"');
19
+ expect(cleaned).toContain('region = "us-east-2"');
20
+ });
21
+
22
+ it('strips legacy deploy-stack headers, tag blocks, and lambda suffixes', () => {
23
+ const content = '# deploy-stack generated infrastructure (Lambda target)\n'
24
+ + 'provider "aws" {\n'
25
+ + ' default_tags {\n'
26
+ + ' tags = {\n'
27
+ + ' ManagedBy = "deploy-stack"\n'
28
+ + ' }\n'
29
+ + ' }\n'
30
+ + '}\n';
31
+ const cleaned = stripTerraformMetadata(content);
32
+ expect(cleaned).not.toContain('deploy-stack');
33
+ expect(cleaned).not.toContain('Lambda target');
34
+ expect(cleaned).toContain('provider "aws"');
35
+ });
36
+
37
+ it.each([
38
+ ['grada', '# grada addon: storage:s3\n# Generated by `grada add storage:s3`. Do not edit by hand unless\n'],
39
+ ['legacy', '# deploy-stack addon: storage:s3\n# Generated by `deploy-stack add storage:s3`. Do not edit by hand unless\n'],
40
+ ])('strips %s addon headers but keeps billing docs', (_label, header) => {
41
+ const content = `${header}# you know what you are doing — re-running with --force overwrites this file.\n`
42
+ + '#\n# Billing: usage-billed.\n\nresource "aws_s3_bucket" "x" {}\n';
43
+ const cleaned = stripTerraformMetadata(content);
44
+ expect(cleaned).not.toContain('addon: storage:s3');
45
+ expect(cleaned).not.toContain('Generated by');
46
+ expect(cleaned).not.toContain('overwrites this file');
47
+ expect(cleaned).toContain('# Billing: usage-billed.');
48
+ expect(cleaned).toContain('resource "aws_s3_bucket" "x" {}');
49
+ });
50
+
51
+ it('strips both gitignore backup stanzas', () => {
52
+ const content = 'node_modules/\n\n# grada backups\n*.bak.*\n\n# deploy-stack backups\n*.bak.*\n';
53
+ const cleaned = stripGitignoreMetadata(content);
54
+ expect(cleaned).not.toContain('backups');
55
+ expect(cleaned).not.toContain('*.bak.*');
56
+ expect(cleaned).toContain('node_modules/');
57
+ });
58
+
59
+ it('leaves domain markers and user content untouched', () => {
60
+ const content = '# grada:domain-mode=route53\n# grada:zone-id=Z123\n'
61
+ + '# A user comment about grada add workflows.\n';
62
+ expect(stripTerraformMetadata(content)).toBe(content);
63
+ expect(stripGitignoreMetadata('dist/\n')).toBe('dist/\n');
64
+ });
65
+ });
@@ -0,0 +1,366 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ // NOTE: helper imports must stay above src imports: vi.mock factories run
3
+ // during module evaluation and need the factories initialized.
4
+ import { clackPromptsMockFactory } from './helpers/clack.js';
5
+ import { telemetryMockFactory } from './helpers/telemetry.js';
6
+ import fs from 'fs';
7
+ import os from 'os';
8
+ import path from 'path';
9
+ import { EventEmitter } from 'events';
10
+ import {
11
+ runExec,
12
+ parseExecArgs,
13
+ buildExecuteCommandArgs,
14
+ hasSessionManagerPlugin,
15
+ findRunningTask,
16
+ resolveContainer,
17
+ DEFAULT_SHELL,
18
+ } from '../src/commands/exec.js';
19
+ import {
20
+ resolveCluster,
21
+ resolveService,
22
+ } from '../src/utils/resolvers.js';
23
+ import { hasAwsCli } from '../src/utils/aws.js';
24
+ import { trackEvent } from '../src/core/telemetry.js';
25
+
26
+ vi.mock('@clack/prompts', () => clackPromptsMockFactory());
27
+
28
+ vi.mock('../src/core/telemetry.js', (importOriginal) => telemetryMockFactory(importOriginal));
29
+
30
+ const {
31
+ MockListTasksCommand,
32
+ MockDescribeTasksCommand,
33
+ } = vi.hoisted(() => ({
34
+ MockListTasksCommand: vi.fn(function (input) { Object.assign(this, input); }),
35
+ MockDescribeTasksCommand: vi.fn(function (input) { Object.assign(this, input); }),
36
+ }));
37
+
38
+ vi.mock('@aws-sdk/client-ecs', () => ({
39
+ ECSClient: vi.fn(function () { this.send = vi.fn(); }),
40
+ ListTasksCommand: MockListTasksCommand,
41
+ DescribeTasksCommand: MockDescribeTasksCommand,
42
+ }));
43
+
44
+ function mockEcsClient({ taskArns = [], tasks = [] } = {}) {
45
+ return {
46
+ send: vi.fn((cmd) => {
47
+ if (cmd instanceof MockListTasksCommand) return Promise.resolve({ taskArns });
48
+ if (cmd instanceof MockDescribeTasksCommand) return Promise.resolve({ tasks });
49
+ return Promise.resolve({});
50
+ }),
51
+ };
52
+ }
53
+
54
+ function mockSpawnImpl(calls, exitCode = 0) {
55
+ return vi.fn((cmd, args, opts) => {
56
+ calls.push({ cmd, args, opts });
57
+ const child = new EventEmitter();
58
+ queueMicrotask(() => child.emit('close', exitCode));
59
+ return child;
60
+ });
61
+ }
62
+
63
+ describe('exec: CLI args', () => {
64
+ it('parses cluster/service/container/command/region flags', () => {
65
+ expect(parseExecArgs(['exec', '--cluster', 'c1', '--service', 's1', '--container', 'web', '--command', '/bin/bash', '--region', 'eu-west-1'])).toEqual({
66
+ cluster: 'c1',
67
+ service: 's1',
68
+ container: 'web',
69
+ command: '/bin/bash',
70
+ region: 'eu-west-1',
71
+ });
72
+ });
73
+
74
+ it('supports = syntax and bare exec', () => {
75
+ expect(parseExecArgs(['exec', '--cluster=c1', '--region=us-west-2'])).toEqual({ cluster: 'c1', region: 'us-west-2' });
76
+ expect(parseExecArgs(['exec'])).toEqual({});
77
+ });
78
+
79
+ it('resolves names from project directory with env overrides', () => {
80
+ expect(resolveCluster({ projectName: 'myapp' }, '/tmp')).toBe('myapp-cluster');
81
+ expect(resolveService({ projectName: 'myapp' }, '/tmp')).toBe('myapp-service');
82
+ expect(resolveContainer({ projectName: 'myapp' }, '/tmp')).toBe('myapp-container');
83
+ });
84
+ });
85
+
86
+ describe('exec: AWS CLI command construction', () => {
87
+ it('builds the exact execute-command arguments', () => {
88
+ const args = buildExecuteCommandArgs({
89
+ cluster: 'myapp-cluster',
90
+ taskArn: 'arn:aws:ecs:us-east-2:123:task/myapp-cluster/abc',
91
+ container: 'myapp-container',
92
+ command: '/bin/sh',
93
+ region: 'us-east-2',
94
+ });
95
+ expect(args).toEqual([
96
+ 'ecs', 'execute-command',
97
+ '--cluster', 'myapp-cluster',
98
+ '--task', 'arn:aws:ecs:us-east-2:123:task/myapp-cluster/abc',
99
+ '--container', 'myapp-container',
100
+ '--interactive',
101
+ '--command', '/bin/sh',
102
+ '--region', 'us-east-2',
103
+ ]);
104
+ });
105
+
106
+ it('defaults to /bin/sh', () => {
107
+ const args = buildExecuteCommandArgs({ cluster: 'c', taskArn: 't', container: 'web' });
108
+ expect(args).toContain('/bin/sh');
109
+ expect(DEFAULT_SHELL).toBe('/bin/sh');
110
+ });
111
+
112
+ it('detects a missing AWS CLI via spawnSync', () => {
113
+ expect(hasAwsCli({ spawnSyncImpl: () => { throw new Error('ENOENT'); } })).toBe(false);
114
+ expect(hasAwsCli({ spawnSyncImpl: () => ({ status: 0 }) })).toBe(true);
115
+ expect(hasAwsCli({ spawnSyncImpl: () => ({ status: 1 }) })).toBe(false);
116
+ });
117
+
118
+ it('detects a missing Session Manager plugin via spawnSync', () => {
119
+ expect(hasSessionManagerPlugin({ spawnSyncImpl: () => { throw new Error('ENOENT'); } })).toBe(false);
120
+ const enoent = new Error('spawnSync session-manager-plugin ENOENT');
121
+ enoent.code = 'ENOENT';
122
+ expect(hasSessionManagerPlugin({ spawnSyncImpl: () => ({ error: enoent }) })).toBe(false);
123
+ expect(hasSessionManagerPlugin({ spawnSyncImpl: () => ({}) })).toBe(true);
124
+ });
125
+ });
126
+
127
+ describe('Command: exec (mocked ECS + spawn)', () => {
128
+ let exitSpy;
129
+
130
+ beforeEach(() => {
131
+ vi.clearAllMocks();
132
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
133
+ });
134
+
135
+ afterEach(() => {
136
+ exitSpy.mockRestore();
137
+ });
138
+
139
+ it('spawns aws ecs execute-command with inherited stdio on success', async () => {
140
+ const taskArn = 'arn:aws:ecs:us-east-2:123:task/myapp-cluster/abc123';
141
+ const ecsClient = mockEcsClient({
142
+ taskArns: [taskArn],
143
+ tasks: [{ taskArn, containers: [{ name: 'myapp-container', lastStatus: 'RUNNING' }] }],
144
+ });
145
+ const calls = [];
146
+ const spawnImpl = mockSpawnImpl(calls, 0);
147
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
148
+
149
+ try {
150
+ const result = await runExec({
151
+ projectName: 'myapp',
152
+ region: 'us-east-2',
153
+ ecsClient,
154
+ spawnImpl,
155
+ hasAwsCli: true,
156
+ hasSsmPlugin: true,
157
+ });
158
+ expect(result.ok).toBe(true);
159
+ expect(result.taskArn).toBe(taskArn);
160
+ expect(exitSpy).not.toHaveBeenCalled();
161
+ expect(spawnImpl).toHaveBeenCalledTimes(1);
162
+ const [cmd, args, opts] = spawnImpl.mock.calls[0];
163
+ expect(cmd).toBe('aws');
164
+ expect(args).toEqual(expect.arrayContaining([
165
+ 'ecs', 'execute-command',
166
+ '--cluster', 'myapp-cluster',
167
+ '--task', taskArn,
168
+ '--container', 'myapp-container',
169
+ '--interactive',
170
+ '--command', '/bin/sh',
171
+ ]));
172
+ expect(opts).toEqual(expect.objectContaining({ stdio: 'inherit' }));
173
+ expect(calls).toHaveLength(1);
174
+ } finally {
175
+ consoleSpy.mockRestore();
176
+ }
177
+ });
178
+
179
+ it('exits gracefully when no tasks are running', async () => {
180
+ const ecsClient = mockEcsClient({ taskArns: [], tasks: [] });
181
+ const spawnImpl = mockSpawnImpl([], 0);
182
+ const output = [];
183
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
184
+ output.push(args.join(' '));
185
+ });
186
+
187
+ try {
188
+ const result = await runExec({
189
+ projectName: 'myapp',
190
+ region: 'us-east-2',
191
+ ecsClient,
192
+ spawnImpl,
193
+ hasAwsCli: true,
194
+ hasSsmPlugin: true,
195
+ });
196
+ expect(result.ok).toBe(false);
197
+ expect(spawnImpl).not.toHaveBeenCalled();
198
+ expect(output.join('\n')).toMatch(/running container is required|No running containers/i);
199
+ expect(exitSpy).toHaveBeenCalledWith(1);
200
+ } finally {
201
+ consoleSpy.mockRestore();
202
+ }
203
+ });
204
+
205
+ it('fails fast with install guidance when AWS CLI is missing', async () => {
206
+ const ecsClient = mockEcsClient({ taskArns: ['t'], tasks: [] });
207
+ const spawnImpl = mockSpawnImpl([], 0);
208
+ const output = [];
209
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
210
+ output.push(args.join(' '));
211
+ });
212
+
213
+ try {
214
+ const result = await runExec({
215
+ projectName: 'myapp',
216
+ region: 'us-east-2',
217
+ ecsClient,
218
+ spawnImpl,
219
+ hasAwsCli: false,
220
+ });
221
+ expect(result.ok).toBe(false);
222
+ expect(spawnImpl).not.toHaveBeenCalled();
223
+ expect(ecsClient.send).not.toHaveBeenCalled();
224
+ expect(output.join('\n')).toMatch(/AWS CLI not found/);
225
+ expect(output.join('\n')).toMatch(/https:\/\//);
226
+ expect(exitSpy).toHaveBeenCalledWith(1);
227
+ } finally {
228
+ consoleSpy.mockRestore();
229
+ }
230
+ });
231
+
232
+ it('fails fast with platform guidance when the Session Manager plugin is missing', async () => {
233
+ const ecsClient = mockEcsClient({ taskArns: ['t'], tasks: [] });
234
+ const spawnImpl = mockSpawnImpl([], 0);
235
+ const output = [];
236
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
237
+ output.push(args.join(' '));
238
+ });
239
+
240
+ try {
241
+ const result = await runExec({
242
+ projectName: 'myapp',
243
+ region: 'us-east-2',
244
+ ecsClient,
245
+ spawnImpl,
246
+ hasAwsCli: true,
247
+ hasSsmPlugin: false,
248
+ });
249
+ expect(result.ok).toBe(false);
250
+ expect(result.reason).toBe('ssm-plugin-missing');
251
+ expect(spawnImpl).not.toHaveBeenCalled();
252
+ expect(ecsClient.send).not.toHaveBeenCalled();
253
+ expect(output.join('\n')).toMatch(/Session Manager plugin not found/);
254
+ expect(exitSpy).toHaveBeenCalledWith(1);
255
+ } finally {
256
+ consoleSpy.mockRestore();
257
+ }
258
+ });
259
+
260
+ it('finds a running task via ListTasks + DescribeTasks', async () => {
261
+ const taskArn = 'arn:task/1';
262
+ const ecsClient = mockEcsClient({
263
+ taskArns: [taskArn],
264
+ tasks: [{ taskArn, containers: [{ name: 'myapp-container' }] }],
265
+ });
266
+ const found = await findRunningTask(ecsClient, { cluster: 'myapp-cluster', service: 'myapp-service' });
267
+ expect(found.taskArn).toBe(taskArn);
268
+ expect(ecsClient.send).toHaveBeenCalledTimes(2);
269
+ });
270
+
271
+ it('returns null when no RUNNING tasks exist', async () => {
272
+ const ecsClient = mockEcsClient({ taskArns: [] });
273
+ await expect(findRunningTask(ecsClient, { cluster: 'c', service: 's' })).resolves.toBeNull();
274
+ });
275
+
276
+ it('is wired into bin/cli.js', () => {
277
+ const cliPath = path.resolve(__dirname, '../bin/cli.js');
278
+ const content = fs.readFileSync(cliPath, 'utf8');
279
+ expect(content).toContain('runExec');
280
+ expect(content).toContain("'exec'");
281
+ });
282
+
283
+ it('enables ECS Exec in Terraform templates with SSM permissions on the task role', () => {
284
+ const mainTf = fs.readFileSync(path.resolve(__dirname, '../templates/terraform/main.tf'), 'utf8');
285
+ expect(mainTf).toContain('enable_execute_command = true');
286
+ for (const perm of [
287
+ 'ssmmessages:CreateControlChannel',
288
+ 'ssmmessages:CreateDataChannel',
289
+ 'ssmmessages:OpenControlChannel',
290
+ 'ssmmessages:OpenDataChannel',
291
+ ]) {
292
+ expect(mainTf).toContain(perm);
293
+ }
294
+ expect(mainTf).toMatch(/resource\s+"aws_iam_role_policy"\s+"ecs_exec"/);
295
+ expect(mainTf).toMatch(/aws_iam_role\.task_role/);
296
+ });
297
+ });
298
+
299
+ describe('exec: fuzzer hardening', () => {
300
+ let exitSpy;
301
+ let consoleSpy;
302
+
303
+ beforeEach(() => {
304
+ vi.clearAllMocks();
305
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
306
+ consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
307
+ });
308
+
309
+ afterEach(() => {
310
+ exitSpy.mockRestore();
311
+ consoleSpy.mockRestore();
312
+ });
313
+
314
+ it('routes unresolvable projects through PROJECT_NOT_INITIALIZED', async () => {
315
+ const cwdSpy = vi.spyOn(process, 'cwd').mockImplementation(() => { throw new Error('deleted'); });
316
+ try {
317
+ const result = await runExec(null);
318
+ expect(result).toEqual({ ok: false, reason: 'project-not-initialized' });
319
+ expect(exitSpy).toHaveBeenCalledWith(1);
320
+ expect(trackEvent).toHaveBeenCalledWith('exec_run', expect.objectContaining({
321
+ success: false,
322
+ error_code: 'PROJECT_NOT_INITIALIZED',
323
+ }));
324
+ } finally {
325
+ cwdSpy.mockRestore();
326
+ }
327
+ });
328
+
329
+ it.each([null, 42, true, { port: 'string' }])('parseExecArgs(%s) returns defaults', (bad) => {
330
+ expect(parseExecArgs(bad)).toEqual({});
331
+ });
332
+ });
333
+
334
+ describe('exec on --target lambda projects', () => {
335
+ let exitSpy;
336
+ let logSpy;
337
+
338
+ beforeEach(() => {
339
+ vi.clearAllMocks();
340
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
341
+ logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
342
+ });
343
+
344
+ afterEach(() => {
345
+ exitSpy.mockRestore();
346
+ logSpy.mockRestore();
347
+ });
348
+
349
+ it('fails gracefully with logs guidance instead of ECS Exec', async () => {
350
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'exec-lambda-test-'));
351
+ try {
352
+ fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
353
+ fs.writeFileSync(
354
+ path.join(dir, 'terraform', 'main.tf'),
355
+ 'locals {\n app_name = "myapp${local.env_suffix}"\n}\nresource "aws_lambda_function" "app" {}\n'
356
+ );
357
+ const result = await runExec({ cwd: dir, hasAwsCli: true, hasSsmPlugin: true });
358
+ expect(result).toMatchObject({ ok: false, reason: 'lambda-target-unsupported' });
359
+ expect(exitSpy).toHaveBeenCalledWith(1);
360
+ expect(trackEvent).toHaveBeenCalledWith('exec_run', expect.objectContaining({ error_code: 'LAMBDA_TARGET_UNSUPPORTED' }));
361
+ expect(logSpy.mock.calls.map((call) => String(call[0])).join('\n')).toContain('npx grada-run logs');
362
+ } finally {
363
+ fs.rmSync(dir, { recursive: true, force: true });
364
+ }
365
+ });
366
+ });