@grada-run/grada 0.0.1 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/deploy-docs.yml +37 -0
- package/.github/workflows/e2e.yml +73 -0
- package/.github/workflows/iac-validation.yml +303 -0
- package/.github/workflows/publish.yml +68 -0
- package/.github/workflows/sync-bedrock-models.yml +57 -0
- package/.github/workflows/test.yml +43 -0
- package/.muserules +31 -0
- package/LICENSE +21 -0
- package/README.md +193 -3
- package/apps/docs/.astro/collections/docs.schema.json +644 -0
- package/apps/docs/.astro/content-assets.mjs +4 -0
- package/apps/docs/.astro/content-modules.mjs +4 -0
- package/apps/docs/.astro/content.d.ts +179 -0
- package/apps/docs/.astro/data-store.json +1 -0
- package/apps/docs/.astro/dev.json +14 -0
- package/apps/docs/.astro/settings.json +5 -0
- package/apps/docs/.astro/types.d.ts +2 -0
- package/apps/docs/astro.config.mjs +97 -0
- package/apps/docs/package.json +17 -0
- package/apps/docs/src/content/docs/adrs/0001-s3-native-state-locking.md +37 -0
- package/apps/docs/src/content/docs/adrs/0002-eject-mechanism-pure-iac.md +39 -0
- package/apps/docs/src/content/docs/adrs/0003-sync-ai-context-strategy.md +48 -0
- package/apps/docs/src/content/docs/adrs/0004-iac-driven-diagnostic-context.md +37 -0
- package/apps/docs/src/content/docs/adrs/0005-ecs-fargate-alb-runtime-target.md +38 -0
- package/apps/docs/src/content/docs/adrs/0006-github-oidc-no-stored-keys.md +37 -0
- package/apps/docs/src/content/docs/adrs/0007-framework-detection-with-fallback.md +37 -0
- package/apps/docs/src/content/docs/adrs/0008-secrets-names-in-git-values-in-aws.md +37 -0
- package/apps/docs/src/content/docs/adrs/0009-regenerate-with-backup-on-rerun.md +37 -0
- package/apps/docs/src/content/docs/adrs/0010-advisory-only-security-scans.md +37 -0
- package/apps/docs/src/content/docs/cli/add.md +85 -0
- package/apps/docs/src/content/docs/cli/apply.md +35 -0
- package/apps/docs/src/content/docs/cli/db.md +200 -0
- package/apps/docs/src/content/docs/cli/destroy.md +35 -0
- package/apps/docs/src/content/docs/cli/diagnose.md +37 -0
- package/apps/docs/src/content/docs/cli/doctor.md +28 -0
- package/apps/docs/src/content/docs/cli/domain.md +57 -0
- package/apps/docs/src/content/docs/cli/drift.md +40 -0
- package/apps/docs/src/content/docs/cli/eject.md +33 -0
- package/apps/docs/src/content/docs/cli/exec.md +49 -0
- package/apps/docs/src/content/docs/cli/gc.md +37 -0
- package/apps/docs/src/content/docs/cli/init.md +72 -0
- package/apps/docs/src/content/docs/cli/logs.md +39 -0
- package/apps/docs/src/content/docs/cli/rollback.md +51 -0
- package/apps/docs/src/content/docs/cli/secrets.md +73 -0
- package/apps/docs/src/content/docs/cli/sleep.md +53 -0
- package/apps/docs/src/content/docs/cli/status.md +34 -0
- package/apps/docs/src/content/docs/cli/sync-ai.md +27 -0
- package/apps/docs/src/content/docs/guides/architecture.md +87 -0
- package/apps/docs/src/content/docs/guides/aws-credentials.md +72 -0
- package/apps/docs/src/content/docs/guides/background-workers.md +45 -0
- package/apps/docs/src/content/docs/guides/cicd-pipeline.md +64 -0
- package/apps/docs/src/content/docs/guides/database-connections.md +64 -0
- package/apps/docs/src/content/docs/guides/docker-compose.md +37 -0
- package/apps/docs/src/content/docs/guides/dockerfiles.md +46 -0
- package/apps/docs/src/content/docs/guides/ephemeral-pr-previews.md +39 -0
- package/apps/docs/src/content/docs/guides/examples.md +50 -0
- package/apps/docs/src/content/docs/guides/frameworks.md +88 -0
- package/apps/docs/src/content/docs/guides/headless.md +87 -0
- package/apps/docs/src/content/docs/guides/quickstart.md +52 -0
- package/apps/docs/src/content/docs/guides/rerun-init.md +43 -0
- package/apps/docs/src/content/docs/guides/secrets-management.md +83 -0
- package/apps/docs/src/content/docs/guides/understanding-your-bill.md +63 -0
- package/apps/docs/src/content/docs/index.mdx +103 -0
- package/apps/docs/src/content/docs/migrations/astro-vercel-to-aws.md +55 -0
- package/apps/docs/src/content/docs/migrations/heroku-procfile-to-aws.md +41 -0
- package/apps/docs/src/content/docs/migrations/nextjs-vercel-to-aws.md +51 -0
- package/apps/docs/src/content/docs/migrations/sveltekit-vercel-to-aws.md +63 -0
- package/apps/docs/src/content/docs/roadmap.md +99 -0
- package/apps/docs/src/content/docs/testing-strategy.md +37 -0
- package/apps/docs/src/content.config.ts +7 -0
- package/apps/docs/src/custom.css +14 -0
- package/apps/docs/tsconfig.json +6 -0
- package/bin/cli.js +140 -0
- package/package.json +107 -7
- package/scripts/sync-bedrock-models.js +22 -0
- package/scripts/test-iac.js +261 -0
- package/specs/add-redis-sqs-bedrock.md +128 -0
- package/specs/add-storage-dynamodb.md +106 -0
- package/specs/bedrock-model-catalog.md +131 -0
- package/specs/ci-pipeline.md +17 -0
- package/specs/cost-transparency.md +115 -0
- package/specs/custom-domains-and-ses.md +153 -0
- package/specs/database-suite-expansion.md +151 -0
- package/specs/db-connect.md +69 -0
- package/specs/db-lifecycle-migrations.md +159 -0
- package/specs/dependency-aware-init.md +176 -0
- package/specs/deployment-safety.md +170 -0
- package/specs/diagnose.md +16 -0
- package/specs/docs-hub.md +16 -0
- package/specs/dx-polish.md +46 -0
- package/specs/e2e-testing.md +50 -0
- package/specs/exec.md +25 -0
- package/specs/finops-cron-drift.md +161 -0
- package/specs/gc.md +26 -0
- package/specs/integration-suite.md +16 -0
- package/specs/logs.md +32 -0
- package/specs/rollback-live-polling.md +40 -0
- package/specs/secrets-pull-audit.md +51 -0
- package/specs/serverless-lambda-target.md +133 -0
- package/specs/status.md +31 -0
- package/specs/telemetry-and-spawn-hardening.md +69 -0
- package/specs/telemetry-hardening.md +35 -0
- package/specs/test-suite-deduplication.md +42 -0
- package/src/commands/add.js +1111 -0
- package/src/commands/apply.js +214 -0
- package/src/commands/db/backup.js +229 -0
- package/src/commands/db/connect.js +304 -0
- package/src/commands/db/enable-vector.js +344 -0
- package/src/commands/db/import.js +604 -0
- package/src/commands/db/migrate.js +477 -0
- package/src/commands/db/restore.js +361 -0
- package/src/commands/db.js +87 -0
- package/src/commands/destroy.js +217 -0
- package/src/commands/diagnose.js +460 -0
- package/src/commands/doctor.js +109 -0
- package/src/commands/domain.js +685 -0
- package/src/commands/drift.js +243 -0
- package/src/commands/eject.js +130 -0
- package/src/commands/exec.js +222 -0
- package/src/commands/gc.js +250 -0
- package/src/commands/init.js +649 -0
- package/src/commands/logs.js +256 -0
- package/src/commands/rollback.js +323 -0
- package/src/commands/secrets.js +485 -0
- package/src/commands/sleep.js +347 -0
- package/src/commands/status.js +309 -0
- package/src/commands/sync-ai.js +115 -0
- package/src/commands/wake.js +337 -0
- package/src/core/parser.js +128 -0
- package/src/core/telemetry.js +244 -0
- package/src/data/bedrock-models.json +896 -0
- package/src/utils/addons.js +126 -0
- package/src/utils/ai-rules.js +59 -0
- package/src/utils/args.js +91 -0
- package/src/utils/aws.js +178 -0
- package/src/utils/backup.js +69 -0
- package/src/utils/bedrock-catalog.js +511 -0
- package/src/utils/capabilities.js +500 -0
- package/src/utils/command.js +78 -0
- package/src/utils/db-tunnel.js +164 -0
- package/src/utils/detector.js +298 -0
- package/src/utils/dockerCompose.js +65 -0
- package/src/utils/domains.js +73 -0
- package/src/utils/ecs-runner.js +289 -0
- package/src/utils/ecs.js +92 -0
- package/src/utils/frameworks.js +55 -0
- package/src/utils/generator.js +527 -0
- package/src/utils/hcl.js +426 -0
- package/src/utils/lambda-ecr.js +185 -0
- package/src/utils/prompts.js +278 -0
- package/src/utils/rds.js +131 -0
- package/src/utils/resolvers.js +174 -0
- package/src/utils/sleep-state.js +140 -0
- package/src/utils/sleep-targets.js +139 -0
- package/src/utils/system.js +42 -0
- package/src/utils/terraform.js +70 -0
- package/src/utils/visualizer.js +381 -0
- package/src/utils/warnings.js +49 -0
- package/templates/README.md +150 -0
- package/templates/docker/django.Dockerfile +40 -0
- package/templates/docker/go.Dockerfile +23 -0
- package/templates/docker/nestjs.Dockerfile +33 -0
- package/templates/docker/nextjs.Dockerfile +55 -0
- package/templates/docker/node.Dockerfile +24 -0
- package/templates/docker/nuxt.Dockerfile +47 -0
- package/templates/docker/python.Dockerfile +38 -0
- package/templates/docker/rails.Dockerfile +59 -0
- package/templates/docker/static.Dockerfile +32 -0
- package/templates/docker/svelte.Dockerfile +52 -0
- package/templates/github/deploy-lambda.yml +120 -0
- package/templates/github/deploy.yml +138 -0
- package/templates/github/drift.yml +112 -0
- package/templates/github/preview-lambda.yml +86 -0
- package/templates/github/preview.yml +69 -0
- package/templates/github/teardown.yml +43 -0
- package/templates/terraform/addons/bedrock.tf +34 -0
- package/templates/terraform/addons/cron-lambda.tf +78 -0
- package/templates/terraform/addons/cron.tf +101 -0
- package/templates/terraform/addons/dynamodb.tf +73 -0
- package/templates/terraform/addons/redis.tf +64 -0
- package/templates/terraform/addons/s3.tf +143 -0
- package/templates/terraform/addons/ses.tf +73 -0
- package/templates/terraform/addons/sqs.tf +67 -0
- package/templates/terraform/backend.tf +22 -0
- package/templates/terraform/cloudfront-lambda.tf +80 -0
- package/templates/terraform/cloudfront.tf +80 -0
- package/templates/terraform/database-aurora-postgresql.tf +92 -0
- package/templates/terraform/database-mysql.tf +72 -0
- package/templates/terraform/database.tf +71 -0
- package/templates/terraform/main-lambda.tf +229 -0
- package/templates/terraform/main.tf +296 -0
- package/templates/terraform/network.tf +95 -0
- package/templates/terraform/oidc.tf +64 -0
- package/templates/terraform/secrets.tf +31 -0
- package/templates/terraform/worker.tf +69 -0
- package/tests/__snapshots__/generator.test.js.snap +9633 -0
- package/tests/add.test.js +2018 -0
- package/tests/ai.test.js +91 -0
- package/tests/apply.test.js +446 -0
- package/tests/args.test.js +86 -0
- package/tests/aws.test.js +244 -0
- package/tests/capabilities.test.js +304 -0
- package/tests/cli.test.js +29 -0
- package/tests/command.test.js +99 -0
- package/tests/commands-import.test.js +74 -0
- package/tests/db.test.js +2673 -0
- package/tests/destroy.test.js +365 -0
- package/tests/detector.test.js +79 -0
- package/tests/diagnose.test.js +765 -0
- package/tests/doctor.test.js +195 -0
- package/tests/domain.test.js +882 -0
- package/tests/drift.test.js +228 -0
- package/tests/e2e/helpers.js +122 -0
- package/tests/e2e/tier0.e2e.test.js +142 -0
- package/tests/e2e/tier1.live.e2e.test.js +118 -0
- package/tests/ecs.test.js +130 -0
- package/tests/eject.test.js +65 -0
- package/tests/exec.test.js +366 -0
- package/tests/gc.test.js +466 -0
- package/tests/generator.test.js +794 -0
- package/tests/headless.test.js +491 -0
- package/tests/helpers/clack.js +103 -0
- package/tests/helpers/console.js +41 -0
- package/tests/helpers/telemetry.js +37 -0
- package/tests/helpers/tmpdir.js +36 -0
- package/tests/lambda-ecr.test.js +185 -0
- package/tests/logs.test.js +436 -0
- package/tests/parser.test.js +160 -0
- package/tests/rds.test.js +244 -0
- package/tests/resolvers.test.js +279 -0
- package/tests/rollback.test.js +670 -0
- package/tests/secrets.test.js +729 -0
- package/tests/sleep-wake.test.js +998 -0
- package/tests/status.test.js +356 -0
- package/tests/system.test.js +70 -0
- package/tests/telemetry.test.js +520 -0
- package/tests/terraform.test.js +84 -0
- package/tests/visualizer.test.js +480 -0
- package/vitest.config.js +10 -0
- package/vitest.e2e.tier0.config.js +8 -0
- package/vitest.e2e.tier1.config.js +8 -0
- package/index.js +0 -2
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
import { RDSClient } from '@aws-sdk/client-rds';
|
|
2
|
+
import { ECSClient } from '@aws-sdk/client-ecs';
|
|
3
|
+
import { SecretsManagerClient } from '@aws-sdk/client-secrets-manager';
|
|
4
|
+
import { spawn } from 'child_process';
|
|
5
|
+
import fsSync from 'node:fs';
|
|
6
|
+
import { createReadStream } from 'node:fs';
|
|
7
|
+
import zlib from 'node:zlib';
|
|
8
|
+
import color from 'picocolors';
|
|
9
|
+
import { intro, outro, spinner, select, text, password, confirm, cancel, isCancel } from '@clack/prompts';
|
|
10
|
+
import { trackSuccess, trackFailure } from '../../core/telemetry.js';
|
|
11
|
+
import { failCommand, failProjectNotInitialized } from '../../utils/command.js';
|
|
12
|
+
import { parseFlags, normalizeOptions, normalizeArgv } from '../../utils/args.js';
|
|
13
|
+
import { hasAwsCli, AWS_CLI_INSTALL_URL, handleAuthErrorBranch, resolveClient } from '../../utils/aws.js';
|
|
14
|
+
import {
|
|
15
|
+
resolveRegion,
|
|
16
|
+
resolveProjectName,
|
|
17
|
+
resolveCluster,
|
|
18
|
+
resolveService,
|
|
19
|
+
resolveAppName,
|
|
20
|
+
resolveHeadless,
|
|
21
|
+
resolveCwd,
|
|
22
|
+
} from '../../utils/resolvers.js';
|
|
23
|
+
import { findDbTarget, resolveDbIdentifier, resolveDbClusterIdentifier } from '../../utils/rds.js';
|
|
24
|
+
import {
|
|
25
|
+
buildSsmArgs,
|
|
26
|
+
fetchManagedDbCredentials,
|
|
27
|
+
findJumpHostTarget,
|
|
28
|
+
waitForTcpPort,
|
|
29
|
+
getFreeLocalPort,
|
|
30
|
+
redactUri,
|
|
31
|
+
parseSourceUri,
|
|
32
|
+
findMissingBinaries,
|
|
33
|
+
} from '../../utils/db-tunnel.js';
|
|
34
|
+
import {
|
|
35
|
+
hasSessionManagerPlugin,
|
|
36
|
+
resolveContainer,
|
|
37
|
+
printAwsCliGuidance,
|
|
38
|
+
printSessionManagerGuidance,
|
|
39
|
+
printNoTasksGuidance,
|
|
40
|
+
} from '../../utils/ecs.js';
|
|
41
|
+
import { printNoDatabaseGuidance } from './connect.js';
|
|
42
|
+
|
|
43
|
+
export const DEFAULT_TUNNEL_TIMEOUT_MS = 30000;
|
|
44
|
+
export const DEFAULT_TUNNEL_POLL_INTERVAL_MS = 500;
|
|
45
|
+
// Grace period between SIGTERM and SIGKILL when tearing down the tunnel.
|
|
46
|
+
export const TUNNEL_SIGKILL_TIMEOUT_MS = 2000;
|
|
47
|
+
|
|
48
|
+
export function parseDbImportArgs(argv = []) {
|
|
49
|
+
const args = normalizeArgv(argv);
|
|
50
|
+
if (args[0] === 'db') args.shift();
|
|
51
|
+
if (args[0] === 'import') args.shift();
|
|
52
|
+
const { options, rest } = parseFlags(args, {
|
|
53
|
+
string: [
|
|
54
|
+
{ name: 'file', key: 'file' },
|
|
55
|
+
{ name: 'from', key: 'from' },
|
|
56
|
+
{ name: 'db-identifier', key: 'dbIdentifier' },
|
|
57
|
+
{ name: 'project-name', key: 'projectName' },
|
|
58
|
+
{ name: 'region', key: 'region' },
|
|
59
|
+
{ name: 'workspace', key: 'workspace' },
|
|
60
|
+
],
|
|
61
|
+
boolean: ['headless', 'yes', 'force'],
|
|
62
|
+
});
|
|
63
|
+
const positionals = rest.filter((arg) => typeof arg === 'string' && !arg.startsWith('-'));
|
|
64
|
+
if (positionals.length > 0) options.unexpectedPositionals = positionals;
|
|
65
|
+
return options;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Classifies an import file by extension: 'dump' (Postgres custom
|
|
69
|
+
// archive), 'gzip' (.sql.gz / .gz), or 'sql' (plain text, the default for
|
|
70
|
+
// unknown extensions). Pure and unit-tested.
|
|
71
|
+
export function classifyImportFile(filePath) {
|
|
72
|
+
const lower = String(filePath || '').toLowerCase();
|
|
73
|
+
if (lower.endsWith('.dump')) return 'dump';
|
|
74
|
+
if (lower.endsWith('.gz')) return 'gzip';
|
|
75
|
+
return 'sql';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Local client binaries required for an import: `{ targetScheme: 'mysql'
|
|
79
|
+
// | 'postgresql', source: { type: 'file', kind } | { type: 'url', scheme
|
|
80
|
+
// } }`. Pure and unit-tested.
|
|
81
|
+
export function requiredClientBinaries({ targetScheme, source }) {
|
|
82
|
+
if (source.type === 'url') {
|
|
83
|
+
return targetScheme === 'mysql' ? ['mysqldump', 'mysql'] : ['pg_dump', 'psql'];
|
|
84
|
+
}
|
|
85
|
+
if (source.kind === 'dump') return ['pg_restore'];
|
|
86
|
+
return targetScheme === 'mysql' ? ['mysql'] : ['psql'];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function clientInstallHint(missing) {
|
|
90
|
+
const lines = missing.map((bin) => {
|
|
91
|
+
if (bin === 'mysql' || bin === 'mysqldump') {
|
|
92
|
+
return ` ${bin}: brew install mysql-client (then add $(brew --prefix mysql-client)/bin to PATH)`;
|
|
93
|
+
}
|
|
94
|
+
return ` ${bin}: brew install libpq (then add $(brew --prefix libpq)/bin to PATH)`;
|
|
95
|
+
});
|
|
96
|
+
return `Install the missing database client tools:\n${lines.join('\n')}\n`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Target-side client invocation: `{ bin, args }` (file path included for
|
|
100
|
+
// pg_restore). Passwords travel via process env, never argv. Pure.
|
|
101
|
+
export function buildTargetClientCommand({ targetScheme, localPort, username, dbName, fileKind, filePath }) {
|
|
102
|
+
if (targetScheme === 'mysql') {
|
|
103
|
+
return {
|
|
104
|
+
bin: 'mysql',
|
|
105
|
+
args: ['-h', '127.0.0.1', '-P', String(localPort), '-u', username, dbName],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (fileKind === 'dump') {
|
|
109
|
+
return {
|
|
110
|
+
bin: 'pg_restore',
|
|
111
|
+
args: ['--no-owner', '--no-acl', '--no-password', '-h', '127.0.0.1', '-p', String(localPort), '-U', username, '-d', dbName, filePath],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
bin: 'psql',
|
|
116
|
+
args: ['-h', '127.0.0.1', '-p', String(localPort), '-U', username, '-d', dbName, '-v', 'ON_ERROR_STOP=1', '-q', '--no-password'],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Source-side dump invocation for `--from`. Passwords travel via process
|
|
121
|
+
// env (`PGPASSWORD` / `MYSQL_PWD`), never argv. Pure and unit-tested.
|
|
122
|
+
export function buildSourceDumpCommand(source) {
|
|
123
|
+
if (source.scheme === 'mysql') {
|
|
124
|
+
const args = ['-h', source.host, '-P', String(source.port)];
|
|
125
|
+
if (source.user) args.push('-u', source.user);
|
|
126
|
+
args.push(source.database);
|
|
127
|
+
return { bin: 'mysqldump', args };
|
|
128
|
+
}
|
|
129
|
+
const args = ['--no-owner', '--no-acl', '--no-password', '-h', source.host, '-p', String(source.port)];
|
|
130
|
+
if (source.user) args.push('-U', source.user);
|
|
131
|
+
args.push('-d', source.database);
|
|
132
|
+
return { bin: 'pg_dump', args };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function targetEnv(targetScheme, password) {
|
|
136
|
+
if (targetScheme === 'mysql') {
|
|
137
|
+
return { ...process.env, MYSQL_PWD: password };
|
|
138
|
+
}
|
|
139
|
+
// RDS/Aurora enforces `rds.force_ssl = 1` by default: require TLS for
|
|
140
|
+
// psql/pg_restore unless the caller already pinned a PGSSLMODE.
|
|
141
|
+
return { ...process.env, PGPASSWORD: password, PGSSLMODE: process.env.PGSSLMODE || 'require' };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function waitForExit(child) {
|
|
145
|
+
return new Promise((resolve) => {
|
|
146
|
+
child.once('error', (error) => resolve({ code: 1, error }));
|
|
147
|
+
child.once('close', (code) => resolve({ code: code ?? 0 }));
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function printDbNoTasksGuidance(service, cluster) {
|
|
152
|
+
printNoTasksGuidance({ service, cluster, reason: 'to act as a jump host for the import tunnel' });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function runDbImport(input = {}) {
|
|
156
|
+
const options = normalizeOptions(input);
|
|
157
|
+
let cwd;
|
|
158
|
+
let region;
|
|
159
|
+
let projectName;
|
|
160
|
+
let cluster;
|
|
161
|
+
let service;
|
|
162
|
+
let expectedContainer;
|
|
163
|
+
let dbIdentifier;
|
|
164
|
+
let dbClusterIdentifier;
|
|
165
|
+
try {
|
|
166
|
+
cwd = resolveCwd(options);
|
|
167
|
+
region = resolveRegion(options, cwd);
|
|
168
|
+
projectName = resolveProjectName(options, cwd);
|
|
169
|
+
const appName = resolveAppName(projectName, options.workspace, cwd);
|
|
170
|
+
const namespacedOptions = { ...options, projectName: appName };
|
|
171
|
+
cluster = resolveCluster(namespacedOptions, cwd);
|
|
172
|
+
service = resolveService(namespacedOptions, cwd);
|
|
173
|
+
expectedContainer = resolveContainer(namespacedOptions, cwd);
|
|
174
|
+
dbIdentifier = resolveDbIdentifier(options, cwd);
|
|
175
|
+
dbClusterIdentifier = resolveDbClusterIdentifier(options, cwd);
|
|
176
|
+
} catch {
|
|
177
|
+
return failProjectNotInitialized({ event: 'db_import_run' });
|
|
178
|
+
}
|
|
179
|
+
const headless = resolveHeadless(options);
|
|
180
|
+
|
|
181
|
+
intro(color.bgCyan(color.black(' grada db import 📥 ')));
|
|
182
|
+
|
|
183
|
+
if (Array.isArray(options.unexpectedPositionals) && options.unexpectedPositionals.length > 0) {
|
|
184
|
+
return failCommand({
|
|
185
|
+
message: `\n✖ Unexpected argument "${options.unexpectedPositionals[0]}". Use --file <path> or --from <url>.\n`,
|
|
186
|
+
event: 'db_import_run',
|
|
187
|
+
telemetry: { projectName },
|
|
188
|
+
errorCode: 'UNEXPECTED_POSITIONAL_ARGS',
|
|
189
|
+
reason: 'unexpected-positional-args',
|
|
190
|
+
resultExtra: { cluster, service, region },
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 1. Source selection: exactly one of --file / --from.
|
|
195
|
+
let file = typeof options.file === 'string' && options.file.trim() ? options.file.trim() : null;
|
|
196
|
+
let from = typeof options.from === 'string' && options.from.trim() ? options.from.trim() : null;
|
|
197
|
+
if (file && from) {
|
|
198
|
+
return failCommand({
|
|
199
|
+
message: '\n✖ Pass exactly one import source: --file <path> or --from <url>, not both.\n',
|
|
200
|
+
event: 'db_import_run',
|
|
201
|
+
telemetry: { projectName },
|
|
202
|
+
errorCode: 'INVALID_IMPORT_SOURCE',
|
|
203
|
+
reason: 'invalid-import-source',
|
|
204
|
+
resultExtra: { cluster, service, region },
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (!file && !from && headless) {
|
|
208
|
+
return failCommand({
|
|
209
|
+
message: '\n✖ No import source given. Pass --file <path> or --from <url>.\n',
|
|
210
|
+
event: 'db_import_run',
|
|
211
|
+
telemetry: { projectName },
|
|
212
|
+
errorCode: 'INVALID_IMPORT_SOURCE',
|
|
213
|
+
reason: 'invalid-import-source',
|
|
214
|
+
resultExtra: { cluster, service, region },
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
if (!file && !from) {
|
|
218
|
+
const kind = await select({
|
|
219
|
+
message: 'Import source:',
|
|
220
|
+
options: [
|
|
221
|
+
{ value: 'file', label: 'Local SQL/dump file' },
|
|
222
|
+
{ value: 'from', label: 'Remote database URL' },
|
|
223
|
+
],
|
|
224
|
+
});
|
|
225
|
+
if (isCancel(kind)) {
|
|
226
|
+
cancel('Cancelled.');
|
|
227
|
+
return { ok: false, reason: 'cancelled', cluster, service, region };
|
|
228
|
+
}
|
|
229
|
+
if (kind === 'file') {
|
|
230
|
+
const answer = await text({ message: 'Path to the SQL/dump file:' });
|
|
231
|
+
if (isCancel(answer) || !String(answer || '').trim()) {
|
|
232
|
+
cancel('Cancelled.');
|
|
233
|
+
return { ok: false, reason: 'cancelled', cluster, service, region };
|
|
234
|
+
}
|
|
235
|
+
file = String(answer).trim();
|
|
236
|
+
} else {
|
|
237
|
+
// Masked: URLs usually embed the source password.
|
|
238
|
+
const answer = await password({ message: 'Source database URL:' });
|
|
239
|
+
if (isCancel(answer) || !String(answer || '').trim()) {
|
|
240
|
+
cancel('Cancelled.');
|
|
241
|
+
return { ok: false, reason: 'cancelled', cluster, service, region };
|
|
242
|
+
}
|
|
243
|
+
from = String(answer).trim();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let source;
|
|
248
|
+
if (from) {
|
|
249
|
+
const parsed = parseSourceUri(from);
|
|
250
|
+
if (!parsed) {
|
|
251
|
+
return failCommand({
|
|
252
|
+
message: `\n✖ Invalid --from "${redactUri(from)}". Use a postgresql:// or mysql:// URL with a database name.\n`,
|
|
253
|
+
event: 'db_import_run',
|
|
254
|
+
telemetry: { projectName },
|
|
255
|
+
errorCode: 'INVALID_SOURCE_URI',
|
|
256
|
+
reason: 'invalid-source-uri',
|
|
257
|
+
resultExtra: { cluster, service, region },
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
source = { type: 'url', ...parsed, redacted: redactUri(from) };
|
|
261
|
+
} else {
|
|
262
|
+
// Verified before any AWS API call.
|
|
263
|
+
let readable = false;
|
|
264
|
+
try {
|
|
265
|
+
readable = fsSync.existsSync(file)
|
|
266
|
+
&& fsSync.statSync(file).isFile();
|
|
267
|
+
if (readable) fsSync.accessSync(file, fsSync.constants.R_OK);
|
|
268
|
+
} catch {
|
|
269
|
+
readable = false;
|
|
270
|
+
}
|
|
271
|
+
if (!readable) {
|
|
272
|
+
return failCommand({
|
|
273
|
+
message: `\n✖ Import file not found or unreadable: ${file}.\n`,
|
|
274
|
+
event: 'db_import_run',
|
|
275
|
+
telemetry: { projectName },
|
|
276
|
+
errorCode: 'IMPORT_FILE_NOT_FOUND',
|
|
277
|
+
reason: 'import-file-not-found',
|
|
278
|
+
resultExtra: { cluster, service, region },
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
source = { type: 'file', path: file, kind: classifyImportFile(file) };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const rdsClient = resolveClient(options.rdsClient, RDSClient, { region });
|
|
285
|
+
const ecsClient = resolveClient(options.ecsClient, ECSClient, { region });
|
|
286
|
+
const secretsClient = resolveClient(options.secretsClient, SecretsManagerClient, { region });
|
|
287
|
+
const spawnImpl = options.spawnImpl || spawn;
|
|
288
|
+
const spawnSyncImpl = options.spawnSyncImpl;
|
|
289
|
+
const waitForTunnelImpl = options.waitForTunnelImpl
|
|
290
|
+
|| ((host, port) => waitForTcpPort(host, port, {
|
|
291
|
+
timeoutMs: options.tunnelTimeoutMs ?? DEFAULT_TUNNEL_TIMEOUT_MS,
|
|
292
|
+
pollIntervalMs: options.tunnelPollIntervalMs ?? DEFAULT_TUNNEL_POLL_INTERVAL_MS,
|
|
293
|
+
}));
|
|
294
|
+
|
|
295
|
+
// 2. Target discovery (shared lookup: instance or Aurora cluster).
|
|
296
|
+
const target = await findDbTarget(rdsClient, { dbIdentifier, dbClusterIdentifier });
|
|
297
|
+
if (!target) {
|
|
298
|
+
return failCommand({
|
|
299
|
+
print: () => printNoDatabaseGuidance(dbIdentifier),
|
|
300
|
+
event: 'db_import_run',
|
|
301
|
+
telemetry: { projectName, source: source.type, error_code: 'NO_DATABASE' },
|
|
302
|
+
reason: 'no-database',
|
|
303
|
+
resultExtra: { cluster, service, region },
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
const targetIsMysql = target.engine === 'mysql' || target.engine === 'aurora-mysql';
|
|
307
|
+
const targetScheme = targetIsMysql ? 'mysql' : 'postgresql';
|
|
308
|
+
if (!target.endpoint || !target.dbName || !target.masterSecretArn) {
|
|
309
|
+
return failCommand({
|
|
310
|
+
message: `\n✖ The database ${color.cyan(target.id)} is missing its endpoint, name, or managed secret.`,
|
|
311
|
+
event: 'db_import_run',
|
|
312
|
+
telemetry: { projectName, source: source.type, error_code: 'DB_DETAILS_INCOMPLETE' },
|
|
313
|
+
reason: 'db-details-incomplete',
|
|
314
|
+
resultExtra: { cluster, service, region },
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
if (source.type === 'file' && source.kind === 'dump' && targetIsMysql) {
|
|
318
|
+
return failCommand({
|
|
319
|
+
message: '\n✖ Cannot restore a Postgres custom-format archive (.dump) into MySQL. Export plain SQL instead.\n',
|
|
320
|
+
event: 'db_import_run',
|
|
321
|
+
telemetry: { projectName, source: source.type },
|
|
322
|
+
errorCode: 'UNSUPPORTED_IMPORT_FORMAT',
|
|
323
|
+
reason: 'unsupported-import-format',
|
|
324
|
+
resultExtra: { cluster, service, region },
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 3. Local preflight: AWS CLI, Session Manager plugin, db clients.
|
|
329
|
+
if (!hasAwsCli({ spawnSyncImpl })) {
|
|
330
|
+
return failCommand({
|
|
331
|
+
print: () => printAwsCliGuidance({ commandName: 'db import' }),
|
|
332
|
+
event: 'db_import_run',
|
|
333
|
+
telemetry: { projectName, source: source.type, error_code: 'AWS_CLI_MISSING' },
|
|
334
|
+
reason: 'aws-cli-missing',
|
|
335
|
+
resultExtra: { cluster, service, region },
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
if (!hasSessionManagerPlugin({ spawnSyncImpl })) {
|
|
339
|
+
return failCommand({
|
|
340
|
+
print: () => printSessionManagerGuidance({ commandName: 'db import' }),
|
|
341
|
+
event: 'db_import_run',
|
|
342
|
+
telemetry: { projectName, source: source.type, error_code: 'SSM_PLUGIN_MISSING' },
|
|
343
|
+
reason: 'ssm-plugin-missing',
|
|
344
|
+
resultExtra: { cluster, service, region },
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
const missingBinaries = findMissingBinaries(
|
|
348
|
+
requiredClientBinaries({ targetScheme, source }),
|
|
349
|
+
{ spawnSyncImpl }
|
|
350
|
+
);
|
|
351
|
+
if (missingBinaries.length > 0) {
|
|
352
|
+
return failCommand({
|
|
353
|
+
message: `\n✖ Missing database client tools: ${missingBinaries.join(', ')}.`,
|
|
354
|
+
hint: clientInstallHint(missingBinaries),
|
|
355
|
+
event: 'db_import_run',
|
|
356
|
+
telemetry: { projectName, source: source.type, error_code: 'MISSING_DB_CLIENT_BINARY' },
|
|
357
|
+
reason: 'missing-db-client-binary',
|
|
358
|
+
resultExtra: { cluster, service, region },
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// 4. Confirmation (destructive write to the remote database).
|
|
363
|
+
const skipConfirm = options.yes === true || options.yes === 'true'
|
|
364
|
+
|| options.force === true || options.force === 'true';
|
|
365
|
+
const sourceLabel = source.type === 'file' ? source.path : source.redacted;
|
|
366
|
+
if (!skipConfirm && headless) {
|
|
367
|
+
return failCommand({
|
|
368
|
+
message: '\n✖ Refusing to import without confirmation in non-interactive mode. Re-run with --yes.\n',
|
|
369
|
+
event: 'db_import_run',
|
|
370
|
+
telemetry: { projectName, source: source.type },
|
|
371
|
+
errorCode: 'CONFIRMATION_REQUIRED',
|
|
372
|
+
reason: 'confirmation-required',
|
|
373
|
+
resultExtra: { cluster, service, region },
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
if (!skipConfirm) {
|
|
377
|
+
const confirmed = await confirm({
|
|
378
|
+
message: `Import ${sourceLabel} into ${target.id}? This writes to the remote database.`,
|
|
379
|
+
});
|
|
380
|
+
if (isCancel(confirmed) || !confirmed) {
|
|
381
|
+
cancel('Cancelled.');
|
|
382
|
+
return { ok: false, reason: 'cancelled', cluster, service, region };
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// 5. Credentials + jump host (shared tunnel discovery).
|
|
387
|
+
const s = spinner();
|
|
388
|
+
s.start('Opening import tunnel...');
|
|
389
|
+
const creds = await fetchManagedDbCredentials(secretsClient, target.masterSecretArn);
|
|
390
|
+
if (!creds) {
|
|
391
|
+
s.stop(color.red('Could not read database credentials.'));
|
|
392
|
+
return failCommand({
|
|
393
|
+
message: '\n✖ The managed database secret did not contain a username and password.',
|
|
394
|
+
event: 'db_import_run',
|
|
395
|
+
telemetry: { projectName, source: source.type, error_code: 'SECRET_MALFORMED' },
|
|
396
|
+
reason: 'secret-malformed',
|
|
397
|
+
resultExtra: { cluster, service, region },
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
const jumpHost = await findJumpHostTarget(ecsClient, { cluster, service, expectedContainer });
|
|
401
|
+
if (jumpHost.error === 'NO_RUNNING_TASKS') {
|
|
402
|
+
s.stop(color.yellow('No running tasks.'));
|
|
403
|
+
return failCommand({
|
|
404
|
+
print: () => printDbNoTasksGuidance(service, cluster),
|
|
405
|
+
event: 'db_import_run',
|
|
406
|
+
telemetry: { projectName, source: source.type, error_code: 'NO_RUNNING_TASKS' },
|
|
407
|
+
reason: 'no-running-tasks',
|
|
408
|
+
resultExtra: { cluster, service, region },
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
if (jumpHost.error === 'NO_RUNTIME_ID') {
|
|
412
|
+
s.stop(color.red('Container runtime ID unavailable.'));
|
|
413
|
+
return failCommand({
|
|
414
|
+
message: '\n✖ The running container did not report a runtime ID, so the tunnel cannot attach.',
|
|
415
|
+
hint: 'Wait a moment for the task to stabilize, then try again.\n',
|
|
416
|
+
event: 'db_import_run',
|
|
417
|
+
telemetry: { projectName, source: source.type, error_code: 'NO_RUNTIME_ID' },
|
|
418
|
+
reason: 'no-runtime-id',
|
|
419
|
+
resultExtra: { cluster, service, region },
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// 6. Background tunnel on an ephemeral loopback port (never 5432/3306,
|
|
424
|
+
// which may already serve a local database).
|
|
425
|
+
const allocatePort = options.allocatePortImpl || getFreeLocalPort;
|
|
426
|
+
const localPort = await allocatePort();
|
|
427
|
+
const ssmArgs = buildSsmArgs({
|
|
428
|
+
cluster,
|
|
429
|
+
taskId: jumpHost.taskId,
|
|
430
|
+
runtimeId: jumpHost.runtimeId,
|
|
431
|
+
dbHost: target.endpoint,
|
|
432
|
+
remotePort: target.port || (targetIsMysql ? '3306' : '5432'),
|
|
433
|
+
localPort: String(localPort),
|
|
434
|
+
region,
|
|
435
|
+
});
|
|
436
|
+
const tunnel = spawnImpl('aws', ssmArgs, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
437
|
+
let tunnelStderr = '';
|
|
438
|
+
try {
|
|
439
|
+
tunnel.stderr?.on('data', (chunk) => {
|
|
440
|
+
tunnelStderr = (tunnelStderr + String(chunk)).slice(-4000);
|
|
441
|
+
});
|
|
442
|
+
} catch {
|
|
443
|
+
// Best-effort diagnostics only.
|
|
444
|
+
}
|
|
445
|
+
const killTunnel = () => {
|
|
446
|
+
if (!tunnel) return;
|
|
447
|
+
// Detach stdio first: `aws ssm start-session` spawns a
|
|
448
|
+
// session-manager-plugin grandchild that inherits the pipes, so our
|
|
449
|
+
// handles stay open (and hold Node's event loop, hanging the CLI
|
|
450
|
+
// after `Done.`) until they are destroyed.
|
|
451
|
+
for (const stream of [tunnel.stdin, tunnel.stdout, tunnel.stderr]) {
|
|
452
|
+
try { stream?.removeAllListeners?.(); } catch { /* best-effort */ }
|
|
453
|
+
try { stream?.destroy?.(); } catch { /* best-effort */ }
|
|
454
|
+
}
|
|
455
|
+
try { tunnel.unref?.(); } catch { /* best-effort */ }
|
|
456
|
+
// Escalate SIGTERM -> SIGKILL so the plugin is never orphaned. The
|
|
457
|
+
// timer is unref'd so cleanup itself can never hold the event loop.
|
|
458
|
+
const stillAlive = () => tunnel.exitCode == null && tunnel.signalCode == null;
|
|
459
|
+
try {
|
|
460
|
+
if (typeof tunnel.kill === 'function' && stillAlive()) {
|
|
461
|
+
tunnel.kill('SIGTERM');
|
|
462
|
+
const escalation = setTimeout(() => {
|
|
463
|
+
try {
|
|
464
|
+
if (stillAlive()) tunnel.kill('SIGKILL');
|
|
465
|
+
} catch { /* best-effort */ }
|
|
466
|
+
}, options.tunnelSigkillTimeoutMs ?? TUNNEL_SIGKILL_TIMEOUT_MS);
|
|
467
|
+
escalation.unref?.();
|
|
468
|
+
}
|
|
469
|
+
} catch {
|
|
470
|
+
// Best-effort: the tunnel is a background child.
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
const onSigint = () => {
|
|
474
|
+
killTunnel();
|
|
475
|
+
process.exit(130);
|
|
476
|
+
};
|
|
477
|
+
process.once('SIGINT', onSigint);
|
|
478
|
+
|
|
479
|
+
try {
|
|
480
|
+
s.message('Waiting for the tunnel...');
|
|
481
|
+
const ready = await waitForTunnelImpl('127.0.0.1', localPort);
|
|
482
|
+
if (!ready || ready.timedOut) {
|
|
483
|
+
s.stop(color.red('Tunnel did not come up.'));
|
|
484
|
+
const tail = tunnelStderr.trim() ? `\n Tunnel output: ${tunnelStderr.trim().split('\n').pop()}` : '';
|
|
485
|
+
return failCommand({
|
|
486
|
+
message: `\n✖ The SSM tunnel did not accept connections within ${Math.round((options.tunnelTimeoutMs ?? DEFAULT_TUNNEL_TIMEOUT_MS) / 1000)}s.${tail}\n`,
|
|
487
|
+
event: 'db_import_run',
|
|
488
|
+
telemetry: { projectName, source: source.type, error_code: 'TUNNEL_TIMEOUT' },
|
|
489
|
+
reason: 'tunnel-timeout',
|
|
490
|
+
resultExtra: { cluster, service, region },
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
s.stop(color.green('Tunnel ready. Importing...'));
|
|
494
|
+
|
|
495
|
+
const targetCmd = buildTargetClientCommand({
|
|
496
|
+
targetScheme,
|
|
497
|
+
localPort,
|
|
498
|
+
username: creds.username,
|
|
499
|
+
dbName: target.dbName,
|
|
500
|
+
fileKind: source.type === 'file' ? source.kind : null,
|
|
501
|
+
filePath: source.type === 'file' ? source.path : null,
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
if (source.type === 'url') {
|
|
505
|
+
const dump = buildSourceDumpCommand(source);
|
|
506
|
+
const dumpChild = spawnImpl(dump.bin, dump.args, {
|
|
507
|
+
stdio: ['ignore', 'pipe', 'inherit'],
|
|
508
|
+
env: source.scheme === 'mysql'
|
|
509
|
+
? { ...process.env, MYSQL_PWD: source.password }
|
|
510
|
+
: { ...process.env, PGPASSWORD: source.password, PGSSLMODE: process.env.PGSSLMODE || 'require' },
|
|
511
|
+
});
|
|
512
|
+
const importChild = spawnImpl(targetCmd.bin, targetCmd.args, {
|
|
513
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
514
|
+
env: targetEnv(targetScheme, creds.password),
|
|
515
|
+
});
|
|
516
|
+
dumpChild.stdout.pipe(importChild.stdin);
|
|
517
|
+
const [dumpResult, importResult] = await Promise.all([
|
|
518
|
+
waitForExit(dumpChild),
|
|
519
|
+
waitForExit(importChild),
|
|
520
|
+
]);
|
|
521
|
+
if (dumpResult.code !== 0) {
|
|
522
|
+
return failCommand({
|
|
523
|
+
message: `\n✖ Source dump failed with exit code ${dumpResult.code}${dumpResult.error ? `: ${dumpResult.error.message}` : ''}.\n`,
|
|
524
|
+
event: 'db_import_run',
|
|
525
|
+
telemetry: { projectName, source: source.type, error_code: 'IMPORT_FAILED' },
|
|
526
|
+
reason: 'import-failed',
|
|
527
|
+
resultExtra: { cluster, service, region },
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
if (importResult.code !== 0) {
|
|
531
|
+
return failCommand({
|
|
532
|
+
message: `\n✖ Import failed with exit code ${importResult.code}${importResult.error ? `: ${importResult.error.message}` : ''}.\n`,
|
|
533
|
+
event: 'db_import_run',
|
|
534
|
+
telemetry: { projectName, source: source.type, error_code: 'IMPORT_FAILED' },
|
|
535
|
+
reason: 'import-failed',
|
|
536
|
+
resultExtra: { cluster, service, region },
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
} else if (source.kind === 'dump') {
|
|
540
|
+
const child = spawnImpl(targetCmd.bin, targetCmd.args, {
|
|
541
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
542
|
+
env: targetEnv(targetScheme, creds.password),
|
|
543
|
+
});
|
|
544
|
+
const result = await waitForExit(child);
|
|
545
|
+
if (result.code !== 0) {
|
|
546
|
+
return failCommand({
|
|
547
|
+
message: `\n✖ pg_restore failed with exit code ${result.code}${result.error ? `: ${result.error.message}` : ''}.\n`,
|
|
548
|
+
event: 'db_import_run',
|
|
549
|
+
telemetry: { projectName, source: source.type, error_code: 'IMPORT_FAILED' },
|
|
550
|
+
reason: 'import-failed',
|
|
551
|
+
resultExtra: { cluster, service, region },
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
} else {
|
|
555
|
+
const child = spawnImpl(targetCmd.bin, targetCmd.args, {
|
|
556
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
557
|
+
env: targetEnv(targetScheme, creds.password),
|
|
558
|
+
});
|
|
559
|
+
const fileStream = createReadStream(source.path);
|
|
560
|
+
const input = source.kind === 'gzip' ? fileStream.pipe(zlib.createGunzip()) : fileStream;
|
|
561
|
+
input.on('error', (error) => {
|
|
562
|
+
try { child.stdin.destroy(error); } catch { /* handled below */ }
|
|
563
|
+
});
|
|
564
|
+
input.pipe(child.stdin);
|
|
565
|
+
const result = await waitForExit(child);
|
|
566
|
+
if (result.code !== 0) {
|
|
567
|
+
return failCommand({
|
|
568
|
+
message: `\n✖ Import failed with exit code ${result.code}${result.error ? `: ${result.error.message}` : ''}.\n`,
|
|
569
|
+
event: 'db_import_run',
|
|
570
|
+
telemetry: { projectName, source: source.type, error_code: 'IMPORT_FAILED' },
|
|
571
|
+
reason: 'import-failed',
|
|
572
|
+
resultExtra: { cluster, service, region },
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
console.log(color.green('\n✅ Import complete.'));
|
|
578
|
+
await trackSuccess('db_import_run', { projectName, source: source.type, target_engine: target.engine || 'unknown' });
|
|
579
|
+
outro(color.green('Done.'));
|
|
580
|
+
return { ok: true, source: source.type, targetId: target.id };
|
|
581
|
+
} catch (error) {
|
|
582
|
+
await trackFailure('db_import_run', {
|
|
583
|
+
projectName,
|
|
584
|
+
source: source?.type,
|
|
585
|
+
error_code: error?.name || 'UNKNOWN',
|
|
586
|
+
error_message: error?.message,
|
|
587
|
+
});
|
|
588
|
+
if (handleAuthErrorBranch(error, s, options)) {
|
|
589
|
+
return { ok: false, reason: 'error', cluster, service, region };
|
|
590
|
+
}
|
|
591
|
+
try { s.stop(color.red('❌ Db import failed.')); } catch { /* spinner already stopped */ }
|
|
592
|
+
return failCommand({
|
|
593
|
+
message: `✖ ${error?.message || error}`,
|
|
594
|
+
hint: 'Check your AWS credentials and region, then try again.',
|
|
595
|
+
reason: 'error',
|
|
596
|
+
resultExtra: { cluster, service, region },
|
|
597
|
+
});
|
|
598
|
+
} finally {
|
|
599
|
+
killTunnel();
|
|
600
|
+
process.removeListener('SIGINT', onSigint);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export default runDbImport;
|