grada-run 0.0.2 → 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.
- package/.github/workflows/deploy-docs.yml +37 -0
- package/.github/workflows/e2e.yml +73 -0
- package/.github/workflows/iac-validation.yml +303 -0
- package/.github/workflows/publish.yml +68 -0
- package/.github/workflows/sync-bedrock-models.yml +57 -0
- package/.github/workflows/test.yml +43 -0
- package/.muserules +31 -0
- package/LICENSE +21 -0
- package/README.md +193 -3
- package/apps/docs/.astro/collections/docs.schema.json +644 -0
- package/apps/docs/.astro/content-assets.mjs +4 -0
- package/apps/docs/.astro/content-modules.mjs +4 -0
- package/apps/docs/.astro/content.d.ts +179 -0
- package/apps/docs/.astro/data-store.json +1 -0
- package/apps/docs/.astro/dev.json +14 -0
- package/apps/docs/.astro/settings.json +5 -0
- package/apps/docs/.astro/types.d.ts +2 -0
- package/apps/docs/astro.config.mjs +97 -0
- package/apps/docs/package.json +17 -0
- package/apps/docs/src/content/docs/adrs/0001-s3-native-state-locking.md +37 -0
- package/apps/docs/src/content/docs/adrs/0002-eject-mechanism-pure-iac.md +39 -0
- package/apps/docs/src/content/docs/adrs/0003-sync-ai-context-strategy.md +48 -0
- package/apps/docs/src/content/docs/adrs/0004-iac-driven-diagnostic-context.md +37 -0
- package/apps/docs/src/content/docs/adrs/0005-ecs-fargate-alb-runtime-target.md +38 -0
- package/apps/docs/src/content/docs/adrs/0006-github-oidc-no-stored-keys.md +37 -0
- package/apps/docs/src/content/docs/adrs/0007-framework-detection-with-fallback.md +37 -0
- package/apps/docs/src/content/docs/adrs/0008-secrets-names-in-git-values-in-aws.md +37 -0
- package/apps/docs/src/content/docs/adrs/0009-regenerate-with-backup-on-rerun.md +37 -0
- package/apps/docs/src/content/docs/adrs/0010-advisory-only-security-scans.md +37 -0
- package/apps/docs/src/content/docs/cli/add.md +85 -0
- package/apps/docs/src/content/docs/cli/apply.md +35 -0
- package/apps/docs/src/content/docs/cli/db.md +200 -0
- package/apps/docs/src/content/docs/cli/destroy.md +35 -0
- package/apps/docs/src/content/docs/cli/diagnose.md +37 -0
- package/apps/docs/src/content/docs/cli/doctor.md +28 -0
- package/apps/docs/src/content/docs/cli/domain.md +57 -0
- package/apps/docs/src/content/docs/cli/drift.md +40 -0
- package/apps/docs/src/content/docs/cli/eject.md +33 -0
- package/apps/docs/src/content/docs/cli/exec.md +49 -0
- package/apps/docs/src/content/docs/cli/gc.md +37 -0
- package/apps/docs/src/content/docs/cli/init.md +72 -0
- package/apps/docs/src/content/docs/cli/logs.md +39 -0
- package/apps/docs/src/content/docs/cli/rollback.md +51 -0
- package/apps/docs/src/content/docs/cli/secrets.md +73 -0
- package/apps/docs/src/content/docs/cli/sleep.md +53 -0
- package/apps/docs/src/content/docs/cli/status.md +34 -0
- package/apps/docs/src/content/docs/cli/sync-ai.md +27 -0
- package/apps/docs/src/content/docs/guides/architecture.md +87 -0
- package/apps/docs/src/content/docs/guides/aws-credentials.md +72 -0
- package/apps/docs/src/content/docs/guides/background-workers.md +45 -0
- package/apps/docs/src/content/docs/guides/cicd-pipeline.md +64 -0
- package/apps/docs/src/content/docs/guides/database-connections.md +64 -0
- package/apps/docs/src/content/docs/guides/docker-compose.md +37 -0
- package/apps/docs/src/content/docs/guides/dockerfiles.md +46 -0
- package/apps/docs/src/content/docs/guides/ephemeral-pr-previews.md +39 -0
- package/apps/docs/src/content/docs/guides/examples.md +50 -0
- package/apps/docs/src/content/docs/guides/frameworks.md +88 -0
- package/apps/docs/src/content/docs/guides/headless.md +87 -0
- package/apps/docs/src/content/docs/guides/quickstart.md +52 -0
- package/apps/docs/src/content/docs/guides/rerun-init.md +43 -0
- package/apps/docs/src/content/docs/guides/secrets-management.md +83 -0
- package/apps/docs/src/content/docs/guides/understanding-your-bill.md +63 -0
- package/apps/docs/src/content/docs/index.mdx +103 -0
- package/apps/docs/src/content/docs/migrations/astro-vercel-to-aws.md +55 -0
- package/apps/docs/src/content/docs/migrations/heroku-procfile-to-aws.md +41 -0
- package/apps/docs/src/content/docs/migrations/nextjs-vercel-to-aws.md +51 -0
- package/apps/docs/src/content/docs/migrations/sveltekit-vercel-to-aws.md +63 -0
- package/apps/docs/src/content/docs/roadmap.md +99 -0
- package/apps/docs/src/content/docs/testing-strategy.md +37 -0
- package/apps/docs/src/content.config.ts +7 -0
- package/apps/docs/src/custom.css +14 -0
- package/apps/docs/tsconfig.json +6 -0
- package/bin/cli.js +140 -0
- package/package.json +107 -7
- package/scripts/sync-bedrock-models.js +22 -0
- package/scripts/test-iac.js +261 -0
- package/specs/add-redis-sqs-bedrock.md +128 -0
- package/specs/add-storage-dynamodb.md +106 -0
- package/specs/bedrock-model-catalog.md +131 -0
- package/specs/ci-pipeline.md +17 -0
- package/specs/cost-transparency.md +115 -0
- package/specs/custom-domains-and-ses.md +153 -0
- package/specs/database-suite-expansion.md +151 -0
- package/specs/db-connect.md +69 -0
- package/specs/db-lifecycle-migrations.md +159 -0
- package/specs/dependency-aware-init.md +176 -0
- package/specs/deployment-safety.md +170 -0
- package/specs/diagnose.md +16 -0
- package/specs/docs-hub.md +16 -0
- package/specs/dx-polish.md +46 -0
- package/specs/e2e-testing.md +50 -0
- package/specs/exec.md +25 -0
- package/specs/finops-cron-drift.md +161 -0
- package/specs/gc.md +26 -0
- package/specs/integration-suite.md +16 -0
- package/specs/logs.md +32 -0
- package/specs/rollback-live-polling.md +40 -0
- package/specs/secrets-pull-audit.md +51 -0
- package/specs/serverless-lambda-target.md +133 -0
- package/specs/status.md +31 -0
- package/specs/telemetry-and-spawn-hardening.md +69 -0
- package/specs/telemetry-hardening.md +35 -0
- package/specs/test-suite-deduplication.md +42 -0
- package/src/commands/add.js +1111 -0
- package/src/commands/apply.js +214 -0
- package/src/commands/db/backup.js +229 -0
- package/src/commands/db/connect.js +304 -0
- package/src/commands/db/enable-vector.js +344 -0
- package/src/commands/db/import.js +604 -0
- package/src/commands/db/migrate.js +477 -0
- package/src/commands/db/restore.js +361 -0
- package/src/commands/db.js +87 -0
- package/src/commands/destroy.js +217 -0
- package/src/commands/diagnose.js +460 -0
- package/src/commands/doctor.js +109 -0
- package/src/commands/domain.js +685 -0
- package/src/commands/drift.js +243 -0
- package/src/commands/eject.js +130 -0
- package/src/commands/exec.js +222 -0
- package/src/commands/gc.js +250 -0
- package/src/commands/init.js +649 -0
- package/src/commands/logs.js +256 -0
- package/src/commands/rollback.js +323 -0
- package/src/commands/secrets.js +485 -0
- package/src/commands/sleep.js +347 -0
- package/src/commands/status.js +309 -0
- package/src/commands/sync-ai.js +115 -0
- package/src/commands/wake.js +337 -0
- package/src/core/parser.js +128 -0
- package/src/core/telemetry.js +244 -0
- package/src/data/bedrock-models.json +896 -0
- package/src/utils/addons.js +126 -0
- package/src/utils/ai-rules.js +59 -0
- package/src/utils/args.js +91 -0
- package/src/utils/aws.js +178 -0
- package/src/utils/backup.js +69 -0
- package/src/utils/bedrock-catalog.js +511 -0
- package/src/utils/capabilities.js +500 -0
- package/src/utils/command.js +78 -0
- package/src/utils/db-tunnel.js +164 -0
- package/src/utils/detector.js +298 -0
- package/src/utils/dockerCompose.js +65 -0
- package/src/utils/domains.js +73 -0
- package/src/utils/ecs-runner.js +289 -0
- package/src/utils/ecs.js +92 -0
- package/src/utils/frameworks.js +55 -0
- package/src/utils/generator.js +527 -0
- package/src/utils/hcl.js +426 -0
- package/src/utils/lambda-ecr.js +185 -0
- package/src/utils/prompts.js +278 -0
- package/src/utils/rds.js +131 -0
- package/src/utils/resolvers.js +174 -0
- package/src/utils/sleep-state.js +140 -0
- package/src/utils/sleep-targets.js +139 -0
- package/src/utils/system.js +42 -0
- package/src/utils/terraform.js +70 -0
- package/src/utils/visualizer.js +381 -0
- package/src/utils/warnings.js +49 -0
- package/templates/README.md +150 -0
- package/templates/docker/django.Dockerfile +40 -0
- package/templates/docker/go.Dockerfile +23 -0
- package/templates/docker/nestjs.Dockerfile +33 -0
- package/templates/docker/nextjs.Dockerfile +55 -0
- package/templates/docker/node.Dockerfile +24 -0
- package/templates/docker/nuxt.Dockerfile +47 -0
- package/templates/docker/python.Dockerfile +38 -0
- package/templates/docker/rails.Dockerfile +59 -0
- package/templates/docker/static.Dockerfile +32 -0
- package/templates/docker/svelte.Dockerfile +52 -0
- package/templates/github/deploy-lambda.yml +120 -0
- package/templates/github/deploy.yml +138 -0
- package/templates/github/drift.yml +112 -0
- package/templates/github/preview-lambda.yml +86 -0
- package/templates/github/preview.yml +69 -0
- package/templates/github/teardown.yml +43 -0
- package/templates/terraform/addons/bedrock.tf +34 -0
- package/templates/terraform/addons/cron-lambda.tf +78 -0
- package/templates/terraform/addons/cron.tf +101 -0
- package/templates/terraform/addons/dynamodb.tf +73 -0
- package/templates/terraform/addons/redis.tf +64 -0
- package/templates/terraform/addons/s3.tf +143 -0
- package/templates/terraform/addons/ses.tf +73 -0
- package/templates/terraform/addons/sqs.tf +67 -0
- package/templates/terraform/backend.tf +22 -0
- package/templates/terraform/cloudfront-lambda.tf +80 -0
- package/templates/terraform/cloudfront.tf +80 -0
- package/templates/terraform/database-aurora-postgresql.tf +92 -0
- package/templates/terraform/database-mysql.tf +72 -0
- package/templates/terraform/database.tf +71 -0
- package/templates/terraform/main-lambda.tf +229 -0
- package/templates/terraform/main.tf +296 -0
- package/templates/terraform/network.tf +95 -0
- package/templates/terraform/oidc.tf +64 -0
- package/templates/terraform/secrets.tf +31 -0
- package/templates/terraform/worker.tf +69 -0
- package/tests/__snapshots__/generator.test.js.snap +9633 -0
- package/tests/add.test.js +2018 -0
- package/tests/ai.test.js +91 -0
- package/tests/apply.test.js +446 -0
- package/tests/args.test.js +86 -0
- package/tests/aws.test.js +244 -0
- package/tests/capabilities.test.js +304 -0
- package/tests/cli.test.js +29 -0
- package/tests/command.test.js +99 -0
- package/tests/commands-import.test.js +74 -0
- package/tests/db.test.js +2673 -0
- package/tests/destroy.test.js +365 -0
- package/tests/detector.test.js +79 -0
- package/tests/diagnose.test.js +765 -0
- package/tests/doctor.test.js +195 -0
- package/tests/domain.test.js +882 -0
- package/tests/drift.test.js +228 -0
- package/tests/e2e/helpers.js +122 -0
- package/tests/e2e/tier0.e2e.test.js +142 -0
- package/tests/e2e/tier1.live.e2e.test.js +118 -0
- package/tests/ecs.test.js +130 -0
- package/tests/eject.test.js +65 -0
- package/tests/exec.test.js +366 -0
- package/tests/gc.test.js +466 -0
- package/tests/generator.test.js +794 -0
- package/tests/headless.test.js +491 -0
- package/tests/helpers/clack.js +103 -0
- package/tests/helpers/console.js +41 -0
- package/tests/helpers/telemetry.js +37 -0
- package/tests/helpers/tmpdir.js +36 -0
- package/tests/lambda-ecr.test.js +185 -0
- package/tests/logs.test.js +436 -0
- package/tests/parser.test.js +160 -0
- package/tests/rds.test.js +244 -0
- package/tests/resolvers.test.js +279 -0
- package/tests/rollback.test.js +670 -0
- package/tests/secrets.test.js +729 -0
- package/tests/sleep-wake.test.js +998 -0
- package/tests/status.test.js +356 -0
- package/tests/system.test.js +70 -0
- package/tests/telemetry.test.js +520 -0
- package/tests/terraform.test.js +84 -0
- package/tests/visualizer.test.js +480 -0
- package/vitest.config.js +10 -0
- package/vitest.e2e.tier0.config.js +8 -0
- package/vitest.e2e.tier1.config.js +8 -0
- 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
|
+
});
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { createTmpDirTracker } from './helpers/tmpdir.js';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { readFileSafe, readTerraformProjectName, readTerraformRegion, resolveProjectName, resolveRegion, resolveLogGroup, resolveHeadless, resolveAppName, resolveCwd, resolveCluster, resolveService, resolveWorkspaceSuffix, detectComputeTargetFromMainTf, readTerraformComputeTarget } from '../src/utils/resolvers.js';
|
|
7
|
+
|
|
8
|
+
const tmp = createTmpDirTracker();
|
|
9
|
+
|
|
10
|
+
function makeTmp() {
|
|
11
|
+
return tmp.makeTmp('resolvers-test-');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
tmp.reset();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
tmp.cleanup();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('readTerraformProjectName', () => {
|
|
23
|
+
it('returns null when terraform/main.tf is missing', () => {
|
|
24
|
+
expect(readTerraformProjectName(makeTmp())).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('extracts the project name from local.app_name', () => {
|
|
28
|
+
const dir = makeTmp();
|
|
29
|
+
fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
|
|
30
|
+
fs.writeFileSync(
|
|
31
|
+
path.join(dir, 'terraform', 'main.tf'),
|
|
32
|
+
'locals {\n app_name = "myapp${local.env_suffix}"\n}\n'
|
|
33
|
+
);
|
|
34
|
+
expect(readTerraformProjectName(dir)).toBe('myapp');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('falls back to the aws_ecr_repository app name', () => {
|
|
38
|
+
const dir = makeTmp();
|
|
39
|
+
fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
|
|
40
|
+
fs.writeFileSync(
|
|
41
|
+
path.join(dir, 'terraform', 'main.tf'),
|
|
42
|
+
'resource "aws_ecr_repository" "app" {\n name = "fallback-app-repo"\n}\n'
|
|
43
|
+
);
|
|
44
|
+
expect(readTerraformProjectName(dir)).toBe('fallback-app');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns null when neither pattern matches', () => {
|
|
48
|
+
const dir = makeTmp();
|
|
49
|
+
fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
|
|
50
|
+
fs.writeFileSync(path.join(dir, 'terraform', 'main.tf'), 'provider "aws" {}\n');
|
|
51
|
+
expect(readTerraformProjectName(dir)).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('prefers a plain app_name over the ECR repository heuristic', () => {
|
|
55
|
+
const dir = makeTmp();
|
|
56
|
+
fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
|
|
57
|
+
fs.writeFileSync(
|
|
58
|
+
path.join(dir, 'terraform', 'main.tf'),
|
|
59
|
+
'locals {\n app_name = "billing-app"\n}\nresource "aws_ecr_repository" "app" {\n name = "other-repo"\n}\n'
|
|
60
|
+
);
|
|
61
|
+
expect(readTerraformProjectName(dir)).toBe('billing-app');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('strips ${...} suffixes and trailing separators from a plain app_name', () => {
|
|
65
|
+
const dir = makeTmp();
|
|
66
|
+
fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
|
|
67
|
+
fs.writeFileSync(
|
|
68
|
+
path.join(dir, 'terraform', 'main.tf'),
|
|
69
|
+
'locals {\n app_name = "billing-app-${var.env}"\n}\n'
|
|
70
|
+
);
|
|
71
|
+
expect(readTerraformProjectName(dir)).toBe('billing-app');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('ignores unrendered template placeholders', () => {
|
|
75
|
+
const dir = makeTmp();
|
|
76
|
+
fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
|
|
77
|
+
fs.writeFileSync(
|
|
78
|
+
path.join(dir, 'terraform', 'main.tf'),
|
|
79
|
+
'locals {\n app_name = "{{PROJECT_NAME}}"\n}\n'
|
|
80
|
+
);
|
|
81
|
+
expect(readTerraformProjectName(dir)).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('resolveProjectName', () => {
|
|
86
|
+
it('prefers an explicit --project-name option', () => {
|
|
87
|
+
const dir = makeTmp();
|
|
88
|
+
expect(resolveProjectName({ projectName: 'explicit' }, dir)).toBe('explicit');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('reads the rendered terraform/main.tf before the directory basename', () => {
|
|
92
|
+
const dir = makeTmp();
|
|
93
|
+
fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
|
|
94
|
+
fs.writeFileSync(
|
|
95
|
+
path.join(dir, 'terraform', 'main.tf'),
|
|
96
|
+
'locals {\n app_name = "tf-app${local.env_suffix}"\n}\n'
|
|
97
|
+
);
|
|
98
|
+
expect(resolveProjectName({}, dir)).toBe('tf-app');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('falls back to the directory basename', () => {
|
|
102
|
+
const dir = makeTmp();
|
|
103
|
+
expect(resolveProjectName({}, dir)).toBe(path.basename(dir));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('still resolves the region from flags as before', () => {
|
|
107
|
+
expect(resolveRegion({ region: 'eu-west-1' }, makeTmp())).toBe('eu-west-1');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('reads a plain app_name the same way for every consumer', () => {
|
|
111
|
+
const dir = makeTmp();
|
|
112
|
+
fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
|
|
113
|
+
fs.writeFileSync(
|
|
114
|
+
path.join(dir, 'terraform', 'main.tf'),
|
|
115
|
+
'locals {\n app_name = "billing-app"\n}\nresource "aws_ecr_repository" "app" {\n name = "other-repo"\n}\n'
|
|
116
|
+
);
|
|
117
|
+
expect(resolveProjectName({}, dir)).toBe('billing-app');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('resolveLogGroup', () => {
|
|
122
|
+
it('prefers explicit flags, then env, then the project default', () => {
|
|
123
|
+
const dir = makeTmp();
|
|
124
|
+
expect(resolveLogGroup({ logGroup: ' /custom/group ' }, dir)).toBe('/custom/group');
|
|
125
|
+
expect(resolveLogGroup({ logGroupName: '/named/group' }, dir)).toBe('/named/group');
|
|
126
|
+
process.env.ECS_LOG_GROUP = '/env/group';
|
|
127
|
+
try {
|
|
128
|
+
expect(resolveLogGroup({}, dir)).toBe('/env/group');
|
|
129
|
+
} finally {
|
|
130
|
+
delete process.env.ECS_LOG_GROUP;
|
|
131
|
+
}
|
|
132
|
+
expect(resolveLogGroup({ projectName: 'myapp' }, dir)).toBe('/ecs/myapp');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('detectComputeTargetFromMainTf', () => {
|
|
137
|
+
it('returns lambda when main.tf provisions the serverless function', () => {
|
|
138
|
+
expect(detectComputeTargetFromMainTf('resource "aws_lambda_function" "app" {\n}')).toBe('lambda');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('returns ecs for ECS content, empty, and non-string inputs', () => {
|
|
142
|
+
expect(detectComputeTargetFromMainTf('resource "aws_ecs_service" "app" {}')).toBe('ecs');
|
|
143
|
+
expect(detectComputeTargetFromMainTf('')).toBe('ecs');
|
|
144
|
+
expect(detectComputeTargetFromMainTf(null)).toBe('ecs');
|
|
145
|
+
expect(detectComputeTargetFromMainTf(undefined)).toBe('ecs');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('readTerraformComputeTarget', () => {
|
|
150
|
+
it('reads lambda from a lambda project and defaults to ecs when missing', () => {
|
|
151
|
+
const lambdaDir = makeTmp();
|
|
152
|
+
fs.mkdirSync(path.join(lambdaDir, 'terraform'), { recursive: true });
|
|
153
|
+
fs.writeFileSync(
|
|
154
|
+
path.join(lambdaDir, 'terraform', 'main.tf'),
|
|
155
|
+
'locals {\n app_name = "myapp${local.env_suffix}"\n}\nresource "aws_lambda_function" "app" {}\n'
|
|
156
|
+
);
|
|
157
|
+
expect(readTerraformComputeTarget(lambdaDir)).toBe('lambda');
|
|
158
|
+
expect(readTerraformComputeTarget(makeTmp())).toBe('ecs');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('resolveLogGroup lambda target', () => {
|
|
163
|
+
it('defaults to the Lambda log group on lambda projects', () => {
|
|
164
|
+
const dir = makeTmp();
|
|
165
|
+
fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
|
|
166
|
+
fs.writeFileSync(
|
|
167
|
+
path.join(dir, 'terraform', 'main.tf'),
|
|
168
|
+
'locals {\n app_name = "myapp${local.env_suffix}"\n}\nresource "aws_lambda_function" "app" {}\n'
|
|
169
|
+
);
|
|
170
|
+
expect(resolveLogGroup({}, dir)).toBe('/aws/lambda/myapp-fn');
|
|
171
|
+
expect(resolveLogGroup({ logGroup: '/custom/group' }, dir)).toBe('/custom/group');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('resolveHeadless', () => {
|
|
176
|
+
const tty = { isTTY: true };
|
|
177
|
+
const noTty = {};
|
|
178
|
+
|
|
179
|
+
it('returns true for explicit headless flags and CI/test envs', () => {
|
|
180
|
+
expect(resolveHeadless({ isHeadless: true }, {}, tty, tty)).toBe(true);
|
|
181
|
+
expect(resolveHeadless({ headless: true }, {}, tty, tty)).toBe(true);
|
|
182
|
+
expect(resolveHeadless({}, { CI: 'true' }, tty, tty)).toBe(true);
|
|
183
|
+
expect(resolveHeadless({}, { VITEST: 'true' }, tty, tty)).toBe(true);
|
|
184
|
+
expect(resolveHeadless({}, { NODE_ENV: 'test' }, tty, tty)).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('returns true when stdio is not a TTY', () => {
|
|
188
|
+
expect(resolveHeadless({}, {}, noTty, tty)).toBe(true);
|
|
189
|
+
expect(resolveHeadless({}, {}, tty, noTty)).toBe(true);
|
|
190
|
+
expect(resolveHeadless({}, {}, undefined, undefined)).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('returns false for real interactive terminals', () => {
|
|
194
|
+
expect(resolveHeadless({}, {}, tty, tty)).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('honors an explicit false override for simulated prompts', () => {
|
|
198
|
+
expect(resolveHeadless({ isHeadless: false }, { CI: 'true', VITEST: 'true' }, noTty, noTty)).toBe(false);
|
|
199
|
+
expect(resolveHeadless({ headless: false }, { NODE_ENV: 'test' }, noTty, noTty)).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('resolveAppName', () => {
|
|
204
|
+
it('combines the project name with the workspace suffix', () => {
|
|
205
|
+
const dir = makeTmp();
|
|
206
|
+
expect(resolveAppName('myapp', undefined, dir)).toBe('myapp');
|
|
207
|
+
expect(resolveAppName('myapp', 'pr-7', dir)).toBe('myapp-pr-7');
|
|
208
|
+
expect(resolveAppName('myapp', 'default', dir)).toBe('myapp');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('auto-detects the workspace from .terraform/environment', () => {
|
|
212
|
+
const dir = makeTmp();
|
|
213
|
+
fs.mkdirSync(path.join(dir, '.terraform'), { recursive: true });
|
|
214
|
+
fs.writeFileSync(path.join(dir, '.terraform', 'environment'), 'pr-42\n');
|
|
215
|
+
expect(resolveAppName('myapp', undefined, dir)).toBe('myapp-pr-42');
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('resolveCwd', () => {
|
|
220
|
+
it('prefers an explicit string cwd option', () => {
|
|
221
|
+
expect(resolveCwd({ cwd: '/tmp/x' })).toBe('/tmp/x');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('falls back to the process cwd for missing or non-string values', () => {
|
|
225
|
+
expect(resolveCwd({})).toBe(process.cwd());
|
|
226
|
+
expect(resolveCwd({ cwd: 42 })).toBe(process.cwd());
|
|
227
|
+
expect(resolveCwd({ cwd: '' })).toBe(process.cwd());
|
|
228
|
+
expect(resolveCwd(null)).toBe(process.cwd());
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('honors a usable positional fallback before the process cwd', () => {
|
|
232
|
+
const dir = makeTmp();
|
|
233
|
+
expect(resolveCwd({}, dir)).toBe(dir);
|
|
234
|
+
expect(resolveCwd({ cwd: '/tmp/x' }, dir)).toBe('/tmp/x');
|
|
235
|
+
expect(resolveCwd({ cwd: 42 }, dir)).toBe(dir);
|
|
236
|
+
expect(resolveCwd(null, dir)).toBe(dir);
|
|
237
|
+
expect(resolveCwd({}, 42)).toBe(process.cwd());
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('resolvers: fuzzer hardening', () => {
|
|
242
|
+
const BAD_OPTIONS = [null, undefined, 'string', 42, true, { port: 'string' }];
|
|
243
|
+
|
|
244
|
+
it.each(BAD_OPTIONS)('resolveProjectName(%s) behaves like {}', (bad) => {
|
|
245
|
+
expect(resolveProjectName(bad)).toBe(resolveProjectName({}));
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it.each(BAD_OPTIONS)('resolveRegion/resolveCluster/resolveService/resolveLogGroup(%s) behave like {}', (bad) => {
|
|
249
|
+
expect(resolveRegion(bad)).toBe(resolveRegion({}));
|
|
250
|
+
expect(resolveCluster(bad)).toBe(resolveCluster({}));
|
|
251
|
+
expect(resolveService(bad)).toBe(resolveService({}));
|
|
252
|
+
expect(resolveLogGroup(bad)).toBe(resolveLogGroup({}));
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('survives non-string cwd positions', () => {
|
|
256
|
+
const dir = makeTmp();
|
|
257
|
+
expect(resolveProjectName({}, 42)).toBe(resolveProjectName({}));
|
|
258
|
+
expect(resolveProjectName({ cwd: 42 }, dir)).toBe(resolveProjectName({}, dir));
|
|
259
|
+
expect(resolveRegion({}, 42)).toBe(resolveRegion({}));
|
|
260
|
+
expect(resolveWorkspaceSuffix({}, 42)).toBe(resolveWorkspaceSuffix({}));
|
|
261
|
+
expect(resolveWorkspaceSuffix({ cwd: 42 }, dir)).toBe('');
|
|
262
|
+
expect(readTerraformRegion(42)).toBe(readTerraformRegion());
|
|
263
|
+
expect(readTerraformProjectName(42)).toBe(readTerraformProjectName());
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('resolveHeadless and resolveAppName coerce synthetic inputs', () => {
|
|
267
|
+
const dir = makeTmp();
|
|
268
|
+
expect(typeof resolveHeadless(null)).toBe('boolean');
|
|
269
|
+
expect(typeof resolveHeadless('string')).toBe('boolean');
|
|
270
|
+
expect(resolveAppName(42, 42, dir)).toBe('42');
|
|
271
|
+
expect(resolveAppName(null, null, dir)).toBe('null');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('readFileSafe returns null for unusable paths', () => {
|
|
275
|
+
expect(readFileSafe(42)).toBeNull();
|
|
276
|
+
expect(readFileSafe(null)).toBeNull();
|
|
277
|
+
expect(readFileSafe({})).toBeNull();
|
|
278
|
+
});
|
|
279
|
+
});
|