grada-run 0.0.1 → 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 -6
- 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
|
@@ -0,0 +1,304 @@
|
|
|
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 color from 'picocolors';
|
|
6
|
+
import { intro, outro, spinner } from '@clack/prompts';
|
|
7
|
+
import { trackSuccess, trackFailure } from '../../core/telemetry.js';
|
|
8
|
+
import { failCommand, failProjectNotInitialized } from '../../utils/command.js';
|
|
9
|
+
import { parseFlags, normalizeOptions, normalizeArgv } from '../../utils/args.js';
|
|
10
|
+
import { hasAwsCli, AWS_CLI_INSTALL_URL, handleAuthErrorBranch, resolveClient } from '../../utils/aws.js';
|
|
11
|
+
import { resolveRegion, resolveProjectName, resolveCluster, resolveService, resolveAppName, resolveCwd } from '../../utils/resolvers.js';
|
|
12
|
+
import { findDbTarget, resolveDbIdentifier, resolveDbClusterIdentifier } from '../../utils/rds.js';
|
|
13
|
+
import {
|
|
14
|
+
buildSsmArgs,
|
|
15
|
+
fetchManagedDbCredentials,
|
|
16
|
+
findJumpHostTarget,
|
|
17
|
+
} from '../../utils/db-tunnel.js';
|
|
18
|
+
import {
|
|
19
|
+
hasSessionManagerPlugin,
|
|
20
|
+
resolveContainer,
|
|
21
|
+
printAwsCliGuidance,
|
|
22
|
+
printSessionManagerGuidance,
|
|
23
|
+
printNoTasksGuidance,
|
|
24
|
+
} from '../../utils/ecs.js';
|
|
25
|
+
|
|
26
|
+
// Re-exported so `db import` and existing barrel consumers share one builder.
|
|
27
|
+
export { buildSsmArgs };
|
|
28
|
+
|
|
29
|
+
// `db connect` wording for the shared ECS pre-flight guidance.
|
|
30
|
+
const printDbAwsCliGuidance = () => printAwsCliGuidance({
|
|
31
|
+
commandName: 'db connect',
|
|
32
|
+
purpose: 'to open a secure tunnel to your database',
|
|
33
|
+
});
|
|
34
|
+
const printDbSessionManagerGuidance = () => printSessionManagerGuidance({
|
|
35
|
+
commandName: 'db connect',
|
|
36
|
+
purpose: 'to securely tunnel to your database',
|
|
37
|
+
});
|
|
38
|
+
const printDbNoTasksGuidance = (service, cluster) => printNoTasksGuidance(
|
|
39
|
+
service, cluster, 'to act as a jump host for the tunnel'
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
export const DEFAULT_LOCAL_PORT = '5432';
|
|
43
|
+
export const MASKED_PASSWORD = '********';
|
|
44
|
+
|
|
45
|
+
export function parseDbArgs(argv = []) {
|
|
46
|
+
const args = normalizeArgv(argv);
|
|
47
|
+
if (args[0] === 'db') args.shift();
|
|
48
|
+
if (args[0] === 'connect') args.shift();
|
|
49
|
+
const { options } = parseFlags(args, {
|
|
50
|
+
string: ['port', 'region', 'cluster', 'service', 'workspace'],
|
|
51
|
+
boolean: ['show-credentials'],
|
|
52
|
+
});
|
|
53
|
+
return options;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isValidPort(port) {
|
|
57
|
+
if (typeof port !== 'string' || !/^\d+$/.test(port)) return false;
|
|
58
|
+
const num = parseInt(port, 10);
|
|
59
|
+
return num >= 1 && num <= 65535;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function buildConnectionString({ username, password, localPort, dbName, showCredentials = false, scheme = 'postgresql' }) {
|
|
63
|
+
// Percent-encode credentials ONLY for the URI: AWS-generated passwords can
|
|
64
|
+
// contain characters like @ [ / : that break connection-string parsers.
|
|
65
|
+
// The standalone Password: line stays unencoded so it can be copied verbatim.
|
|
66
|
+
const secret = showCredentials ? encodeURIComponent(password) : MASKED_PASSWORD;
|
|
67
|
+
return `${scheme}://${encodeURIComponent(username)}:${secret}@localhost:${localPort}/${dbName}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function formatConnectionInfo({ localPort, dbName, username, password, showCredentials = false, scheme = 'postgresql' }) {
|
|
71
|
+
const lines = [
|
|
72
|
+
` ${color.dim('Local Host:')} ${color.cyan('localhost')}`,
|
|
73
|
+
` ${color.dim('Local Port:')} ${color.cyan(localPort)}`,
|
|
74
|
+
` ${color.dim('Database:')} ${color.cyan(dbName)}`,
|
|
75
|
+
` ${color.dim('Username:')} ${color.cyan(username)}`,
|
|
76
|
+
` ${color.dim('Password:')} ${showCredentials ? color.yellow(password) : color.dim(MASKED_PASSWORD)}`,
|
|
77
|
+
'',
|
|
78
|
+
` ${color.dim('Connection string:')}`,
|
|
79
|
+
` ${color.green(buildConnectionString({ username, password, localPort, dbName, showCredentials, scheme }))}`,
|
|
80
|
+
];
|
|
81
|
+
if (!showCredentials) {
|
|
82
|
+
lines.push(` ${color.dim('Re-run with --show-credentials to reveal the password.')}`);
|
|
83
|
+
}
|
|
84
|
+
return lines.join('\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function printNoDatabaseGuidance(dbIdentifier) {
|
|
88
|
+
console.log(color.yellow('\n⚠ No database found.'));
|
|
89
|
+
console.log(` No RDS database named ${color.cyan(dbIdentifier)} exists in this environment.`);
|
|
90
|
+
console.log(' This project was likely provisioned without a managed database.');
|
|
91
|
+
console.log(` Re-run ${color.green('npx grada-run')} and answer "Yes" to the database prompt, then ${color.green('npx grada-run apply')}.\n`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function runDbConnect(input = {}) {
|
|
95
|
+
const options = normalizeOptions(input);
|
|
96
|
+
let cwd;
|
|
97
|
+
let region;
|
|
98
|
+
let projectName;
|
|
99
|
+
let cluster;
|
|
100
|
+
let service;
|
|
101
|
+
let expectedContainer;
|
|
102
|
+
let dbIdentifier;
|
|
103
|
+
let dbClusterIdentifier;
|
|
104
|
+
try {
|
|
105
|
+
cwd = resolveCwd(options);
|
|
106
|
+
region = resolveRegion(options, cwd);
|
|
107
|
+
// Namespaced project name so PR-preview workspaces resolve correctly.
|
|
108
|
+
projectName = resolveProjectName(options, cwd);
|
|
109
|
+
const appName = resolveAppName(projectName, options.workspace, cwd);
|
|
110
|
+
const namespacedOptions = { ...options, projectName: appName };
|
|
111
|
+
cluster = resolveCluster(namespacedOptions, cwd);
|
|
112
|
+
service = resolveService(namespacedOptions, cwd);
|
|
113
|
+
expectedContainer = resolveContainer(namespacedOptions, cwd);
|
|
114
|
+
dbIdentifier = resolveDbIdentifier(options, cwd);
|
|
115
|
+
dbClusterIdentifier = resolveDbClusterIdentifier(options, cwd);
|
|
116
|
+
} catch {
|
|
117
|
+
return failProjectNotInitialized({ event: 'db_connect_run' });
|
|
118
|
+
}
|
|
119
|
+
const showCredentials = options.showCredentials === true || options.showCredentials === 'true';
|
|
120
|
+
// An explicit --port wins; otherwise the local port defaults to the
|
|
121
|
+
// remote database port once discovery completes below.
|
|
122
|
+
const portOverride = typeof options.port === 'string' && options.port.trim()
|
|
123
|
+
? options.port.trim()
|
|
124
|
+
: null;
|
|
125
|
+
|
|
126
|
+
const rdsClient = resolveClient(options.rdsClient, RDSClient, { region });
|
|
127
|
+
const ecsClient = resolveClient(options.ecsClient, ECSClient, { region });
|
|
128
|
+
const secretsClient = resolveClient(options.secretsClient, SecretsManagerClient, { region });
|
|
129
|
+
|
|
130
|
+
const spawnImpl = options.spawnImpl || spawn;
|
|
131
|
+
const awsCliPresent = options.hasAwsCli ?? hasAwsCli({ spawnSyncImpl: options.spawnSyncImpl });
|
|
132
|
+
const ssmPluginPresent = options.hasSsmPlugin ?? hasSessionManagerPlugin({ spawnSyncImpl: options.spawnSyncImpl });
|
|
133
|
+
|
|
134
|
+
intro(color.bgCyan(color.black(' grada db 🛢️ ')));
|
|
135
|
+
|
|
136
|
+
if (portOverride !== null && !isValidPort(portOverride)) {
|
|
137
|
+
return failCommand({
|
|
138
|
+
message: `\n✖ Invalid --port "${portOverride}". Use a number between 1 and 65535.\n`,
|
|
139
|
+
event: 'db_connect_run',
|
|
140
|
+
telemetry: { projectName, error_code: 'INVALID_PORT' },
|
|
141
|
+
reason: 'invalid-port',
|
|
142
|
+
resultExtra: { cluster, service, region },
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!awsCliPresent) {
|
|
147
|
+
return failCommand({
|
|
148
|
+
print: printDbAwsCliGuidance,
|
|
149
|
+
event: 'db_connect_run',
|
|
150
|
+
telemetry: { projectName, error_code: 'AWS_CLI_MISSING' },
|
|
151
|
+
reason: 'aws-cli-missing',
|
|
152
|
+
resultExtra: { cluster, service, region },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!ssmPluginPresent) {
|
|
157
|
+
return failCommand({
|
|
158
|
+
print: printDbSessionManagerGuidance,
|
|
159
|
+
event: 'db_connect_run',
|
|
160
|
+
telemetry: { projectName, error_code: 'SSM_PLUGIN_MISSING' },
|
|
161
|
+
reason: 'ssm-plugin-missing',
|
|
162
|
+
resultExtra: { cluster, service, region },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const s = spinner();
|
|
167
|
+
s.start('Finding your database...');
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
// 1. Find the RDS target (instance or Aurora cluster; shared lookup,
|
|
171
|
+
// null when not provisioned).
|
|
172
|
+
const target = await findDbTarget(rdsClient, { dbIdentifier, dbClusterIdentifier });
|
|
173
|
+
|
|
174
|
+
if (!target) {
|
|
175
|
+
s.stop();
|
|
176
|
+
return failCommand({
|
|
177
|
+
print: () => printNoDatabaseGuidance(dbIdentifier),
|
|
178
|
+
event: 'db_connect_run',
|
|
179
|
+
telemetry: { projectName, error_code: 'NO_DATABASE' },
|
|
180
|
+
reason: 'no-database',
|
|
181
|
+
resultExtra: { cluster, service, region },
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const dbHost = target.endpoint;
|
|
186
|
+
const dbName = target.dbName;
|
|
187
|
+
const secretArn = target.masterSecretArn;
|
|
188
|
+
const remotePort = target.port || DEFAULT_LOCAL_PORT;
|
|
189
|
+
const localPort = portOverride || remotePort;
|
|
190
|
+
const scheme = target.engine === 'mysql' || target.engine === 'aurora-mysql' ? 'mysql' : 'postgresql';
|
|
191
|
+
if (!dbHost || !dbName || !secretArn) {
|
|
192
|
+
s.stop(color.red('Database details incomplete.'));
|
|
193
|
+
return failCommand({
|
|
194
|
+
message: `\n✖ The database ${color.cyan(target.id)} is missing its endpoint, name, or managed secret.`,
|
|
195
|
+
event: 'db_connect_run',
|
|
196
|
+
telemetry: { projectName, error_code: 'DB_DETAILS_INCOMPLETE' },
|
|
197
|
+
reason: 'db-details-incomplete',
|
|
198
|
+
resultExtra: { cluster, service, region },
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 2. Fetch the managed credentials (never logged, never telemetered).
|
|
203
|
+
s.message('Fetching database credentials...');
|
|
204
|
+
const creds = await fetchManagedDbCredentials(secretsClient, secretArn);
|
|
205
|
+
if (!creds) {
|
|
206
|
+
s.stop(color.red('Could not read database credentials.'));
|
|
207
|
+
return failCommand({
|
|
208
|
+
message: '\n✖ The managed database secret did not contain a username and password.',
|
|
209
|
+
event: 'db_connect_run',
|
|
210
|
+
telemetry: { projectName, error_code: 'SECRET_MALFORMED' },
|
|
211
|
+
reason: 'secret-malformed',
|
|
212
|
+
resultExtra: { cluster, service, region },
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
const { username, password } = creds;
|
|
216
|
+
|
|
217
|
+
// 3. Find a running ECS task to act as the jump host.
|
|
218
|
+
s.message('Finding a running container...');
|
|
219
|
+
const jumpHost = await findJumpHostTarget(ecsClient, { cluster, service, expectedContainer });
|
|
220
|
+
if (jumpHost.error === 'NO_RUNNING_TASKS') {
|
|
221
|
+
s.stop(color.yellow('No running tasks.'));
|
|
222
|
+
return failCommand({
|
|
223
|
+
print: () => printDbNoTasksGuidance(service, cluster),
|
|
224
|
+
event: 'db_connect_run',
|
|
225
|
+
telemetry: { projectName, error_code: 'NO_RUNNING_TASKS' },
|
|
226
|
+
reason: 'no-running-tasks',
|
|
227
|
+
resultExtra: { cluster, service, region },
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
if (jumpHost.error === 'NO_RUNTIME_ID') {
|
|
231
|
+
s.stop(color.red('Container runtime ID unavailable.'));
|
|
232
|
+
return failCommand({
|
|
233
|
+
message: '\n✖ The running container did not report a runtime ID, so the tunnel cannot attach.',
|
|
234
|
+
hint: 'Wait a moment for the task to stabilize, then try again.\n',
|
|
235
|
+
event: 'db_connect_run',
|
|
236
|
+
telemetry: { projectName, error_code: 'NO_RUNTIME_ID' },
|
|
237
|
+
reason: 'no-runtime-id',
|
|
238
|
+
resultExtra: { cluster, service, region },
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
const { taskId, runtimeId } = jumpHost;
|
|
242
|
+
|
|
243
|
+
s.stop(color.green('Tunnel details ready.'));
|
|
244
|
+
console.log(formatConnectionInfo({ localPort, dbName, username, password, showCredentials, scheme }));
|
|
245
|
+
console.log(color.dim(`\n Opening a tunnel via ${jumpHost.containerName || expectedContainer} — press Ctrl+C to close.\n`));
|
|
246
|
+
|
|
247
|
+
const ssmArgs = buildSsmArgs({
|
|
248
|
+
cluster,
|
|
249
|
+
taskId,
|
|
250
|
+
runtimeId,
|
|
251
|
+
dbHost,
|
|
252
|
+
remotePort,
|
|
253
|
+
localPort,
|
|
254
|
+
region,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// Telemetry carries only non-sensitive fields. Credentials never leave this process
|
|
258
|
+
// except to the terminal above and the local SSM session below.
|
|
259
|
+
await trackSuccess('db_connect_run', { projectName, db_engine: target.engine || 'unknown', db_kind: target.kind });
|
|
260
|
+
|
|
261
|
+
await new Promise((resolve) => {
|
|
262
|
+
const child = spawnImpl('aws', ssmArgs, { stdio: 'inherit' });
|
|
263
|
+
child.on('error', (err) => {
|
|
264
|
+
console.log(color.red(`\n✖ Failed to start AWS CLI: ${err?.message || err}`));
|
|
265
|
+
console.log(color.dim(`Install help: ${AWS_CLI_INSTALL_URL}`));
|
|
266
|
+
resolve({ code: 1 });
|
|
267
|
+
});
|
|
268
|
+
child.on('close', (code) => resolve({ code: code ?? 0 }));
|
|
269
|
+
}).then(async ({ code }) => {
|
|
270
|
+
if (code === 0) {
|
|
271
|
+
outro(color.green('Tunnel closed. 👋'));
|
|
272
|
+
} else {
|
|
273
|
+
console.log(color.yellow(`\nTunnel exited with code ${code}.`));
|
|
274
|
+
console.log(color.dim('If the connection failed, ensure the Session Manager plugin is installed and your AWS session is fresh.'));
|
|
275
|
+
outro(color.yellow('Db connect finished.'));
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return { ok: true, cluster, service, dbIdentifier, taskArn: jumpHost.taskArn, localPort, region };
|
|
280
|
+
} catch (error) {
|
|
281
|
+
// Only non-sensitive metadata is telemetered here. The password, username, and
|
|
282
|
+
// connection string are never passed to trackEvent in any path.
|
|
283
|
+
await trackFailure('db_connect_run', {
|
|
284
|
+
projectName,
|
|
285
|
+
error_code: error?.name || 'UNKNOWN',
|
|
286
|
+
error_message: error?.message,
|
|
287
|
+
});
|
|
288
|
+
if (handleAuthErrorBranch(error, s, options)) {
|
|
289
|
+
return { ok: false, reason: 'error', cluster, service, region };
|
|
290
|
+
}
|
|
291
|
+
s.stop(color.red('❌ Db connect failed.'));
|
|
292
|
+
return failCommand({
|
|
293
|
+
message: `✖ ${error?.message || error}`,
|
|
294
|
+
hint: 'Check your AWS credentials and region, then try again.',
|
|
295
|
+
reason: 'error',
|
|
296
|
+
resultExtra: { cluster, service, region },
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Convenience alias mirroring the CLI verb.
|
|
302
|
+
export const dbCommand = runDbConnect;
|
|
303
|
+
|
|
304
|
+
export default runDbConnect;
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { ECSClient } from '@aws-sdk/client-ecs';
|
|
2
|
+
import { CloudWatchLogsClient } from '@aws-sdk/client-cloudwatch-logs';
|
|
3
|
+
import color from 'picocolors';
|
|
4
|
+
import { intro, outro, spinner } from '@clack/prompts';
|
|
5
|
+
import { trackSuccess, trackFailure } from '../../core/telemetry.js';
|
|
6
|
+
import { failCommand, failProjectNotInitialized } from '../../utils/command.js';
|
|
7
|
+
import { parseFlags, normalizeOptions, normalizeArgv } from '../../utils/args.js';
|
|
8
|
+
import { handleAuthErrorBranch, resolveClient } from '../../utils/aws.js';
|
|
9
|
+
import {
|
|
10
|
+
resolveRegion,
|
|
11
|
+
resolveProjectName,
|
|
12
|
+
resolveCluster,
|
|
13
|
+
resolveService,
|
|
14
|
+
resolveLogGroup,
|
|
15
|
+
resolveAppName,
|
|
16
|
+
resolveCwd,
|
|
17
|
+
} from '../../utils/resolvers.js';
|
|
18
|
+
import { resolveContainer } from '../../utils/ecs.js';
|
|
19
|
+
import { runEphemeralEcsTask } from '../../utils/ecs-runner.js';
|
|
20
|
+
import { parseTimeoutSeconds } from '../../utils/system.js';
|
|
21
|
+
import { buildMigrationCommand } from './migrate.js';
|
|
22
|
+
import fsSync from 'fs';
|
|
23
|
+
import path from 'path';
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_VECTOR_TIMEOUT_SECONDS = 600;
|
|
26
|
+
export const DEFAULT_VECTOR_POLL_INTERVAL_MS = 2000;
|
|
27
|
+
export const DEFAULT_VECTOR_FLUSH_MAX_POLLS = 6;
|
|
28
|
+
export const DEFAULT_VECTOR_FLUSH_INTERVAL_MS = 1000;
|
|
29
|
+
|
|
30
|
+
// Exit code when the container has no usable PostgreSQL client. The CLI
|
|
31
|
+
// maps it to install guidance (see NO_CLIENT_HINT).
|
|
32
|
+
export const VECTOR_NO_CLIENT_EXIT_CODE = 3;
|
|
33
|
+
|
|
34
|
+
const NO_CLIENT_HINT = 'Install a PostgreSQL client in your image (postgresql-client for psql, or the pg / @prisma/client / psycopg package) and retry.\n';
|
|
35
|
+
|
|
36
|
+
export function parseDbEnableVectorArgs(argv = []) {
|
|
37
|
+
const args = normalizeArgv(argv);
|
|
38
|
+
if (args[0] === 'db') args.shift();
|
|
39
|
+
if (args[0] === 'enable-vector') args.shift();
|
|
40
|
+
const { options, rest } = parseFlags(args, {
|
|
41
|
+
string: [
|
|
42
|
+
{ name: 'cluster', key: 'cluster' },
|
|
43
|
+
{ name: 'service', key: 'service' },
|
|
44
|
+
{ name: 'container', key: 'container' },
|
|
45
|
+
{ name: 'task-def', key: 'taskDef' },
|
|
46
|
+
{ name: 'timeout', key: 'timeout' },
|
|
47
|
+
{ name: 'project-name', key: 'projectName' },
|
|
48
|
+
{ name: 'region', key: 'region' },
|
|
49
|
+
{ name: 'workspace', key: 'workspace' },
|
|
50
|
+
],
|
|
51
|
+
boolean: ['headless', 'yes'],
|
|
52
|
+
});
|
|
53
|
+
const positionals = rest.filter((arg) => typeof arg === 'string' && !arg.startsWith('-'));
|
|
54
|
+
if (positionals.length > 0) options.unexpectedPositionals = positionals;
|
|
55
|
+
return options;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Self-contained `sh` ladder that enables pgvector inside the ephemeral
|
|
59
|
+
// task without assuming any particular client binary. Tries, in order:
|
|
60
|
+
// psql → node (pg, then @prisma/client) → python (psycopg, psycopg2,
|
|
61
|
+
// asyncpg; Django projects carry psycopg under the hood) → diagnostic
|
|
62
|
+
// exit 3. Reads $DATABASE_URL (exported by the caller's command wrapper).
|
|
63
|
+
// RDS/Aurora enforces `rds.force_ssl = 1` by default, so every branch
|
|
64
|
+
// negotiates TLS: PGSSLMODE covers psql and the libpq-based drivers
|
|
65
|
+
// (psycopg/psycopg2), while node-postgres, Prisma, and asyncpg carry
|
|
66
|
+
// explicit opts (they don't read PGSSLMODE).
|
|
67
|
+
// Pure and unit-tested.
|
|
68
|
+
export function buildVectorExtensionCommand() {
|
|
69
|
+
const lines = [
|
|
70
|
+
'VECTOR_SQL="CREATE EXTENSION IF NOT EXISTS vector;"',
|
|
71
|
+
'export PGSSLMODE="${PGSSLMODE:-require}"',
|
|
72
|
+
'PYBIN="$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true)"',
|
|
73
|
+
'if command -v psql >/dev/null 2>&1; then',
|
|
74
|
+
' psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "$VECTOR_SQL" -c "SELECT extversion FROM pg_extension WHERE extname = \'vector\';"',
|
|
75
|
+
'elif command -v node >/dev/null 2>&1 && node -e "require.resolve(\'pg\')" >/dev/null 2>&1; then',
|
|
76
|
+
' node -e "const{Client}=require(\'pg\');(async()=>{const c=new Client({connectionString:process.env.DATABASE_URL,ssl:{rejectUnauthorized:false}});await c.connect();await c.query(\'CREATE EXTENSION IF NOT EXISTS vector\');const r=await c.query(\\"SELECT extversion FROM pg_extension WHERE extname=\'vector\'\\");console.log(\'pgvector extension version: \'+(r.rows[0]&&r.rows[0].extversion));await c.end();})().catch(e=>{console.error(e&&e.message||e);process.exit(1);})"',
|
|
77
|
+
'elif command -v node >/dev/null 2>&1 && node -e "require.resolve(\'@prisma/client\')" >/dev/null 2>&1; then',
|
|
78
|
+
' node -e "const{PrismaClient}=require(\'@prisma/client\');(async()=>{let U=process.env.DATABASE_URL;if(!/[?&]sslmode=/i.test(U))U+=U.includes(\'?\')?\'&sslmode=require\':\'?sslmode=require\';process.env.DATABASE_URL=U;const p=new PrismaClient();await p.\\$executeRawUnsafe(\'CREATE EXTENSION IF NOT EXISTS vector\');const r=await p.\\$queryRawUnsafe(\\"SELECT extversion FROM pg_extension WHERE extname=\'vector\'\\");console.log(\'pgvector extension ready: \'+JSON.stringify(r));await p.\\$disconnect();})().catch(e=>{console.error(e&&e.message||e);process.exit(1);})"',
|
|
79
|
+
'elif [ -n "$PYBIN" ] && "$PYBIN" -c "import psycopg" >/dev/null 2>&1; then',
|
|
80
|
+
' "$PYBIN" -c "import os,psycopg;c=psycopg.connect(os.environ[\'DATABASE_URL\'],autocommit=True);c.execute(\'CREATE EXTENSION IF NOT EXISTS vector\');print(\'pgvector extension version:\',c.execute(\\"SELECT extversion FROM pg_extension WHERE extname=\'vector\'\\").fetchone()[0])"',
|
|
81
|
+
'elif [ -n "$PYBIN" ] && "$PYBIN" -c "import psycopg2" >/dev/null 2>&1; then',
|
|
82
|
+
' "$PYBIN" -c "import os,psycopg2;c=psycopg2.connect(os.environ[\'DATABASE_URL\']);c.autocommit=True;cur=c.cursor();cur.execute(\'CREATE EXTENSION IF NOT EXISTS vector\');cur.execute(\\"SELECT extversion FROM pg_extension WHERE extname=\'vector\'\\");print(\'pgvector extension version:\',cur.fetchone()[0])"',
|
|
83
|
+
'elif [ -n "$PYBIN" ] && "$PYBIN" -c "import asyncpg" >/dev/null 2>&1; then',
|
|
84
|
+
' "$PYBIN" -c "import os,asyncio,asyncpg',
|
|
85
|
+
'async def __vector_setup():',
|
|
86
|
+
' c=await asyncpg.connect(os.environ[\'DATABASE_URL\'],ssl=\'require\')',
|
|
87
|
+
' await c.execute(\'CREATE EXTENSION IF NOT EXISTS vector\')',
|
|
88
|
+
' print(\'pgvector extension version:\',await c.fetchval(\\"SELECT extversion FROM pg_extension WHERE extname=\'vector\'\\"))',
|
|
89
|
+
'asyncio.run(__vector_setup())"',
|
|
90
|
+
'else',
|
|
91
|
+
' echo "grada: no PostgreSQL client found (need psql, pg/@prisma/client, or psycopg) to enable pgvector" >&2',
|
|
92
|
+
` exit ${VECTOR_NO_CLIENT_EXIT_CODE}`,
|
|
93
|
+
'fi',
|
|
94
|
+
];
|
|
95
|
+
return lines.join('\n');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function detectDatabaseEngine(cwd) {
|
|
99
|
+
try {
|
|
100
|
+
const databaseTf = path.join(cwd, 'terraform', 'database.tf');
|
|
101
|
+
if (!fsSync.existsSync(databaseTf)) return { present: false, engine: 'unknown' };
|
|
102
|
+
const hcl = fsSync.readFileSync(databaseTf, 'utf8');
|
|
103
|
+
if (hcl.includes('resource "aws_rds_cluster"')) return { present: true, engine: 'aurora-postgresql' };
|
|
104
|
+
if (/engine\s*=\s*"mysql"/.test(hcl)) return { present: true, engine: 'mysql' };
|
|
105
|
+
return { present: true, engine: 'postgres' };
|
|
106
|
+
} catch {
|
|
107
|
+
return { present: false, engine: 'unknown' };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function printPrismaVectorHint() {
|
|
112
|
+
console.log(color.cyan('\n 💡 Prisma detected: enable the extension in prisma/schema.prisma:'));
|
|
113
|
+
console.log(color.dim(' generator client { provider = "prisma-client-js"'));
|
|
114
|
+
console.log(color.dim(' previewFeatures = ["postgresqlExtensions"] }'));
|
|
115
|
+
console.log(color.dim(' datasource db { provider = "postgresql"'));
|
|
116
|
+
console.log(color.dim(' extensions = [vector] }\n'));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function runDbEnableVector(input = {}) {
|
|
120
|
+
const options = normalizeOptions(input);
|
|
121
|
+
const startTime = Date.now();
|
|
122
|
+
let cwd;
|
|
123
|
+
let region;
|
|
124
|
+
let projectName;
|
|
125
|
+
let cluster;
|
|
126
|
+
let service;
|
|
127
|
+
let containerName;
|
|
128
|
+
let logGroupName;
|
|
129
|
+
try {
|
|
130
|
+
cwd = resolveCwd(options);
|
|
131
|
+
region = resolveRegion(options, cwd);
|
|
132
|
+
projectName = resolveProjectName(options, cwd);
|
|
133
|
+
const appName = resolveAppName(projectName, options.workspace, cwd);
|
|
134
|
+
const namespacedOptions = { ...options, projectName: appName };
|
|
135
|
+
cluster = resolveCluster(namespacedOptions, cwd);
|
|
136
|
+
service = resolveService(namespacedOptions, cwd);
|
|
137
|
+
containerName = resolveContainer(namespacedOptions, cwd);
|
|
138
|
+
logGroupName = resolveLogGroup(namespacedOptions, cwd);
|
|
139
|
+
} catch {
|
|
140
|
+
return failProjectNotInitialized({ event: 'db_enable_vector_run' });
|
|
141
|
+
}
|
|
142
|
+
// Accepted for script uniformity with the other db subcommands; this
|
|
143
|
+
// command never prompts, so --yes only forces non-interactive mode.
|
|
144
|
+
// --headless / --yes are accepted for script uniformity with the other
|
|
145
|
+
// db subcommands, but this command never prompts: the task runs
|
|
146
|
+
// unconditionally once the guards pass.
|
|
147
|
+
intro(color.bgCyan(color.black(' grada db enable-vector 🧬 ')));
|
|
148
|
+
|
|
149
|
+
if (Array.isArray(options.unexpectedPositionals) && options.unexpectedPositionals.length > 0) {
|
|
150
|
+
return failCommand({
|
|
151
|
+
message: `\n✖ Unexpected argument "${options.unexpectedPositionals[0]}". This command takes flags only: db enable-vector [--task-def <task-def>] [--timeout <seconds>].\n`,
|
|
152
|
+
event: 'db_enable_vector_run',
|
|
153
|
+
telemetry: { projectName },
|
|
154
|
+
errorCode: 'UNEXPECTED_POSITIONAL_ARGS',
|
|
155
|
+
reason: 'unexpected-positional-args',
|
|
156
|
+
resultExtra: { cluster, service, region },
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const timeoutSeconds = parseTimeoutSeconds(options.timeout, DEFAULT_VECTOR_TIMEOUT_SECONDS);
|
|
161
|
+
if (timeoutSeconds === null) {
|
|
162
|
+
return failCommand({
|
|
163
|
+
message: `\n✖ Invalid --timeout "${options.timeout}". Use a positive number of seconds (default ${DEFAULT_VECTOR_TIMEOUT_SECONDS}).\n`,
|
|
164
|
+
event: 'db_enable_vector_run',
|
|
165
|
+
telemetry: { projectName },
|
|
166
|
+
errorCode: 'INVALID_TIMEOUT',
|
|
167
|
+
reason: 'invalid-timeout',
|
|
168
|
+
resultExtra: { cluster, service, region },
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Engine & prerequisite guards (before any AWS calls).
|
|
173
|
+
const { present: hasDatabaseTf, engine } = detectDatabaseEngine(cwd);
|
|
174
|
+
if (hasDatabaseTf && engine === 'mysql') {
|
|
175
|
+
return failCommand({
|
|
176
|
+
message: '\n✖ pgvector requires PostgreSQL or Aurora PostgreSQL. This project provisions MySQL.\n',
|
|
177
|
+
event: 'db_enable_vector_run',
|
|
178
|
+
telemetry: { projectName, engine },
|
|
179
|
+
errorCode: 'UNSUPPORTED_VECTOR_ENGINE',
|
|
180
|
+
reason: 'unsupported-vector-engine',
|
|
181
|
+
resultExtra: { cluster, service, region },
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
if (!hasDatabaseTf) {
|
|
185
|
+
const hasCluster = typeof options.cluster === 'string' && options.cluster.trim() !== '';
|
|
186
|
+
const hasService = typeof options.service === 'string' && options.service.trim() !== '';
|
|
187
|
+
if (!hasCluster && !hasService) {
|
|
188
|
+
return failCommand({
|
|
189
|
+
message: `\n✖ No database configured. Run ${color.green('npx grada-run')} with a managed PostgreSQL database first, or target a service explicitly with --cluster/--service.\n`,
|
|
190
|
+
event: 'db_enable_vector_run',
|
|
191
|
+
telemetry: { projectName },
|
|
192
|
+
errorCode: 'NO_DATABASE_CONFIGURED',
|
|
193
|
+
reason: 'no-database-configured',
|
|
194
|
+
resultExtra: { cluster, service, region },
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const ecsClient = resolveClient(options.ecsClient, ECSClient, { region });
|
|
200
|
+
const logsClient = resolveClient(options.logsClient ?? options.cloudwatchClient, CloudWatchLogsClient, { region });
|
|
201
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_VECTOR_POLL_INTERVAL_MS;
|
|
202
|
+
const maxFlushPolls = options.maxFlushPolls ?? DEFAULT_VECTOR_FLUSH_MAX_POLLS;
|
|
203
|
+
const flushIntervalMs = options.flushIntervalMs ?? DEFAULT_VECTOR_FLUSH_INTERVAL_MS;
|
|
204
|
+
const timeoutMs = options.timeoutMs ?? timeoutSeconds * 1000;
|
|
205
|
+
|
|
206
|
+
const s = spinner();
|
|
207
|
+
s.start('Starting vector task...');
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
// The DATABASE_URL export prefix comes from the shared migrate
|
|
211
|
+
// helper so discrete DB_* task definitions work unchanged.
|
|
212
|
+
const vectorScript = buildVectorExtensionCommand();
|
|
213
|
+
const outcome = await runEphemeralEcsTask({
|
|
214
|
+
ecsClient,
|
|
215
|
+
logsClient,
|
|
216
|
+
cluster,
|
|
217
|
+
service,
|
|
218
|
+
containerName,
|
|
219
|
+
taskDef: (typeof options.taskDef === 'string' && options.taskDef.trim()) ? options.taskDef.trim() : null,
|
|
220
|
+
command: (containerDef) => buildMigrationCommand(vectorScript, containerDef),
|
|
221
|
+
startedBy: 'grada-db-enable-vector',
|
|
222
|
+
logGroupName,
|
|
223
|
+
timeoutMs,
|
|
224
|
+
timeoutSeconds,
|
|
225
|
+
pollIntervalMs,
|
|
226
|
+
maxFlushPolls,
|
|
227
|
+
flushIntervalMs,
|
|
228
|
+
spinner: s,
|
|
229
|
+
taskNoun: 'vector',
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (!outcome.ok && outcome.code === 'ECS_SERVICE_NOT_FOUND') {
|
|
233
|
+
s.stop(color.yellow('Service not found.'));
|
|
234
|
+
return failCommand({
|
|
235
|
+
print: () => {
|
|
236
|
+
console.log(`\n The ECS service ${color.cyan(service)} does not exist or is inactive.`);
|
|
237
|
+
console.log(` Run ${color.green('npx grada-run apply')} to provision your infrastructure.\n`);
|
|
238
|
+
},
|
|
239
|
+
event: 'db_enable_vector_run',
|
|
240
|
+
telemetry: { projectName },
|
|
241
|
+
errorCode: 'ECS_SERVICE_NOT_FOUND',
|
|
242
|
+
reason: 'ecs-service-not-found',
|
|
243
|
+
resultExtra: { cluster, service, region },
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!outcome.ok && outcome.code === 'NO_VPC_CONFIG') {
|
|
248
|
+
s.stop(color.red('Service has no VPC configuration.'));
|
|
249
|
+
return failCommand({
|
|
250
|
+
message: `\n✖ The ECS service ${color.cyan(service)} has no VPC network configuration, so the vector task cannot be placed.\n`,
|
|
251
|
+
event: 'db_enable_vector_run',
|
|
252
|
+
telemetry: { projectName },
|
|
253
|
+
errorCode: 'RUN_TASK_FAILED',
|
|
254
|
+
reason: 'run-task-failed',
|
|
255
|
+
resultExtra: { cluster, service, region },
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!outcome.ok && outcome.code === 'CONTAINER_NOT_FOUND') {
|
|
260
|
+
s.stop(color.red('Container not found.'));
|
|
261
|
+
return failCommand({
|
|
262
|
+
message: `\n✖ Container "${containerName}" does not exist in task definition "${outcome.targetTaskDef}".\n`,
|
|
263
|
+
hint: `Override with --container <name>. Available: ${outcome.available.join(', ') || 'none'}\n`,
|
|
264
|
+
event: 'db_enable_vector_run',
|
|
265
|
+
telemetry: { projectName },
|
|
266
|
+
errorCode: 'CONTAINER_NOT_FOUND',
|
|
267
|
+
reason: 'container-not-found',
|
|
268
|
+
resultExtra: { cluster, service, region },
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!outcome.ok && outcome.code === 'RUN_TASK_FAILED') {
|
|
273
|
+
s.stop(color.red('Failed to start vector task.'));
|
|
274
|
+
return failCommand({
|
|
275
|
+
message: `\n✖ ECS could not start the vector task: ${outcome.reason}.\n`,
|
|
276
|
+
event: 'db_enable_vector_run',
|
|
277
|
+
telemetry: { projectName },
|
|
278
|
+
errorCode: 'RUN_TASK_FAILED',
|
|
279
|
+
reason: 'run-task-failed',
|
|
280
|
+
resultExtra: { cluster, service, region },
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!outcome.ok && outcome.code === 'TIMEOUT') {
|
|
285
|
+
try { s.stop(color.yellow('Vector task timed out.')); } catch { /* already stopped */ }
|
|
286
|
+
return failCommand({
|
|
287
|
+
message: `\n✖ Vector task timed out after ${timeoutSeconds}s. The task was stopped (best-effort); increase --timeout and retry.\n`,
|
|
288
|
+
event: 'db_enable_vector_run',
|
|
289
|
+
telemetry: { projectName },
|
|
290
|
+
errorCode: 'VECTOR_TIMEOUT',
|
|
291
|
+
reason: 'vector-timeout',
|
|
292
|
+
resultExtra: { cluster, service, region, taskArn: outcome.taskArn },
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const exitCode = outcome.exitCode;
|
|
297
|
+
const stoppedReason = outcome.stoppedReason;
|
|
298
|
+
if (exitCode === 0) {
|
|
299
|
+
console.log(color.green('\n✅ pgvector extension enabled.'));
|
|
300
|
+
try {
|
|
301
|
+
const prismaSchema = path.join(cwd, 'prisma', 'schema.prisma');
|
|
302
|
+
if (fsSync.existsSync(prismaSchema)) {
|
|
303
|
+
const schema = fsSync.readFileSync(prismaSchema, 'utf8');
|
|
304
|
+
if (!schema.includes('postgresqlExtensions')) printPrismaVectorHint();
|
|
305
|
+
}
|
|
306
|
+
} catch {
|
|
307
|
+
// Best-effort hint only; the remote task already succeeded.
|
|
308
|
+
}
|
|
309
|
+
await trackSuccess('db_enable_vector_run', { projectName, duration_ms: Date.now() - startTime, engine });
|
|
310
|
+
outro(color.green('Done.'));
|
|
311
|
+
return { ok: true, taskArn: outcome.taskArn, engine };
|
|
312
|
+
}
|
|
313
|
+
const propagated = (typeof exitCode === 'number' && exitCode > 0) ? exitCode : 1;
|
|
314
|
+
return failCommand({
|
|
315
|
+
message: `\n✖ Vector task failed with exit code ${exitCode ?? 'unknown'}: ${stoppedReason}.\n`,
|
|
316
|
+
hint: exitCode === VECTOR_NO_CLIENT_EXIT_CODE ? NO_CLIENT_HINT : null,
|
|
317
|
+
event: 'db_enable_vector_run',
|
|
318
|
+
telemetry: { projectName, engine },
|
|
319
|
+
errorCode: 'VECTOR_TASK_FAILED',
|
|
320
|
+
extra: { exit_code: exitCode ?? -1 },
|
|
321
|
+
reason: 'vector-task-failed',
|
|
322
|
+
resultExtra: { cluster, service, region, taskArn: outcome.taskArn },
|
|
323
|
+
exitCode: propagated,
|
|
324
|
+
});
|
|
325
|
+
} catch (error) {
|
|
326
|
+
await trackFailure('db_enable_vector_run', {
|
|
327
|
+
projectName,
|
|
328
|
+
error_code: error?.name || 'UNKNOWN',
|
|
329
|
+
error_message: error?.message,
|
|
330
|
+
});
|
|
331
|
+
if (handleAuthErrorBranch(error, s, options)) {
|
|
332
|
+
return { ok: false, reason: 'error', cluster, service, region };
|
|
333
|
+
}
|
|
334
|
+
try { s.stop(color.red('❌ Db enable-vector failed.')); } catch { /* spinner already stopped */ }
|
|
335
|
+
return failCommand({
|
|
336
|
+
message: `✖ ${error?.message || error}`,
|
|
337
|
+
hint: 'Check your AWS credentials and region, then try again.',
|
|
338
|
+
reason: 'error',
|
|
339
|
+
resultExtra: { cluster, service, region },
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export default runDbEnableVector;
|