@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,361 @@
|
|
|
1
|
+
import { RDSClient, DescribeDBSnapshotsCommand, DescribeDBClusterSnapshotsCommand } from '@aws-sdk/client-rds';
|
|
2
|
+
import fsSync from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import color from 'picocolors';
|
|
5
|
+
import { intro, outro, spinner, select, confirm, cancel, isCancel } from '@clack/prompts';
|
|
6
|
+
import { trackSuccess, trackFailure } from '../../core/telemetry.js';
|
|
7
|
+
import { failCommand, failProjectNotInitialized } from '../../utils/command.js';
|
|
8
|
+
import { parseFlags, normalizeOptions, normalizeArgv } from '../../utils/args.js';
|
|
9
|
+
import { handleAuthErrorBranch, resolveClient } from '../../utils/aws.js';
|
|
10
|
+
import { resolveRegion, resolveProjectName, resolveAppName, resolveHeadless, resolveCwd } from '../../utils/resolvers.js';
|
|
11
|
+
import { findResourceBlock } from '../../utils/hcl.js';
|
|
12
|
+
import { resolveDbIdentifier, resolveDbClusterIdentifier } from '../../utils/rds.js';
|
|
13
|
+
|
|
14
|
+
const SNAPSHOT_NOT_FOUND_NAMES = new Set(['DBSnapshotNotFound', 'DBSnapshotNotFoundFault']);
|
|
15
|
+
const INSTANCE_NOT_FOUND_NAMES = new Set(['DBInstanceNotFound', 'DBInstanceNotFoundFault']);
|
|
16
|
+
const CLUSTER_NOT_FOUND_NAMES = new Set(['DBClusterNotFound', 'DBClusterNotFoundFault']);
|
|
17
|
+
const CLUSTER_SNAPSHOT_NOT_FOUND_NAMES = new Set(['DBClusterSnapshotNotFound', 'DBClusterSnapshotNotFoundFault']);
|
|
18
|
+
|
|
19
|
+
export const DB_RESOURCE_HEADER = 'resource "aws_db_instance" "postgres"';
|
|
20
|
+
export const DB_CLUSTER_RESOURCE_HEADER = 'resource "aws_rds_cluster" "postgres"';
|
|
21
|
+
export const SNAPSHOT_COMMENT = '# Restored via grada: keep snapshot_identifier so subsequent applies stay no-op.';
|
|
22
|
+
|
|
23
|
+
export function parseDbRestoreArgs(argv = []) {
|
|
24
|
+
const args = normalizeArgv(argv);
|
|
25
|
+
if (args[0] === 'db') args.shift();
|
|
26
|
+
if (args[0] === 'restore') args.shift();
|
|
27
|
+
const { options, rest } = parseFlags(args, {
|
|
28
|
+
string: [
|
|
29
|
+
{ name: 'project-name', key: 'projectName' },
|
|
30
|
+
{ name: 'region', key: 'region' },
|
|
31
|
+
{ name: 'workspace', key: 'workspace' },
|
|
32
|
+
{ name: 'db-identifier', key: 'dbIdentifier' },
|
|
33
|
+
],
|
|
34
|
+
boolean: ['yes', 'headless'],
|
|
35
|
+
});
|
|
36
|
+
const positionals = rest.filter((arg) => typeof arg === 'string' && !arg.startsWith('-'));
|
|
37
|
+
if (positionals.length > 0) options.snapshotId = positionals[0];
|
|
38
|
+
if (positionals.length > 1) options.unexpectedPositionals = positionals.slice(1);
|
|
39
|
+
return options;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Idempotently sets `snapshot_identifier` inside
|
|
43
|
+
// `resource "<resourceType>" "postgres"` (an `aws_db_instance` or an
|
|
44
|
+
// `aws_rds_cluster`): replaces the existing attribute or inserts it (with a
|
|
45
|
+
// keep-in-place comment) after the identity line. Returns the content
|
|
46
|
+
// unchanged when the resource block cannot be found. Pure and unit-tested.
|
|
47
|
+
export function upsertSnapshotIdentifier(hclContent, snapshotId, resourceType = 'aws_db_instance') {
|
|
48
|
+
const content = String(hclContent ?? '');
|
|
49
|
+
const bounds = findResourceBlock(content, resourceType, 'postgres');
|
|
50
|
+
if (!bounds) return content;
|
|
51
|
+
|
|
52
|
+
const block = content.slice(bounds.openIdx, bounds.closeIdx + 1);
|
|
53
|
+
const attrPattern = /^[ \t]*snapshot_identifier\s*=\s*"[^"]*"\s*$/m;
|
|
54
|
+
const attrMatch = attrPattern.exec(block);
|
|
55
|
+
|
|
56
|
+
const indent = ' ';
|
|
57
|
+
const attrLine = `${indent}snapshot_identifier = "${snapshotId}"`;
|
|
58
|
+
const commentLine = `${indent}${SNAPSHOT_COMMENT}`;
|
|
59
|
+
|
|
60
|
+
if (attrMatch) {
|
|
61
|
+
let next = block.slice(0, attrMatch.index) + attrLine + block.slice(attrMatch.index + attrMatch[0].length);
|
|
62
|
+
if (!next.includes(SNAPSHOT_COMMENT)) {
|
|
63
|
+
const at = next.indexOf(attrLine);
|
|
64
|
+
const lineStart = next.lastIndexOf('\n', at) + 1;
|
|
65
|
+
next = `${next.slice(0, lineStart)}${commentLine}\n${next.slice(lineStart)}`;
|
|
66
|
+
}
|
|
67
|
+
return content.slice(0, bounds.openIdx) + next + content.slice(bounds.closeIdx + 1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Insert after the identity line (`identifier` or `cluster_identifier`)
|
|
71
|
+
// so the restore pin sits next to the resource identity; fall back to
|
|
72
|
+
// the top of the block.
|
|
73
|
+
const identifierPattern = resourceType === 'aws_rds_cluster'
|
|
74
|
+
? /^[ \t]*cluster_identifier\s*=\s*"[^"]*"\s*$/m
|
|
75
|
+
: /^[ \t]*identifier\s*=\s*"[^"]*"\s*$/m;
|
|
76
|
+
const identifierMatch = identifierPattern.exec(block);
|
|
77
|
+
const insertion = `${commentLine}\n${attrLine}\n`;
|
|
78
|
+
let next;
|
|
79
|
+
if (identifierMatch) {
|
|
80
|
+
const after = identifierMatch.index + identifierMatch[0].length;
|
|
81
|
+
const eol = block.indexOf('\n', after);
|
|
82
|
+
const at = eol === -1 ? block.length : eol + 1;
|
|
83
|
+
next = `${block.slice(0, at)}${insertion}${block.slice(at)}`;
|
|
84
|
+
} else {
|
|
85
|
+
const firstEol = block.indexOf('\n');
|
|
86
|
+
const at = firstEol === -1 ? block.length : firstEol + 1;
|
|
87
|
+
next = `${block.slice(0, at)}${insertion}${block.slice(at)}`;
|
|
88
|
+
}
|
|
89
|
+
return content.slice(0, bounds.openIdx) + next + content.slice(bounds.closeIdx + 1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function listSnapshotsForInstance(rdsClient, dbIdentifier) {
|
|
93
|
+
const snapshots = [];
|
|
94
|
+
let marker;
|
|
95
|
+
try {
|
|
96
|
+
do {
|
|
97
|
+
const input = { DBInstanceIdentifier: dbIdentifier };
|
|
98
|
+
if (marker) input.Marker = marker;
|
|
99
|
+
const resp = await rdsClient.send(new DescribeDBSnapshotsCommand(input));
|
|
100
|
+
for (const snapshot of resp.DBSnapshots || []) snapshots.push(snapshot);
|
|
101
|
+
marker = resp.Marker;
|
|
102
|
+
} while (marker);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
// A missing instance simply has no snapshots to list.
|
|
105
|
+
if (error && INSTANCE_NOT_FOUND_NAMES.has(error.name)) return [];
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
return snapshots;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Cluster snapshots are normalized onto the instance field shape
|
|
112
|
+
// (`DBSnapshotIdentifier`) so sorting, selection, and verification below
|
|
113
|
+
// stay shared across both kinds.
|
|
114
|
+
async function listSnapshotsForCluster(rdsClient, dbClusterIdentifier) {
|
|
115
|
+
const snapshots = [];
|
|
116
|
+
let marker;
|
|
117
|
+
try {
|
|
118
|
+
do {
|
|
119
|
+
const input = { DBClusterIdentifier: dbClusterIdentifier };
|
|
120
|
+
if (marker) input.Marker = marker;
|
|
121
|
+
const resp = await rdsClient.send(new DescribeDBClusterSnapshotsCommand(input));
|
|
122
|
+
for (const snapshot of resp.DBClusterSnapshots || []) {
|
|
123
|
+
snapshots.push({ ...snapshot, DBSnapshotIdentifier: snapshot.DBClusterSnapshotIdentifier });
|
|
124
|
+
}
|
|
125
|
+
marker = resp.Marker;
|
|
126
|
+
} while (marker);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
// A missing cluster simply has no snapshots to list.
|
|
129
|
+
if (error && CLUSTER_NOT_FOUND_NAMES.has(error.name)) return [];
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
return snapshots;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function lookupSnapshotById(rdsClient, snapshotId, isCluster = false) {
|
|
136
|
+
try {
|
|
137
|
+
if (isCluster) {
|
|
138
|
+
const resp = await rdsClient.send(new DescribeDBClusterSnapshotsCommand({ DBClusterSnapshotIdentifier: snapshotId }));
|
|
139
|
+
const found = (resp.DBClusterSnapshots || [])[0] || null;
|
|
140
|
+
return found ? { ...found, DBSnapshotIdentifier: found.DBClusterSnapshotIdentifier } : null;
|
|
141
|
+
}
|
|
142
|
+
const resp = await rdsClient.send(new DescribeDBSnapshotsCommand({ DBSnapshotIdentifier: snapshotId }));
|
|
143
|
+
return (resp.DBSnapshots || [])[0] || null;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
const notFoundNames = isCluster ? CLUSTER_SNAPSHOT_NOT_FOUND_NAMES : SNAPSHOT_NOT_FOUND_NAMES;
|
|
146
|
+
if (error && notFoundNames.has(error.name)) return null;
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function formatSnapshotHint(snapshot) {
|
|
152
|
+
const when = snapshot.SnapshotCreateTime instanceof Date
|
|
153
|
+
? snapshot.SnapshotCreateTime.toISOString()
|
|
154
|
+
: String(snapshot.SnapshotCreateTime || 'unknown time');
|
|
155
|
+
const size = snapshot.AllocatedStorage !== undefined && snapshot.AllocatedStorage !== null
|
|
156
|
+
? `${snapshot.AllocatedStorage}GB`
|
|
157
|
+
: 'unknown size';
|
|
158
|
+
const type = snapshot.SnapshotType || 'unknown';
|
|
159
|
+
return `${when} · ${size} · ${type}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function runDbRestore(input = {}) {
|
|
163
|
+
const options = normalizeOptions(input);
|
|
164
|
+
let cwd;
|
|
165
|
+
let region;
|
|
166
|
+
let projectName;
|
|
167
|
+
let dbIdentifier;
|
|
168
|
+
let dbClusterIdentifier;
|
|
169
|
+
try {
|
|
170
|
+
cwd = resolveCwd(options);
|
|
171
|
+
region = resolveRegion(options, cwd);
|
|
172
|
+
projectName = resolveProjectName(options, cwd);
|
|
173
|
+
const appName = resolveAppName(projectName, options.workspace, cwd);
|
|
174
|
+
dbIdentifier = resolveDbIdentifier({ ...options, projectName }, cwd);
|
|
175
|
+
dbClusterIdentifier = resolveDbClusterIdentifier({ ...options, projectName }, cwd);
|
|
176
|
+
} catch {
|
|
177
|
+
return failProjectNotInitialized({ event: 'db_restore_run' });
|
|
178
|
+
}
|
|
179
|
+
const headless = resolveHeadless(options);
|
|
180
|
+
|
|
181
|
+
intro(color.bgCyan(color.black(' grada db restore 🕰️ ')));
|
|
182
|
+
|
|
183
|
+
if (Array.isArray(options.unexpectedPositionals) && options.unexpectedPositionals.length > 0) {
|
|
184
|
+
return failCommand({
|
|
185
|
+
message: `\n✖ Unexpected argument "${options.unexpectedPositionals[0]}". Pass a single snapshot id: db restore <snapshot-id>.\n`,
|
|
186
|
+
event: 'db_restore_run',
|
|
187
|
+
telemetry: { projectName },
|
|
188
|
+
errorCode: 'UNEXPECTED_POSITIONAL_ARGS',
|
|
189
|
+
reason: 'unexpected-positional-args',
|
|
190
|
+
resultExtra: { dbIdentifier, region },
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Fail fast on a missing Terraform file before any AWS calls.
|
|
195
|
+
const databaseTf = path.join(cwd, 'terraform', 'database.tf');
|
|
196
|
+
let hclContent = null;
|
|
197
|
+
try {
|
|
198
|
+
if (fsSync.existsSync(databaseTf)) hclContent = fsSync.readFileSync(databaseTf, 'utf8');
|
|
199
|
+
} catch {
|
|
200
|
+
hclContent = null;
|
|
201
|
+
}
|
|
202
|
+
if (hclContent === null) {
|
|
203
|
+
return failCommand({
|
|
204
|
+
message: `\n✖ ${color.cyan('terraform/database.tf')} not found. Run ${color.green('npx grada-run')} with a managed database first.\n`,
|
|
205
|
+
event: 'db_restore_run',
|
|
206
|
+
telemetry: { projectName },
|
|
207
|
+
errorCode: 'DATABASE_TF_NOT_FOUND',
|
|
208
|
+
reason: 'database-tf-not-found',
|
|
209
|
+
resultExtra: { dbIdentifier, region },
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const rdsClient = resolveClient(options.rdsClient, RDSClient, { region });
|
|
214
|
+
const s = spinner();
|
|
215
|
+
s.start('Listing database snapshots...');
|
|
216
|
+
|
|
217
|
+
// The required database.tf file doubles as the kind signal: Aurora
|
|
218
|
+
// projects restore cluster snapshots into the cluster block.
|
|
219
|
+
const isCluster = hclContent.includes('resource "aws_rds_cluster"');
|
|
220
|
+
const resourceLabel = isCluster ? 'aws_rds_cluster.postgres' : 'aws_db_instance.postgres';
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
const requestedId = (typeof options.snapshotId === 'string' && options.snapshotId.trim())
|
|
224
|
+
? options.snapshotId.trim()
|
|
225
|
+
: '';
|
|
226
|
+
const listed = isCluster
|
|
227
|
+
? await listSnapshotsForCluster(rdsClient, dbClusterIdentifier)
|
|
228
|
+
: await listSnapshotsForInstance(rdsClient, dbIdentifier);
|
|
229
|
+
listed.sort((a, b) => {
|
|
230
|
+
const ta = a.SnapshotCreateTime instanceof Date ? a.SnapshotCreateTime.getTime() : 0;
|
|
231
|
+
const tb = b.SnapshotCreateTime instanceof Date ? b.SnapshotCreateTime.getTime() : 0;
|
|
232
|
+
return tb - ta;
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
let selected = null;
|
|
236
|
+
if (requestedId) {
|
|
237
|
+
selected = listed.find((snap) => snap.DBSnapshotIdentifier === requestedId) || null;
|
|
238
|
+
// Snapshots outlive replaced databases: fall back to a direct
|
|
239
|
+
// lookup so checkpoints from previous instances stay restorable.
|
|
240
|
+
if (!selected) selected = await lookupSnapshotById(rdsClient, requestedId, isCluster);
|
|
241
|
+
if (!selected) {
|
|
242
|
+
s.stop(color.yellow('Snapshot not found.'));
|
|
243
|
+
return failCommand({
|
|
244
|
+
message: `\n✖ Snapshot "${requestedId}" was not found for database ${color.cyan(dbIdentifier)}.\n`,
|
|
245
|
+
event: 'db_restore_run',
|
|
246
|
+
telemetry: { projectName },
|
|
247
|
+
errorCode: 'SNAPSHOT_NOT_AVAILABLE',
|
|
248
|
+
reason: 'snapshot-not-available',
|
|
249
|
+
resultExtra: { dbIdentifier, region },
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
} else {
|
|
253
|
+
if (listed.length === 0) {
|
|
254
|
+
s.stop(color.yellow('No snapshots found.'));
|
|
255
|
+
return failCommand({
|
|
256
|
+
message: `\n✖ No snapshots found for database ${color.cyan(dbIdentifier)}. Create one with ${color.green('npx grada-run db backup')}.\n`,
|
|
257
|
+
event: 'db_restore_run',
|
|
258
|
+
telemetry: { projectName },
|
|
259
|
+
errorCode: 'NO_SNAPSHOTS_FOUND',
|
|
260
|
+
reason: 'no-snapshots-found',
|
|
261
|
+
resultExtra: { dbIdentifier, region },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
if (headless) {
|
|
265
|
+
s.stop(color.yellow('Snapshot id required.'));
|
|
266
|
+
return failCommand({
|
|
267
|
+
message: '\n✖ No snapshot id provided. Pass one positionally: db restore <snapshot-id>.\n',
|
|
268
|
+
event: 'db_restore_run',
|
|
269
|
+
telemetry: { projectName },
|
|
270
|
+
errorCode: 'MISSING_SNAPSHOT_ID',
|
|
271
|
+
reason: 'missing-snapshot-id',
|
|
272
|
+
resultExtra: { dbIdentifier, region },
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
s.stop('Snapshots found.');
|
|
276
|
+
const choice = await select({
|
|
277
|
+
message: 'Select a snapshot to restore:',
|
|
278
|
+
options: listed.map((snap) => ({
|
|
279
|
+
value: snap.DBSnapshotIdentifier,
|
|
280
|
+
label: snap.DBSnapshotIdentifier,
|
|
281
|
+
hint: formatSnapshotHint(snap),
|
|
282
|
+
})),
|
|
283
|
+
});
|
|
284
|
+
if (isCancel(choice)) {
|
|
285
|
+
outro(color.yellow('Restore cancelled.'));
|
|
286
|
+
return { ok: false, reason: 'cancelled', dbIdentifier, region };
|
|
287
|
+
}
|
|
288
|
+
selected = listed.find((snap) => snap.DBSnapshotIdentifier === choice) || null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!selected || selected.Status !== 'available') {
|
|
292
|
+
s.stop(color.yellow('Snapshot not available.'));
|
|
293
|
+
return failCommand({
|
|
294
|
+
message: `\n✖ Snapshot "${selected?.DBSnapshotIdentifier || requestedId}" is not available (status: ${selected?.Status || 'unknown'}). Only available snapshots can be restored.\n`,
|
|
295
|
+
event: 'db_restore_run',
|
|
296
|
+
telemetry: { projectName },
|
|
297
|
+
errorCode: 'SNAPSHOT_NOT_AVAILABLE',
|
|
298
|
+
reason: 'snapshot-not-available',
|
|
299
|
+
resultExtra: { dbIdentifier, region },
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
const snapshotId = selected.DBSnapshotIdentifier;
|
|
303
|
+
|
|
304
|
+
// Destructive-action confirmation (explicit --yes skips prompting).
|
|
305
|
+
if (options.yes !== true && options.yes !== 'true') {
|
|
306
|
+
if (headless) {
|
|
307
|
+
s.stop(color.yellow('Confirmation required.'));
|
|
308
|
+
return failCommand({
|
|
309
|
+
message: '\n✖ Restoring requires confirmation. Re-run with --yes.\n',
|
|
310
|
+
event: 'db_restore_run',
|
|
311
|
+
telemetry: { projectName },
|
|
312
|
+
errorCode: 'CONFIRMATION_REQUIRED',
|
|
313
|
+
reason: 'confirmation-required',
|
|
314
|
+
resultExtra: { dbIdentifier, region },
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
s.stop('Snapshot selected.');
|
|
318
|
+
console.log(color.yellow('\n⚠ This will replace your database on the next apply.'));
|
|
319
|
+
console.log(` Setting ${color.cyan('snapshot_identifier')} on ${color.cyan(resourceLabel)} restores ${color.cyan(snapshotId)} but permanently discards`);
|
|
320
|
+
console.log(` everything written after the snapshot (this project sets ${color.cyan('skip_final_snapshot = true')}).`);
|
|
321
|
+
console.log(` Back up first with ${color.green('npx grada-run db backup')} if you need the current data.\n`);
|
|
322
|
+
const confirmed = await confirm({
|
|
323
|
+
message: `Restore ${dbIdentifier} from snapshot ${snapshotId}?`,
|
|
324
|
+
initialValue: false,
|
|
325
|
+
});
|
|
326
|
+
if (confirmed !== true) {
|
|
327
|
+
cancel('Cancelled. terraform/database.tf was not modified.');
|
|
328
|
+
return { ok: false, reason: 'cancelled', dbIdentifier, region };
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
s.stop('Snapshot selected.');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const updated = upsertSnapshotIdentifier(hclContent, snapshotId, isCluster ? 'aws_rds_cluster' : 'aws_db_instance');
|
|
335
|
+
fsSync.writeFileSync(databaseTf, updated, 'utf8');
|
|
336
|
+
console.log(color.green(`\n✅ terraform/database.tf now pins snapshot_identifier = "${snapshotId}".`));
|
|
337
|
+
console.log(` Run ${color.green('npx grada-run apply')} to restore the database.`);
|
|
338
|
+
console.log(color.dim(' Keep snapshot_identifier in place afterwards so future applies stay no-op.\n'));
|
|
339
|
+
await trackSuccess('db_restore_run', { projectName, db_kind: isCluster ? 'cluster' : 'instance' });
|
|
340
|
+
outro(color.green('Done.'));
|
|
341
|
+
return { ok: true, snapshotId };
|
|
342
|
+
} catch (error) {
|
|
343
|
+
await trackFailure('db_restore_run', {
|
|
344
|
+
projectName,
|
|
345
|
+
error_code: error?.name || 'UNKNOWN',
|
|
346
|
+
error_message: error?.message,
|
|
347
|
+
});
|
|
348
|
+
if (handleAuthErrorBranch(error, s, options)) {
|
|
349
|
+
return { ok: false, reason: 'error', dbIdentifier, region };
|
|
350
|
+
}
|
|
351
|
+
try { s.stop(color.red('❌ Db restore failed.')); } catch { /* spinner already stopped */ }
|
|
352
|
+
return failCommand({
|
|
353
|
+
message: `✖ ${error?.message || error}`,
|
|
354
|
+
hint: 'Check your AWS credentials and region, then try again.',
|
|
355
|
+
reason: 'error',
|
|
356
|
+
resultExtra: { dbIdentifier, region },
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export default runDbRestore;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import color from 'picocolors';
|
|
2
|
+
import { failCommand } from '../utils/command.js';
|
|
3
|
+
import { normalizeOptions, normalizeArgv } from '../utils/args.js';
|
|
4
|
+
import { runDbConnect, parseDbArgs } from './db/connect.js';
|
|
5
|
+
import { runDbMigrate, parseDbMigrateArgs } from './db/migrate.js';
|
|
6
|
+
import { runDbBackup, parseDbBackupArgs } from './db/backup.js';
|
|
7
|
+
import { runDbRestore, parseDbRestoreArgs } from './db/restore.js';
|
|
8
|
+
import { runDbEnableVector, parseDbEnableVectorArgs } from './db/enable-vector.js';
|
|
9
|
+
import { runDbImport, parseDbImportArgs } from './db/import.js';
|
|
10
|
+
|
|
11
|
+
// Dispatcher + re-export barrel for the `db` subcommands. Existing imports
|
|
12
|
+
// of `../src/commands/db.js` (bin/cli.js, tests) keep working unchanged.
|
|
13
|
+
export { runDbConnect, parseDbArgs } from './db/connect.js';
|
|
14
|
+
export {
|
|
15
|
+
isValidPort,
|
|
16
|
+
buildConnectionString,
|
|
17
|
+
formatConnectionInfo,
|
|
18
|
+
buildSsmArgs,
|
|
19
|
+
DEFAULT_LOCAL_PORT,
|
|
20
|
+
MASKED_PASSWORD,
|
|
21
|
+
dbCommand,
|
|
22
|
+
} from './db/connect.js';
|
|
23
|
+
export { runDbMigrate, parseDbMigrateArgs } from './db/migrate.js';
|
|
24
|
+
export { runDbBackup, parseDbBackupArgs } from './db/backup.js';
|
|
25
|
+
export { runDbRestore, parseDbRestoreArgs, upsertSnapshotIdentifier } from './db/restore.js';
|
|
26
|
+
export { runDbEnableVector, parseDbEnableVectorArgs, buildVectorExtensionCommand } from './db/enable-vector.js';
|
|
27
|
+
export { runDbImport, parseDbImportArgs, classifyImportFile, requiredClientBinaries, buildTargetClientCommand, buildSourceDumpCommand } from './db/import.js';
|
|
28
|
+
export { resolveDbIdentifier, resolveDbClusterIdentifier, findDbInstance, findDbCluster, findDbTarget, generateSnapshotId, isValidSnapshotId } from '../utils/rds.js';
|
|
29
|
+
export { pickRuntimeContainer } from '../utils/ecs.js';
|
|
30
|
+
export { detectMigrationCommand } from '../utils/detector.js';
|
|
31
|
+
|
|
32
|
+
export const DB_SUBCOMMANDS = ['connect', 'migrate', 'backup', 'restore', 'enable-vector', 'import'];
|
|
33
|
+
|
|
34
|
+
function printDbUsage() {
|
|
35
|
+
console.log('Usage:');
|
|
36
|
+
console.log(' grada db connect [--port <local-port>] [--show-credentials] [--workspace <name>] [--region <region>] [--cluster <name>] [--service <name>]');
|
|
37
|
+
console.log(' grada db migrate [--cmd <command>] [--task-def <task-def>] [--timeout <seconds>] [--setup-ci] [--project-name <name>] [--workspace <name>] [--region <region>] [--cluster <name>] [--service <name>] [--container <name>]');
|
|
38
|
+
console.log(' grada db backup [--id <snapshot-id>] [--timeout <seconds>] [--no-wait] [--project-name <name>] [--workspace <name>] [--region <region>] [--db-identifier <id>]');
|
|
39
|
+
console.log(' grada db restore [<snapshot-id>] [--yes] [--project-name <name>] [--workspace <name>] [--region <region>] [--db-identifier <id>]');
|
|
40
|
+
console.log(' grada db enable-vector [--task-def <task-def>] [--timeout <seconds>] [--project-name <name>] [--workspace <name>] [--region <region>] [--cluster <name>] [--service <name>] [--container <name>]');
|
|
41
|
+
console.log(' grada db import (--file <path> | --from <url>) [--yes] [--project-name <name>] [--workspace <name>] [--region <region>] [--db-identifier <id>]');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function runDb(argv = [], extraOptions = {}) {
|
|
45
|
+
const args = normalizeArgv(argv);
|
|
46
|
+
const extra = normalizeOptions(extraOptions);
|
|
47
|
+
if (args[0] === 'db') args.shift();
|
|
48
|
+
const subcommand = args[0] && !String(args[0]).startsWith('-') ? args[0] : undefined;
|
|
49
|
+
|
|
50
|
+
if (subcommand === 'connect') {
|
|
51
|
+
return runDbConnect({ ...parseDbArgs(argv), ...extra });
|
|
52
|
+
}
|
|
53
|
+
if (subcommand === 'migrate') {
|
|
54
|
+
return runDbMigrate({ ...parseDbMigrateArgs(argv), ...extra });
|
|
55
|
+
}
|
|
56
|
+
if (subcommand === 'backup') {
|
|
57
|
+
return runDbBackup({ ...parseDbBackupArgs(argv), ...extra });
|
|
58
|
+
}
|
|
59
|
+
if (subcommand === 'restore') {
|
|
60
|
+
return runDbRestore({ ...parseDbRestoreArgs(argv), ...extra });
|
|
61
|
+
}
|
|
62
|
+
if (subcommand === 'enable-vector') {
|
|
63
|
+
return runDbEnableVector({ ...parseDbEnableVectorArgs(argv), ...extra });
|
|
64
|
+
}
|
|
65
|
+
if (subcommand === 'import') {
|
|
66
|
+
return runDbImport({ ...parseDbImportArgs(argv), ...extra });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return failCommand({
|
|
70
|
+
print: () => {
|
|
71
|
+
if (subcommand === undefined) {
|
|
72
|
+
console.log(color.yellow('\n⚠ Missing db subcommand.'));
|
|
73
|
+
} else {
|
|
74
|
+
console.log(color.red(`\n✖ Unknown db subcommand "${subcommand}".`));
|
|
75
|
+
}
|
|
76
|
+
console.log('');
|
|
77
|
+
printDbUsage();
|
|
78
|
+
console.log('');
|
|
79
|
+
},
|
|
80
|
+
event: 'db_run',
|
|
81
|
+
telemetry: {},
|
|
82
|
+
errorCode: 'UNKNOWN_DB_SUBCOMMAND',
|
|
83
|
+
reason: 'unknown-db-subcommand',
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export default runDbConnect;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import fsSync from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { intro, outro, confirm, spinner, cancel } from '@clack/prompts';
|
|
4
|
+
import color from 'picocolors';
|
|
5
|
+
import { RDSClient, DescribeDBInstancesCommand, DescribeDBClustersCommand, StartDBInstanceCommand, StartDBClusterCommand } from '@aws-sdk/client-rds';
|
|
6
|
+
import { teardownStateBucket, resolveClient } from '../utils/aws.js';
|
|
7
|
+
import { checkDependency, pollUntil } from '../utils/system.js';
|
|
8
|
+
import { trackEvent, flushTelemetry, trackSuccess } from '../core/telemetry.js';
|
|
9
|
+
import { failCommand, shouldAutoApprove } from '../utils/command.js';
|
|
10
|
+
import { runTerraformCommand } from '../utils/terraform.js';
|
|
11
|
+
import { findDbTarget, resolveDbIdentifier, resolveDbClusterIdentifier } from '../utils/rds.js';
|
|
12
|
+
import { normalizeOptions } from '../utils/args.js';
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_DESTROY_DB_TIMEOUT_MS = 600000;
|
|
15
|
+
export const DEFAULT_DESTROY_DB_POLL_INTERVAL_MS = 5000;
|
|
16
|
+
|
|
17
|
+
export async function destroyStack(input = {}) {
|
|
18
|
+
const options = normalizeOptions(input);
|
|
19
|
+
intro(color.bgRed(color.white(' grada destroy 🗑️ ')));
|
|
20
|
+
|
|
21
|
+
const tfDirPath = path.join(process.cwd(), 'terraform');
|
|
22
|
+
const backendFilePath = path.join(tfDirPath, 'backend.tf');
|
|
23
|
+
|
|
24
|
+
if (!fsSync.existsSync(backendFilePath)) {
|
|
25
|
+
return failCommand({
|
|
26
|
+
print: () => {
|
|
27
|
+
console.error(color.red('✖ No terraform/backend.tf found in the current directory.'));
|
|
28
|
+
console.log(color.yellow('Are you in the root of a grada project?'));
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const hasTerraform = await checkDependency('terraform');
|
|
34
|
+
if (!hasTerraform) {
|
|
35
|
+
return failCommand({ message: '✖ Terraform is not installed.', useErrorStream: true });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const proceed = shouldAutoApprove(options) ? true : await confirm({
|
|
39
|
+
message: color.red('⚠️ WARNING: This will permanently destroy all AWS resources associated with this project. Are you absolutely sure?'),
|
|
40
|
+
initialValue: false,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!proceed) {
|
|
44
|
+
cancel('Destruction cancelled. Your infrastructure is safe.');
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const s = spinner();
|
|
49
|
+
|
|
50
|
+
// 1. Extract Bucket and Region from backend.tf
|
|
51
|
+
const backendContent = fsSync.readFileSync(backendFilePath, 'utf-8');
|
|
52
|
+
const bucketMatch = backendContent.match(/bucket\s*=\s*"([^"]+)"/);
|
|
53
|
+
const regionMatch = backendContent.match(/region\s*=\s*"([^"]+)"/);
|
|
54
|
+
|
|
55
|
+
const bucketName = bucketMatch ? bucketMatch[1] : null;
|
|
56
|
+
const region = regionMatch ? regionMatch[1] : 'us-east-2';
|
|
57
|
+
|
|
58
|
+
// 1b. Wake a non-available database first: RDS rejects deletes of a
|
|
59
|
+
// stopped (or transitional) cluster/instance with
|
|
60
|
+
// InvalidDBClusterStateFault, which fails `terraform destroy` on asleep
|
|
61
|
+
// environments. No-op when no database exists or it is available.
|
|
62
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_DESTROY_DB_TIMEOUT_MS;
|
|
63
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_DESTROY_DB_POLL_INTERVAL_MS;
|
|
64
|
+
const pollOverrides = {
|
|
65
|
+
...(options.sleepFn ? { sleepFn: options.sleepFn } : {}),
|
|
66
|
+
...(options.nowFn ? { nowFn: options.nowFn } : {}),
|
|
67
|
+
};
|
|
68
|
+
try {
|
|
69
|
+
const rdsClient = resolveClient(options.rdsClient, RDSClient, { region });
|
|
70
|
+
const dbTarget = await findDbTarget(rdsClient, {
|
|
71
|
+
dbIdentifier: resolveDbIdentifier(options, process.cwd()),
|
|
72
|
+
dbClusterIdentifier: resolveDbClusterIdentifier(options, process.cwd()),
|
|
73
|
+
});
|
|
74
|
+
if (dbTarget && dbTarget.status !== 'available') {
|
|
75
|
+
const isCluster = dbTarget.kind === 'cluster';
|
|
76
|
+
const readStatus = async () => {
|
|
77
|
+
const resp = isCluster
|
|
78
|
+
? await rdsClient.send(new DescribeDBClustersCommand({ DBClusterIdentifier: dbTarget.id }))
|
|
79
|
+
: await rdsClient.send(new DescribeDBInstancesCommand({ DBInstanceIdentifier: dbTarget.id }));
|
|
80
|
+
const current = isCluster
|
|
81
|
+
? (resp.DBClusters || [])[0] || null
|
|
82
|
+
: (resp.DBInstances || [])[0] || null;
|
|
83
|
+
return current ? current.Status : null;
|
|
84
|
+
};
|
|
85
|
+
// A `stopping` database settles to `stopped`, never directly to
|
|
86
|
+
// `available` — so transitional states wait for a stable state
|
|
87
|
+
// first, `stopped` issues Start, then everything waits for
|
|
88
|
+
// `available`. A database that disappears mid-wait (null) lets
|
|
89
|
+
// the destroy proceed.
|
|
90
|
+
const waitForStable = () => pollUntil({
|
|
91
|
+
intervalMs: pollIntervalMs,
|
|
92
|
+
timeoutMs,
|
|
93
|
+
...pollOverrides,
|
|
94
|
+
onTick: async ({ elapsedMs }) => {
|
|
95
|
+
const status = await readStatus();
|
|
96
|
+
if (status === 'stopped' || status === 'available' || status === null) {
|
|
97
|
+
return { done: true, value: status };
|
|
98
|
+
}
|
|
99
|
+
s.message(`Waiting for ${dbTarget.id} to settle (${status})... [${Math.floor(elapsedMs / 1000)}s]`);
|
|
100
|
+
return { done: false };
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
const waitForAvailable = () => pollUntil({
|
|
104
|
+
intervalMs: pollIntervalMs,
|
|
105
|
+
timeoutMs,
|
|
106
|
+
...pollOverrides,
|
|
107
|
+
onTick: async ({ elapsedMs }) => {
|
|
108
|
+
const status = await readStatus();
|
|
109
|
+
if (status === 'available' || status === null) return { done: true, value: status };
|
|
110
|
+
s.message(`Waiting for ${dbTarget.id} to become available... [${Math.floor(elapsedMs / 1000)}s]`);
|
|
111
|
+
return { done: false };
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
s.start('Waking database before destruction...');
|
|
115
|
+
let status = dbTarget.status;
|
|
116
|
+
if (status !== 'stopped') {
|
|
117
|
+
const settled = await waitForStable();
|
|
118
|
+
if (settled.timedOut) {
|
|
119
|
+
s.stop(color.yellow('Database still transitioning.'));
|
|
120
|
+
const actualProjectName = path.basename(process.cwd());
|
|
121
|
+
return failCommand({
|
|
122
|
+
print: () => {
|
|
123
|
+
console.log(color.yellow(`\n⚠ ${dbTarget.id} did not leave the ${status} state in time — RDS refuses to delete non-available databases.`));
|
|
124
|
+
console.log(` Re-run ${color.green('npx grada-run destroy')} once the database is available.\n`);
|
|
125
|
+
},
|
|
126
|
+
event: 'infrastructure_destroyed',
|
|
127
|
+
telemetry: { projectName: actualProjectName, error_code: 'RDS_DESTROY_PREFLIGHT_TIMEOUT' },
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
status = settled.value;
|
|
131
|
+
}
|
|
132
|
+
if (status === 'stopped') {
|
|
133
|
+
if (isCluster) {
|
|
134
|
+
await rdsClient.send(new StartDBClusterCommand({ DBClusterIdentifier: dbTarget.id }));
|
|
135
|
+
} else {
|
|
136
|
+
await rdsClient.send(new StartDBInstanceCommand({ DBInstanceIdentifier: dbTarget.id }));
|
|
137
|
+
}
|
|
138
|
+
status = 'starting';
|
|
139
|
+
}
|
|
140
|
+
if (status !== null) {
|
|
141
|
+
const outcome = await waitForAvailable();
|
|
142
|
+
if (outcome.timedOut) {
|
|
143
|
+
s.stop(color.yellow('Database still starting.'));
|
|
144
|
+
const actualProjectName = path.basename(process.cwd());
|
|
145
|
+
return failCommand({
|
|
146
|
+
print: () => {
|
|
147
|
+
console.log(color.yellow(`\n⚠ ${dbTarget.id} did not become available in time — RDS refuses to delete non-available databases.`));
|
|
148
|
+
console.log(` Re-run ${color.green('npx grada-run destroy')} once the database is available.\n`);
|
|
149
|
+
},
|
|
150
|
+
event: 'infrastructure_destroyed',
|
|
151
|
+
telemetry: { projectName: actualProjectName, error_code: 'RDS_DESTROY_PREFLIGHT_TIMEOUT' },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
s.stop('Database is available.');
|
|
156
|
+
}
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (error && typeof error.exitCode === 'number') throw error;
|
|
159
|
+
try { s.stop(color.red('❌ Pre-destroy database check failed.')); } catch { /* spinner already stopped */ }
|
|
160
|
+
const actualProjectName = path.basename(process.cwd());
|
|
161
|
+
return failCommand({
|
|
162
|
+
message: error.message,
|
|
163
|
+
useErrorStream: true,
|
|
164
|
+
event: 'infrastructure_destroyed',
|
|
165
|
+
telemetry: { projectName: actualProjectName, error_code: error.code || 'RDS_DESTROY_PREFLIGHT_FAILED' },
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 2. Execute Terraform Destroy
|
|
170
|
+
s.start('Destroying AWS compute resources (this takes a few minutes)...');
|
|
171
|
+
try {
|
|
172
|
+
await runTerraformCommand(['destroy', '-auto-approve'], tfDirPath, s, 'Destroying');
|
|
173
|
+
s.stop('AWS compute resources destroyed.');
|
|
174
|
+
} catch (error) {
|
|
175
|
+
s.stop(color.red('❌ Terraform destroy failed.'));
|
|
176
|
+
|
|
177
|
+
const actualProjectName = path.basename(process.cwd());
|
|
178
|
+
return failCommand({
|
|
179
|
+
message: error.message,
|
|
180
|
+
useErrorStream: true,
|
|
181
|
+
event: 'infrastructure_destroyed',
|
|
182
|
+
telemetry: { projectName: actualProjectName, error_code: error.code || 'UNKNOWN' },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 3. Clean up the S3 State Bucket
|
|
187
|
+
let deleteS3Bucket = false;
|
|
188
|
+
|
|
189
|
+
if (bucketName) {
|
|
190
|
+
deleteS3Bucket = shouldAutoApprove(options) ? true : await confirm({
|
|
191
|
+
message: color.yellow(`AWS compute resources destroyed. Do you also want to permanently delete the S3 state bucket?\n (Select 'No' if you plan to run 'grada apply' later to spin this back up.)`),
|
|
192
|
+
initialValue: false,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
if (deleteS3Bucket && typeof deleteS3Bucket !== 'symbol') {
|
|
196
|
+
s.start(`Emptying and deleting S3 state bucket: ${bucketName}...`);
|
|
197
|
+
try {
|
|
198
|
+
await teardownStateBucket(region, bucketName);
|
|
199
|
+
s.stop(`S3 bucket ${bucketName} successfully deleted.`);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
s.stop(`❌ Failed to delete S3 bucket. You may need to delete it manually in the AWS Console.`);
|
|
202
|
+
console.error(color.red(`AWS Error: ${error.message}`));
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
console.log(color.cyan(`\n S3 bucket retained. You can run 'npx grada-run apply' anytime to restore your infrastructure.`));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const actualProjectName = path.basename(process.cwd());
|
|
210
|
+
await trackSuccess('infrastructure_destroyed', {
|
|
211
|
+
projectName: actualProjectName,
|
|
212
|
+
region,
|
|
213
|
+
retained_state_bucket: !(deleteS3Bucket && typeof deleteS3Bucket !== 'symbol')
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
outro(color.green('✅ Infrastructure successfully destroyed. Your AWS bill is safe.'));
|
|
217
|
+
}
|