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.
- package/.github/workflows/deploy-docs.yml +37 -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 +84 -0
- package/apps/docs/src/content/docs/cli/apply.md +32 -0
- package/apps/docs/src/content/docs/cli/db.md +200 -0
- package/apps/docs/src/content/docs/cli/destroy.md +31 -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 +29 -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 +75 -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 +97 -0
- package/apps/docs/src/content/docs/testing-strategy.md +32 -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 +105 -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/deploy-stack-to-grada-run-rebrand.md +45 -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/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/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 +127 -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 +126 -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 +65 -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 +2037 -0
- package/tests/ai.test.js +94 -0
- package/tests/apply.test.js +488 -0
- package/tests/args.test.js +86 -0
- package/tests/aws.test.js +244 -0
- package/tests/capabilities.test.js +307 -0
- package/tests/cli.test.js +29 -0
- package/tests/command.test.js +100 -0
- package/tests/commands-import.test.js +74 -0
- package/tests/db.test.js +2704 -0
- package/tests/destroy.test.js +391 -0
- package/tests/detector.test.js +79 -0
- package/tests/diagnose.test.js +779 -0
- package/tests/doctor.test.js +202 -0
- package/tests/domain.test.js +899 -0
- package/tests/drift.test.js +243 -0
- package/tests/ecs.test.js +130 -0
- package/tests/eject.test.js +65 -0
- package/tests/exec.test.js +380 -0
- package/tests/gc.test.js +496 -0
- package/tests/generator.test.js +794 -0
- package/tests/headless.test.js +562 -0
- package/tests/lambda-ecr.test.js +185 -0
- package/tests/logs.test.js +447 -0
- package/tests/parser.test.js +160 -0
- package/tests/rds.test.js +244 -0
- package/tests/resolvers.test.js +282 -0
- package/tests/rollback.test.js +692 -0
- package/tests/secrets.test.js +752 -0
- package/tests/sleep-wake.test.js +1016 -0
- package/tests/status.test.js +370 -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 +496 -0
- package/vitest.config.js +9 -0
- package/index.js +0 -2
package/tests/db.test.js
ADDED
|
@@ -0,0 +1,2704 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { EventEmitter } from 'events';
|
|
6
|
+
import { PassThrough } from 'node:stream';
|
|
7
|
+
import {
|
|
8
|
+
buildSsmArgs as sharedBuildSsmArgs,
|
|
9
|
+
fetchManagedDbCredentials,
|
|
10
|
+
findJumpHostTarget,
|
|
11
|
+
waitForTcpPort,
|
|
12
|
+
getFreeLocalPort,
|
|
13
|
+
redactUri,
|
|
14
|
+
parseSourceUri,
|
|
15
|
+
findMissingBinaries,
|
|
16
|
+
} from '../src/utils/db-tunnel.js';
|
|
17
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
18
|
+
import {
|
|
19
|
+
runDb,
|
|
20
|
+
runDbConnect,
|
|
21
|
+
parseDbArgs,
|
|
22
|
+
isValidPort,
|
|
23
|
+
resolveDbIdentifier,
|
|
24
|
+
buildConnectionString,
|
|
25
|
+
formatConnectionInfo,
|
|
26
|
+
buildSsmArgs,
|
|
27
|
+
pickRuntimeContainer,
|
|
28
|
+
DEFAULT_LOCAL_PORT,
|
|
29
|
+
MASKED_PASSWORD,
|
|
30
|
+
runDbMigrate,
|
|
31
|
+
parseDbMigrateArgs,
|
|
32
|
+
runDbBackup,
|
|
33
|
+
parseDbBackupArgs,
|
|
34
|
+
runDbRestore,
|
|
35
|
+
parseDbRestoreArgs,
|
|
36
|
+
upsertSnapshotIdentifier,
|
|
37
|
+
runDbEnableVector,
|
|
38
|
+
parseDbEnableVectorArgs,
|
|
39
|
+
buildVectorExtensionCommand,
|
|
40
|
+
runDbImport,
|
|
41
|
+
parseDbImportArgs,
|
|
42
|
+
classifyImportFile,
|
|
43
|
+
requiredClientBinaries,
|
|
44
|
+
buildTargetClientCommand,
|
|
45
|
+
buildSourceDumpCommand,
|
|
46
|
+
} from '../src/commands/db.js';
|
|
47
|
+
import { injectMigrationGate, quoteShellArg, buildMigrationCommand, findGateBlock } from '../src/commands/db/migrate.js';
|
|
48
|
+
import { resolveWorkspaceSuffix } from '../src/utils/resolvers.js';
|
|
49
|
+
|
|
50
|
+
const { mockText, mockSelect, mockConfirm, mockPassword, mockSpinner } = vi.hoisted(() => ({
|
|
51
|
+
mockText: vi.fn(),
|
|
52
|
+
mockSelect: vi.fn(),
|
|
53
|
+
mockConfirm: vi.fn(),
|
|
54
|
+
mockPassword: vi.fn(),
|
|
55
|
+
mockSpinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn() })),
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
vi.mock('@clack/prompts', () => ({
|
|
59
|
+
intro: vi.fn(),
|
|
60
|
+
outro: vi.fn(),
|
|
61
|
+
spinner: (...args) => mockSpinner(...args),
|
|
62
|
+
text: (...args) => mockText(...args),
|
|
63
|
+
select: (...args) => mockSelect(...args),
|
|
64
|
+
confirm: (...args) => mockConfirm(...args),
|
|
65
|
+
password: (...args) => mockPassword(...args),
|
|
66
|
+
cancel: vi.fn(),
|
|
67
|
+
isCancel: (value) => typeof value === 'symbol',
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
const { mockTrackEvent } = vi.hoisted(() => ({
|
|
71
|
+
mockTrackEvent: vi.fn(),
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
vi.mock('../src/core/telemetry.js', () => {
|
|
75
|
+
const flushTelemetry = vi.fn(() => Promise.resolve());
|
|
76
|
+
// Mirrors the real trackSuccess delegation so success-path assertions
|
|
77
|
+
// keep observing trackEvent (the real helper is unit-tested separately).
|
|
78
|
+
const trackSuccess = vi.fn(async (event, properties) => {
|
|
79
|
+
mockTrackEvent(event, { ...properties, success: true });
|
|
80
|
+
await flushTelemetry();
|
|
81
|
+
});
|
|
82
|
+
const trackFailure = vi.fn(async (event, properties) => {
|
|
83
|
+
mockTrackEvent(event, { ...properties, success: false });
|
|
84
|
+
await flushTelemetry();
|
|
85
|
+
});
|
|
86
|
+
return { trackEvent: mockTrackEvent, flushTelemetry, trackSuccess, trackFailure };
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const {
|
|
90
|
+
MockDescribeDBInstancesCommand,
|
|
91
|
+
MockDescribeDBClustersCommand,
|
|
92
|
+
MockCreateDBSnapshotCommand,
|
|
93
|
+
MockDescribeDBSnapshotsCommand,
|
|
94
|
+
MockCreateDBClusterSnapshotCommand,
|
|
95
|
+
MockDescribeDBClusterSnapshotsCommand,
|
|
96
|
+
MockListTasksCommand,
|
|
97
|
+
MockDescribeTasksCommand,
|
|
98
|
+
MockDescribeServicesCommand,
|
|
99
|
+
MockDescribeTaskDefinitionCommand,
|
|
100
|
+
MockRunTaskCommand,
|
|
101
|
+
MockStopTaskCommand,
|
|
102
|
+
MockGetSecretValueCommand,
|
|
103
|
+
MockFilterLogEventsCommand,
|
|
104
|
+
MockGetLogEventsCommand,
|
|
105
|
+
} = vi.hoisted(() => {
|
|
106
|
+
const cmd = () => vi.fn(function (input) { Object.assign(this, input); });
|
|
107
|
+
return {
|
|
108
|
+
MockDescribeDBInstancesCommand: cmd(),
|
|
109
|
+
MockDescribeDBClustersCommand: cmd(),
|
|
110
|
+
MockCreateDBSnapshotCommand: cmd(),
|
|
111
|
+
MockDescribeDBSnapshotsCommand: cmd(),
|
|
112
|
+
MockCreateDBClusterSnapshotCommand: cmd(),
|
|
113
|
+
MockDescribeDBClusterSnapshotsCommand: cmd(),
|
|
114
|
+
MockListTasksCommand: cmd(),
|
|
115
|
+
MockDescribeTasksCommand: cmd(),
|
|
116
|
+
MockDescribeServicesCommand: cmd(),
|
|
117
|
+
MockDescribeTaskDefinitionCommand: cmd(),
|
|
118
|
+
MockRunTaskCommand: cmd(),
|
|
119
|
+
MockStopTaskCommand: cmd(),
|
|
120
|
+
MockGetSecretValueCommand: cmd(),
|
|
121
|
+
MockFilterLogEventsCommand: cmd(),
|
|
122
|
+
MockGetLogEventsCommand: cmd(),
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
vi.mock('@aws-sdk/client-rds', () => ({
|
|
127
|
+
RDSClient: vi.fn(function () { this.send = vi.fn(); }),
|
|
128
|
+
DescribeDBInstancesCommand: MockDescribeDBInstancesCommand,
|
|
129
|
+
DescribeDBClustersCommand: MockDescribeDBClustersCommand,
|
|
130
|
+
CreateDBSnapshotCommand: MockCreateDBSnapshotCommand,
|
|
131
|
+
DescribeDBSnapshotsCommand: MockDescribeDBSnapshotsCommand,
|
|
132
|
+
CreateDBClusterSnapshotCommand: MockCreateDBClusterSnapshotCommand,
|
|
133
|
+
DescribeDBClusterSnapshotsCommand: MockDescribeDBClusterSnapshotsCommand,
|
|
134
|
+
}));
|
|
135
|
+
|
|
136
|
+
vi.mock('@aws-sdk/client-ecs', () => ({
|
|
137
|
+
ECSClient: vi.fn(function () { this.send = vi.fn(); }),
|
|
138
|
+
ListTasksCommand: MockListTasksCommand,
|
|
139
|
+
DescribeTasksCommand: MockDescribeTasksCommand,
|
|
140
|
+
DescribeServicesCommand: MockDescribeServicesCommand,
|
|
141
|
+
DescribeTaskDefinitionCommand: MockDescribeTaskDefinitionCommand,
|
|
142
|
+
RunTaskCommand: MockRunTaskCommand,
|
|
143
|
+
StopTaskCommand: MockStopTaskCommand,
|
|
144
|
+
}));
|
|
145
|
+
|
|
146
|
+
vi.mock('@aws-sdk/client-cloudwatch-logs', () => ({
|
|
147
|
+
CloudWatchLogsClient: vi.fn(function () { this.send = vi.fn(); }),
|
|
148
|
+
FilterLogEventsCommand: MockFilterLogEventsCommand,
|
|
149
|
+
GetLogEventsCommand: MockGetLogEventsCommand,
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
vi.mock('@aws-sdk/client-secrets-manager', () => ({
|
|
153
|
+
SecretsManagerClient: vi.fn(function () { this.send = vi.fn(); }),
|
|
154
|
+
GetSecretValueCommand: MockGetSecretValueCommand,
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
const TASK_ARN = 'arn:aws:ecs:us-east-2:123456789012:task/myapp-cluster/abc123def456';
|
|
158
|
+
const SECRET_ARN = 'arn:aws:secretsmanager:us-east-2:123456789012:secret:rds!db-xyz';
|
|
159
|
+
const DB_PASSWORD = 's3cr3t-db-password';
|
|
160
|
+
const DB_USERNAME = 'dbadmin';
|
|
161
|
+
|
|
162
|
+
function mockRdsClient(dbInstance) {
|
|
163
|
+
return {
|
|
164
|
+
send: vi.fn((cmd) => {
|
|
165
|
+
if (cmd instanceof MockDescribeDBInstancesCommand) {
|
|
166
|
+
return Promise.resolve({ DBInstances: dbInstance ? [dbInstance] : [] });
|
|
167
|
+
}
|
|
168
|
+
return Promise.resolve({});
|
|
169
|
+
}),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function mockRdsNotFoundClient() {
|
|
174
|
+
return {
|
|
175
|
+
send: vi.fn((cmd) => {
|
|
176
|
+
const err = new Error('DB not found');
|
|
177
|
+
err.name = cmd instanceof MockDescribeDBClustersCommand ? 'DBClusterNotFound' : 'DBInstanceNotFound';
|
|
178
|
+
return Promise.reject(err);
|
|
179
|
+
}),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function mockSecretsClient(secretString) {
|
|
184
|
+
return {
|
|
185
|
+
send: vi.fn((cmd) => {
|
|
186
|
+
if (cmd instanceof MockGetSecretValueCommand) {
|
|
187
|
+
return Promise.resolve({ SecretString: secretString });
|
|
188
|
+
}
|
|
189
|
+
return Promise.resolve({});
|
|
190
|
+
}),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function mockEcsClient({ taskArns = [], tasks = [] } = {}) {
|
|
195
|
+
return {
|
|
196
|
+
send: vi.fn((cmd) => {
|
|
197
|
+
if (cmd instanceof MockListTasksCommand) return Promise.resolve({ taskArns });
|
|
198
|
+
if (cmd instanceof MockDescribeTasksCommand) return Promise.resolve({ tasks });
|
|
199
|
+
return Promise.resolve({});
|
|
200
|
+
}),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function healthyDbInstance(overrides = {}) {
|
|
205
|
+
return {
|
|
206
|
+
DBInstanceIdentifier: 'myapp-db',
|
|
207
|
+
Endpoint: { Address: 'myapp-db.abc123.us-east-2.rds.amazonaws.com' },
|
|
208
|
+
DBName: 'myapp',
|
|
209
|
+
MasterUserSecret: { SecretArn: SECRET_ARN },
|
|
210
|
+
...overrides,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function healthyDbCluster(overrides = {}) {
|
|
215
|
+
return {
|
|
216
|
+
DBClusterIdentifier: 'myapp-db-cluster',
|
|
217
|
+
Engine: 'aurora-postgresql',
|
|
218
|
+
Status: 'available',
|
|
219
|
+
Endpoint: 'myapp-db-cluster.xyz789.us-east-2.rds.amazonaws.com',
|
|
220
|
+
Port: 5432,
|
|
221
|
+
DatabaseName: 'myapp',
|
|
222
|
+
MasterUserSecret: { SecretArn: SECRET_ARN },
|
|
223
|
+
...overrides,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function mockRdsClusterClient(cluster) {
|
|
228
|
+
return {
|
|
229
|
+
send: vi.fn((cmd) => {
|
|
230
|
+
if (cmd instanceof MockDescribeDBInstancesCommand) {
|
|
231
|
+
const err = new Error('DB instance not found');
|
|
232
|
+
err.name = 'DBInstanceNotFound';
|
|
233
|
+
return Promise.reject(err);
|
|
234
|
+
}
|
|
235
|
+
if (cmd instanceof MockDescribeDBClustersCommand) {
|
|
236
|
+
return Promise.resolve({ DBClusters: cluster ? [cluster] : [] });
|
|
237
|
+
}
|
|
238
|
+
return Promise.resolve({});
|
|
239
|
+
}),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function healthyTask(overrides = {}) {
|
|
244
|
+
return {
|
|
245
|
+
taskArn: TASK_ARN,
|
|
246
|
+
containers: [{ name: 'myapp-container', lastStatus: 'RUNNING', runtimeId: 'runtime-1' }],
|
|
247
|
+
...overrides,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function mockSpawnImpl(calls, exitCode = 0) {
|
|
252
|
+
return vi.fn((cmd, args, opts) => {
|
|
253
|
+
calls.push({ cmd, args, opts });
|
|
254
|
+
const child = new EventEmitter();
|
|
255
|
+
queueMicrotask(() => child.emit('close', exitCode));
|
|
256
|
+
return child;
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function baseOptions(overrides = {}) {
|
|
261
|
+
return {
|
|
262
|
+
projectName: 'myapp',
|
|
263
|
+
region: 'us-east-2',
|
|
264
|
+
cwd: fs.mkdtempSync(path.join(os.tmpdir(), 'db-test-')),
|
|
265
|
+
hasAwsCli: true,
|
|
266
|
+
hasSsmPlugin: true,
|
|
267
|
+
spawnImpl: mockSpawnImpl([], 0),
|
|
268
|
+
...overrides,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
describe('db: CLI args', () => {
|
|
273
|
+
it('parses port, flags, and overrides after db connect', () => {
|
|
274
|
+
expect(parseDbArgs(['db', 'connect', '--port', '5433', '--show-credentials', '--workspace', 'pr-7', '--region', 'eu-west-1', '--cluster', 'c', '--service', 's'])).toEqual({
|
|
275
|
+
port: '5433',
|
|
276
|
+
showCredentials: true,
|
|
277
|
+
workspace: 'pr-7',
|
|
278
|
+
region: 'eu-west-1',
|
|
279
|
+
cluster: 'c',
|
|
280
|
+
service: 's',
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('supports = syntax and defaults to empty options', () => {
|
|
285
|
+
expect(parseDbArgs(['db', 'connect', '--port=5544', '--region=us-west-2'])).toEqual({
|
|
286
|
+
port: '5544',
|
|
287
|
+
region: 'us-west-2',
|
|
288
|
+
});
|
|
289
|
+
expect(parseDbArgs(['db', 'connect'])).toEqual({});
|
|
290
|
+
expect(parseDbArgs([])).toEqual({});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('validates ports as purely numeric in range', () => {
|
|
294
|
+
expect(isValidPort('5432')).toBe(true);
|
|
295
|
+
expect(isValidPort('1')).toBe(true);
|
|
296
|
+
expect(isValidPort('65535')).toBe(true);
|
|
297
|
+
expect(isValidPort('abc')).toBe(false);
|
|
298
|
+
expect(isValidPort('54a2')).toBe(false);
|
|
299
|
+
expect(isValidPort('')).toBe(false);
|
|
300
|
+
expect(isValidPort('0')).toBe(false);
|
|
301
|
+
expect(isValidPort('65536')).toBe(false);
|
|
302
|
+
expect(isValidPort('-1')).toBe(false);
|
|
303
|
+
expect(isValidPort(undefined)).toBe(false);
|
|
304
|
+
expect(DEFAULT_LOCAL_PORT).toBe('5432');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it.each([null, 42, true, { port: 'string' }])('parseDbArgs(%s) returns defaults', (bad) => {
|
|
308
|
+
expect(parseDbArgs(bad)).toEqual({});
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe('db: workspace resolution', () => {
|
|
313
|
+
it('returns no suffix without a workspace', () => {
|
|
314
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-ws-'));
|
|
315
|
+
expect(resolveWorkspaceSuffix({ projectName: 'myapp', cwd: dir }, dir)).toBe('');
|
|
316
|
+
expect(resolveDbIdentifier({ projectName: 'myapp', cwd: dir }, dir)).toBe('myapp-db');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('appends an explicit --workspace flag', () => {
|
|
320
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-ws-'));
|
|
321
|
+
expect(resolveWorkspaceSuffix({ workspace: 'pr-123' }, dir)).toBe('-pr-123');
|
|
322
|
+
expect(resolveDbIdentifier({ projectName: 'myapp', workspace: 'pr-123', cwd: dir }, dir)).toBe('myapp-pr-123-db');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('detects the workspace from .terraform/environment', () => {
|
|
326
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-ws-'));
|
|
327
|
+
fs.mkdirSync(path.join(dir, '.terraform'), { recursive: true });
|
|
328
|
+
fs.writeFileSync(path.join(dir, '.terraform', 'environment'), 'pr-42\n');
|
|
329
|
+
expect(resolveWorkspaceSuffix({}, dir)).toBe('-pr-42');
|
|
330
|
+
expect(resolveDbIdentifier({ projectName: 'myapp' }, dir)).toBe('myapp-pr-42-db');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('treats the default workspace as no suffix', () => {
|
|
334
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-ws-'));
|
|
335
|
+
fs.mkdirSync(path.join(dir, '.terraform'), { recursive: true });
|
|
336
|
+
fs.writeFileSync(path.join(dir, '.terraform', 'environment'), 'default');
|
|
337
|
+
expect(resolveWorkspaceSuffix({}, dir)).toBe('');
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe('db: output formatting', () => {
|
|
342
|
+
const details = { localPort: '5432', dbName: 'myapp', username: 'dbadmin', password: DB_PASSWORD };
|
|
343
|
+
|
|
344
|
+
it('masks the password unless --show-credentials is passed', () => {
|
|
345
|
+
const masked = formatConnectionInfo({ ...details, showCredentials: false });
|
|
346
|
+
expect(masked).toContain(MASKED_PASSWORD);
|
|
347
|
+
expect(masked).not.toContain(DB_PASSWORD);
|
|
348
|
+
expect(masked).toContain(`postgresql://dbadmin:${MASKED_PASSWORD}@localhost:5432/myapp`);
|
|
349
|
+
|
|
350
|
+
const shown = formatConnectionInfo({ ...details, showCredentials: true });
|
|
351
|
+
expect(shown).toContain(DB_PASSWORD);
|
|
352
|
+
expect(shown).toContain(`postgresql://dbadmin:${DB_PASSWORD}@localhost:5432/myapp`);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('builds masked connection strings by default', () => {
|
|
356
|
+
expect(buildConnectionString(details)).toBe(`postgresql://dbadmin:${MASKED_PASSWORD}@localhost:5432/myapp`);
|
|
357
|
+
expect(buildConnectionString({ ...details, showCredentials: true })).toContain(DB_PASSWORD);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('supports the mysql scheme and remote SSM ports', () => {
|
|
361
|
+
expect(buildConnectionString({ ...details, scheme: 'mysql' }))
|
|
362
|
+
.toBe(`mysql://dbadmin:${MASKED_PASSWORD}@localhost:5432/myapp`);
|
|
363
|
+
expect(formatConnectionInfo({ ...details, scheme: 'mysql' })).toContain('mysql://dbadmin:');
|
|
364
|
+
const args = buildSsmArgs({
|
|
365
|
+
cluster: 'c', taskId: 't', runtimeId: 'r', dbHost: 'h', remotePort: '3306', localPort: '3306', region: 'us-east-2',
|
|
366
|
+
});
|
|
367
|
+
expect(args.join(' ')).toContain('"portNumber":["3306"]');
|
|
368
|
+
expect(args.join(' ')).toContain('"localPortNumber":["3306"]');
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('percent-encodes credentials in the URI but not the standalone password line', () => {
|
|
372
|
+
const tricky = {
|
|
373
|
+
localPort: '5432',
|
|
374
|
+
dbName: 'myapp',
|
|
375
|
+
username: 'db@admin',
|
|
376
|
+
password: 'p@ss[w]/ord:!',
|
|
377
|
+
};
|
|
378
|
+
const encodedUser = encodeURIComponent('db@admin');
|
|
379
|
+
const encodedPass = encodeURIComponent('p@ss[w]/ord:!');
|
|
380
|
+
expect(encodedPass).toBe('p%40ss%5Bw%5D%2Ford%3A!');
|
|
381
|
+
|
|
382
|
+
expect(buildConnectionString({ ...tricky, showCredentials: true }))
|
|
383
|
+
.toBe(`postgresql://${encodedUser}:${encodedPass}@localhost:5432/myapp`);
|
|
384
|
+
|
|
385
|
+
const shown = formatConnectionInfo({ ...tricky, showCredentials: true });
|
|
386
|
+
// URI line carries the encoded form so parsers don't break...
|
|
387
|
+
expect(shown).toContain(`postgresql://${encodedUser}:${encodedPass}@localhost:5432/myapp`);
|
|
388
|
+
// ...while the standalone Password line stays verbatim for copy-paste.
|
|
389
|
+
expect(shown).toContain('p@ss[w]/ord:!');
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('builds the SSM target from cluster, task id, and runtime id', () => {
|
|
393
|
+
expect(buildSsmArgs({
|
|
394
|
+
cluster: 'myapp-cluster',
|
|
395
|
+
taskId: 'abc123',
|
|
396
|
+
runtimeId: 'runtime-1',
|
|
397
|
+
dbHost: 'db.host',
|
|
398
|
+
localPort: '5433',
|
|
399
|
+
region: 'us-east-2',
|
|
400
|
+
})).toEqual([
|
|
401
|
+
'ssm', 'start-session',
|
|
402
|
+
'--target', 'ecs:myapp-cluster_abc123_runtime-1',
|
|
403
|
+
'--document-name', 'AWS-StartPortForwardingSessionToRemoteHost',
|
|
404
|
+
'--parameters', '{"host":["db.host"],"portNumber":["5432"],"localPortNumber":["5433"]}',
|
|
405
|
+
'--region', 'us-east-2',
|
|
406
|
+
]);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('picks the expected container, then first RUNNING, then first', () => {
|
|
410
|
+
const task = {
|
|
411
|
+
containers: [
|
|
412
|
+
{ name: 'redis', lastStatus: 'RUNNING', runtimeId: 'rt-redis' },
|
|
413
|
+
{ name: 'myapp-container', lastStatus: 'RUNNING', runtimeId: 'rt-app' },
|
|
414
|
+
],
|
|
415
|
+
};
|
|
416
|
+
expect(pickRuntimeContainer(task, 'myapp-container').runtimeId).toBe('rt-app');
|
|
417
|
+
expect(pickRuntimeContainer(task, 'missing').runtimeId).toBe('rt-redis');
|
|
418
|
+
expect(pickRuntimeContainer({ containers: [{ name: 'only' }] }, 'missing').name).toBe('only');
|
|
419
|
+
expect(pickRuntimeContainer({ containers: [] }, 'missing')).toBeNull();
|
|
420
|
+
expect(pickRuntimeContainer({}, 'missing')).toBeNull();
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
describe('Command: db connect (mocked AWS + spawn)', () => {
|
|
425
|
+
let exitSpy;
|
|
426
|
+
let consoleSpy;
|
|
427
|
+
let output;
|
|
428
|
+
|
|
429
|
+
beforeEach(() => {
|
|
430
|
+
vi.clearAllMocks();
|
|
431
|
+
output = [];
|
|
432
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
433
|
+
consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
434
|
+
output.push(args.join(' '));
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
afterEach(() => {
|
|
439
|
+
exitSpy.mockRestore();
|
|
440
|
+
consoleSpy.mockRestore();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
function telemetryPayloads() {
|
|
444
|
+
return mockTrackEvent.mock.calls.map(([, props]) => props || {});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function assertNoCredentialLeak() {
|
|
448
|
+
const serialized = JSON.stringify(mockTrackEvent.mock.calls);
|
|
449
|
+
expect(serialized).not.toContain(DB_PASSWORD);
|
|
450
|
+
expect(serialized).not.toContain('postgresql://');
|
|
451
|
+
for (const props of telemetryPayloads()) {
|
|
452
|
+
expect(props).not.toHaveProperty('password');
|
|
453
|
+
expect(props).not.toHaveProperty('connectionString');
|
|
454
|
+
expect(props).not.toHaveProperty('secret');
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function healthyClients() {
|
|
459
|
+
return {
|
|
460
|
+
rdsClient: mockRdsClient(healthyDbInstance()),
|
|
461
|
+
secretsClient: mockSecretsClient(JSON.stringify({ username: DB_USERNAME, password: DB_PASSWORD })),
|
|
462
|
+
ecsClient: mockEcsClient({ taskArns: [TASK_ARN], tasks: [healthyTask()] }),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
it('opens the tunnel and prints masked credentials by default', async () => {
|
|
467
|
+
const calls = [];
|
|
468
|
+
const result = await runDbConnect({ ...baseOptions(), ...healthyClients(), spawnImpl: mockSpawnImpl(calls, 0) });
|
|
469
|
+
|
|
470
|
+
expect(result.ok).toBe(true);
|
|
471
|
+
expect(result.dbIdentifier).toBe('myapp-db');
|
|
472
|
+
expect(result.localPort).toBe('5432');
|
|
473
|
+
expect(result).not.toHaveProperty('password');
|
|
474
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
475
|
+
|
|
476
|
+
expect(calls).toHaveLength(1);
|
|
477
|
+
const { cmd, args, opts } = calls[0];
|
|
478
|
+
expect(cmd).toBe('aws');
|
|
479
|
+
expect(args).toEqual([
|
|
480
|
+
'ssm', 'start-session',
|
|
481
|
+
'--target', 'ecs:myapp-cluster_abc123def456_runtime-1',
|
|
482
|
+
'--document-name', 'AWS-StartPortForwardingSessionToRemoteHost',
|
|
483
|
+
'--parameters', '{"host":["myapp-db.abc123.us-east-2.rds.amazonaws.com"],"portNumber":["5432"],"localPortNumber":["5432"]}',
|
|
484
|
+
'--region', 'us-east-2',
|
|
485
|
+
]);
|
|
486
|
+
expect(opts).toEqual(expect.objectContaining({ stdio: 'inherit' }));
|
|
487
|
+
|
|
488
|
+
const text = output.join('\n');
|
|
489
|
+
expect(text).toContain(MASKED_PASSWORD);
|
|
490
|
+
expect(text).not.toContain(DB_PASSWORD);
|
|
491
|
+
|
|
492
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_connect_run', expect.objectContaining({ success: true }));
|
|
493
|
+
assertNoCredentialLeak();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('reveals credentials and uses a custom local port when requested', async () => {
|
|
497
|
+
const calls = [];
|
|
498
|
+
const result = await runDbConnect({
|
|
499
|
+
...baseOptions({ port: '5544', showCredentials: true }),
|
|
500
|
+
...healthyClients(),
|
|
501
|
+
spawnImpl: mockSpawnImpl(calls, 0),
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
expect(result.ok).toBe(true);
|
|
505
|
+
expect(result.localPort).toBe('5544');
|
|
506
|
+
const text = output.join('\n');
|
|
507
|
+
expect(text).toContain(DB_PASSWORD);
|
|
508
|
+
expect(text).toContain('localhost:5544');
|
|
509
|
+
// Terminal output intentionally shows credentials here, but telemetry must not.
|
|
510
|
+
assertNoCredentialLeak();
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it('targets PR-preview resources with --workspace', async () => {
|
|
514
|
+
const calls = [];
|
|
515
|
+
const rdsClient = mockRdsClient(healthyDbInstance({ DBInstanceIdentifier: 'myapp-pr-9-db' }));
|
|
516
|
+
const result = await runDbConnect({
|
|
517
|
+
...baseOptions({ workspace: 'pr-9' }),
|
|
518
|
+
rdsClient,
|
|
519
|
+
secretsClient: mockSecretsClient(JSON.stringify({ username: DB_USERNAME, password: DB_PASSWORD })),
|
|
520
|
+
ecsClient: mockEcsClient({ taskArns: [TASK_ARN], tasks: [healthyTask()] }),
|
|
521
|
+
spawnImpl: mockSpawnImpl(calls, 0),
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
expect(result.ok).toBe(true);
|
|
525
|
+
expect(result.dbIdentifier).toBe('myapp-pr-9-db');
|
|
526
|
+
const describeInput = rdsClient.send.mock.calls[0][0];
|
|
527
|
+
expect(describeInput.DBInstanceIdentifier).toBe('myapp-pr-9-db');
|
|
528
|
+
expect(calls[0].args.join(' ')).toContain('ecs:myapp-pr-9-cluster_');
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('exits gracefully when no database is provisioned', async () => {
|
|
532
|
+
const spawnImpl = mockSpawnImpl([], 0);
|
|
533
|
+
const result = await runDbConnect({
|
|
534
|
+
...baseOptions(),
|
|
535
|
+
rdsClient: mockRdsNotFoundClient(),
|
|
536
|
+
secretsClient: mockSecretsClient('{}'),
|
|
537
|
+
ecsClient: mockEcsClient(),
|
|
538
|
+
spawnImpl,
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
expect(result.ok).toBe(false);
|
|
542
|
+
expect(result.reason).toBe('no-database');
|
|
543
|
+
expect(spawnImpl).not.toHaveBeenCalled();
|
|
544
|
+
expect(output.join('\n')).toMatch(/No database found|no database is provisioned/i);
|
|
545
|
+
const spin = mockSpinner.mock.results[mockSpinner.mock.results.length - 1].value;
|
|
546
|
+
expect(spin.stop).toHaveBeenCalledWith();
|
|
547
|
+
expect(output.join('\n').split('No database found').length - 1).toBe(1);
|
|
548
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
549
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_connect_run', expect.objectContaining({ success: false, error_code: 'NO_DATABASE' }));
|
|
550
|
+
assertNoCredentialLeak();
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('exits gracefully when no tasks are running', async () => {
|
|
554
|
+
const spawnImpl = mockSpawnImpl([], 0);
|
|
555
|
+
const result = await runDbConnect({
|
|
556
|
+
...baseOptions(),
|
|
557
|
+
...healthyClients(),
|
|
558
|
+
ecsClient: mockEcsClient({ taskArns: [], tasks: [] }),
|
|
559
|
+
spawnImpl,
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
expect(result.ok).toBe(false);
|
|
563
|
+
expect(result.reason).toBe('no-running-tasks');
|
|
564
|
+
expect(spawnImpl).not.toHaveBeenCalled();
|
|
565
|
+
expect(output.join('\n')).toMatch(/No running containers|jump host/i);
|
|
566
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it('rejects a non-numeric --port before any AWS call', async () => {
|
|
570
|
+
const clients = healthyClients();
|
|
571
|
+
const spawnImpl = mockSpawnImpl([], 0);
|
|
572
|
+
const result = await runDbConnect({ ...baseOptions({ port: 'abc' }), ...clients, spawnImpl });
|
|
573
|
+
|
|
574
|
+
expect(result.ok).toBe(false);
|
|
575
|
+
expect(result.reason).toBe('invalid-port');
|
|
576
|
+
expect(clients.rdsClient.send).not.toHaveBeenCalled();
|
|
577
|
+
expect(spawnImpl).not.toHaveBeenCalled();
|
|
578
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
579
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_connect_run', expect.objectContaining({ success: false, error_code: 'INVALID_PORT' }));
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('fails fast with install guidance when AWS CLI is missing', async () => {
|
|
583
|
+
const clients = healthyClients();
|
|
584
|
+
const spawnImpl = mockSpawnImpl([], 0);
|
|
585
|
+
const result = await runDbConnect({ ...baseOptions({ hasAwsCli: false }), ...clients, spawnImpl });
|
|
586
|
+
|
|
587
|
+
expect(result.ok).toBe(false);
|
|
588
|
+
expect(result.reason).toBe('aws-cli-missing');
|
|
589
|
+
expect(clients.rdsClient.send).not.toHaveBeenCalled();
|
|
590
|
+
expect(spawnImpl).not.toHaveBeenCalled();
|
|
591
|
+
expect(output.join('\n')).toMatch(/AWS CLI not found/);
|
|
592
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it('fails fast when the Session Manager plugin is missing', async () => {
|
|
596
|
+
const clients = healthyClients();
|
|
597
|
+
const spawnImpl = mockSpawnImpl([], 0);
|
|
598
|
+
const result = await runDbConnect({ ...baseOptions({ hasSsmPlugin: false }), ...clients, spawnImpl });
|
|
599
|
+
|
|
600
|
+
expect(result.ok).toBe(false);
|
|
601
|
+
expect(result.reason).toBe('ssm-plugin-missing');
|
|
602
|
+
expect(clients.rdsClient.send).not.toHaveBeenCalled();
|
|
603
|
+
expect(spawnImpl).not.toHaveBeenCalled();
|
|
604
|
+
expect(output.join('\n')).toMatch(/Session Manager plugin not found/);
|
|
605
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('exits gracefully when the managed secret is malformed', async () => {
|
|
609
|
+
const spawnImpl = mockSpawnImpl([], 0);
|
|
610
|
+
const result = await runDbConnect({
|
|
611
|
+
...baseOptions(),
|
|
612
|
+
rdsClient: mockRdsClient(healthyDbInstance()),
|
|
613
|
+
secretsClient: mockSecretsClient('not-json{{{'),
|
|
614
|
+
ecsClient: mockEcsClient({ taskArns: [TASK_ARN], tasks: [healthyTask()] }),
|
|
615
|
+
spawnImpl,
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
expect(result.ok).toBe(false);
|
|
619
|
+
expect(result.reason).toBe('secret-malformed');
|
|
620
|
+
expect(spawnImpl).not.toHaveBeenCalled();
|
|
621
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
622
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_connect_run', expect.objectContaining({ success: false, error_code: 'SECRET_MALFORMED' }));
|
|
623
|
+
assertNoCredentialLeak();
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('tunnels to MySQL on 3306 with a mysql:// connection string', async () => {
|
|
627
|
+
const calls = [];
|
|
628
|
+
const result = await runDbConnect({
|
|
629
|
+
...baseOptions(),
|
|
630
|
+
rdsClient: mockRdsClient(healthyDbInstance({
|
|
631
|
+
Engine: 'mysql',
|
|
632
|
+
Endpoint: { Address: 'myapp-db.abc123.us-east-2.rds.amazonaws.com', Port: 3306 },
|
|
633
|
+
})),
|
|
634
|
+
secretsClient: mockSecretsClient(JSON.stringify({ username: DB_USERNAME, password: DB_PASSWORD })),
|
|
635
|
+
ecsClient: mockEcsClient({ taskArns: [TASK_ARN], tasks: [healthyTask()] }),
|
|
636
|
+
spawnImpl: mockSpawnImpl(calls, 0),
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
expect(result.ok).toBe(true);
|
|
640
|
+
expect(result.localPort).toBe('3306');
|
|
641
|
+
expect(calls[0].args.join(' ')).toContain('"portNumber":["3306"]');
|
|
642
|
+
expect(calls[0].args.join(' ')).toContain('"localPortNumber":["3306"]');
|
|
643
|
+
expect(output.join('\n')).toContain('mysql://dbadmin:********@localhost:3306/myapp');
|
|
644
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_connect_run', expect.objectContaining({ success: true, db_engine: 'mysql' }));
|
|
645
|
+
assertNoCredentialLeak();
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it('keeps an explicit --port while forwarding to the remote MySQL port', async () => {
|
|
649
|
+
const calls = [];
|
|
650
|
+
const result = await runDbConnect({
|
|
651
|
+
...baseOptions({ port: '5433' }),
|
|
652
|
+
rdsClient: mockRdsClient(healthyDbInstance({
|
|
653
|
+
Engine: 'mysql',
|
|
654
|
+
Endpoint: { Address: 'myapp-db.abc123.us-east-2.rds.amazonaws.com', Port: 3306 },
|
|
655
|
+
})),
|
|
656
|
+
secretsClient: mockSecretsClient(JSON.stringify({ username: DB_USERNAME, password: DB_PASSWORD })),
|
|
657
|
+
ecsClient: mockEcsClient({ taskArns: [TASK_ARN], tasks: [healthyTask()] }),
|
|
658
|
+
spawnImpl: mockSpawnImpl(calls, 0),
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
expect(result.ok).toBe(true);
|
|
662
|
+
expect(result.localPort).toBe('5433');
|
|
663
|
+
expect(calls[0].args.join(' ')).toContain('"portNumber":["3306"]');
|
|
664
|
+
expect(calls[0].args.join(' ')).toContain('"localPortNumber":["5433"]');
|
|
665
|
+
assertNoCredentialLeak();
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it('discovers an Aurora cluster via the -db-cluster identifier', async () => {
|
|
669
|
+
const calls = [];
|
|
670
|
+
const result = await runDbConnect({
|
|
671
|
+
...baseOptions(),
|
|
672
|
+
rdsClient: mockRdsClusterClient(healthyDbCluster()),
|
|
673
|
+
secretsClient: mockSecretsClient(JSON.stringify({ username: DB_USERNAME, password: DB_PASSWORD })),
|
|
674
|
+
ecsClient: mockEcsClient({ taskArns: [TASK_ARN], tasks: [healthyTask()] }),
|
|
675
|
+
spawnImpl: mockSpawnImpl(calls, 0),
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
expect(result.ok).toBe(true);
|
|
679
|
+
expect(result.localPort).toBe('5432');
|
|
680
|
+
expect(calls[0].args.join(' ')).toContain('myapp-db-cluster.xyz789.us-east-2.rds.amazonaws.com');
|
|
681
|
+
expect(output.join('\n')).toContain('postgresql://dbadmin:********@localhost:5432/myapp');
|
|
682
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_connect_run', expect.objectContaining({ success: true, db_engine: 'aurora-postgresql', db_kind: 'cluster' }));
|
|
683
|
+
assertNoCredentialLeak();
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
describe('db: dispatcher', () => {
|
|
688
|
+
let exitSpy;
|
|
689
|
+
let consoleSpy;
|
|
690
|
+
let output;
|
|
691
|
+
|
|
692
|
+
beforeEach(() => {
|
|
693
|
+
vi.clearAllMocks();
|
|
694
|
+
output = [];
|
|
695
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
696
|
+
consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
697
|
+
output.push(args.join(' '));
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
afterEach(() => {
|
|
702
|
+
exitSpy.mockRestore();
|
|
703
|
+
consoleSpy.mockRestore();
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('reports unknown subcommands with usage and telemetry', async () => {
|
|
707
|
+
const result = await runDb(['db', 'frobnicate']);
|
|
708
|
+
expect(result.ok).toBe(false);
|
|
709
|
+
expect(result.reason).toBe('unknown-db-subcommand');
|
|
710
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
711
|
+
const text = stripVTControlCharacters(output.join('\n'));
|
|
712
|
+
expect(text).toContain('Unknown db subcommand "frobnicate"');
|
|
713
|
+
expect(text).toContain('db connect');
|
|
714
|
+
expect(text).toContain('db migrate');
|
|
715
|
+
expect(text).toContain('db backup');
|
|
716
|
+
expect(text).toContain('db restore');
|
|
717
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_run', expect.objectContaining({
|
|
718
|
+
success: false,
|
|
719
|
+
error_code: 'UNKNOWN_DB_SUBCOMMAND',
|
|
720
|
+
}));
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it('reports a missing subcommand', async () => {
|
|
724
|
+
const result = await runDb(['db']);
|
|
725
|
+
expect(result.ok).toBe(false);
|
|
726
|
+
expect(result.reason).toBe('unknown-db-subcommand');
|
|
727
|
+
expect(stripVTControlCharacters(output.join('\n'))).toContain('Missing db subcommand');
|
|
728
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it('routes to each subcommand', async () => {
|
|
732
|
+
// Each proof fails before any AWS call, so no clients are needed.
|
|
733
|
+
const migrate = await runDb(['db', 'migrate', 'oops-unquoted', '--cmd', 'x']);
|
|
734
|
+
expect(migrate.reason).toBe('unexpected-positional-args');
|
|
735
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_migrate_run', expect.objectContaining({ success: false }));
|
|
736
|
+
|
|
737
|
+
const backup = await runDb(['db', 'backup', 'oops']);
|
|
738
|
+
expect(backup.reason).toBe('unexpected-positional-args');
|
|
739
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_backup_run', expect.objectContaining({ success: false }));
|
|
740
|
+
|
|
741
|
+
// The repo root has no terraform/database.tf, so restore fails fast
|
|
742
|
+
// on the missing file before any AWS call.
|
|
743
|
+
const restore = await runDb(['db', 'restore', '--project-name', 'myapp']);
|
|
744
|
+
expect(restore.reason).toBe('database-tf-not-found');
|
|
745
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_restore_run', expect.objectContaining({ success: false }));
|
|
746
|
+
|
|
747
|
+
// enable-vector fails fast on the missing file before any AWS call.
|
|
748
|
+
const vector = await runDb(['db', 'enable-vector', '--project-name', 'myapp']);
|
|
749
|
+
expect(vector.reason).toBe('no-database-configured');
|
|
750
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_enable_vector_run', expect.objectContaining({ success: false }));
|
|
751
|
+
|
|
752
|
+
// import fails fast on the missing source before any AWS call.
|
|
753
|
+
const imported = await runDb(['db', 'import', '--project-name', 'myapp']);
|
|
754
|
+
expect(imported.reason).toBe('invalid-import-source');
|
|
755
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_import_run', expect.objectContaining({ success: false }));
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it('keeps routing connect without headless interference', async () => {
|
|
759
|
+
const result = await runDb(['db', 'connect', '--port', 'abc']);
|
|
760
|
+
expect(result.reason).toBe('invalid-port');
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it.each([null, 42, true, { port: 'string' }])('runDb(%s) reports a missing subcommand', async (bad) => {
|
|
764
|
+
const result = await runDb(bad, null);
|
|
765
|
+
expect(result.ok).toBe(false);
|
|
766
|
+
expect(result.reason).toBe('unknown-db-subcommand');
|
|
767
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
768
|
+
});
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
describe('db: migrate/backup/restore parsers', () => {
|
|
772
|
+
it('parses migrate flags, = syntax, and boolean shorthands', () => {
|
|
773
|
+
expect(parseDbMigrateArgs([
|
|
774
|
+
'db', 'migrate', '--cmd', 'npx prisma migrate deploy', '--task-def', 'fam:3',
|
|
775
|
+
'--timeout', '120', '--setup-ci', '--project-name', 'p', '--region', 'r',
|
|
776
|
+
'--workspace', 'w', '--cluster', 'c', '--service', 's', '--container', 'ct',
|
|
777
|
+
])).toEqual({
|
|
778
|
+
cmd: 'npx prisma migrate deploy',
|
|
779
|
+
taskDef: 'fam:3',
|
|
780
|
+
timeout: '120',
|
|
781
|
+
setupCi: true,
|
|
782
|
+
projectName: 'p',
|
|
783
|
+
region: 'r',
|
|
784
|
+
workspace: 'w',
|
|
785
|
+
cluster: 'c',
|
|
786
|
+
service: 's',
|
|
787
|
+
container: 'ct',
|
|
788
|
+
});
|
|
789
|
+
expect(parseDbMigrateArgs(['migrate', '--cmd=x y', '--timeout=30'])).toEqual({
|
|
790
|
+
cmd: 'x y',
|
|
791
|
+
timeout: '30',
|
|
792
|
+
});
|
|
793
|
+
expect(parseDbMigrateArgs(['db', 'migrate'])).toEqual({});
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
it('captures unexpected migrate positionals for the quoting guard', () => {
|
|
797
|
+
expect(parseDbMigrateArgs(['db', 'migrate', '--cmd', 'prisma', 'migrate', 'deploy']).unexpectedPositionals)
|
|
798
|
+
.toEqual(['migrate', 'deploy']);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it('parses backup flags', () => {
|
|
802
|
+
expect(parseDbBackupArgs(['db', 'backup', '--id', 'snap-1', '--no-wait', '--timeout', '60', '--db-identifier', 'dbx'])).toEqual({
|
|
803
|
+
snapshotId: 'snap-1',
|
|
804
|
+
noWait: true,
|
|
805
|
+
timeout: '60',
|
|
806
|
+
dbIdentifier: 'dbx',
|
|
807
|
+
});
|
|
808
|
+
expect(parseDbBackupArgs(['db', 'backup', '--id=snap-2'])).toEqual({ snapshotId: 'snap-2' });
|
|
809
|
+
expect(parseDbBackupArgs(['db', 'backup', 'oops']).unexpectedPositionals).toEqual(['oops']);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it('parses restore flags and the positional snapshot id', () => {
|
|
813
|
+
expect(parseDbRestoreArgs(['db', 'restore', 'snap-9', '--yes', '--db-identifier', 'dbx'])).toEqual({
|
|
814
|
+
snapshotId: 'snap-9',
|
|
815
|
+
yes: true,
|
|
816
|
+
dbIdentifier: 'dbx',
|
|
817
|
+
});
|
|
818
|
+
expect(parseDbRestoreArgs(['db', 'restore'])).toEqual({});
|
|
819
|
+
expect(parseDbRestoreArgs(['restore', 'a', 'b']).unexpectedPositionals).toEqual(['b']);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it.each([null, 42, true, { port: 'string' }])('migrate/backup/restore parsers return defaults for %s', (bad) => {
|
|
823
|
+
expect(parseDbMigrateArgs(bad)).toEqual({});
|
|
824
|
+
expect(parseDbBackupArgs(bad)).toEqual({});
|
|
825
|
+
expect(parseDbRestoreArgs(bad)).toEqual({});
|
|
826
|
+
expect(parseDbEnableVectorArgs(bad)).toEqual({});
|
|
827
|
+
});
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
const MIGRATE_TASK_ARN = 'arn:aws:ecs:us-east-2:123456789012:task/myapp-cluster/migrate123';
|
|
831
|
+
|
|
832
|
+
function activeServiceDesc(overrides = {}) {
|
|
833
|
+
return {
|
|
834
|
+
serviceName: 'myapp-service',
|
|
835
|
+
status: 'ACTIVE',
|
|
836
|
+
taskDefinition: 'arn:aws:ecs:us-east-2:123456789012:task-definition/myapp-task:7',
|
|
837
|
+
networkConfiguration: {
|
|
838
|
+
awsvpcConfiguration: { subnets: ['sub-1', 'sub-2'], securityGroups: ['sg-1'], assignPublicIp: 'ENABLED' },
|
|
839
|
+
},
|
|
840
|
+
...overrides,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function runningTask() {
|
|
845
|
+
return { taskArn: MIGRATE_TASK_ARN, lastStatus: 'RUNNING', containers: [{ name: 'myapp-container' }] };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function stoppedTask(exitCode, reason = 'Essential container exited') {
|
|
849
|
+
const container = exitCode === undefined
|
|
850
|
+
? { name: 'myapp-container', reason }
|
|
851
|
+
: { name: 'myapp-container', exitCode, reason };
|
|
852
|
+
return { taskArn: MIGRATE_TASK_ARN, lastStatus: 'STOPPED', stoppedReason: 'task-level', containers: [container] };
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function mockEcsMigrateClient({
|
|
856
|
+
service = activeServiceDesc(),
|
|
857
|
+
taskDefNames = ['myapp-container'],
|
|
858
|
+
containerDefinitions = null,
|
|
859
|
+
runTasks = [{ taskArn: MIGRATE_TASK_ARN }],
|
|
860
|
+
failures = [],
|
|
861
|
+
taskSequence = [],
|
|
862
|
+
} = {}) {
|
|
863
|
+
const queue = [...taskSequence];
|
|
864
|
+
const runs = [];
|
|
865
|
+
const stops = [];
|
|
866
|
+
const client = {
|
|
867
|
+
send: vi.fn((cmd) => {
|
|
868
|
+
if (cmd instanceof MockDescribeServicesCommand) {
|
|
869
|
+
return Promise.resolve({ services: service ? [service] : [] });
|
|
870
|
+
}
|
|
871
|
+
if (cmd instanceof MockDescribeTaskDefinitionCommand) {
|
|
872
|
+
const defs = containerDefinitions || taskDefNames.map((name) => ({ name }));
|
|
873
|
+
return Promise.resolve({ taskDefinition: { containerDefinitions: defs } });
|
|
874
|
+
}
|
|
875
|
+
if (cmd instanceof MockRunTaskCommand) {
|
|
876
|
+
runs.push(cmd);
|
|
877
|
+
return Promise.resolve({ tasks: runTasks, failures });
|
|
878
|
+
}
|
|
879
|
+
if (cmd instanceof MockDescribeTasksCommand) {
|
|
880
|
+
const next = queue.length > 0 ? queue.shift() : runningTask();
|
|
881
|
+
return Promise.resolve({ tasks: [next] });
|
|
882
|
+
}
|
|
883
|
+
if (cmd instanceof MockStopTaskCommand) {
|
|
884
|
+
stops.push(cmd);
|
|
885
|
+
return Promise.resolve({});
|
|
886
|
+
}
|
|
887
|
+
return Promise.resolve({});
|
|
888
|
+
}),
|
|
889
|
+
runs,
|
|
890
|
+
stops,
|
|
891
|
+
};
|
|
892
|
+
return client;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function mockLogsClient(script = [], flushScript = []) {
|
|
896
|
+
const queue = [...script];
|
|
897
|
+
const flushQueue = [...flushScript];
|
|
898
|
+
const calls = [];
|
|
899
|
+
const flushCalls = [];
|
|
900
|
+
const client = {
|
|
901
|
+
send: vi.fn((cmd) => {
|
|
902
|
+
if (cmd instanceof MockGetLogEventsCommand) {
|
|
903
|
+
flushCalls.push(cmd);
|
|
904
|
+
const next = flushQueue.length > 0 ? flushQueue.shift() : { events: [] };
|
|
905
|
+
if (next.error) return Promise.reject(next.error);
|
|
906
|
+
return Promise.resolve({ events: next.events || [], nextForwardToken: next.nextForwardToken });
|
|
907
|
+
}
|
|
908
|
+
calls.push(cmd);
|
|
909
|
+
const next = queue.length > 0 ? queue.shift() : { events: [] };
|
|
910
|
+
if (next.error) return Promise.reject(next.error);
|
|
911
|
+
return Promise.resolve({ events: next.events || [] });
|
|
912
|
+
}),
|
|
913
|
+
calls,
|
|
914
|
+
flushCalls,
|
|
915
|
+
};
|
|
916
|
+
return client;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function migrateOptions(overrides = {}) {
|
|
920
|
+
return {
|
|
921
|
+
...baseOptions(),
|
|
922
|
+
projectName: 'myapp',
|
|
923
|
+
region: 'us-east-2',
|
|
924
|
+
cmd: 'npx prisma migrate deploy',
|
|
925
|
+
pollIntervalMs: 5,
|
|
926
|
+
flushIntervalMs: 0,
|
|
927
|
+
...overrides,
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
describe('Command: db migrate (mocked AWS)', () => {
|
|
932
|
+
let exitSpy;
|
|
933
|
+
let consoleSpy;
|
|
934
|
+
let output;
|
|
935
|
+
|
|
936
|
+
beforeEach(() => {
|
|
937
|
+
vi.clearAllMocks();
|
|
938
|
+
mockText.mockReset();
|
|
939
|
+
output = [];
|
|
940
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
941
|
+
consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
942
|
+
output.push(args.join(' '));
|
|
943
|
+
});
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
afterEach(() => {
|
|
947
|
+
exitSpy.mockRestore();
|
|
948
|
+
consoleSpy.mockRestore();
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
function assertNoCmdLeak(cmd) {
|
|
952
|
+
const serialized = JSON.stringify(mockTrackEvent.mock.calls);
|
|
953
|
+
expect(serialized).not.toContain(cmd);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
it('fails fast on invalid --timeout before AWS calls', async () => {
|
|
957
|
+
const ecsClient = mockEcsMigrateClient();
|
|
958
|
+
const result = await runDbMigrate(migrateOptions({ timeout: 'soon', ecsClient, logsClient: mockLogsClient() }));
|
|
959
|
+
expect(result.ok).toBe(false);
|
|
960
|
+
expect(result.reason).toBe('invalid-timeout');
|
|
961
|
+
expect(ecsClient.send).not.toHaveBeenCalled();
|
|
962
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_migrate_run', expect.objectContaining({
|
|
963
|
+
success: false, error_code: 'INVALID_TIMEOUT',
|
|
964
|
+
}));
|
|
965
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
it('fails fast on unexpected positional args with a quoting hint', async () => {
|
|
969
|
+
const ecsClient = mockEcsMigrateClient();
|
|
970
|
+
const result = await runDbMigrate(migrateOptions({
|
|
971
|
+
ecsClient,
|
|
972
|
+
logsClient: mockLogsClient(),
|
|
973
|
+
unexpectedPositionals: ['migrate', 'deploy'],
|
|
974
|
+
}));
|
|
975
|
+
expect(result.reason).toBe('unexpected-positional-args');
|
|
976
|
+
expect(stripVTControlCharacters(output.join('\n'))).toContain('Wrap multi-word --cmd values in quotes');
|
|
977
|
+
expect(ecsClient.send).not.toHaveBeenCalled();
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
it('fails headless without --cmd when nothing is detected', async () => {
|
|
981
|
+
const ecsClient = mockEcsMigrateClient();
|
|
982
|
+
const { cmd: _cmd, ...noCmd } = migrateOptions({ ecsClient, logsClient: mockLogsClient() });
|
|
983
|
+
const result = await runDbMigrate(noCmd);
|
|
984
|
+
expect(result.reason).toBe('missing-migration-cmd');
|
|
985
|
+
expect(ecsClient.send).not.toHaveBeenCalled();
|
|
986
|
+
expect(mockText).not.toHaveBeenCalled();
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
it('uses the detected command in headless mode', async () => {
|
|
990
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-migrate-detect-'));
|
|
991
|
+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ scripts: { migrate: 'knex migrate' } }));
|
|
992
|
+
const ecsClient = mockEcsMigrateClient({ taskSequence: [stoppedTask(0)] });
|
|
993
|
+
const { cmd: _cmd, ...noCmd } = migrateOptions({ cwd: dir, ecsClient, logsClient: mockLogsClient() });
|
|
994
|
+
const result = await runDbMigrate(noCmd);
|
|
995
|
+
expect(result.success).toBe(true);
|
|
996
|
+
expect(ecsClient.runs[0].overrides.containerOverrides[0].command).toEqual(['sh', '-c', 'npm run migrate']);
|
|
997
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_migrate_run', expect.objectContaining({
|
|
998
|
+
success: true, cmd_source: 'detected', ci_setup: false,
|
|
999
|
+
}));
|
|
1000
|
+
assertNoCmdLeak('npm run migrate');
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
it('prompts interactively and honors cancellation', async () => {
|
|
1004
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-migrate-prompt-'));
|
|
1005
|
+
fs.writeFileSync(path.join(dir, 'alembic.ini'), '[alembic]');
|
|
1006
|
+
const ecsClient = mockEcsMigrateClient({ taskSequence: [stoppedTask(0)] });
|
|
1007
|
+
mockText.mockResolvedValueOnce('alembic upgrade head --verbose');
|
|
1008
|
+
const { cmd: _cmd, ...noCmd } = migrateOptions({
|
|
1009
|
+
cwd: dir, isHeadless: false, ecsClient, logsClient: mockLogsClient(),
|
|
1010
|
+
});
|
|
1011
|
+
const result = await runDbMigrate(noCmd);
|
|
1012
|
+
expect(result.success).toBe(true);
|
|
1013
|
+
expect(mockText).toHaveBeenCalledWith(expect.objectContaining({ initialValue: 'alembic upgrade head' }));
|
|
1014
|
+
expect(ecsClient.runs[0].overrides.containerOverrides[0].command[2]).toBe('alembic upgrade head --verbose');
|
|
1015
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_migrate_run', expect.objectContaining({ cmd_source: 'prompted' }));
|
|
1016
|
+
assertNoCmdLeak('alembic upgrade head --verbose');
|
|
1017
|
+
|
|
1018
|
+
mockText.mockResolvedValueOnce(Symbol('clack-cancel'));
|
|
1019
|
+
const ecsClient2 = mockEcsMigrateClient();
|
|
1020
|
+
const { cmd: _c2, ...noCmd2 } = migrateOptions({
|
|
1021
|
+
cwd: dir, isHeadless: false, ecsClient: ecsClient2, logsClient: mockLogsClient(),
|
|
1022
|
+
});
|
|
1023
|
+
const cancelled = await runDbMigrate(noCmd2);
|
|
1024
|
+
expect(cancelled).toEqual(expect.objectContaining({ ok: false, reason: 'cancelled' }));
|
|
1025
|
+
expect(ecsClient2.send).not.toHaveBeenCalled();
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
it('fails when the service is missing or inactive', async () => {
|
|
1029
|
+
const ecsClient = mockEcsMigrateClient({ service: null });
|
|
1030
|
+
const result = await runDbMigrate(migrateOptions({ ecsClient, logsClient: mockLogsClient() }));
|
|
1031
|
+
expect(result.reason).toBe('ecs-service-not-found');
|
|
1032
|
+
expect(ecsClient.runs).toHaveLength(0);
|
|
1033
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_migrate_run', expect.objectContaining({
|
|
1034
|
+
success: false, error_code: 'ECS_SERVICE_NOT_FOUND', cmd_source: 'explicit',
|
|
1035
|
+
}));
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
it('fails before RunTask when the container is missing from the task definition', async () => {
|
|
1039
|
+
const ecsClient = mockEcsMigrateClient({ taskDefNames: ['sidecar'] });
|
|
1040
|
+
const result = await runDbMigrate(migrateOptions({ ecsClient, logsClient: mockLogsClient() }));
|
|
1041
|
+
expect(result.reason).toBe('container-not-found');
|
|
1042
|
+
expect(ecsClient.runs).toHaveLength(0);
|
|
1043
|
+
expect(stripVTControlCharacters(output.join('\n'))).toContain('--container');
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
it('fails when RunTask reports failures', async () => {
|
|
1047
|
+
const ecsClient = mockEcsMigrateClient({ runTasks: [], failures: [{ reason: 'RESOURCE:ENI' }] });
|
|
1048
|
+
const result = await runDbMigrate(migrateOptions({ ecsClient, logsClient: mockLogsClient() }));
|
|
1049
|
+
expect(result.reason).toBe('run-task-failed');
|
|
1050
|
+
expect(stripVTControlCharacters(output.join('\n'))).toContain('RESOURCE:ENI');
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it('uses an explicit --task-def revision for validation and RunTask', async () => {
|
|
1054
|
+
const ecsClient = mockEcsMigrateClient({ taskSequence: [stoppedTask(0)] });
|
|
1055
|
+
const result = await runDbMigrate(migrateOptions({
|
|
1056
|
+
taskDef: 'myapp-task:9', ecsClient, logsClient: mockLogsClient(),
|
|
1057
|
+
}));
|
|
1058
|
+
expect(result.success).toBe(true);
|
|
1059
|
+
const describeInput = ecsClient.send.mock.calls.find(([c]) => c instanceof MockDescribeTaskDefinitionCommand)[0];
|
|
1060
|
+
expect(describeInput.taskDefinition).toBe('myapp-task:9');
|
|
1061
|
+
expect(ecsClient.runs[0].taskDefinition).toBe('myapp-task:9');
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
it('streams deduplicated logs and returns exit 0 on success', async () => {
|
|
1065
|
+
const sigintBefore = process.listenerCount('SIGINT');
|
|
1066
|
+
const ecsClient = mockEcsMigrateClient({ taskSequence: [runningTask(), stoppedTask(0)] });
|
|
1067
|
+
const logsClient = mockLogsClient([
|
|
1068
|
+
{ events: [{ eventId: '1', message: 'applying migration 001' }] },
|
|
1069
|
+
{ events: [{ eventId: '1', message: 'applying migration 001' }, { eventId: '2', message: 'done' }] },
|
|
1070
|
+
{ events: [] },
|
|
1071
|
+
]);
|
|
1072
|
+
const result = await runDbMigrate(migrateOptions({ ecsClient, logsClient }));
|
|
1073
|
+
expect(result).toEqual(expect.objectContaining({ ok: true, success: true, exitCode: 0, taskArn: MIGRATE_TASK_ARN }));
|
|
1074
|
+
|
|
1075
|
+
const fetchInput = logsClient.calls[0];
|
|
1076
|
+
expect(fetchInput.logGroupName).toBe('/ecs/myapp');
|
|
1077
|
+
expect(fetchInput.logStreamNames).toEqual(['ecs/myapp-container/migrate123']);
|
|
1078
|
+
|
|
1079
|
+
const text = output.join('\n');
|
|
1080
|
+
expect(text.match(/applying migration 001/g)).toHaveLength(1);
|
|
1081
|
+
expect(text).toContain('done');
|
|
1082
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_migrate_run', expect.objectContaining({
|
|
1083
|
+
success: true, cmd_source: 'explicit', ci_setup: false,
|
|
1084
|
+
}));
|
|
1085
|
+
assertNoCmdLeak('npx prisma migrate deploy');
|
|
1086
|
+
expect(process.listenerCount('SIGINT')).toBe(sigintBefore);
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
it('ignores ResourceNotFoundException while the stream initializes', async () => {
|
|
1090
|
+
const notFound = new Error('stream missing');
|
|
1091
|
+
notFound.name = 'ResourceNotFoundException';
|
|
1092
|
+
const ecsClient = mockEcsMigrateClient({ taskSequence: [runningTask(), stoppedTask(0)] });
|
|
1093
|
+
const logsClient = mockLogsClient([
|
|
1094
|
+
{ error: notFound },
|
|
1095
|
+
{ events: [{ eventId: '7', message: 'late log line' }] },
|
|
1096
|
+
{ events: [] },
|
|
1097
|
+
]);
|
|
1098
|
+
const result = await runDbMigrate(migrateOptions({ ecsClient, logsClient }));
|
|
1099
|
+
expect(result.success).toBe(true);
|
|
1100
|
+
expect(output.join('\n')).toContain('late log line');
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
it('propagates non-zero exit codes via failCommand', async () => {
|
|
1104
|
+
const ecsClient = mockEcsMigrateClient({ taskSequence: [stoppedTask(3, 'migration boom')] });
|
|
1105
|
+
const result = await runDbMigrate(migrateOptions({ ecsClient, logsClient: mockLogsClient() }));
|
|
1106
|
+
expect(result).toEqual(expect.objectContaining({ ok: false, reason: 'migration-task-failed' }));
|
|
1107
|
+
expect(exitSpy).toHaveBeenCalledWith(3);
|
|
1108
|
+
expect(stripVTControlCharacters(output.join('\n'))).toContain('migration boom');
|
|
1109
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_migrate_run', expect.objectContaining({
|
|
1110
|
+
success: false, error_code: 'MIGRATION_TASK_FAILED', exit_code: 3,
|
|
1111
|
+
}));
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
it('exits 1 with exit_code -1 when no exit code is reported', async () => {
|
|
1115
|
+
const ecsClient = mockEcsMigrateClient({ taskSequence: [stoppedTask(undefined, 'CannotPullContainerError')] });
|
|
1116
|
+
const result = await runDbMigrate(migrateOptions({ ecsClient, logsClient: mockLogsClient() }));
|
|
1117
|
+
expect(result.reason).toBe('migration-task-failed');
|
|
1118
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
1119
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_migrate_run', expect.objectContaining({ exit_code: -1 }));
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
it('stops the task and fails on timeout', async () => {
|
|
1123
|
+
const ecsClient = mockEcsMigrateClient({});
|
|
1124
|
+
const result = await runDbMigrate(migrateOptions({
|
|
1125
|
+
ecsClient, logsClient: mockLogsClient(), timeoutMs: 30,
|
|
1126
|
+
}));
|
|
1127
|
+
expect(result.reason).toBe('migration-timeout');
|
|
1128
|
+
expect(ecsClient.stops).toHaveLength(1);
|
|
1129
|
+
expect(ecsClient.stops[0].task).toBe(MIGRATE_TASK_ARN);
|
|
1130
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_migrate_run', expect.objectContaining({
|
|
1131
|
+
success: false, error_code: 'MIGRATION_TIMEOUT',
|
|
1132
|
+
}));
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
it('stops the task on SIGINT', async () => {
|
|
1136
|
+
const ecsClient = mockEcsMigrateClient({});
|
|
1137
|
+
const promise = runDbMigrate(migrateOptions({
|
|
1138
|
+
ecsClient, logsClient: mockLogsClient(), timeoutMs: 500,
|
|
1139
|
+
}));
|
|
1140
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1141
|
+
process.emit('SIGINT');
|
|
1142
|
+
await promise;
|
|
1143
|
+
expect(ecsClient.stops.length).toBeGreaterThanOrEqual(1);
|
|
1144
|
+
expect(ecsClient.stops[0].reason).toContain('SIGINT');
|
|
1145
|
+
expect(exitSpy).toHaveBeenCalledWith(130);
|
|
1146
|
+
});
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
const WORKFLOW_FIXTURE = `name: Deploy
|
|
1150
|
+
jobs:
|
|
1151
|
+
deploy:
|
|
1152
|
+
steps:
|
|
1153
|
+
- name: Register new task definition revision
|
|
1154
|
+
id: register-task-def
|
|
1155
|
+
run: echo hi
|
|
1156
|
+
- name: Force ECS deployment
|
|
1157
|
+
run: echo deploy
|
|
1158
|
+
`;
|
|
1159
|
+
|
|
1160
|
+
function writeWorkflow(dir, content = WORKFLOW_FIXTURE) {
|
|
1161
|
+
fs.mkdirSync(path.join(dir, '.github', 'workflows'), { recursive: true });
|
|
1162
|
+
fs.writeFileSync(path.join(dir, '.github', 'workflows', 'deploy.yml'), content);
|
|
1163
|
+
return path.join(dir, '.github', 'workflows', 'deploy.yml');
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
describe('Command: db migrate live-tail fixes (mocked AWS)', () => {
|
|
1167
|
+
let exitSpy;
|
|
1168
|
+
let consoleSpy;
|
|
1169
|
+
let output;
|
|
1170
|
+
|
|
1171
|
+
beforeEach(() => {
|
|
1172
|
+
vi.clearAllMocks();
|
|
1173
|
+
mockText.mockReset();
|
|
1174
|
+
output = [];
|
|
1175
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
1176
|
+
consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
1177
|
+
output.push(args.join(' '));
|
|
1178
|
+
});
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
afterEach(() => {
|
|
1182
|
+
exitSpy.mockRestore();
|
|
1183
|
+
consoleSpy.mockRestore();
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
function spinnerInstance() {
|
|
1187
|
+
return mockSpinner.mock.results[mockSpinner.mock.results.length - 1].value;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function describeTasksCalls(ecsClient) {
|
|
1191
|
+
return ecsClient.send.mock.calls.filter(([cmd]) => cmd instanceof MockDescribeTasksCommand);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
it('updates the phase spinner through PROVISIONING/PENDING before streaming', async () => {
|
|
1195
|
+
const ecsClient = mockEcsMigrateClient({
|
|
1196
|
+
taskSequence: [
|
|
1197
|
+
{ taskArn: MIGRATE_TASK_ARN, lastStatus: 'PROVISIONING', containers: [] },
|
|
1198
|
+
{ taskArn: MIGRATE_TASK_ARN, lastStatus: 'PENDING', containers: [] },
|
|
1199
|
+
runningTask(),
|
|
1200
|
+
stoppedTask(0),
|
|
1201
|
+
],
|
|
1202
|
+
});
|
|
1203
|
+
const logsClient = mockLogsClient();
|
|
1204
|
+
const result = await runDbMigrate(migrateOptions({ ecsClient, logsClient }));
|
|
1205
|
+
expect(result.ok).toBe(true);
|
|
1206
|
+
const spin = spinnerInstance();
|
|
1207
|
+
const shortId = MIGRATE_TASK_ARN.split('/').pop().slice(0, 8);
|
|
1208
|
+
const messages = spin.message.mock.calls.map((call) => call[0]);
|
|
1209
|
+
expect(messages).toEqual([
|
|
1210
|
+
`Starting migration task (PROVISIONING, ${shortId})...`,
|
|
1211
|
+
`Starting migration task (PENDING, ${shortId})...`,
|
|
1212
|
+
]);
|
|
1213
|
+
expect(spin.stop).toHaveBeenCalledWith(expect.stringContaining('Migration container running. Streaming logs...'));
|
|
1214
|
+
const text = stripVTControlCharacters(output.join('\n'));
|
|
1215
|
+
expect(text).toContain('press Ctrl+C to abort and stop the remote task.');
|
|
1216
|
+
expect(text).not.toContain('Ctrl+C cancels (the task is stopped)');
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
it('uses a generic spinner message for unexpected pre-RUNNING statuses', async () => {
|
|
1220
|
+
const ecsClient = mockEcsMigrateClient({
|
|
1221
|
+
taskSequence: [
|
|
1222
|
+
{ taskArn: MIGRATE_TASK_ARN, lastStatus: 'DEPROVISIONING', containers: [] },
|
|
1223
|
+
runningTask(),
|
|
1224
|
+
stoppedTask(0),
|
|
1225
|
+
],
|
|
1226
|
+
});
|
|
1227
|
+
const result = await runDbMigrate(migrateOptions({ ecsClient, logsClient: mockLogsClient() }));
|
|
1228
|
+
expect(result.ok).toBe(true);
|
|
1229
|
+
const messages = spinnerInstance().message.mock.calls.map((call) => call[0]);
|
|
1230
|
+
const shortId = MIGRATE_TASK_ARN.split('/').pop().slice(0, 8);
|
|
1231
|
+
expect(messages).toEqual([`Waiting on Fargate task (DEPROVISIONING, ${shortId})...`]);
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
it('exits early when the migration container stops and stops the task best-effort', async () => {
|
|
1235
|
+
const containerDone = {
|
|
1236
|
+
taskArn: MIGRATE_TASK_ARN,
|
|
1237
|
+
lastStatus: 'RUNNING',
|
|
1238
|
+
containers: [{ name: 'myapp-container', lastStatus: 'STOPPED', exitCode: 0 }],
|
|
1239
|
+
};
|
|
1240
|
+
const ecsClient = mockEcsMigrateClient({ taskSequence: [runningTask(), containerDone] });
|
|
1241
|
+
const result = await runDbMigrate(migrateOptions({ ecsClient, logsClient: mockLogsClient() }));
|
|
1242
|
+
expect(result.ok).toBe(true);
|
|
1243
|
+
// Never waited for task STOPPED: only the two scripted polls ran.
|
|
1244
|
+
expect(describeTasksCalls(ecsClient)).toHaveLength(2);
|
|
1245
|
+
expect(ecsClient.stops).toHaveLength(1);
|
|
1246
|
+
expect(String(ecsClient.stops[0].reason)).toContain('Migration container finished');
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
it('ignores a sidecar container finishing before the migration container', async () => {
|
|
1250
|
+
const sidecarFirst = {
|
|
1251
|
+
taskArn: MIGRATE_TASK_ARN,
|
|
1252
|
+
lastStatus: 'RUNNING',
|
|
1253
|
+
containers: [
|
|
1254
|
+
{ name: 'otel-sidecar', lastStatus: 'STOPPED', exitCode: 5 },
|
|
1255
|
+
{ name: 'myapp-container', lastStatus: 'RUNNING' },
|
|
1256
|
+
],
|
|
1257
|
+
};
|
|
1258
|
+
const migrationDone = {
|
|
1259
|
+
taskArn: MIGRATE_TASK_ARN,
|
|
1260
|
+
lastStatus: 'RUNNING',
|
|
1261
|
+
containers: [
|
|
1262
|
+
{ name: 'otel-sidecar', lastStatus: 'STOPPED', exitCode: 5 },
|
|
1263
|
+
{ name: 'myapp-container', lastStatus: 'STOPPED', exitCode: 0 },
|
|
1264
|
+
],
|
|
1265
|
+
};
|
|
1266
|
+
const ecsClient = mockEcsMigrateClient({ taskSequence: [sidecarFirst, migrationDone] });
|
|
1267
|
+
const result = await runDbMigrate(migrateOptions({ ecsClient, logsClient: mockLogsClient() }));
|
|
1268
|
+
expect(result.ok).toBe(true);
|
|
1269
|
+
expect(describeTasksCalls(ecsClient)).toHaveLength(2);
|
|
1270
|
+
expect(result.exitCode).toBe(0);
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
it('derives the log group and stream prefix from the task definition logConfiguration', async () => {
|
|
1274
|
+
const ecsClient = mockEcsMigrateClient({
|
|
1275
|
+
taskSequence: [stoppedTask(0)],
|
|
1276
|
+
containerDefinitions: [{
|
|
1277
|
+
name: 'myapp-container',
|
|
1278
|
+
logConfiguration: { options: { 'awslogs-group': '/custom/group', 'awslogs-stream-prefix': 'custom' } },
|
|
1279
|
+
}],
|
|
1280
|
+
});
|
|
1281
|
+
const logsClient = mockLogsClient();
|
|
1282
|
+
const result = await runDbMigrate(migrateOptions({ ecsClient, logsClient }));
|
|
1283
|
+
expect(result.ok).toBe(true);
|
|
1284
|
+
expect(logsClient.calls.length).toBeGreaterThan(0);
|
|
1285
|
+
expect(logsClient.calls[0].logGroupName).toBe('/custom/group');
|
|
1286
|
+
expect(logsClient.calls[0].logStreamNames).toEqual(['custom/myapp-container/migrate123']);
|
|
1287
|
+
expect('startTime' in logsClient.calls[0]).toBe(false);
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
it('flushes missed lines from the stream head with GetLogEvents on completion', async () => {
|
|
1291
|
+
const ecsClient = mockEcsMigrateClient({ taskSequence: [runningTask(), stoppedTask(0)] });
|
|
1292
|
+
const logsClient = mockLogsClient([], [
|
|
1293
|
+
{ events: [{ eventId: 'f1', message: 'flushed line' }] },
|
|
1294
|
+
]);
|
|
1295
|
+
const result = await runDbMigrate(migrateOptions({ ecsClient, logsClient }));
|
|
1296
|
+
expect(result.ok).toBe(true);
|
|
1297
|
+
expect(logsClient.flushCalls).toHaveLength(1);
|
|
1298
|
+
expect(logsClient.flushCalls[0].logStreamName).toBe('ecs/myapp-container/migrate123');
|
|
1299
|
+
expect(logsClient.flushCalls[0].startFromHead).toBe(true);
|
|
1300
|
+
expect(stripVTControlCharacters(output.join('\n'))).toContain('flushed line');
|
|
1301
|
+
// Task already STOPPED: no best-effort StopTask.
|
|
1302
|
+
expect(ecsClient.stops).toHaveLength(0);
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
it('re-polls the flush while nothing has printed yet, up to the max polls', async () => {
|
|
1306
|
+
const ecsClient = mockEcsMigrateClient({ taskSequence: [stoppedTask(0)] });
|
|
1307
|
+
const logsClient = mockLogsClient();
|
|
1308
|
+
const result = await runDbMigrate(migrateOptions({ ecsClient, logsClient, maxFlushPolls: 3 }));
|
|
1309
|
+
expect(result.ok).toBe(true);
|
|
1310
|
+
expect(logsClient.flushCalls).toHaveLength(3);
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
it('stops re-polling the flush as soon as lines print', async () => {
|
|
1314
|
+
const ecsClient = mockEcsMigrateClient({ taskSequence: [stoppedTask(0)] });
|
|
1315
|
+
const logsClient = mockLogsClient([], [
|
|
1316
|
+
{ events: [] },
|
|
1317
|
+
{ events: [{ eventId: 'late', message: 'late line' }] },
|
|
1318
|
+
]);
|
|
1319
|
+
const result = await runDbMigrate(migrateOptions({ ecsClient, logsClient, maxFlushPolls: 6 }));
|
|
1320
|
+
expect(result.ok).toBe(true);
|
|
1321
|
+
expect(logsClient.flushCalls).toHaveLength(2);
|
|
1322
|
+
expect(stripVTControlCharacters(output.join('\n'))).toContain('late line');
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
it('defaults the empty-flush retry loop to six polls', async () => {
|
|
1326
|
+
const ecsClient = mockEcsMigrateClient({ taskSequence: [stoppedTask(0)] });
|
|
1327
|
+
const logsClient = mockLogsClient();
|
|
1328
|
+
const result = await runDbMigrate(migrateOptions({ ecsClient, logsClient }));
|
|
1329
|
+
expect(result.ok).toBe(true);
|
|
1330
|
+
expect(logsClient.flushCalls).toHaveLength(6);
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
it('prints a line returned by both FilterLogEvents and GetLogEvents only once', async () => {
|
|
1334
|
+
const ecsClient = mockEcsMigrateClient({ taskSequence: [runningTask(), stoppedTask(0)] });
|
|
1335
|
+
const logsClient = mockLogsClient(
|
|
1336
|
+
[{ events: [{ eventId: 'e1', timestamp: 1727440000000, message: 'shared line' }] }],
|
|
1337
|
+
[{ events: [{ timestamp: 1727440000000, message: 'shared line' }] }],
|
|
1338
|
+
);
|
|
1339
|
+
const result = await runDbMigrate(migrateOptions({ ecsClient, logsClient }));
|
|
1340
|
+
expect(result.ok).toBe(true);
|
|
1341
|
+
expect(logsClient.flushCalls).toHaveLength(1);
|
|
1342
|
+
const text = stripVTControlCharacters(output.join('\n'));
|
|
1343
|
+
expect(text.split('shared line').length - 1).toBe(1);
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
it('prints the task line only after the spinner stops (no line collision)', async () => {
|
|
1347
|
+
const ecsClient = mockEcsMigrateClient({
|
|
1348
|
+
taskSequence: [
|
|
1349
|
+
{ taskArn: MIGRATE_TASK_ARN, lastStatus: 'PROVISIONING', containers: [] },
|
|
1350
|
+
runningTask(),
|
|
1351
|
+
stoppedTask(0),
|
|
1352
|
+
],
|
|
1353
|
+
});
|
|
1354
|
+
const result = await runDbMigrate(migrateOptions({ ecsClient, logsClient: mockLogsClient() }));
|
|
1355
|
+
expect(result.ok).toBe(true);
|
|
1356
|
+
const spin = spinnerInstance();
|
|
1357
|
+
const streamStopOrder = spin.stop.mock.invocationCallOrder[0];
|
|
1358
|
+
const taskLineIndex = consoleSpy.mock.calls.findIndex((args) => String(args[0]).includes('press Ctrl+C'));
|
|
1359
|
+
expect(taskLineIndex).toBeGreaterThanOrEqual(0);
|
|
1360
|
+
expect(consoleSpy.mock.invocationCallOrder[taskLineIndex]).toBeGreaterThan(streamStopOrder);
|
|
1361
|
+
const shortId = MIGRATE_TASK_ARN.split('/').pop().slice(0, 8);
|
|
1362
|
+
expect(spin.message.mock.calls[0][0]).toBe(`Starting migration task (PROVISIONING, ${shortId})...`);
|
|
1363
|
+
});
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
describe('db migrate: --setup-ci and gate injection', () => {
|
|
1367
|
+
let exitSpy;
|
|
1368
|
+
let consoleSpy;
|
|
1369
|
+
let output;
|
|
1370
|
+
|
|
1371
|
+
beforeEach(() => {
|
|
1372
|
+
vi.clearAllMocks();
|
|
1373
|
+
mockText.mockReset();
|
|
1374
|
+
output = [];
|
|
1375
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
1376
|
+
consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
1377
|
+
output.push(args.join(' '));
|
|
1378
|
+
});
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
afterEach(() => {
|
|
1382
|
+
exitSpy.mockRestore();
|
|
1383
|
+
consoleSpy.mockRestore();
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
it('installs the gate after task-def registration without AWS calls', async () => {
|
|
1387
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-setup-ci-'));
|
|
1388
|
+
const workflowFile = writeWorkflow(dir);
|
|
1389
|
+
const throwing = { send: () => { throw new Error('must not call AWS'); } };
|
|
1390
|
+
const result = await runDbMigrate(migrateOptions({
|
|
1391
|
+
cwd: dir,
|
|
1392
|
+
setupCi: true,
|
|
1393
|
+
cmd: 'npx prisma migrate deploy',
|
|
1394
|
+
ecsClient: throwing,
|
|
1395
|
+
logsClient: throwing,
|
|
1396
|
+
}));
|
|
1397
|
+
expect(result).toEqual(expect.objectContaining({ ok: true, ciSetup: true, workflowFile }));
|
|
1398
|
+
const updated = fs.readFileSync(workflowFile, 'utf8');
|
|
1399
|
+
expect(updated).toContain('# grada:db-migrate-start');
|
|
1400
|
+
expect(updated).toContain('# grada:db-migrate-end');
|
|
1401
|
+
expect(updated).toContain('actions/setup-node@v4');
|
|
1402
|
+
expect(updated).toContain(`--cmd 'npx prisma migrate deploy'`);
|
|
1403
|
+
expect(updated).toContain('${{ steps.register-task-def.outputs.task-arn }}');
|
|
1404
|
+
expect(updated.indexOf('# grada:db-migrate-start')).toBeLessThan(updated.indexOf('- name: Force ECS deployment'));
|
|
1405
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_migrate_run', expect.objectContaining({
|
|
1406
|
+
success: true, cmd_source: 'explicit', ci_setup: true,
|
|
1407
|
+
}));
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
it('is idempotent and refreshes the command on re-runs', async () => {
|
|
1411
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-setup-ci-'));
|
|
1412
|
+
const workflowFile = writeWorkflow(dir);
|
|
1413
|
+
const base = { cwd: dir, setupCi: true, ecsClient: mockEcsMigrateClient(), logsClient: mockLogsClient() };
|
|
1414
|
+
await runDbMigrate(migrateOptions({ ...base, cmd: 'first cmd' }));
|
|
1415
|
+
await runDbMigrate(migrateOptions({ ...base, cmd: 'second cmd' }));
|
|
1416
|
+
const updated = fs.readFileSync(workflowFile, 'utf8');
|
|
1417
|
+
expect(updated.match(/# grada:db-migrate-start/g)).toHaveLength(1);
|
|
1418
|
+
expect(updated).toContain(`--cmd 'second cmd'`);
|
|
1419
|
+
expect(updated).not.toContain('first cmd');
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
it('omits setup-node when the workflow already has it', async () => {
|
|
1423
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-setup-ci-'));
|
|
1424
|
+
writeWorkflow(dir, `jobs:\n deploy:\n steps:\n - uses: actions/setup-node@v4\n - name: Force ECS deployment\n run: echo deploy\n`);
|
|
1425
|
+
await runDbMigrate(migrateOptions({
|
|
1426
|
+
cwd: dir, setupCi: true, ecsClient: mockEcsMigrateClient(), logsClient: mockLogsClient(),
|
|
1427
|
+
}));
|
|
1428
|
+
const updated = fs.readFileSync(path.join(dir, '.github', 'workflows', 'deploy.yml'), 'utf8');
|
|
1429
|
+
expect(updated.match(/actions\/setup-node/g)).toHaveLength(1);
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
it('fails when the workflow is missing or has no anchor step', async () => {
|
|
1433
|
+
const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'db-setup-ci-'));
|
|
1434
|
+
const missing = await runDbMigrate(migrateOptions({
|
|
1435
|
+
cwd: empty, setupCi: true, ecsClient: mockEcsMigrateClient(), logsClient: mockLogsClient(),
|
|
1436
|
+
}));
|
|
1437
|
+
expect(missing.reason).toBe('workflow-not-found');
|
|
1438
|
+
|
|
1439
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-setup-ci-'));
|
|
1440
|
+
writeWorkflow(dir, 'name: Custom\njobs:\n deploy:\n steps:\n - run: echo custom\n');
|
|
1441
|
+
const anchored = await runDbMigrate(migrateOptions({
|
|
1442
|
+
cwd: dir, setupCi: true, ecsClient: mockEcsMigrateClient(), logsClient: mockLogsClient(),
|
|
1443
|
+
}));
|
|
1444
|
+
expect(anchored.reason).toBe('workflow-anchor-not-found');
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
it('still requires a resolvable command for --setup-ci', async () => {
|
|
1448
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-setup-ci-'));
|
|
1449
|
+
writeWorkflow(dir);
|
|
1450
|
+
const { cmd: _cmd, ...noCmd } = migrateOptions({
|
|
1451
|
+
cwd: dir, setupCi: true, ecsClient: mockEcsMigrateClient(), logsClient: mockLogsClient(),
|
|
1452
|
+
});
|
|
1453
|
+
const result = await runDbMigrate(noCmd);
|
|
1454
|
+
expect(result.reason).toBe('missing-migration-cmd');
|
|
1455
|
+
});
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
describe('db migrate: gate helpers', () => {
|
|
1459
|
+
it('injectMigrationGate places the block before the Force step', () => {
|
|
1460
|
+
const updated = injectMigrationGate(WORKFLOW_FIXTURE, { cmd: 'npm run migrate' });
|
|
1461
|
+
expect(updated).toContain(`--cmd 'npm run migrate'`);
|
|
1462
|
+
expect(updated).toContain('actions/setup-node@v4');
|
|
1463
|
+
expect(updated.indexOf('# grada:db-migrate-end')).toBeLessThan(updated.indexOf('- name: Force ECS deployment'));
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
it('injectMigrationGate returns null without an anchor', () => {
|
|
1467
|
+
expect(injectMigrationGate('steps: []', { cmd: 'x' })).toBeNull();
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
it('findGateBlock locates legacy gates and injection migrates them', () => {
|
|
1471
|
+
const legacy = WORKFLOW_FIXTURE.replace(
|
|
1472
|
+
'- name: Force ECS deployment',
|
|
1473
|
+
' # deploy-stack:db-migrate-start\n - name: Pre-Deploy Database Migration\n run: echo old\n # deploy-stack:db-migrate-end\n - name: Force ECS deployment'
|
|
1474
|
+
);
|
|
1475
|
+
expect(findGateBlock(legacy)).not.toBeNull();
|
|
1476
|
+
expect(findGateBlock('steps: []')).toBeNull();
|
|
1477
|
+
const updated = injectMigrationGate(legacy, { cmd: 'npm run migrate' });
|
|
1478
|
+
expect(updated).not.toContain('deploy-stack:db-migrate-start');
|
|
1479
|
+
expect(updated).not.toContain('deploy-stack:db-migrate-end');
|
|
1480
|
+
expect(updated.match(/# grada:db-migrate-start/g)).toHaveLength(1);
|
|
1481
|
+
expect(updated).toContain(`--cmd 'npm run migrate'`);
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
it('quoteShellArg single-quotes and escapes embedded quotes', () => {
|
|
1485
|
+
expect(quoteShellArg('npx prisma migrate deploy')).toBe(`'npx prisma migrate deploy'`);
|
|
1486
|
+
expect(quoteShellArg(`don't stop`)).toBe(`'don'\\''t stop'`);
|
|
1487
|
+
});
|
|
1488
|
+
});
|
|
1489
|
+
|
|
1490
|
+
function vectorOptions(overrides = {}) {
|
|
1491
|
+
return {
|
|
1492
|
+
...baseOptions(),
|
|
1493
|
+
projectName: 'myapp',
|
|
1494
|
+
region: 'us-east-2',
|
|
1495
|
+
pollIntervalMs: 5,
|
|
1496
|
+
flushIntervalMs: 0,
|
|
1497
|
+
...overrides,
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
describe('Command: db enable-vector (mocked AWS)', () => {
|
|
1502
|
+
let exitSpy;
|
|
1503
|
+
let consoleSpy;
|
|
1504
|
+
let output;
|
|
1505
|
+
|
|
1506
|
+
beforeEach(() => {
|
|
1507
|
+
vi.clearAllMocks();
|
|
1508
|
+
output = [];
|
|
1509
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
1510
|
+
consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
1511
|
+
output.push(args.join(' '));
|
|
1512
|
+
});
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
afterEach(() => {
|
|
1516
|
+
exitSpy.mockRestore();
|
|
1517
|
+
consoleSpy.mockRestore();
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
it('rejects MySQL projects before any AWS call', async () => {
|
|
1521
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-vector-'));
|
|
1522
|
+
writeDatabaseTf(dir, DATABASE_TF_MYSQL_FIXTURE);
|
|
1523
|
+
const ecsClient = mockEcsMigrateClient();
|
|
1524
|
+
const result = await runDbEnableVector(vectorOptions({ cwd: dir, ecsClient }));
|
|
1525
|
+
expect(result.reason).toBe('unsupported-vector-engine');
|
|
1526
|
+
expect(ecsClient.send).not.toHaveBeenCalled();
|
|
1527
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
1528
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_enable_vector_run', expect.objectContaining({
|
|
1529
|
+
success: false, error_code: 'UNSUPPORTED_VECTOR_ENGINE',
|
|
1530
|
+
}));
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
it('requires database.tf or explicit cluster/service overrides', async () => {
|
|
1534
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-vector-'));
|
|
1535
|
+
const ecsClient = mockEcsMigrateClient();
|
|
1536
|
+
const result = await runDbEnableVector(vectorOptions({ cwd: dir, ecsClient }));
|
|
1537
|
+
expect(result.reason).toBe('no-database-configured');
|
|
1538
|
+
expect(ecsClient.send).not.toHaveBeenCalled();
|
|
1539
|
+
|
|
1540
|
+
// Explicit overrides proceed to service discovery instead.
|
|
1541
|
+
const overrideClient = mockEcsMigrateClient({ service: null });
|
|
1542
|
+
const overrideResult = await runDbEnableVector(vectorOptions({
|
|
1543
|
+
cwd: dir, cluster: 'custom-cluster', ecsClient: overrideClient,
|
|
1544
|
+
}));
|
|
1545
|
+
expect(overrideResult.reason).toBe('ecs-service-not-found');
|
|
1546
|
+
expect(overrideClient.send).toHaveBeenCalled();
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
it('launches the vector task and reports success with engine telemetry', async () => {
|
|
1550
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-vector-'));
|
|
1551
|
+
writeDatabaseTf(dir);
|
|
1552
|
+
const ecsClient = mockEcsMigrateClient({ taskSequence: [stoppedTask(0)] });
|
|
1553
|
+
const result = await runDbEnableVector(vectorOptions({
|
|
1554
|
+
cwd: dir, ecsClient, logsClient: mockLogsClient(),
|
|
1555
|
+
}));
|
|
1556
|
+
expect(result).toEqual(expect.objectContaining({ ok: true, taskArn: MIGRATE_TASK_ARN, engine: 'postgres' }));
|
|
1557
|
+
expect(ecsClient.runs).toHaveLength(1);
|
|
1558
|
+
const run = ecsClient.runs[0];
|
|
1559
|
+
expect(run.startedBy).toBe('grada-db-enable-vector');
|
|
1560
|
+
const override = run.overrides.containerOverrides[0];
|
|
1561
|
+
expect(override.command.slice(0, 2)).toEqual(['sh', '-c']);
|
|
1562
|
+
expect(override.command[2]).toContain('CREATE EXTENSION IF NOT EXISTS vector');
|
|
1563
|
+
expect(stripVTControlCharacters(output.join('\n'))).toContain('pgvector extension enabled');
|
|
1564
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_enable_vector_run', expect.objectContaining({
|
|
1565
|
+
success: true, engine: 'postgres', duration_ms: expect.any(Number),
|
|
1566
|
+
}));
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
it('prints the Prisma hint only when postgresqlExtensions is missing', async () => {
|
|
1570
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-vector-'));
|
|
1571
|
+
writeDatabaseTf(dir);
|
|
1572
|
+
fs.mkdirSync(path.join(dir, 'prisma'), { recursive: true });
|
|
1573
|
+
fs.writeFileSync(path.join(dir, 'prisma', 'schema.prisma'), 'datasource db { provider = "postgresql" }\n');
|
|
1574
|
+
const ecsClient = mockEcsMigrateClient({ taskSequence: [stoppedTask(0)] });
|
|
1575
|
+
await runDbEnableVector(vectorOptions({ cwd: dir, ecsClient, logsClient: mockLogsClient() }));
|
|
1576
|
+
expect(stripVTControlCharacters(output.join('\n'))).toContain('postgresqlExtensions');
|
|
1577
|
+
|
|
1578
|
+
const dir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'db-vector-'));
|
|
1579
|
+
writeDatabaseTf(dir2);
|
|
1580
|
+
fs.mkdirSync(path.join(dir2, 'prisma'), { recursive: true });
|
|
1581
|
+
fs.writeFileSync(path.join(dir2, 'prisma', 'schema.prisma'), 'previewFeatures = ["postgresqlExtensions"]\n');
|
|
1582
|
+
output = [];
|
|
1583
|
+
const ecsClient2 = mockEcsMigrateClient({ taskSequence: [stoppedTask(0)] });
|
|
1584
|
+
await runDbEnableVector(vectorOptions({ cwd: dir2, ecsClient: ecsClient2, logsClient: mockLogsClient() }));
|
|
1585
|
+
expect(stripVTControlCharacters(output.join('\n'))).not.toContain('postgresqlExtensions');
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
it('maps the no-client exit code to install guidance', async () => {
|
|
1589
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-vector-'));
|
|
1590
|
+
writeDatabaseTf(dir);
|
|
1591
|
+
const ecsClient = mockEcsMigrateClient({ taskSequence: [stoppedTask(3, 'no client')] });
|
|
1592
|
+
const result = await runDbEnableVector(vectorOptions({
|
|
1593
|
+
cwd: dir, ecsClient, logsClient: mockLogsClient(),
|
|
1594
|
+
}));
|
|
1595
|
+
expect(result.reason).toBe('vector-task-failed');
|
|
1596
|
+
expect(stripVTControlCharacters(output.join('\n'))).toContain('postgresql-client');
|
|
1597
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_enable_vector_run', expect.objectContaining({
|
|
1598
|
+
success: false, error_code: 'VECTOR_TASK_FAILED',
|
|
1599
|
+
}));
|
|
1600
|
+
});
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
describe('buildVectorExtensionCommand', () => {
|
|
1604
|
+
it('orders the client ladder psql, node, python with a diagnostic tail', () => {
|
|
1605
|
+
const script = buildVectorExtensionCommand();
|
|
1606
|
+
const psql = script.indexOf('command -v psql');
|
|
1607
|
+
const pg = script.indexOf("require.resolve('pg')");
|
|
1608
|
+
const prisma = script.indexOf("require.resolve('@prisma/client')");
|
|
1609
|
+
const psycopg = script.indexOf('import psycopg"');
|
|
1610
|
+
const tail = script.indexOf('exit 3');
|
|
1611
|
+
expect(psql).toBeGreaterThanOrEqual(0);
|
|
1612
|
+
expect(pg).toBeGreaterThan(psql);
|
|
1613
|
+
expect(prisma).toBeGreaterThan(pg);
|
|
1614
|
+
expect(psycopg).toBeGreaterThan(prisma);
|
|
1615
|
+
expect(tail).toBeGreaterThan(psycopg);
|
|
1616
|
+
expect(script).toContain('CREATE EXTENSION IF NOT EXISTS vector');
|
|
1617
|
+
expect(script).toContain('$DATABASE_URL');
|
|
1618
|
+
});
|
|
1619
|
+
|
|
1620
|
+
it('negotiates TLS on every branch (rds.force_ssl)', () => {
|
|
1621
|
+
const script = buildVectorExtensionCommand();
|
|
1622
|
+
// libpq clients (psql, psycopg/psycopg2) via PGSSLMODE, exported
|
|
1623
|
+
// before the ladder runs.
|
|
1624
|
+
expect(script).toContain('export PGSSLMODE="${PGSSLMODE:-require}"');
|
|
1625
|
+
expect(script.indexOf('export PGSSLMODE=')).toBeLessThan(script.indexOf('command -v psql'));
|
|
1626
|
+
// node-postgres ignores PGSSLMODE: explicit ssl opt instead.
|
|
1627
|
+
expect(script).toContain('ssl:{rejectUnauthorized:false}');
|
|
1628
|
+
// Prisma ignores PGSSLMODE too: sslmode is appended to the URL when
|
|
1629
|
+
// missing, preserving an existing query string.
|
|
1630
|
+
expect(script).toContain('sslmode=require');
|
|
1631
|
+
expect(script).toContain("[?&]sslmode=");
|
|
1632
|
+
// asyncpg is not libpq-based either: explicit ssl='require'.
|
|
1633
|
+
expect(script).toContain("asyncpg.connect(os.environ['DATABASE_URL'],ssl='require')");
|
|
1634
|
+
});
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1637
|
+
function mockImportSpawn({ exitCode = 0 } = {}) {
|
|
1638
|
+
const calls = [];
|
|
1639
|
+
const spawnImpl = (bin, args, opts) => {
|
|
1640
|
+
const child = new EventEmitter();
|
|
1641
|
+
child.stdin = new PassThrough();
|
|
1642
|
+
child.stdout = new PassThrough();
|
|
1643
|
+
child.stderr = new PassThrough();
|
|
1644
|
+
child.kill = vi.fn();
|
|
1645
|
+
child.unref = vi.fn();
|
|
1646
|
+
const call = { bin, args, opts, child, stdinBytes: Buffer.alloc(0) };
|
|
1647
|
+
calls.push(call);
|
|
1648
|
+
child.stdin.on('data', (chunk) => {
|
|
1649
|
+
call.stdinBytes = Buffer.concat([call.stdinBytes, chunk]);
|
|
1650
|
+
});
|
|
1651
|
+
// Close once piped input completes (file streams) or shortly after
|
|
1652
|
+
// spawn when nothing is piped (tunnel, pg_restore, dump binaries).
|
|
1653
|
+
let closed = false;
|
|
1654
|
+
const close = () => {
|
|
1655
|
+
if (closed) return;
|
|
1656
|
+
closed = true;
|
|
1657
|
+
child.emit('close', exitCode);
|
|
1658
|
+
};
|
|
1659
|
+
child.stdin.once('finish', close);
|
|
1660
|
+
const timer = setTimeout(close, 25);
|
|
1661
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
1662
|
+
return child;
|
|
1663
|
+
};
|
|
1664
|
+
return { calls, spawnImpl };
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
function importOptions(overrides = {}) {
|
|
1668
|
+
return {
|
|
1669
|
+
...baseOptions(),
|
|
1670
|
+
projectName: 'myapp',
|
|
1671
|
+
region: 'us-east-2',
|
|
1672
|
+
yes: true,
|
|
1673
|
+
rdsClient: mockRdsClient(healthyDbInstance({ Engine: 'postgres' })),
|
|
1674
|
+
secretsClient: mockSecretsClient(JSON.stringify({ username: DB_USERNAME, password: DB_PASSWORD })),
|
|
1675
|
+
ecsClient: mockEcsClient({ taskArns: [TASK_ARN], tasks: [healthyTask()] }),
|
|
1676
|
+
spawnSyncImpl: () => ({}),
|
|
1677
|
+
waitForTunnelImpl: async () => ({ done: true, value: true }),
|
|
1678
|
+
// Fixed port keeps the suite hermetic (no loopback binding); the
|
|
1679
|
+
// real allocator is covered by the db-tunnel helper tests.
|
|
1680
|
+
allocatePortImpl: async () => 54399,
|
|
1681
|
+
...overrides,
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
describe('Command: db import (mocked AWS + spawn)', () => {
|
|
1686
|
+
let exitSpy;
|
|
1687
|
+
let consoleSpy;
|
|
1688
|
+
let output;
|
|
1689
|
+
|
|
1690
|
+
beforeEach(() => {
|
|
1691
|
+
vi.clearAllMocks();
|
|
1692
|
+
output = [];
|
|
1693
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
1694
|
+
consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
1695
|
+
output.push(args.join(' '));
|
|
1696
|
+
});
|
|
1697
|
+
mockSelect.mockReset();
|
|
1698
|
+
mockText.mockReset();
|
|
1699
|
+
mockPassword.mockReset();
|
|
1700
|
+
mockConfirm.mockReset();
|
|
1701
|
+
});
|
|
1702
|
+
|
|
1703
|
+
afterEach(() => {
|
|
1704
|
+
exitSpy.mockRestore();
|
|
1705
|
+
consoleSpy.mockRestore();
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1708
|
+
function writeSqlFile(dir, name = 'seed.sql', content = 'CREATE TABLE t (id int);\n') {
|
|
1709
|
+
const filePath = path.join(dir, name);
|
|
1710
|
+
fs.writeFileSync(filePath, content);
|
|
1711
|
+
return filePath;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
it('rejects both/neither sources before any AWS call', async () => {
|
|
1715
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-import-'));
|
|
1716
|
+
const sqlFile = writeSqlFile(dir);
|
|
1717
|
+
const ecsClient = { send: vi.fn() };
|
|
1718
|
+
const both = await runDbImport(importOptions({
|
|
1719
|
+
file: sqlFile, from: 'postgresql://u:p@h/db', ecsClient,
|
|
1720
|
+
}));
|
|
1721
|
+
expect(both.reason).toBe('invalid-import-source');
|
|
1722
|
+
const neither = await runDbImport(importOptions({ ecsClient }));
|
|
1723
|
+
expect(neither.reason).toBe('invalid-import-source');
|
|
1724
|
+
expect(ecsClient.send).not.toHaveBeenCalled();
|
|
1725
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
1726
|
+
});
|
|
1727
|
+
|
|
1728
|
+
it('rejects missing files and bad URLs before any AWS call', async () => {
|
|
1729
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-import-'));
|
|
1730
|
+
const ecsClient = { send: vi.fn() };
|
|
1731
|
+
const missing = await runDbImport(importOptions({
|
|
1732
|
+
file: path.join(dir, 'nope.sql'), ecsClient,
|
|
1733
|
+
}));
|
|
1734
|
+
expect(missing.reason).toBe('import-file-not-found');
|
|
1735
|
+
expect(ecsClient.send).not.toHaveBeenCalled();
|
|
1736
|
+
|
|
1737
|
+
const bad = await runDbImport(importOptions({
|
|
1738
|
+
from: 'http://user:s3cret@host/db', ecsClient,
|
|
1739
|
+
}));
|
|
1740
|
+
expect(bad.reason).toBe('invalid-source-uri');
|
|
1741
|
+
expect(ecsClient.send).not.toHaveBeenCalled();
|
|
1742
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
it('redacts the source password in validation output', async () => {
|
|
1746
|
+
const bad = await runDbImport(importOptions({ from: 'postgresql://u:p@ssw0rd@h' }));
|
|
1747
|
+
expect(bad.reason).toBe('invalid-source-uri');
|
|
1748
|
+
const textOut = stripVTControlCharacters(output.join('\n'));
|
|
1749
|
+
expect(textOut).toContain('****');
|
|
1750
|
+
expect(textOut).not.toContain('p@ssw0rd');
|
|
1751
|
+
const serialized = JSON.stringify(mockTrackEvent.mock.calls);
|
|
1752
|
+
expect(serialized).not.toContain('p@ssw0rd');
|
|
1753
|
+
});
|
|
1754
|
+
|
|
1755
|
+
it('requires client binaries with install guidance', async () => {
|
|
1756
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-import-'));
|
|
1757
|
+
const sqlFile = writeSqlFile(dir);
|
|
1758
|
+
const enoent = Object.assign(new Error('not found'), { code: 'ENOENT' });
|
|
1759
|
+
const result = await runDbImport(importOptions({
|
|
1760
|
+
file: sqlFile,
|
|
1761
|
+
spawnSyncImpl: (bin) => (bin === 'psql' ? { error: enoent } : ({})),
|
|
1762
|
+
}));
|
|
1763
|
+
expect(result.reason).toBe('missing-db-client-binary');
|
|
1764
|
+
expect(stripVTControlCharacters(output.join('\n'))).toContain('brew install libpq');
|
|
1765
|
+
});
|
|
1766
|
+
|
|
1767
|
+
it('requires confirmation in headless mode without --yes', async () => {
|
|
1768
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-import-'));
|
|
1769
|
+
const sqlFile = writeSqlFile(dir);
|
|
1770
|
+
const { spawnImpl } = mockImportSpawn();
|
|
1771
|
+
const result = await runDbImport(importOptions({ file: sqlFile, yes: false, spawnImpl }));
|
|
1772
|
+
expect(result.reason).toBe('confirmation-required');
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
it('streams a .sql file into psql with env-only secrets', async () => {
|
|
1776
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-import-'));
|
|
1777
|
+
const sqlFile = writeSqlFile(dir);
|
|
1778
|
+
const { calls, spawnImpl } = mockImportSpawn();
|
|
1779
|
+
const result = await runDbImport(importOptions({ file: sqlFile, spawnImpl }));
|
|
1780
|
+
expect(result).toEqual(expect.objectContaining({ ok: true, source: 'file' }));
|
|
1781
|
+
|
|
1782
|
+
const tunnel = calls[0];
|
|
1783
|
+
expect(tunnel.bin).toBe('aws');
|
|
1784
|
+
expect(tunnel.args).toContain('start-session');
|
|
1785
|
+
expect(tunnel.args.join(' ')).toContain('"portNumber":["5432"]');
|
|
1786
|
+
expect(tunnel.args.join(' ')).toContain('"localPortNumber":["54399"]');
|
|
1787
|
+
expect(tunnel.child.kill).toHaveBeenCalledWith('SIGTERM');
|
|
1788
|
+
// Hang fix: stdio torn down and the handle unref'd so an orphaned
|
|
1789
|
+
// session-manager-plugin grandchild can't hold the event loop.
|
|
1790
|
+
expect(tunnel.child.unref).toHaveBeenCalled();
|
|
1791
|
+
expect(tunnel.child.stdin.destroyed).toBe(true);
|
|
1792
|
+
expect(tunnel.child.stdout.destroyed).toBe(true);
|
|
1793
|
+
expect(tunnel.child.stderr.destroyed).toBe(true);
|
|
1794
|
+
|
|
1795
|
+
const client = calls[1];
|
|
1796
|
+
expect(client.bin).toBe('psql');
|
|
1797
|
+
expect(client.args).toContain('myapp');
|
|
1798
|
+
expect(client.opts.env.PGPASSWORD).toBe(DB_PASSWORD);
|
|
1799
|
+
expect(client.opts.env.PGSSLMODE).toBe(process.env.PGSSLMODE || 'require');
|
|
1800
|
+
expect(calls.flatMap((c) => c.args).join(' ')).not.toContain(DB_PASSWORD);
|
|
1801
|
+
expect(client.stdinBytes.toString()).toBe('CREATE TABLE t (id int);\n');
|
|
1802
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_import_run', expect.objectContaining({
|
|
1803
|
+
success: true, source: 'file', target_engine: 'postgres',
|
|
1804
|
+
}));
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
it('gunzips .sql.gz files before streaming', async () => {
|
|
1808
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-import-'));
|
|
1809
|
+
const { gzipSync } = await import('node:zlib');
|
|
1810
|
+
const filePath = path.join(dir, 'seed.sql.gz');
|
|
1811
|
+
fs.writeFileSync(filePath, gzipSync('INSERT INTO t VALUES (1);\n'));
|
|
1812
|
+
const { calls, spawnImpl } = mockImportSpawn();
|
|
1813
|
+
const result = await runDbImport(importOptions({ file: filePath, spawnImpl }));
|
|
1814
|
+
expect(result.ok).toBe(true);
|
|
1815
|
+
expect(calls[1].bin).toBe('psql');
|
|
1816
|
+
expect(calls[1].stdinBytes.toString()).toBe('INSERT INTO t VALUES (1);\n');
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
it('restores .dump archives with pg_restore', async () => {
|
|
1820
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-import-'));
|
|
1821
|
+
const filePath = writeSqlFile(dir, 'seed.dump', 'PGDMP');
|
|
1822
|
+
const { calls, spawnImpl } = mockImportSpawn();
|
|
1823
|
+
const result = await runDbImport(importOptions({ file: filePath, spawnImpl }));
|
|
1824
|
+
expect(result.ok).toBe(true);
|
|
1825
|
+
expect(calls[1].bin).toBe('pg_restore');
|
|
1826
|
+
expect(calls[1].args).toContain('--no-owner');
|
|
1827
|
+
expect(calls[1].args).toContain(filePath);
|
|
1828
|
+
expect(calls[1].opts.env.PGPASSWORD).toBe(DB_PASSWORD);
|
|
1829
|
+
expect(calls[1].opts.env.PGSSLMODE).toBe(process.env.PGSSLMODE || 'require');
|
|
1830
|
+
});
|
|
1831
|
+
|
|
1832
|
+
it('pipes pg_dump into psql with per-process passwords for --from', async () => {
|
|
1833
|
+
const { calls, spawnImpl } = mockImportSpawn();
|
|
1834
|
+
const result = await runDbImport(importOptions({
|
|
1835
|
+
from: 'postgresql://srcuser:src-pass@src-host:5433/sourcedb',
|
|
1836
|
+
spawnImpl,
|
|
1837
|
+
}));
|
|
1838
|
+
expect(result.ok).toBe(true);
|
|
1839
|
+
expect(calls.map((c) => c.bin)).toEqual(['aws', 'pg_dump', 'psql']);
|
|
1840
|
+
const dump = calls[1];
|
|
1841
|
+
expect(dump.args).toEqual(expect.arrayContaining(['--no-owner', '--no-acl', '-h', 'src-host', '-p', '5433', '-U', 'srcuser', '-d', 'sourcedb']));
|
|
1842
|
+
expect(dump.opts.env.PGPASSWORD).toBe('src-pass');
|
|
1843
|
+
expect(dump.opts.env.PGSSLMODE).toBe(process.env.PGSSLMODE || 'require');
|
|
1844
|
+
expect(calls[2].opts.env.PGPASSWORD).toBe(DB_PASSWORD);
|
|
1845
|
+
expect(calls[2].opts.env.PGSSLMODE).toBe(process.env.PGSSLMODE || 'require');
|
|
1846
|
+
expect(calls.flatMap((c) => c.args).join(' ')).not.toContain('src-pass');
|
|
1847
|
+
expect(calls.flatMap((c) => c.args).join(' ')).not.toContain(DB_PASSWORD);
|
|
1848
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_import_run', expect.objectContaining({ success: true, source: 'url' }));
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1851
|
+
it('uses mysql/mysqldump with MYSQL_PWD for MySQL targets', async () => {
|
|
1852
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-import-'));
|
|
1853
|
+
const sqlFile = writeSqlFile(dir);
|
|
1854
|
+
const mysqlInstance = healthyDbInstance({
|
|
1855
|
+
Engine: 'mysql',
|
|
1856
|
+
Endpoint: { Address: 'myapp-db.abc123.us-east-2.rds.amazonaws.com', Port: 3306 },
|
|
1857
|
+
});
|
|
1858
|
+
const { calls, spawnImpl } = mockImportSpawn();
|
|
1859
|
+
const result = await runDbImport(importOptions({
|
|
1860
|
+
file: sqlFile,
|
|
1861
|
+
rdsClient: mockRdsClient(mysqlInstance),
|
|
1862
|
+
spawnImpl,
|
|
1863
|
+
}));
|
|
1864
|
+
expect(result.ok).toBe(true);
|
|
1865
|
+
expect(calls[1].bin).toBe('mysql');
|
|
1866
|
+
expect(calls[1].opts.env.MYSQL_PWD).toBe(DB_PASSWORD);
|
|
1867
|
+
// MySQL branch adds nothing: whatever the ambient shell exported (if
|
|
1868
|
+
// anything) passes through untouched.
|
|
1869
|
+
expect(calls[1].opts.env.PGSSLMODE).toBe(process.env.PGSSLMODE);
|
|
1870
|
+
expect(calls[0].args.join(' ')).toContain('"portNumber":["3306"]');
|
|
1871
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_import_run', expect.objectContaining({ target_engine: 'mysql' }));
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1874
|
+
it('rejects .dump archives for MySQL targets', async () => {
|
|
1875
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-import-'));
|
|
1876
|
+
const filePath = writeSqlFile(dir, 'seed.dump', 'PGDMP');
|
|
1877
|
+
const mysqlInstance = healthyDbInstance({
|
|
1878
|
+
Engine: 'mysql',
|
|
1879
|
+
Endpoint: { Address: 'myapp-db.abc123.us-east-2.rds.amazonaws.com', Port: 3306 },
|
|
1880
|
+
});
|
|
1881
|
+
const { spawnImpl } = mockImportSpawn();
|
|
1882
|
+
const result = await runDbImport(importOptions({
|
|
1883
|
+
file: filePath,
|
|
1884
|
+
rdsClient: mockRdsClient(mysqlInstance),
|
|
1885
|
+
spawnImpl,
|
|
1886
|
+
}));
|
|
1887
|
+
expect(result.reason).toBe('unsupported-import-format');
|
|
1888
|
+
});
|
|
1889
|
+
|
|
1890
|
+
it('fails cleanly on tunnel timeout and client errors', async () => {
|
|
1891
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-import-'));
|
|
1892
|
+
const sqlFile = writeSqlFile(dir);
|
|
1893
|
+
const { calls, spawnImpl } = mockImportSpawn();
|
|
1894
|
+
const timedOut = await runDbImport(importOptions({
|
|
1895
|
+
file: sqlFile,
|
|
1896
|
+
spawnImpl,
|
|
1897
|
+
waitForTunnelImpl: async () => ({ timedOut: true }),
|
|
1898
|
+
}));
|
|
1899
|
+
expect(timedOut.reason).toBe('tunnel-timeout');
|
|
1900
|
+
expect(calls).toHaveLength(1);
|
|
1901
|
+
expect(calls[0].child.kill).toHaveBeenCalledWith('SIGTERM');
|
|
1902
|
+
|
|
1903
|
+
const failing = mockImportSpawn({ exitCode: 1 });
|
|
1904
|
+
const failed = await runDbImport(importOptions({ file: sqlFile, spawnImpl: failing.spawnImpl }));
|
|
1905
|
+
expect(failed.reason).toBe('import-failed');
|
|
1906
|
+
expect(failing.calls[0].child.kill).toHaveBeenCalledWith('SIGTERM');
|
|
1907
|
+
});
|
|
1908
|
+
|
|
1909
|
+
it('escalates to SIGKILL when the tunnel survives SIGTERM', async () => {
|
|
1910
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-import-'));
|
|
1911
|
+
const sqlFile = writeSqlFile(dir);
|
|
1912
|
+
const { calls, spawnImpl } = mockImportSpawn();
|
|
1913
|
+
const result = await runDbImport(importOptions({ file: sqlFile, spawnImpl, tunnelSigkillTimeoutMs: 5 }));
|
|
1914
|
+
expect(result.ok).toBe(true);
|
|
1915
|
+
// Mock children never report an exit code, so escalation always fires.
|
|
1916
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
1917
|
+
expect(calls[0].child.kill).toHaveBeenCalledWith('SIGTERM');
|
|
1918
|
+
expect(calls[0].child.kill).toHaveBeenCalledWith('SIGKILL');
|
|
1919
|
+
});
|
|
1920
|
+
|
|
1921
|
+
it('defaults PGSSLMODE=require for Postgres targets unless the caller pinned it', async () => {
|
|
1922
|
+
const saved = process.env.PGSSLMODE;
|
|
1923
|
+
delete process.env.PGSSLMODE;
|
|
1924
|
+
try {
|
|
1925
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-import-'));
|
|
1926
|
+
const sqlFile = writeSqlFile(dir);
|
|
1927
|
+
|
|
1928
|
+
const fresh = mockImportSpawn();
|
|
1929
|
+
const okDefault = await runDbImport(importOptions({ file: sqlFile, spawnImpl: fresh.spawnImpl }));
|
|
1930
|
+
expect(okDefault.ok).toBe(true);
|
|
1931
|
+
expect(fresh.calls[1].opts.env.PGSSLMODE).toBe('require');
|
|
1932
|
+
|
|
1933
|
+
const fromFresh = mockImportSpawn();
|
|
1934
|
+
const okFrom = await runDbImport(importOptions({
|
|
1935
|
+
from: 'postgresql://srcuser:src-pass@src-host:5433/sourcedb',
|
|
1936
|
+
spawnImpl: fromFresh.spawnImpl,
|
|
1937
|
+
}));
|
|
1938
|
+
expect(okFrom.ok).toBe(true);
|
|
1939
|
+
expect(fromFresh.calls[1].opts.env.PGSSLMODE).toBe('require');
|
|
1940
|
+
|
|
1941
|
+
process.env.PGSSLMODE = 'disable';
|
|
1942
|
+
const pinned = mockImportSpawn();
|
|
1943
|
+
const okPinned = await runDbImport(importOptions({ file: sqlFile, spawnImpl: pinned.spawnImpl }));
|
|
1944
|
+
expect(okPinned.ok).toBe(true);
|
|
1945
|
+
expect(pinned.calls[1].opts.env.PGSSLMODE).toBe('disable');
|
|
1946
|
+
|
|
1947
|
+
delete process.env.PGSSLMODE;
|
|
1948
|
+
const mysqlInstance = healthyDbInstance({
|
|
1949
|
+
Engine: 'mysql',
|
|
1950
|
+
Endpoint: { Address: 'myapp-db.abc123.us-east-2.rds.amazonaws.com', Port: 3306 },
|
|
1951
|
+
});
|
|
1952
|
+
const mysql = mockImportSpawn();
|
|
1953
|
+
const okMysql = await runDbImport(importOptions({
|
|
1954
|
+
file: sqlFile,
|
|
1955
|
+
rdsClient: mockRdsClient(mysqlInstance),
|
|
1956
|
+
spawnImpl: mysql.spawnImpl,
|
|
1957
|
+
}));
|
|
1958
|
+
expect(okMysql.ok).toBe(true);
|
|
1959
|
+
expect(mysql.calls[1].opts.env.PGSSLMODE).toBeUndefined();
|
|
1960
|
+
} finally {
|
|
1961
|
+
if (saved === undefined) delete process.env.PGSSLMODE;
|
|
1962
|
+
else process.env.PGSSLMODE = saved;
|
|
1963
|
+
}
|
|
1964
|
+
});
|
|
1965
|
+
|
|
1966
|
+
it('prompts for the source when interactive without flags', async () => {
|
|
1967
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-import-'));
|
|
1968
|
+
const sqlFile = writeSqlFile(dir);
|
|
1969
|
+
mockSelect.mockResolvedValueOnce('file');
|
|
1970
|
+
mockText.mockResolvedValueOnce(sqlFile);
|
|
1971
|
+
mockConfirm.mockResolvedValueOnce(true);
|
|
1972
|
+
const { calls, spawnImpl } = mockImportSpawn();
|
|
1973
|
+
const result = await runDbImport(importOptions({ isHeadless: false, yes: false, spawnImpl }));
|
|
1974
|
+
expect(result.ok).toBe(true);
|
|
1975
|
+
expect(mockSelect).toHaveBeenCalled();
|
|
1976
|
+
expect(mockText).toHaveBeenCalled();
|
|
1977
|
+
expect(calls[1].bin).toBe('psql');
|
|
1978
|
+
});
|
|
1979
|
+
});
|
|
1980
|
+
|
|
1981
|
+
describe('db import: pure builders', () => {
|
|
1982
|
+
it('classifies import files by extension', () => {
|
|
1983
|
+
expect(classifyImportFile('seed.sql')).toBe('sql');
|
|
1984
|
+
expect(classifyImportFile('seed.SQL.GZ')).toBe('gzip');
|
|
1985
|
+
expect(classifyImportFile('backup.dump')).toBe('dump');
|
|
1986
|
+
expect(classifyImportFile('weird.backup')).toBe('sql');
|
|
1987
|
+
});
|
|
1988
|
+
|
|
1989
|
+
it('selects client binaries per scheme and source', () => {
|
|
1990
|
+
expect(requiredClientBinaries({ targetScheme: 'postgresql', source: { type: 'file', kind: 'sql' } })).toEqual(['psql']);
|
|
1991
|
+
expect(requiredClientBinaries({ targetScheme: 'postgresql', source: { type: 'file', kind: 'dump' } })).toEqual(['pg_restore']);
|
|
1992
|
+
expect(requiredClientBinaries({ targetScheme: 'postgresql', source: { type: 'url' } })).toEqual(['pg_dump', 'psql']);
|
|
1993
|
+
expect(requiredClientBinaries({ targetScheme: 'mysql', source: { type: 'file', kind: 'sql' } })).toEqual(['mysql']);
|
|
1994
|
+
expect(requiredClientBinaries({ targetScheme: 'mysql', source: { type: 'url' } })).toEqual(['mysqldump', 'mysql']);
|
|
1995
|
+
});
|
|
1996
|
+
|
|
1997
|
+
it('builds target and dump commands without secrets in argv', () => {
|
|
1998
|
+
const target = buildTargetClientCommand({ targetScheme: 'postgresql', localPort: 5555, username: 'u', dbName: 'd', fileKind: 'sql' });
|
|
1999
|
+
expect(target).toEqual({ bin: 'psql', args: ['-h', '127.0.0.1', '-p', '5555', '-U', 'u', '-d', 'd', '-v', 'ON_ERROR_STOP=1', '-q', '--no-password'] });
|
|
2000
|
+
const dump = buildSourceDumpCommand({ scheme: 'postgresql', host: 'h', port: '5432', user: 'u', database: 'd' });
|
|
2001
|
+
expect(dump).toEqual({ bin: 'pg_dump', args: ['--no-owner', '--no-acl', '--no-password', '-h', 'h', '-p', '5432', '-U', 'u', '-d', 'd'] });
|
|
2002
|
+
const mysqlDump = buildSourceDumpCommand({ scheme: 'mysql', host: 'h', port: '3306', user: '', database: 'd' });
|
|
2003
|
+
expect(mysqlDump.args).not.toContain('-u');
|
|
2004
|
+
});
|
|
2005
|
+
|
|
2006
|
+
it('parses db import flags', () => {
|
|
2007
|
+
expect(parseDbImportArgs(['db', 'import', '--file', 'a.sql', '--yes'])).toMatchObject({ file: 'a.sql', yes: true });
|
|
2008
|
+
expect(parseDbImportArgs(['--from=x', '--force'])).toMatchObject({ from: 'x', force: true });
|
|
2009
|
+
expect(parseDbImportArgs(null)).toEqual({});
|
|
2010
|
+
expect(parseDbImportArgs(['db', 'import', 'oops']).unexpectedPositionals).toEqual(['oops']);
|
|
2011
|
+
});
|
|
2012
|
+
});
|
|
2013
|
+
|
|
2014
|
+
describe('db-tunnel: shared helpers', () => {
|
|
2015
|
+
it('re-exports the shared SSM builder through connect', () => {
|
|
2016
|
+
expect(sharedBuildSsmArgs).toBe(buildSsmArgs);
|
|
2017
|
+
});
|
|
2018
|
+
|
|
2019
|
+
it('fetches managed credentials or null when malformed', async () => {
|
|
2020
|
+
const good = { send: vi.fn(async () => ({ SecretString: JSON.stringify({ username: 'u', password: 'p' }) })) };
|
|
2021
|
+
await expect(fetchManagedDbCredentials(good, 'arn')).resolves.toEqual({ username: 'u', password: 'p' });
|
|
2022
|
+
const bad = { send: vi.fn(async () => ({ SecretString: 'nope{{{' })) };
|
|
2023
|
+
await expect(fetchManagedDbCredentials(bad, 'arn')).resolves.toBeNull();
|
|
2024
|
+
const empty = { send: vi.fn(async () => ({})) };
|
|
2025
|
+
await expect(fetchManagedDbCredentials(empty, 'arn')).resolves.toBeNull();
|
|
2026
|
+
});
|
|
2027
|
+
|
|
2028
|
+
it('finds jump-host targets and reports task gaps', async () => {
|
|
2029
|
+
const ecsClient = mockEcsClient({ taskArns: [TASK_ARN], tasks: [healthyTask()] });
|
|
2030
|
+
const found = await findJumpHostTarget(ecsClient, { cluster: 'c', service: 's', expectedContainer: 'x' });
|
|
2031
|
+
expect(found.taskId).toBe(TASK_ARN.split('/').pop());
|
|
2032
|
+
expect(found.runtimeId).toBeTruthy();
|
|
2033
|
+
const none = await findJumpHostTarget(mockEcsClient(), { cluster: 'c', service: 's' });
|
|
2034
|
+
expect(none).toEqual({ error: 'NO_RUNNING_TASKS' });
|
|
2035
|
+
});
|
|
2036
|
+
|
|
2037
|
+
it('redacts passwords in database URIs', () => {
|
|
2038
|
+
expect(redactUri('postgresql://u:p@ss@host:5432/db')).toBe('postgresql://u:****@host:5432/db');
|
|
2039
|
+
expect(redactUri('mysql://u@host/db')).toBe('mysql://u@host/db');
|
|
2040
|
+
expect(redactUri('not-a-uri')).toBe('not-a-uri');
|
|
2041
|
+
expect(redactUri(null)).toBe('');
|
|
2042
|
+
});
|
|
2043
|
+
|
|
2044
|
+
it('parses source URIs into discrete parts', () => {
|
|
2045
|
+
expect(parseSourceUri('postgresql://u:p%40ss@h:5433/db')).toEqual({
|
|
2046
|
+
scheme: 'postgresql', user: 'u', password: 'p@ss', host: 'h', port: '5433', database: 'db',
|
|
2047
|
+
});
|
|
2048
|
+
expect(parseSourceUri('postgres://u@h/db')).toMatchObject({ scheme: 'postgresql', port: '5432' });
|
|
2049
|
+
expect(parseSourceUri('mysql://h/db')).toMatchObject({ scheme: 'mysql', port: '3306' });
|
|
2050
|
+
expect(parseSourceUri('http://h/db')).toBeNull();
|
|
2051
|
+
expect(parseSourceUri('postgresql://h/')).toBeNull();
|
|
2052
|
+
expect(parseSourceUri('garbage')).toBeNull();
|
|
2053
|
+
});
|
|
2054
|
+
|
|
2055
|
+
it('reports missing binaries via ENOENT', () => {
|
|
2056
|
+
const enoent = Object.assign(new Error('x'), { code: 'ENOENT' });
|
|
2057
|
+
expect(findMissingBinaries(['psql', 'pg_dump'], { spawnSyncImpl: () => ({}) })).toEqual([]);
|
|
2058
|
+
expect(findMissingBinaries(['psql', 'pg_dump'], {
|
|
2059
|
+
spawnSyncImpl: (bin) => (bin === 'psql' ? { error: enoent } : ({})),
|
|
2060
|
+
})).toEqual(['psql']);
|
|
2061
|
+
});
|
|
2062
|
+
|
|
2063
|
+
it('times out on closed TCP ports without binding', async () => {
|
|
2064
|
+
const closed = await waitForTcpPort('127.0.0.1', 54399, { timeoutMs: 30, pollIntervalMs: 5 });
|
|
2065
|
+
expect(closed).toEqual({ timedOut: true });
|
|
2066
|
+
});
|
|
2067
|
+
|
|
2068
|
+
it('allocates free loopback ports and detects open TCP ports', async () => {
|
|
2069
|
+
const port = await getFreeLocalPort();
|
|
2070
|
+
expect(Number.isInteger(port)).toBe(true);
|
|
2071
|
+
expect(port).toBeGreaterThan(0);
|
|
2072
|
+
|
|
2073
|
+
const net = await import('node:net');
|
|
2074
|
+
const server = net.createServer();
|
|
2075
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
2076
|
+
try {
|
|
2077
|
+
const open = await waitForTcpPort('127.0.0.1', server.address().port, { timeoutMs: 2000, pollIntervalMs: 5 });
|
|
2078
|
+
expect(open).toEqual({ done: true, value: true });
|
|
2079
|
+
} finally {
|
|
2080
|
+
server.close();
|
|
2081
|
+
}
|
|
2082
|
+
});
|
|
2083
|
+
});
|
|
2084
|
+
|
|
2085
|
+
function backupOptions(overrides = {}) {
|
|
2086
|
+
return {
|
|
2087
|
+
...baseOptions(),
|
|
2088
|
+
projectName: 'myapp',
|
|
2089
|
+
region: 'us-east-2',
|
|
2090
|
+
pollIntervalMs: 5,
|
|
2091
|
+
...overrides,
|
|
2092
|
+
};
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
function mockRdsBackupClient({ instance, cluster = null, snapshotScript = [], clusterSnapshotScript = [], createError = null } = {}) {
|
|
2096
|
+
const resolved = instance === undefined ? healthyDbInstance() : instance;
|
|
2097
|
+
const queue = [...snapshotScript];
|
|
2098
|
+
const clusterQueue = [...clusterSnapshotScript];
|
|
2099
|
+
const created = [];
|
|
2100
|
+
const described = [];
|
|
2101
|
+
const client = {
|
|
2102
|
+
send: vi.fn((cmd) => {
|
|
2103
|
+
if (cmd instanceof MockDescribeDBInstancesCommand) {
|
|
2104
|
+
return Promise.resolve({ DBInstances: resolved ? [resolved] : [] });
|
|
2105
|
+
}
|
|
2106
|
+
if (cmd instanceof MockDescribeDBClustersCommand) {
|
|
2107
|
+
return Promise.resolve({ DBClusters: cluster ? [cluster] : [] });
|
|
2108
|
+
}
|
|
2109
|
+
if (cmd instanceof MockCreateDBSnapshotCommand) {
|
|
2110
|
+
created.push(cmd);
|
|
2111
|
+
if (createError) return Promise.reject(createError);
|
|
2112
|
+
return Promise.resolve({ DBSnapshot: { DBSnapshotIdentifier: cmd.DBSnapshotIdentifier, Status: 'creating' } });
|
|
2113
|
+
}
|
|
2114
|
+
if (cmd instanceof MockCreateDBClusterSnapshotCommand) {
|
|
2115
|
+
created.push(cmd);
|
|
2116
|
+
if (createError) return Promise.reject(createError);
|
|
2117
|
+
return Promise.resolve({ DBClusterSnapshot: { DBClusterSnapshotIdentifier: cmd.DBClusterSnapshotIdentifier, Status: 'creating' } });
|
|
2118
|
+
}
|
|
2119
|
+
if (cmd instanceof MockDescribeDBSnapshotsCommand) {
|
|
2120
|
+
described.push(cmd);
|
|
2121
|
+
const next = queue.length > 0 ? queue.shift() : { snapshots: [] };
|
|
2122
|
+
if (next.error) return Promise.reject(next.error);
|
|
2123
|
+
return Promise.resolve({ DBSnapshots: next.snapshots || [] });
|
|
2124
|
+
}
|
|
2125
|
+
if (cmd instanceof MockDescribeDBClusterSnapshotsCommand) {
|
|
2126
|
+
described.push(cmd);
|
|
2127
|
+
const next = clusterQueue.length > 0 ? clusterQueue.shift() : { snapshots: [] };
|
|
2128
|
+
if (next.error) return Promise.reject(next.error);
|
|
2129
|
+
return Promise.resolve({ DBClusterSnapshots: next.snapshots || [] });
|
|
2130
|
+
}
|
|
2131
|
+
return Promise.resolve({});
|
|
2132
|
+
}),
|
|
2133
|
+
created,
|
|
2134
|
+
described,
|
|
2135
|
+
};
|
|
2136
|
+
return client;
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
describe('Command: db backup (mocked AWS)', () => {
|
|
2140
|
+
let exitSpy;
|
|
2141
|
+
let consoleSpy;
|
|
2142
|
+
let output;
|
|
2143
|
+
|
|
2144
|
+
beforeEach(() => {
|
|
2145
|
+
vi.clearAllMocks();
|
|
2146
|
+
output = [];
|
|
2147
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
2148
|
+
consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
2149
|
+
output.push(args.join(' '));
|
|
2150
|
+
});
|
|
2151
|
+
});
|
|
2152
|
+
|
|
2153
|
+
afterEach(() => {
|
|
2154
|
+
exitSpy.mockRestore();
|
|
2155
|
+
consoleSpy.mockRestore();
|
|
2156
|
+
});
|
|
2157
|
+
|
|
2158
|
+
it('rejects invalid --id and --timeout before AWS calls', async () => {
|
|
2159
|
+
const rdsClient = mockRdsBackupClient();
|
|
2160
|
+
const badId = await runDbBackup(backupOptions({ snapshotId: 'bad--id-', rdsClient }));
|
|
2161
|
+
expect(badId.reason).toBe('invalid-snapshot-id');
|
|
2162
|
+
expect(rdsClient.send).not.toHaveBeenCalled();
|
|
2163
|
+
|
|
2164
|
+
const badTimeout = await runDbBackup(backupOptions({ timeout: 'never', rdsClient }));
|
|
2165
|
+
expect(badTimeout.reason).toBe('invalid-timeout');
|
|
2166
|
+
expect(rdsClient.send).not.toHaveBeenCalled();
|
|
2167
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
2168
|
+
});
|
|
2169
|
+
|
|
2170
|
+
it('fails when no database is provisioned', async () => {
|
|
2171
|
+
const rdsClient = mockRdsBackupClient({ instance: null });
|
|
2172
|
+
const result = await runDbBackup(backupOptions({ rdsClient }));
|
|
2173
|
+
expect(result.reason).toBe('rds-instance-not-found');
|
|
2174
|
+
expect(rdsClient.created).toHaveLength(0);
|
|
2175
|
+
const spin = mockSpinner.mock.results[mockSpinner.mock.results.length - 1].value;
|
|
2176
|
+
expect(spin.stop).toHaveBeenCalledWith();
|
|
2177
|
+
const text = stripVTControlCharacters(output.join('\n'));
|
|
2178
|
+
expect(text).toContain('No database found');
|
|
2179
|
+
expect(text.split('No database found').length - 1).toBe(1);
|
|
2180
|
+
});
|
|
2181
|
+
|
|
2182
|
+
it('returns immediately with --no-wait', async () => {
|
|
2183
|
+
const rdsClient = mockRdsBackupClient();
|
|
2184
|
+
const result = await runDbBackup(backupOptions({ snapshotId: 'pre-migrate', noWait: true, rdsClient }));
|
|
2185
|
+
expect(result).toEqual({ ok: true, snapshotId: 'pre-migrate', status: 'creating' });
|
|
2186
|
+
expect(rdsClient.created).toHaveLength(1);
|
|
2187
|
+
const create = rdsClient.created[0];
|
|
2188
|
+
expect(create.DBInstanceIdentifier).toBe('myapp-db');
|
|
2189
|
+
expect(create.DBSnapshotIdentifier).toBe('pre-migrate');
|
|
2190
|
+
expect(create.Tags).toEqual([
|
|
2191
|
+
{ Key: 'ManagedBy', Value: 'grada' },
|
|
2192
|
+
{ Key: 'Project', Value: 'myapp' },
|
|
2193
|
+
]);
|
|
2194
|
+
expect(rdsClient.described).toHaveLength(0);
|
|
2195
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_backup_run', expect.objectContaining({
|
|
2196
|
+
success: true, waited: false,
|
|
2197
|
+
}));
|
|
2198
|
+
});
|
|
2199
|
+
|
|
2200
|
+
it('generates a timestamped id by default', async () => {
|
|
2201
|
+
const rdsClient = mockRdsBackupClient();
|
|
2202
|
+
const result = await runDbBackup(backupOptions({ noWait: true, rdsClient }));
|
|
2203
|
+
expect(result.snapshotId).toMatch(/^myapp-db-manual-\d{8}-\d{6}$/);
|
|
2204
|
+
});
|
|
2205
|
+
|
|
2206
|
+
it('polls until the snapshot is available', async () => {
|
|
2207
|
+
const rdsClient = mockRdsBackupClient({
|
|
2208
|
+
snapshotScript: [
|
|
2209
|
+
{ snapshots: [{ DBSnapshotIdentifier: 's1', Status: 'creating' }] },
|
|
2210
|
+
{ snapshots: [{ DBSnapshotIdentifier: 's1', Status: 'available' }] },
|
|
2211
|
+
],
|
|
2212
|
+
});
|
|
2213
|
+
const result = await runDbBackup(backupOptions({ snapshotId: 's1', rdsClient }));
|
|
2214
|
+
expect(result).toEqual({ ok: true, snapshotId: 's1', status: 'available' });
|
|
2215
|
+
expect(rdsClient.described.length).toBeGreaterThanOrEqual(2);
|
|
2216
|
+
expect(stripVTControlCharacters(output.join('\n'))).toContain('npx grada-run db restore s1');
|
|
2217
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_backup_run', expect.objectContaining({
|
|
2218
|
+
success: true, waited: true,
|
|
2219
|
+
}));
|
|
2220
|
+
});
|
|
2221
|
+
|
|
2222
|
+
it('tolerates eventual-consistency not-found errors while polling', async () => {
|
|
2223
|
+
const notFound = new Error('not yet visible');
|
|
2224
|
+
notFound.name = 'DBSnapshotNotFound';
|
|
2225
|
+
const rdsClient = mockRdsBackupClient({
|
|
2226
|
+
snapshotScript: [
|
|
2227
|
+
{ error: notFound },
|
|
2228
|
+
{ snapshots: [{ DBSnapshotIdentifier: 's1', Status: 'available' }] },
|
|
2229
|
+
],
|
|
2230
|
+
});
|
|
2231
|
+
const result = await runDbBackup(backupOptions({ snapshotId: 's1', rdsClient }));
|
|
2232
|
+
expect(result.status).toBe('available');
|
|
2233
|
+
});
|
|
2234
|
+
|
|
2235
|
+
it('fails on timeout while creation continues in the background', async () => {
|
|
2236
|
+
const rdsClient = mockRdsBackupClient({
|
|
2237
|
+
snapshotScript: Array.from({ length: 50 }, () => ({ snapshots: [{ DBSnapshotIdentifier: 's1', Status: 'creating' }] })),
|
|
2238
|
+
});
|
|
2239
|
+
const result = await runDbBackup(backupOptions({ snapshotId: 's1', rdsClient, timeoutMs: 20 }));
|
|
2240
|
+
expect(result.reason).toBe('snapshot-timeout');
|
|
2241
|
+
expect(result.snapshotId).toBe('s1');
|
|
2242
|
+
expect(stripVTControlCharacters(output.join('\n'))).toContain('continues in the background');
|
|
2243
|
+
});
|
|
2244
|
+
|
|
2245
|
+
it('propagates quota and state errors from CreateDBSnapshot', async () => {
|
|
2246
|
+
const quota = new Error('quota exceeded');
|
|
2247
|
+
quota.name = 'SnapshotQuotaExceeded';
|
|
2248
|
+
const rdsClient = mockRdsBackupClient({ createError: quota });
|
|
2249
|
+
const result = await runDbBackup(backupOptions({ rdsClient }));
|
|
2250
|
+
expect(result.reason).toBe('error');
|
|
2251
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_backup_run', expect.objectContaining({
|
|
2252
|
+
success: false, error_code: 'SnapshotQuotaExceeded',
|
|
2253
|
+
}));
|
|
2254
|
+
});
|
|
2255
|
+
|
|
2256
|
+
it('backs up an Aurora cluster with cluster snapshot commands', async () => {
|
|
2257
|
+
const rdsClient = mockRdsBackupClient({
|
|
2258
|
+
instance: null,
|
|
2259
|
+
cluster: healthyDbCluster(),
|
|
2260
|
+
clusterSnapshotScript: [
|
|
2261
|
+
{ snapshots: [{ DBClusterSnapshotIdentifier: 'c1', Status: 'creating' }] },
|
|
2262
|
+
{ snapshots: [{ DBClusterSnapshotIdentifier: 'c1', Status: 'available' }] },
|
|
2263
|
+
],
|
|
2264
|
+
});
|
|
2265
|
+
const result = await runDbBackup(backupOptions({ snapshotId: 'c1', rdsClient }));
|
|
2266
|
+
expect(result).toEqual({ ok: true, snapshotId: 'c1', status: 'available' });
|
|
2267
|
+
expect(rdsClient.created).toHaveLength(1);
|
|
2268
|
+
const create = rdsClient.created[0];
|
|
2269
|
+
expect(create).toBeInstanceOf(MockCreateDBClusterSnapshotCommand);
|
|
2270
|
+
expect(create.DBClusterIdentifier).toBe('myapp-db-cluster');
|
|
2271
|
+
expect(create.DBClusterSnapshotIdentifier).toBe('c1');
|
|
2272
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_backup_run', expect.objectContaining({
|
|
2273
|
+
success: true, waited: true, db_kind: 'cluster',
|
|
2274
|
+
}));
|
|
2275
|
+
});
|
|
2276
|
+
});
|
|
2277
|
+
|
|
2278
|
+
const DATABASE_TF_FIXTURE = `resource "aws_db_instance" "postgres" {
|
|
2279
|
+
identifier = "myapp-db"
|
|
2280
|
+
engine = "postgres"
|
|
2281
|
+
skip_final_snapshot = true
|
|
2282
|
+
}
|
|
2283
|
+
`;
|
|
2284
|
+
|
|
2285
|
+
function writeDatabaseTf(dir, content = DATABASE_TF_FIXTURE) {
|
|
2286
|
+
fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
|
|
2287
|
+
fs.writeFileSync(path.join(dir, 'terraform', 'database.tf'), content);
|
|
2288
|
+
return path.join(dir, 'terraform', 'database.tf');
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
function snapshotFixture(id, overrides = {}) {
|
|
2292
|
+
return {
|
|
2293
|
+
DBSnapshotIdentifier: id,
|
|
2294
|
+
Status: 'available',
|
|
2295
|
+
SnapshotCreateTime: new Date('2026-05-01T10:00:00.000Z'),
|
|
2296
|
+
AllocatedStorage: 20,
|
|
2297
|
+
SnapshotType: 'manual',
|
|
2298
|
+
...overrides,
|
|
2299
|
+
};
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
function mockRdsRestoreClient({ pages = [], byId = {}, clusterPages = [], clusterById = {} } = {}) {
|
|
2303
|
+
const queue = [...pages];
|
|
2304
|
+
const clusterQueue = [...clusterPages];
|
|
2305
|
+
const instanceCalls = [];
|
|
2306
|
+
const clusterCalls = [];
|
|
2307
|
+
const idCalls = [];
|
|
2308
|
+
const client = {
|
|
2309
|
+
send: vi.fn((cmd) => {
|
|
2310
|
+
if (cmd instanceof MockDescribeDBSnapshotsCommand) {
|
|
2311
|
+
if (cmd.DBSnapshotIdentifier) {
|
|
2312
|
+
idCalls.push(cmd);
|
|
2313
|
+
const found = byId[cmd.DBSnapshotIdentifier];
|
|
2314
|
+
if (!found) {
|
|
2315
|
+
const err = new Error('not found');
|
|
2316
|
+
err.name = 'DBSnapshotNotFound';
|
|
2317
|
+
return Promise.reject(err);
|
|
2318
|
+
}
|
|
2319
|
+
return Promise.resolve({ DBSnapshots: [found] });
|
|
2320
|
+
}
|
|
2321
|
+
instanceCalls.push(cmd);
|
|
2322
|
+
const page = queue.length > 0 ? queue.shift() : [];
|
|
2323
|
+
return Promise.resolve({ DBSnapshots: page, Marker: queue.length > 0 ? 'next-marker' : undefined });
|
|
2324
|
+
}
|
|
2325
|
+
if (cmd instanceof MockDescribeDBClusterSnapshotsCommand) {
|
|
2326
|
+
if (cmd.DBClusterSnapshotIdentifier) {
|
|
2327
|
+
idCalls.push(cmd);
|
|
2328
|
+
const found = clusterById[cmd.DBClusterSnapshotIdentifier];
|
|
2329
|
+
if (!found) {
|
|
2330
|
+
const err = new Error('not found');
|
|
2331
|
+
err.name = 'DBClusterSnapshotNotFound';
|
|
2332
|
+
return Promise.reject(err);
|
|
2333
|
+
}
|
|
2334
|
+
return Promise.resolve({ DBClusterSnapshots: [found] });
|
|
2335
|
+
}
|
|
2336
|
+
clusterCalls.push(cmd);
|
|
2337
|
+
const page = clusterQueue.length > 0 ? clusterQueue.shift() : [];
|
|
2338
|
+
return Promise.resolve({ DBClusterSnapshots: page, Marker: clusterQueue.length > 0 ? 'next-marker' : undefined });
|
|
2339
|
+
}
|
|
2340
|
+
return Promise.resolve({});
|
|
2341
|
+
}),
|
|
2342
|
+
instanceCalls,
|
|
2343
|
+
clusterCalls,
|
|
2344
|
+
idCalls,
|
|
2345
|
+
};
|
|
2346
|
+
return client;
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
const DATABASE_TF_CLUSTER_FIXTURE = `resource "aws_rds_cluster" "postgres" {
|
|
2350
|
+
cluster_identifier = "myapp-db-cluster"
|
|
2351
|
+
engine = "aurora-postgresql"
|
|
2352
|
+
skip_final_snapshot = true
|
|
2353
|
+
}
|
|
2354
|
+
`;
|
|
2355
|
+
|
|
2356
|
+
const DATABASE_TF_MYSQL_FIXTURE = `resource "aws_db_instance" "postgres" {
|
|
2357
|
+
identifier = "myapp-db"
|
|
2358
|
+
engine = "mysql"
|
|
2359
|
+
skip_final_snapshot = true
|
|
2360
|
+
}
|
|
2361
|
+
`;
|
|
2362
|
+
|
|
2363
|
+
function clusterSnapshotFixture(id, overrides = {}) {
|
|
2364
|
+
return {
|
|
2365
|
+
DBClusterSnapshotIdentifier: id,
|
|
2366
|
+
Status: 'available',
|
|
2367
|
+
SnapshotCreateTime: new Date('2026-05-01T10:00:00.000Z'),
|
|
2368
|
+
AllocatedStorage: 10,
|
|
2369
|
+
SnapshotType: 'manual',
|
|
2370
|
+
...overrides,
|
|
2371
|
+
};
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
function restoreOptions(overrides = {}) {
|
|
2375
|
+
return {
|
|
2376
|
+
...baseOptions(),
|
|
2377
|
+
projectName: 'myapp',
|
|
2378
|
+
region: 'us-east-2',
|
|
2379
|
+
...overrides,
|
|
2380
|
+
};
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
describe('Command: db restore (mocked AWS)', () => {
|
|
2384
|
+
let exitSpy;
|
|
2385
|
+
let consoleSpy;
|
|
2386
|
+
let output;
|
|
2387
|
+
|
|
2388
|
+
beforeEach(() => {
|
|
2389
|
+
vi.clearAllMocks();
|
|
2390
|
+
mockSelect.mockReset();
|
|
2391
|
+
mockConfirm.mockReset();
|
|
2392
|
+
output = [];
|
|
2393
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
2394
|
+
consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
2395
|
+
output.push(args.join(' '));
|
|
2396
|
+
});
|
|
2397
|
+
});
|
|
2398
|
+
|
|
2399
|
+
afterEach(() => {
|
|
2400
|
+
exitSpy.mockRestore();
|
|
2401
|
+
consoleSpy.mockRestore();
|
|
2402
|
+
});
|
|
2403
|
+
|
|
2404
|
+
it('fails on a missing database.tf before AWS calls', async () => {
|
|
2405
|
+
const rdsClient = mockRdsRestoreClient();
|
|
2406
|
+
const result = await runDbRestore(restoreOptions({ rdsClient }));
|
|
2407
|
+
expect(result.reason).toBe('database-tf-not-found');
|
|
2408
|
+
expect(rdsClient.send).not.toHaveBeenCalled();
|
|
2409
|
+
});
|
|
2410
|
+
|
|
2411
|
+
it('fails when no snapshots exist', async () => {
|
|
2412
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-restore-'));
|
|
2413
|
+
writeDatabaseTf(dir);
|
|
2414
|
+
const rdsClient = mockRdsRestoreClient({ pages: [[]] });
|
|
2415
|
+
const result = await runDbRestore(restoreOptions({ cwd: dir, rdsClient }));
|
|
2416
|
+
expect(result.reason).toBe('no-snapshots-found');
|
|
2417
|
+
expect(stripVTControlCharacters(output.join('\n'))).toContain('npx grada-run db backup');
|
|
2418
|
+
});
|
|
2419
|
+
|
|
2420
|
+
it('requires a snapshot id and confirmation in headless mode', async () => {
|
|
2421
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-restore-'));
|
|
2422
|
+
writeDatabaseTf(dir);
|
|
2423
|
+
const missing = await runDbRestore(restoreOptions({
|
|
2424
|
+
cwd: dir, rdsClient: mockRdsRestoreClient({ pages: [[snapshotFixture('s1')]] }),
|
|
2425
|
+
}));
|
|
2426
|
+
expect(missing.reason).toBe('missing-snapshot-id');
|
|
2427
|
+
expect(mockSelect).not.toHaveBeenCalled();
|
|
2428
|
+
|
|
2429
|
+
const unconfirmed = await runDbRestore(restoreOptions({
|
|
2430
|
+
cwd: dir,
|
|
2431
|
+
snapshotId: 's1',
|
|
2432
|
+
rdsClient: mockRdsRestoreClient({ pages: [[snapshotFixture('s1')]] }),
|
|
2433
|
+
}));
|
|
2434
|
+
expect(unconfirmed.reason).toBe('confirmation-required');
|
|
2435
|
+
expect(mockConfirm).not.toHaveBeenCalled();
|
|
2436
|
+
});
|
|
2437
|
+
|
|
2438
|
+
it('restores by positional id with --yes and pins the snapshot in HCL', async () => {
|
|
2439
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-restore-'));
|
|
2440
|
+
const tfFile = writeDatabaseTf(dir);
|
|
2441
|
+
const rdsClient = mockRdsRestoreClient({ pages: [[snapshotFixture('snap-1')]] });
|
|
2442
|
+
const result = await runDbRestore(restoreOptions({ cwd: dir, snapshotId: 'snap-1', yes: true, rdsClient }));
|
|
2443
|
+
expect(result).toEqual({ ok: true, snapshotId: 'snap-1' });
|
|
2444
|
+
const updated = fs.readFileSync(tfFile, 'utf8');
|
|
2445
|
+
expect(updated).toContain('snapshot_identifier = "snap-1"');
|
|
2446
|
+
expect(updated).toContain('keep snapshot_identifier');
|
|
2447
|
+
expect(stripVTControlCharacters(output.join('\n'))).toContain('npx grada-run apply');
|
|
2448
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_restore_run', expect.objectContaining({ success: true }));
|
|
2449
|
+
});
|
|
2450
|
+
|
|
2451
|
+
it('restores an Aurora cluster snapshot into the cluster block', async () => {
|
|
2452
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-restore-'));
|
|
2453
|
+
const tfFile = writeDatabaseTf(dir, DATABASE_TF_CLUSTER_FIXTURE);
|
|
2454
|
+
const rdsClient = mockRdsRestoreClient({ clusterPages: [[clusterSnapshotFixture('csnap-1')]] });
|
|
2455
|
+
const result = await runDbRestore(restoreOptions({ cwd: dir, snapshotId: 'csnap-1', yes: true, rdsClient }));
|
|
2456
|
+
expect(result).toEqual({ ok: true, snapshotId: 'csnap-1' });
|
|
2457
|
+
expect(rdsClient.clusterCalls).toHaveLength(1);
|
|
2458
|
+
expect(rdsClient.clusterCalls[0].DBClusterIdentifier).toBe('myapp-db-cluster');
|
|
2459
|
+
expect(rdsClient.instanceCalls).toHaveLength(0);
|
|
2460
|
+
const updated = fs.readFileSync(tfFile, 'utf8');
|
|
2461
|
+
expect(updated).toContain('snapshot_identifier = "csnap-1"');
|
|
2462
|
+
expect(updated.indexOf('snapshot_identifier')).toBeGreaterThan(updated.indexOf('resource "aws_rds_cluster" "postgres"'));
|
|
2463
|
+
expect(mockTrackEvent).toHaveBeenCalledWith('db_restore_run', expect.objectContaining({ success: true, db_kind: 'cluster' }));
|
|
2464
|
+
});
|
|
2465
|
+
|
|
2466
|
+
it('falls back to a direct lookup for snapshots from replaced instances', async () => {
|
|
2467
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-restore-'));
|
|
2468
|
+
writeDatabaseTf(dir);
|
|
2469
|
+
const orphan = snapshotFixture('orphan-snap');
|
|
2470
|
+
const rdsClient = mockRdsRestoreClient({ pages: [[]], byId: { 'orphan-snap': orphan } });
|
|
2471
|
+
const result = await runDbRestore(restoreOptions({ cwd: dir, snapshotId: 'orphan-snap', yes: true, rdsClient }));
|
|
2472
|
+
expect(result).toEqual({ ok: true, snapshotId: 'orphan-snap' });
|
|
2473
|
+
expect(rdsClient.idCalls).toHaveLength(1);
|
|
2474
|
+
expect(rdsClient.idCalls[0].DBSnapshotIdentifier).toBe('orphan-snap');
|
|
2475
|
+
});
|
|
2476
|
+
|
|
2477
|
+
it('rejects unknown ids and non-available snapshots', async () => {
|
|
2478
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-restore-'));
|
|
2479
|
+
writeDatabaseTf(dir);
|
|
2480
|
+
const unknown = await runDbRestore(restoreOptions({
|
|
2481
|
+
cwd: dir, snapshotId: 'nope', yes: true, rdsClient: mockRdsRestoreClient({ pages: [[snapshotFixture('other')]] }),
|
|
2482
|
+
}));
|
|
2483
|
+
expect(unknown.reason).toBe('snapshot-not-available');
|
|
2484
|
+
expect(stripVTControlCharacters(output.join('\n'))).toContain('not found');
|
|
2485
|
+
|
|
2486
|
+
const pending = await runDbRestore(restoreOptions({
|
|
2487
|
+
cwd: dir,
|
|
2488
|
+
snapshotId: 's1',
|
|
2489
|
+
yes: true,
|
|
2490
|
+
rdsClient: mockRdsRestoreClient({ pages: [[snapshotFixture('s1', { Status: 'pending' })]] }),
|
|
2491
|
+
}));
|
|
2492
|
+
expect(pending.reason).toBe('snapshot-not-available');
|
|
2493
|
+
});
|
|
2494
|
+
|
|
2495
|
+
it('follows pagination markers across snapshot pages', async () => {
|
|
2496
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-restore-'));
|
|
2497
|
+
writeDatabaseTf(dir);
|
|
2498
|
+
const rdsClient = mockRdsRestoreClient({ pages: [[snapshotFixture('page-1')], [snapshotFixture('page-2')]] });
|
|
2499
|
+
const result = await runDbRestore(restoreOptions({ cwd: dir, snapshotId: 'page-2', yes: true, rdsClient }));
|
|
2500
|
+
expect(result).toEqual({ ok: true, snapshotId: 'page-2' });
|
|
2501
|
+
expect(rdsClient.instanceCalls).toHaveLength(2);
|
|
2502
|
+
expect(rdsClient.instanceCalls[1].Marker).toBe('next-marker');
|
|
2503
|
+
});
|
|
2504
|
+
|
|
2505
|
+
it('offers an interactive picker sorted newest-first', async () => {
|
|
2506
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-restore-'));
|
|
2507
|
+
writeDatabaseTf(dir);
|
|
2508
|
+
const older = snapshotFixture('older', { SnapshotCreateTime: new Date('2026-04-01T10:00:00.000Z') });
|
|
2509
|
+
const newer = snapshotFixture('newer', { SnapshotCreateTime: new Date('2026-06-01T10:00:00.000Z'), SnapshotType: 'automated' });
|
|
2510
|
+
const rdsClient = mockRdsRestoreClient({ pages: [[older, newer]] });
|
|
2511
|
+
mockSelect.mockResolvedValueOnce('older');
|
|
2512
|
+
mockConfirm.mockResolvedValueOnce(true);
|
|
2513
|
+
const result = await runDbRestore(restoreOptions({ cwd: dir, isHeadless: false, rdsClient }));
|
|
2514
|
+
expect(result).toEqual({ ok: true, snapshotId: 'older' });
|
|
2515
|
+
const promptOptions = mockSelect.mock.calls[0][0].options;
|
|
2516
|
+
expect(promptOptions.map((o) => o.value)).toEqual(['newer', 'older']);
|
|
2517
|
+
expect(promptOptions[0].hint).toContain('2026-06-01');
|
|
2518
|
+
expect(promptOptions[0].hint).toContain('20GB');
|
|
2519
|
+
expect(promptOptions[0].hint).toContain('automated');
|
|
2520
|
+
expect(stripVTControlCharacters(output.join('\n'))).toContain('skip_final_snapshot = true');
|
|
2521
|
+
});
|
|
2522
|
+
|
|
2523
|
+
it('aborts cleanly when the confirmation is declined or cancelled', async () => {
|
|
2524
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-restore-'));
|
|
2525
|
+
const tfFile = writeDatabaseTf(dir);
|
|
2526
|
+
mockSelect.mockResolvedValueOnce('s1');
|
|
2527
|
+
mockConfirm.mockResolvedValueOnce(false);
|
|
2528
|
+
const declined = await runDbRestore(restoreOptions({
|
|
2529
|
+
cwd: dir,
|
|
2530
|
+
isHeadless: false,
|
|
2531
|
+
rdsClient: mockRdsRestoreClient({ pages: [[snapshotFixture('s1')]] }),
|
|
2532
|
+
}));
|
|
2533
|
+
expect(declined).toEqual(expect.objectContaining({ ok: false, reason: 'cancelled' }));
|
|
2534
|
+
expect(fs.readFileSync(tfFile, 'utf8')).toBe(DATABASE_TF_FIXTURE);
|
|
2535
|
+
|
|
2536
|
+
mockSelect.mockResolvedValueOnce(Symbol('clack-cancel'));
|
|
2537
|
+
const cancelled = await runDbRestore(restoreOptions({
|
|
2538
|
+
cwd: dir,
|
|
2539
|
+
isHeadless: false,
|
|
2540
|
+
rdsClient: mockRdsRestoreClient({ pages: [[snapshotFixture('s1')]] }),
|
|
2541
|
+
}));
|
|
2542
|
+
expect(cancelled).toEqual(expect.objectContaining({ ok: false, reason: 'cancelled' }));
|
|
2543
|
+
});
|
|
2544
|
+
});
|
|
2545
|
+
|
|
2546
|
+
describe('db restore: upsertSnapshotIdentifier', () => {
|
|
2547
|
+
it('inserts the attribute after identifier with a keep-in-place comment', () => {
|
|
2548
|
+
const updated = upsertSnapshotIdentifier(DATABASE_TF_FIXTURE, 'snap-1');
|
|
2549
|
+
expect(updated).toContain('snapshot_identifier = "snap-1"');
|
|
2550
|
+
expect(updated).toContain('keep snapshot_identifier');
|
|
2551
|
+
expect(updated.indexOf('snapshot_identifier')).toBeGreaterThan(updated.indexOf('identifier = "myapp-db"'));
|
|
2552
|
+
});
|
|
2553
|
+
|
|
2554
|
+
it('replaces an existing attribute and stays idempotent', () => {
|
|
2555
|
+
const once = upsertSnapshotIdentifier(DATABASE_TF_FIXTURE, 'snap-1');
|
|
2556
|
+
const twice = upsertSnapshotIdentifier(once, 'snap-2');
|
|
2557
|
+
expect(twice).toContain('snapshot_identifier = "snap-2"');
|
|
2558
|
+
expect(twice).not.toContain('snap-1');
|
|
2559
|
+
expect(twice.match(/snapshot_identifier =/g)).toHaveLength(1);
|
|
2560
|
+
expect(upsertSnapshotIdentifier(twice, 'snap-2')).toBe(twice);
|
|
2561
|
+
});
|
|
2562
|
+
|
|
2563
|
+
it('scopes edits to the postgres resource block only', () => {
|
|
2564
|
+
const hcl = `${DATABASE_TF_FIXTURE}\nresource "aws_db_instance" "other" {\n identifier = "other"\n}\n`;
|
|
2565
|
+
const updated = upsertSnapshotIdentifier(hcl, 'snap-1');
|
|
2566
|
+
expect(updated.match(/snapshot_identifier =/g)).toHaveLength(1);
|
|
2567
|
+
expect(updated.indexOf('snapshot_identifier')).toBeLessThan(updated.indexOf('resource "aws_db_instance" "other"'));
|
|
2568
|
+
});
|
|
2569
|
+
|
|
2570
|
+
it('returns content unchanged when the resource is missing', () => {
|
|
2571
|
+
expect(upsertSnapshotIdentifier('resource "aws_s3_bucket" "x" {}', 'snap-1'))
|
|
2572
|
+
.toBe('resource "aws_s3_bucket" "x" {}');
|
|
2573
|
+
});
|
|
2574
|
+
|
|
2575
|
+
it('pins cluster snapshots after cluster_identifier in aws_rds_cluster', () => {
|
|
2576
|
+
const updated = upsertSnapshotIdentifier(DATABASE_TF_CLUSTER_FIXTURE, 'csnap-1', 'aws_rds_cluster');
|
|
2577
|
+
expect(updated).toContain('snapshot_identifier = "csnap-1"');
|
|
2578
|
+
expect(updated).toContain('keep snapshot_identifier');
|
|
2579
|
+
expect(updated.indexOf('snapshot_identifier')).toBeGreaterThan(updated.indexOf('cluster_identifier'));
|
|
2580
|
+
expect(updated.match(/snapshot_identifier =/g)).toHaveLength(1);
|
|
2581
|
+
expect(upsertSnapshotIdentifier(updated, 'csnap-1', 'aws_rds_cluster')).toBe(updated);
|
|
2582
|
+
});
|
|
2583
|
+
|
|
2584
|
+
it('leaves cluster blocks untouched when scoping to aws_db_instance', () => {
|
|
2585
|
+
expect(upsertSnapshotIdentifier(DATABASE_TF_CLUSTER_FIXTURE, 'snap-1')).toBe(DATABASE_TF_CLUSTER_FIXTURE);
|
|
2586
|
+
});
|
|
2587
|
+
});
|
|
2588
|
+
|
|
2589
|
+
describe('db: fuzzer hardening', () => {
|
|
2590
|
+
let exitSpy;
|
|
2591
|
+
let consoleSpy;
|
|
2592
|
+
|
|
2593
|
+
beforeEach(() => {
|
|
2594
|
+
vi.clearAllMocks();
|
|
2595
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
2596
|
+
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
2597
|
+
});
|
|
2598
|
+
|
|
2599
|
+
afterEach(() => {
|
|
2600
|
+
exitSpy.mockRestore();
|
|
2601
|
+
consoleSpy.mockRestore();
|
|
2602
|
+
});
|
|
2603
|
+
|
|
2604
|
+
it.each([
|
|
2605
|
+
['runDbConnect', runDbConnect, 'db_connect_run'],
|
|
2606
|
+
['runDbMigrate', runDbMigrate, 'db_migrate_run'],
|
|
2607
|
+
['runDbBackup', runDbBackup, 'db_backup_run'],
|
|
2608
|
+
['runDbRestore', runDbRestore, 'db_restore_run'],
|
|
2609
|
+
['runDbEnableVector', runDbEnableVector, 'db_enable_vector_run'],
|
|
2610
|
+
['runDbImport', runDbImport, 'db_import_run'],
|
|
2611
|
+
])('%s routes unresolvable projects through PROJECT_NOT_INITIALIZED', async (_name, run, event) => {
|
|
2612
|
+
const cwdSpy = vi.spyOn(process, 'cwd').mockImplementation(() => { throw new Error('deleted'); });
|
|
2613
|
+
try {
|
|
2614
|
+
const result = await run(null);
|
|
2615
|
+
expect(result).toEqual({ ok: false, reason: 'project-not-initialized' });
|
|
2616
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
2617
|
+
expect(mockTrackEvent).toHaveBeenCalledWith(event, expect.objectContaining({
|
|
2618
|
+
success: false,
|
|
2619
|
+
error_code: 'PROJECT_NOT_INITIALIZED',
|
|
2620
|
+
}));
|
|
2621
|
+
} finally {
|
|
2622
|
+
cwdSpy.mockRestore();
|
|
2623
|
+
}
|
|
2624
|
+
});
|
|
2625
|
+
});
|
|
2626
|
+
|
|
2627
|
+
describe('buildMigrationCommand', () => {
|
|
2628
|
+
const DB_ENV = [
|
|
2629
|
+
{ name: 'DB_HOST', value: 'db.internal' },
|
|
2630
|
+
{ name: 'DB_PORT', value: '5432' },
|
|
2631
|
+
{ name: 'DB_NAME', value: 'myapp' },
|
|
2632
|
+
];
|
|
2633
|
+
const DB_SECRETS = [
|
|
2634
|
+
{ name: 'DB_USER', valueFrom: 'arn:username' },
|
|
2635
|
+
{ name: 'DB_PASSWORD', valueFrom: 'arn:password' },
|
|
2636
|
+
];
|
|
2637
|
+
|
|
2638
|
+
it('passes the command through for env-less containers', () => {
|
|
2639
|
+
expect(buildMigrationCommand('npx prisma migrate deploy', { name: 'c' }))
|
|
2640
|
+
.toEqual(['sh', '-c', 'npx prisma migrate deploy']);
|
|
2641
|
+
expect(buildMigrationCommand('npx prisma migrate deploy', null))
|
|
2642
|
+
.toEqual(['sh', '-c', 'npx prisma migrate deploy']);
|
|
2643
|
+
expect(buildMigrationCommand('npx prisma migrate deploy', { name: 'c', environment: [], secrets: [] }))
|
|
2644
|
+
.toEqual(['sh', '-c', 'npx prisma migrate deploy']);
|
|
2645
|
+
});
|
|
2646
|
+
|
|
2647
|
+
it('synthesizes a mysql:// URL for MySQL task definitions', () => {
|
|
2648
|
+
const mysqlEnv = [
|
|
2649
|
+
{ name: 'DB_HOST', value: 'db.internal' },
|
|
2650
|
+
{ name: 'DB_PORT', value: '3306' },
|
|
2651
|
+
{ name: 'DB_NAME', value: 'myapp' },
|
|
2652
|
+
{ name: 'DB_ENGINE', value: 'mysql' },
|
|
2653
|
+
];
|
|
2654
|
+
const [sh, dashC, script] = buildMigrationCommand('migrate', { name: 'c', environment: mysqlEnv, secrets: DB_SECRETS });
|
|
2655
|
+
expect([sh, dashC]).toEqual(['sh', '-c']);
|
|
2656
|
+
expect(script).toContain('export DATABASE_URL="${DATABASE_URL:-mysql://');
|
|
2657
|
+
// MySQL clients ignore PGSSLMODE: no TLS prefix on mysql:// URLs.
|
|
2658
|
+
expect(script).not.toContain('PGSSLMODE');
|
|
2659
|
+
const portOnly = { name: 'c', environment: mysqlEnv.filter((e) => e.name !== 'DB_ENGINE'), secrets: DB_SECRETS };
|
|
2660
|
+
expect(buildMigrationCommand('migrate', portOnly)[2]).toContain(':-mysql://');
|
|
2661
|
+
});
|
|
2662
|
+
|
|
2663
|
+
it('passes through when DATABASE_URL is already defined', () => {
|
|
2664
|
+
const withUrl = { name: 'c', environment: [...DB_ENV, { name: 'DATABASE_URL', value: 'postgres://x' }], secrets: DB_SECRETS };
|
|
2665
|
+
expect(buildMigrationCommand('migrate', withUrl)).toEqual(['sh', '-c', 'migrate']);
|
|
2666
|
+
const withUrlSecret = { name: 'c', environment: DB_ENV, secrets: [...DB_SECRETS, { name: 'DATABASE_URL', valueFrom: 'arn' }] };
|
|
2667
|
+
expect(buildMigrationCommand('migrate', withUrlSecret)).toEqual(['sh', '-c', 'migrate']);
|
|
2668
|
+
});
|
|
2669
|
+
|
|
2670
|
+
it('passes through when credentials are incomplete', () => {
|
|
2671
|
+
const noUser = { name: 'c', environment: DB_ENV, secrets: [{ name: 'DB_PASSWORD', valueFrom: 'arn' }] };
|
|
2672
|
+
expect(buildMigrationCommand('migrate', noUser)).toEqual(['sh', '-c', 'migrate']);
|
|
2673
|
+
});
|
|
2674
|
+
|
|
2675
|
+
it('synthesizes DATABASE_URL from discrete credentials at runtime', () => {
|
|
2676
|
+
const container = { name: 'c', environment: DB_ENV, secrets: DB_SECRETS };
|
|
2677
|
+
const [shell, flag, script] = buildMigrationCommand('npx prisma migrate deploy', container);
|
|
2678
|
+
expect([shell, flag]).toEqual(['sh', '-c']);
|
|
2679
|
+
expect(script).toContain('export DATABASE_URL="${DATABASE_URL:-postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT:-5432}/${DB_NAME:-postgres}}"');
|
|
2680
|
+
// RDS/Aurora enforces rds.force_ssl: TLS is required before the URL.
|
|
2681
|
+
expect(script).toContain('export PGSSLMODE="${PGSSLMODE:-require}"');
|
|
2682
|
+
expect(script.indexOf('export PGSSLMODE=')).toBeLessThan(script.indexOf('export DATABASE_URL='));
|
|
2683
|
+
expect(script.endsWith('npx prisma migrate deploy')).toBe(true);
|
|
2684
|
+
// Secrets stay as runtime expansions, never baked into the command.
|
|
2685
|
+
expect(script).not.toContain('arn:');
|
|
2686
|
+
});
|
|
2687
|
+
|
|
2688
|
+
it('wraps the RunTask command when the task definition carries DB credentials', async () => {
|
|
2689
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
|
2690
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
2691
|
+
try {
|
|
2692
|
+
const ecsClient = mockEcsMigrateClient({
|
|
2693
|
+
containerDefinitions: [{ name: 'myapp-container', environment: DB_ENV, secrets: DB_SECRETS }],
|
|
2694
|
+
taskSequence: [stoppedTask(0)],
|
|
2695
|
+
});
|
|
2696
|
+
const result = await runDbMigrate(migrateOptions({ ecsClient, logsClient: mockLogsClient() }));
|
|
2697
|
+
expect(result.success).toBe(true);
|
|
2698
|
+
expect(ecsClient.runs[0].overrides.containerOverrides[0].command[2]).toContain('export DATABASE_URL=');
|
|
2699
|
+
} finally {
|
|
2700
|
+
exitSpy.mockRestore();
|
|
2701
|
+
consoleSpy.mockRestore();
|
|
2702
|
+
}
|
|
2703
|
+
});
|
|
2704
|
+
});
|