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,477 @@
|
|
|
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, text, isCancel } 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
|
+
resolveHeadless,
|
|
17
|
+
resolveCwd,
|
|
18
|
+
} from '../../utils/resolvers.js';
|
|
19
|
+
import { detectMigrationCommand } from '../../utils/detector.js';
|
|
20
|
+
import { resolveContainer } from '../../utils/ecs.js';
|
|
21
|
+
import { runEphemeralEcsTask } from '../../utils/ecs-runner.js';
|
|
22
|
+
import { parseTimeoutSeconds } from '../../utils/system.js';
|
|
23
|
+
import fsSync from 'fs';
|
|
24
|
+
import path from 'path';
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_MIGRATE_TIMEOUT_SECONDS = 600;
|
|
27
|
+
export const DEFAULT_POLL_INTERVAL_MS = 2000;
|
|
28
|
+
export const DEFAULT_FLUSH_MAX_POLLS = 6;
|
|
29
|
+
export const DEFAULT_FLUSH_INTERVAL_MS = 1000;
|
|
30
|
+
|
|
31
|
+
export const GATE_START_MARKER = '# grada:db-migrate-start';
|
|
32
|
+
export const GATE_END_MARKER = '# grada:db-migrate-end';
|
|
33
|
+
export const LEGACY_GATE_START_MARKER = '# deploy-stack:db-migrate-start';
|
|
34
|
+
export const LEGACY_GATE_END_MARKER = '# deploy-stack:db-migrate-end';
|
|
35
|
+
|
|
36
|
+
// Locates a previously injected gate block in either brand variant so
|
|
37
|
+
// re-runs refresh (and migrate) legacy blocks instead of duplicating
|
|
38
|
+
// them. Returns `{ startIdx, endIdx }` or null. Pure and unit-tested.
|
|
39
|
+
export function findGateBlock(workflowContent) {
|
|
40
|
+
const base = String(workflowContent ?? '');
|
|
41
|
+
const starts = [GATE_START_MARKER, LEGACY_GATE_START_MARKER]
|
|
42
|
+
.map((marker) => base.indexOf(marker))
|
|
43
|
+
.filter((idx) => idx !== -1)
|
|
44
|
+
.sort((a, b) => a - b);
|
|
45
|
+
for (const startIdx of starts) {
|
|
46
|
+
const ends = [GATE_END_MARKER, LEGACY_GATE_END_MARKER]
|
|
47
|
+
.map((marker) => base.indexOf(marker, startIdx))
|
|
48
|
+
.filter((endIdx) => endIdx !== -1 && endIdx > startIdx)
|
|
49
|
+
.sort((a, b) => a - b);
|
|
50
|
+
if (ends.length > 0) return { startIdx, endIdx: ends[0] };
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function parseDbMigrateArgs(argv = []) {
|
|
56
|
+
const args = normalizeArgv(argv);
|
|
57
|
+
if (args[0] === 'db') args.shift();
|
|
58
|
+
if (args[0] === 'migrate') args.shift();
|
|
59
|
+
const { options, rest } = parseFlags(args, {
|
|
60
|
+
string: [
|
|
61
|
+
{ name: 'cmd', key: 'cmd' },
|
|
62
|
+
{ name: 'task-def', key: 'taskDef' },
|
|
63
|
+
{ name: 'timeout', key: 'timeout' },
|
|
64
|
+
{ name: 'project-name', key: 'projectName' },
|
|
65
|
+
{ name: 'region', key: 'region' },
|
|
66
|
+
{ name: 'workspace', key: 'workspace' },
|
|
67
|
+
{ name: 'cluster', key: 'cluster' },
|
|
68
|
+
{ name: 'service', key: 'service' },
|
|
69
|
+
{ name: 'container', key: 'container' },
|
|
70
|
+
],
|
|
71
|
+
boolean: ['setup-ci', 'headless'],
|
|
72
|
+
});
|
|
73
|
+
const positionals = rest.filter((arg) => typeof arg === 'string' && !arg.startsWith('-'));
|
|
74
|
+
if (positionals.length > 0) options.unexpectedPositionals = positionals;
|
|
75
|
+
return options;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Single-quote a shell argument for embedding in the workflow `run: |` block
|
|
79
|
+
// (literal block scalars need no YAML escaping).
|
|
80
|
+
export function quoteShellArg(value) {
|
|
81
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function renderGateBlock({ cmd, includeSetupNode, taskDefRef }) {
|
|
85
|
+
const lines = [` ${GATE_START_MARKER}`];
|
|
86
|
+
if (includeSetupNode) {
|
|
87
|
+
lines.push(
|
|
88
|
+
' - name: Setup Node.js for migrations',
|
|
89
|
+
' uses: actions/setup-node@v4',
|
|
90
|
+
' with:',
|
|
91
|
+
" node-version: '20'",
|
|
92
|
+
''
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
lines.push(
|
|
96
|
+
' - name: Pre-Deploy Database Migration',
|
|
97
|
+
' run: |',
|
|
98
|
+
` npx grada-run db migrate --cmd ${quoteShellArg(cmd)} --task-def "${taskDefRef}" --headless`,
|
|
99
|
+
` ${GATE_END_MARKER}`
|
|
100
|
+
);
|
|
101
|
+
return `${lines.join('\n')}\n`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Idempotently injects (or refreshes) the migration gate block into a
|
|
105
|
+
// deploy.yml document. Returns the updated content, or null when neither
|
|
106
|
+
// anchor step is present. Pure and unit-tested.
|
|
107
|
+
export function injectMigrationGate(workflowContent, { cmd }) {
|
|
108
|
+
const content = String(workflowContent ?? '');
|
|
109
|
+
const taskDefRef = '${{ steps.register-task-def.outputs.task-arn }}';
|
|
110
|
+
|
|
111
|
+
// Strip any previous gate block first so the setup-node check below
|
|
112
|
+
// never matches a node step that only exists inside the old block.
|
|
113
|
+
let base = content;
|
|
114
|
+
const previous = findGateBlock(base);
|
|
115
|
+
if (previous) {
|
|
116
|
+
const lineStart = base.lastIndexOf('\n', previous.startIdx) + 1;
|
|
117
|
+
const lineEnd = base.indexOf('\n', previous.endIdx);
|
|
118
|
+
base = base.slice(0, lineStart) + base.slice(lineEnd === -1 ? base.length : lineEnd + 1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const includeSetupNode = !base.includes('actions/setup-node');
|
|
122
|
+
const block = renderGateBlock({ cmd, includeSetupNode, taskDefRef });
|
|
123
|
+
|
|
124
|
+
const forceIdx = base.indexOf('- name: Force ECS deployment');
|
|
125
|
+
if (forceIdx === -1) return null;
|
|
126
|
+
const insertAt = base.lastIndexOf('\n', forceIdx) + 1;
|
|
127
|
+
return `${base.slice(0, insertAt)}${block}${base.slice(insertAt)}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Builds the container override command for the migration task. When the
|
|
131
|
+
// task definition provides discrete `DB_*` credentials but no
|
|
132
|
+
// `DATABASE_URL`, the command exports a runtime-synthesized URL (from the
|
|
133
|
+
// container's own environment, so secrets never cross the RunTask API
|
|
134
|
+
// call) before running the user's migration command. Pure and unit-tested.
|
|
135
|
+
export function buildMigrationCommand(resolvedCmd, containerDef) {
|
|
136
|
+
const cmd = String(resolvedCmd ?? '');
|
|
137
|
+
const environment = Array.isArray(containerDef?.environment) ? containerDef.environment : [];
|
|
138
|
+
const secrets = Array.isArray(containerDef?.secrets) ? containerDef.secrets : [];
|
|
139
|
+
const entries = [...environment, ...secrets];
|
|
140
|
+
const names = new Set(entries.map((entry) => entry?.name));
|
|
141
|
+
if (names.has('DATABASE_URL')) return ['sh', '-c', cmd];
|
|
142
|
+
if (!names.has('DB_HOST') || !names.has('DB_USER') || !names.has('DB_PASSWORD')) {
|
|
143
|
+
return ['sh', '-c', cmd];
|
|
144
|
+
}
|
|
145
|
+
const values = new Map(entries.map((entry) => [entry?.name, entry?.value]));
|
|
146
|
+
const scheme = values.get('DB_PORT') === '3306' || values.get('DB_ENGINE') === 'mysql' ? 'mysql' : 'postgresql';
|
|
147
|
+
// RDS/Aurora enforces `rds.force_ssl = 1` by default: require TLS for
|
|
148
|
+
// synthesized postgres URLs so libpq-based migrations never fail on a
|
|
149
|
+
// plaintext connection (MySQL clients ignore PGSSLMODE; leave them alone).
|
|
150
|
+
const tlsPrefix = scheme === 'postgresql' ? 'export PGSSLMODE="${PGSSLMODE:-require}"; ' : '';
|
|
151
|
+
return ['sh', '-c', `${tlsPrefix}export DATABASE_URL="\${DATABASE_URL:-${scheme}://\${DB_USER}:\${DB_PASSWORD}@\${DB_HOST}:\${DB_PORT:-5432}/\${DB_NAME:-postgres}}"; ${cmd}`];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Identity keys for one log line. FilterLogEvents results carry `eventId`
|
|
155
|
+
// but GetLogEvents results do not, so every line registers its
|
|
156
|
+
// timestamp:message fingerprint (present in both APIs) plus its `eventId`
|
|
157
|
+
// when it has one — the final head-flush then never reprints live lines.
|
|
158
|
+
function eventKeys(event) {
|
|
159
|
+
const keys = [`${event.timestamp ?? ''}:${event.message ?? ''}`];
|
|
160
|
+
if (event.eventId) keys.push(`id:${event.eventId}`);
|
|
161
|
+
return keys;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function printNewEvents(events, seen) {
|
|
165
|
+
if (seen.size > 5000) seen.clear();
|
|
166
|
+
for (const event of events || []) {
|
|
167
|
+
const keys = eventKeys(event);
|
|
168
|
+
if (keys.some((key) => seen.has(key))) continue;
|
|
169
|
+
for (const key of keys) seen.add(key);
|
|
170
|
+
console.log(String(event.message ?? '').replace(/\n$/, ''));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Live tail: FilterLogEvents scoped to the single task stream. `startTime`
|
|
175
|
+
// is deliberately omitted — the stream is unique per task run, so there is
|
|
176
|
+
// nothing stale to exclude and local clock skew can never drop early lines.
|
|
177
|
+
async function fetchNewLogEvents({ logsClient, logGroupName, logStreamName, seen }) {
|
|
178
|
+
let events = [];
|
|
179
|
+
try {
|
|
180
|
+
const resp = await logsClient.send(new FilterLogEventsCommand({
|
|
181
|
+
logGroupName,
|
|
182
|
+
logStreamNames: [logStreamName],
|
|
183
|
+
}));
|
|
184
|
+
events = resp.events || [];
|
|
185
|
+
} catch (error) {
|
|
186
|
+
// The stream does not exist until the container starts emitting.
|
|
187
|
+
if (isNotFoundError(error)) return;
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
printNewEvents(events, seen);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Final flush: GetLogEvents reads the exact stream from the head, catching
|
|
194
|
+
// anything the live tail missed (Fargate ingestion lags several seconds on
|
|
195
|
+
// sub-second commands). Shares the `seen` set so lines print exactly once.
|
|
196
|
+
async function flushRemainingLogs({ logsClient, logGroupName, logStreamName, seen }) {
|
|
197
|
+
let nextToken;
|
|
198
|
+
for (;;) {
|
|
199
|
+
let resp;
|
|
200
|
+
try {
|
|
201
|
+
resp = await logsClient.send(new GetLogEventsCommand({
|
|
202
|
+
logGroupName,
|
|
203
|
+
logStreamName,
|
|
204
|
+
startFromHead: true,
|
|
205
|
+
...(nextToken ? { nextToken } : {}),
|
|
206
|
+
}));
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (isNotFoundError(error)) return;
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
printNewEvents(resp.events, seen);
|
|
212
|
+
const forward = resp.nextForwardToken;
|
|
213
|
+
if (!forward || forward === nextToken) return;
|
|
214
|
+
nextToken = forward;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function runDbMigrate(input = {}) {
|
|
219
|
+
const options = normalizeOptions(input);
|
|
220
|
+
let cwd;
|
|
221
|
+
let region;
|
|
222
|
+
let projectName;
|
|
223
|
+
let cluster;
|
|
224
|
+
let service;
|
|
225
|
+
let containerName;
|
|
226
|
+
let logGroupName;
|
|
227
|
+
try {
|
|
228
|
+
cwd = resolveCwd(options);
|
|
229
|
+
region = resolveRegion(options, cwd);
|
|
230
|
+
projectName = resolveProjectName(options, cwd);
|
|
231
|
+
const appName = resolveAppName(projectName, options.workspace, cwd);
|
|
232
|
+
const namespacedOptions = { ...options, projectName: appName };
|
|
233
|
+
cluster = resolveCluster(namespacedOptions, cwd);
|
|
234
|
+
service = resolveService(namespacedOptions, cwd);
|
|
235
|
+
containerName = resolveContainer(namespacedOptions, cwd);
|
|
236
|
+
logGroupName = resolveLogGroup(namespacedOptions, cwd);
|
|
237
|
+
} catch {
|
|
238
|
+
return failProjectNotInitialized({ event: 'db_migrate_run' });
|
|
239
|
+
}
|
|
240
|
+
const headless = resolveHeadless(options);
|
|
241
|
+
|
|
242
|
+
const setupCi = options.setupCi === true || options.setupCi === 'true';
|
|
243
|
+
|
|
244
|
+
intro(color.bgCyan(color.black(' grada db migrate 🚀 ')));
|
|
245
|
+
|
|
246
|
+
if (Array.isArray(options.unexpectedPositionals) && options.unexpectedPositionals.length > 0) {
|
|
247
|
+
return failCommand({
|
|
248
|
+
message: `\n✖ Unexpected argument "${options.unexpectedPositionals[0]}". Wrap multi-word --cmd values in quotes: --cmd "${options.unexpectedPositionals.join(' ')}".\n`,
|
|
249
|
+
event: 'db_migrate_run',
|
|
250
|
+
telemetry: { projectName },
|
|
251
|
+
errorCode: 'UNEXPECTED_POSITIONAL_ARGS',
|
|
252
|
+
reason: 'unexpected-positional-args',
|
|
253
|
+
resultExtra: { cluster, service, region },
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const timeoutSeconds = parseTimeoutSeconds(options.timeout, DEFAULT_MIGRATE_TIMEOUT_SECONDS);
|
|
258
|
+
if (timeoutSeconds === null) {
|
|
259
|
+
return failCommand({
|
|
260
|
+
message: `\n✖ Invalid --timeout "${options.timeout}". Use a positive number of seconds (default ${DEFAULT_MIGRATE_TIMEOUT_SECONDS}).\n`,
|
|
261
|
+
event: 'db_migrate_run',
|
|
262
|
+
telemetry: { projectName },
|
|
263
|
+
errorCode: 'INVALID_TIMEOUT',
|
|
264
|
+
reason: 'invalid-timeout',
|
|
265
|
+
resultExtra: { cluster, service, region },
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 1. Resolve the migration command (shared by live runs and --setup-ci).
|
|
270
|
+
let resolvedCmd = null;
|
|
271
|
+
let cmdSource = null;
|
|
272
|
+
if (typeof options.cmd === 'string' && options.cmd.trim()) {
|
|
273
|
+
resolvedCmd = options.cmd.trim();
|
|
274
|
+
cmdSource = 'explicit';
|
|
275
|
+
} else {
|
|
276
|
+
const detectedCmd = detectMigrationCommand(cwd);
|
|
277
|
+
if (!headless) {
|
|
278
|
+
const answer = await text({
|
|
279
|
+
message: 'Migration command to run inside the VPC:',
|
|
280
|
+
placeholder: 'e.g. npx prisma migrate deploy',
|
|
281
|
+
initialValue: detectedCmd || '',
|
|
282
|
+
});
|
|
283
|
+
if (isCancel(answer) || typeof answer !== 'string' || !answer.trim()) {
|
|
284
|
+
outro(color.yellow('Migration cancelled.'));
|
|
285
|
+
return { ok: false, reason: 'cancelled', cluster, service, region };
|
|
286
|
+
}
|
|
287
|
+
resolvedCmd = answer.trim();
|
|
288
|
+
cmdSource = 'prompted';
|
|
289
|
+
} else if (detectedCmd) {
|
|
290
|
+
resolvedCmd = detectedCmd;
|
|
291
|
+
cmdSource = 'detected';
|
|
292
|
+
console.log(color.dim(` Using detected migration command: ${resolvedCmd}`));
|
|
293
|
+
} else {
|
|
294
|
+
return failCommand({
|
|
295
|
+
message: '\n✖ No migration command found. Pass --cmd "<command>" (e.g. --cmd "npx prisma migrate deploy").\n',
|
|
296
|
+
event: 'db_migrate_run',
|
|
297
|
+
telemetry: { projectName, cmd_source: 'none', ci_setup: setupCi },
|
|
298
|
+
errorCode: 'MISSING_MIGRATION_CMD',
|
|
299
|
+
reason: 'missing-migration-cmd',
|
|
300
|
+
resultExtra: { cluster, service, region },
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 2. --setup-ci: patch the workflow file, no AWS calls.
|
|
306
|
+
if (setupCi) {
|
|
307
|
+
const workflowFile = path.join(cwd, '.github', 'workflows', 'deploy.yml');
|
|
308
|
+
let workflowContent = null;
|
|
309
|
+
try {
|
|
310
|
+
if (fsSync.existsSync(workflowFile)) workflowContent = fsSync.readFileSync(workflowFile, 'utf8');
|
|
311
|
+
} catch {
|
|
312
|
+
workflowContent = null;
|
|
313
|
+
}
|
|
314
|
+
if (workflowContent === null) {
|
|
315
|
+
return failCommand({
|
|
316
|
+
message: `\n✖ Workflow not found at ${color.cyan('.github/workflows/deploy.yml')}. Run ${color.green('npx grada-run')} first.\n`,
|
|
317
|
+
event: 'db_migrate_run',
|
|
318
|
+
telemetry: { projectName, cmd_source: cmdSource, ci_setup: true },
|
|
319
|
+
errorCode: 'WORKFLOW_NOT_FOUND',
|
|
320
|
+
reason: 'workflow-not-found',
|
|
321
|
+
resultExtra: { cluster, service, region },
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
const updated = injectMigrationGate(workflowContent, { cmd: resolvedCmd });
|
|
325
|
+
if (updated === null) {
|
|
326
|
+
return failCommand({
|
|
327
|
+
message: '\n✖ Could not find the task-definition registration or ECS deployment step in .github/workflows/deploy.yml to anchor the migration gate.\n',
|
|
328
|
+
event: 'db_migrate_run',
|
|
329
|
+
telemetry: { projectName, cmd_source: cmdSource, ci_setup: true },
|
|
330
|
+
errorCode: 'WORKFLOW_ANCHOR_NOT_FOUND',
|
|
331
|
+
reason: 'workflow-anchor-not-found',
|
|
332
|
+
resultExtra: { cluster, service, region },
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
fsSync.writeFileSync(workflowFile, updated, 'utf8');
|
|
336
|
+
console.log(color.green('\n✅ Pre-deploy migration gate installed in .github/workflows/deploy.yml.'));
|
|
337
|
+
console.log(color.dim(' Migrations now run against the new image revision before the ECS service updates.\n'));
|
|
338
|
+
await trackSuccess('db_migrate_run', { projectName, cmd_source: cmdSource, ci_setup: true });
|
|
339
|
+
outro(color.green('Setup complete.'));
|
|
340
|
+
return { ok: true, ciSetup: true, workflowFile, cmdSource };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 3. Live run: discover the service and launch a one-off task.
|
|
344
|
+
const ecsClient = resolveClient(options.ecsClient, ECSClient, { region });
|
|
345
|
+
const logsClient = resolveClient(options.logsClient ?? options.cloudwatchClient, CloudWatchLogsClient, { region });
|
|
346
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
347
|
+
const maxFlushPolls = options.maxFlushPolls ?? DEFAULT_FLUSH_MAX_POLLS;
|
|
348
|
+
const flushIntervalMs = options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
|
|
349
|
+
const timeoutMs = options.timeoutMs ?? timeoutSeconds * 1000;
|
|
350
|
+
|
|
351
|
+
const s = spinner();
|
|
352
|
+
s.start('Starting migration task...');
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
const outcome = await runEphemeralEcsTask({
|
|
356
|
+
ecsClient,
|
|
357
|
+
logsClient,
|
|
358
|
+
cluster,
|
|
359
|
+
service,
|
|
360
|
+
containerName,
|
|
361
|
+
taskDef: (typeof options.taskDef === 'string' && options.taskDef.trim()) ? options.taskDef.trim() : null,
|
|
362
|
+
command: (containerDef) => buildMigrationCommand(resolvedCmd, containerDef),
|
|
363
|
+
startedBy: 'grada-db-migrate',
|
|
364
|
+
logGroupName,
|
|
365
|
+
timeoutMs,
|
|
366
|
+
timeoutSeconds,
|
|
367
|
+
pollIntervalMs,
|
|
368
|
+
maxFlushPolls,
|
|
369
|
+
flushIntervalMs,
|
|
370
|
+
spinner: s,
|
|
371
|
+
taskNoun: 'migration',
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
if (!outcome.ok && outcome.code === 'ECS_SERVICE_NOT_FOUND') {
|
|
375
|
+
s.stop(color.yellow('Service not found.'));
|
|
376
|
+
return failCommand({
|
|
377
|
+
print: () => {
|
|
378
|
+
console.log(`\n The ECS service ${color.cyan(service)} does not exist or is inactive.`);
|
|
379
|
+
console.log(` Run ${color.green('npx grada-run apply')} to provision your infrastructure.\n`);
|
|
380
|
+
},
|
|
381
|
+
event: 'db_migrate_run',
|
|
382
|
+
telemetry: { projectName, cmd_source: cmdSource, ci_setup: false },
|
|
383
|
+
errorCode: 'ECS_SERVICE_NOT_FOUND',
|
|
384
|
+
reason: 'ecs-service-not-found',
|
|
385
|
+
resultExtra: { cluster, service, region },
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (!outcome.ok && outcome.code === 'NO_VPC_CONFIG') {
|
|
390
|
+
s.stop(color.red('Service has no VPC configuration.'));
|
|
391
|
+
return failCommand({
|
|
392
|
+
message: `\n✖ The ECS service ${color.cyan(service)} has no VPC network configuration, so the migration task cannot be placed.\n`,
|
|
393
|
+
event: 'db_migrate_run',
|
|
394
|
+
telemetry: { projectName, cmd_source: cmdSource, ci_setup: false },
|
|
395
|
+
errorCode: 'RUN_TASK_FAILED',
|
|
396
|
+
reason: 'run-task-failed',
|
|
397
|
+
resultExtra: { cluster, service, region },
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (!outcome.ok && outcome.code === 'CONTAINER_NOT_FOUND') {
|
|
402
|
+
s.stop(color.red('Container not found.'));
|
|
403
|
+
return failCommand({
|
|
404
|
+
message: `\n✖ Container "${containerName}" does not exist in task definition "${outcome.targetTaskDef}".\n`,
|
|
405
|
+
hint: `Override with --container <name>. Available: ${outcome.available.join(', ') || 'none'}\n`,
|
|
406
|
+
event: 'db_migrate_run',
|
|
407
|
+
telemetry: { projectName, cmd_source: cmdSource, ci_setup: false },
|
|
408
|
+
errorCode: 'CONTAINER_NOT_FOUND',
|
|
409
|
+
reason: 'container-not-found',
|
|
410
|
+
resultExtra: { cluster, service, region },
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (!outcome.ok && outcome.code === 'RUN_TASK_FAILED') {
|
|
415
|
+
s.stop(color.red('Failed to start migration task.'));
|
|
416
|
+
return failCommand({
|
|
417
|
+
message: `\n✖ ECS could not start the migration task: ${outcome.reason}.\n`,
|
|
418
|
+
event: 'db_migrate_run',
|
|
419
|
+
telemetry: { projectName, cmd_source: cmdSource, ci_setup: false },
|
|
420
|
+
errorCode: 'RUN_TASK_FAILED',
|
|
421
|
+
reason: 'run-task-failed',
|
|
422
|
+
resultExtra: { cluster, service, region },
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
if (!outcome.ok && outcome.code === 'TIMEOUT') {
|
|
426
|
+
try { s.stop(color.yellow('Migration timed out.')); } catch { /* already stopped */ }
|
|
427
|
+
return failCommand({
|
|
428
|
+
message: `\n✖ Migration timed out after ${timeoutSeconds}s. The task was stopped (best-effort); increase --timeout and retry.\n`,
|
|
429
|
+
event: 'db_migrate_run',
|
|
430
|
+
telemetry: { projectName, cmd_source: cmdSource, ci_setup: false },
|
|
431
|
+
errorCode: 'MIGRATION_TIMEOUT',
|
|
432
|
+
reason: 'migration-timeout',
|
|
433
|
+
resultExtra: { cluster, service, region, taskArn: outcome.taskArn },
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const exitCode = outcome.exitCode;
|
|
438
|
+
const stoppedReason = outcome.stoppedReason;
|
|
439
|
+
if (exitCode === 0) {
|
|
440
|
+
console.log(color.green('\n✅ Migration succeeded.'));
|
|
441
|
+
await trackSuccess('db_migrate_run', { projectName, cmd_source: cmdSource, ci_setup: false });
|
|
442
|
+
outro(color.green('Done.'));
|
|
443
|
+
return { ok: true, success: true, exitCode: 0, taskArn: outcome.taskArn };
|
|
444
|
+
}
|
|
445
|
+
const propagated = (typeof exitCode === 'number' && exitCode > 0) ? exitCode : 1;
|
|
446
|
+
return failCommand({
|
|
447
|
+
message: `\n✖ Migration failed with exit code ${exitCode ?? 'unknown'}: ${stoppedReason}.\n`,
|
|
448
|
+
event: 'db_migrate_run',
|
|
449
|
+
telemetry: { projectName, cmd_source: cmdSource, ci_setup: false },
|
|
450
|
+
errorCode: 'MIGRATION_TASK_FAILED',
|
|
451
|
+
extra: { exit_code: exitCode ?? -1 },
|
|
452
|
+
reason: 'migration-task-failed',
|
|
453
|
+
resultExtra: { cluster, service, region, taskArn: outcome.taskArn },
|
|
454
|
+
exitCode: propagated,
|
|
455
|
+
});
|
|
456
|
+
} catch (error) {
|
|
457
|
+
await trackFailure('db_migrate_run', {
|
|
458
|
+
projectName,
|
|
459
|
+
cmd_source: cmdSource,
|
|
460
|
+
ci_setup: false,
|
|
461
|
+
error_code: error?.name || 'UNKNOWN',
|
|
462
|
+
error_message: error?.message,
|
|
463
|
+
});
|
|
464
|
+
if (handleAuthErrorBranch(error, s, options)) {
|
|
465
|
+
return { ok: false, reason: 'error', cluster, service, region };
|
|
466
|
+
}
|
|
467
|
+
try { s.stop(color.red('❌ Db migrate failed.')); } catch { /* spinner already stopped */ }
|
|
468
|
+
return failCommand({
|
|
469
|
+
message: `✖ ${error?.message || error}`,
|
|
470
|
+
hint: 'Check your AWS credentials and region, then try again.',
|
|
471
|
+
reason: 'error',
|
|
472
|
+
resultExtra: { cluster, service, region },
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export default runDbMigrate;
|