grada-run 0.0.2 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/deploy-docs.yml +37 -0
- package/.github/workflows/iac-validation.yml +303 -0
- package/.github/workflows/publish.yml +68 -0
- package/.github/workflows/sync-bedrock-models.yml +57 -0
- package/.github/workflows/test.yml +43 -0
- package/.muserules +31 -0
- package/LICENSE +21 -0
- package/README.md +193 -3
- package/apps/docs/.astro/collections/docs.schema.json +644 -0
- package/apps/docs/.astro/content-assets.mjs +4 -0
- package/apps/docs/.astro/content-modules.mjs +4 -0
- package/apps/docs/.astro/content.d.ts +179 -0
- package/apps/docs/.astro/data-store.json +1 -0
- package/apps/docs/.astro/dev.json +14 -0
- package/apps/docs/.astro/settings.json +5 -0
- package/apps/docs/.astro/types.d.ts +2 -0
- package/apps/docs/astro.config.mjs +97 -0
- package/apps/docs/package.json +17 -0
- package/apps/docs/src/content/docs/adrs/0001-s3-native-state-locking.md +37 -0
- package/apps/docs/src/content/docs/adrs/0002-eject-mechanism-pure-iac.md +39 -0
- package/apps/docs/src/content/docs/adrs/0003-sync-ai-context-strategy.md +48 -0
- package/apps/docs/src/content/docs/adrs/0004-iac-driven-diagnostic-context.md +37 -0
- package/apps/docs/src/content/docs/adrs/0005-ecs-fargate-alb-runtime-target.md +38 -0
- package/apps/docs/src/content/docs/adrs/0006-github-oidc-no-stored-keys.md +37 -0
- package/apps/docs/src/content/docs/adrs/0007-framework-detection-with-fallback.md +37 -0
- package/apps/docs/src/content/docs/adrs/0008-secrets-names-in-git-values-in-aws.md +37 -0
- package/apps/docs/src/content/docs/adrs/0009-regenerate-with-backup-on-rerun.md +37 -0
- package/apps/docs/src/content/docs/adrs/0010-advisory-only-security-scans.md +37 -0
- package/apps/docs/src/content/docs/cli/add.md +84 -0
- package/apps/docs/src/content/docs/cli/apply.md +32 -0
- package/apps/docs/src/content/docs/cli/db.md +200 -0
- package/apps/docs/src/content/docs/cli/destroy.md +31 -0
- package/apps/docs/src/content/docs/cli/diagnose.md +37 -0
- package/apps/docs/src/content/docs/cli/doctor.md +28 -0
- package/apps/docs/src/content/docs/cli/domain.md +57 -0
- package/apps/docs/src/content/docs/cli/drift.md +40 -0
- package/apps/docs/src/content/docs/cli/eject.md +29 -0
- package/apps/docs/src/content/docs/cli/exec.md +49 -0
- package/apps/docs/src/content/docs/cli/gc.md +37 -0
- package/apps/docs/src/content/docs/cli/init.md +72 -0
- package/apps/docs/src/content/docs/cli/logs.md +39 -0
- package/apps/docs/src/content/docs/cli/rollback.md +51 -0
- package/apps/docs/src/content/docs/cli/secrets.md +73 -0
- package/apps/docs/src/content/docs/cli/sleep.md +53 -0
- package/apps/docs/src/content/docs/cli/status.md +34 -0
- package/apps/docs/src/content/docs/cli/sync-ai.md +27 -0
- package/apps/docs/src/content/docs/guides/architecture.md +87 -0
- package/apps/docs/src/content/docs/guides/aws-credentials.md +72 -0
- package/apps/docs/src/content/docs/guides/background-workers.md +45 -0
- package/apps/docs/src/content/docs/guides/cicd-pipeline.md +64 -0
- package/apps/docs/src/content/docs/guides/database-connections.md +64 -0
- package/apps/docs/src/content/docs/guides/docker-compose.md +37 -0
- package/apps/docs/src/content/docs/guides/dockerfiles.md +46 -0
- package/apps/docs/src/content/docs/guides/ephemeral-pr-previews.md +39 -0
- package/apps/docs/src/content/docs/guides/examples.md +50 -0
- package/apps/docs/src/content/docs/guides/frameworks.md +88 -0
- package/apps/docs/src/content/docs/guides/headless.md +75 -0
- package/apps/docs/src/content/docs/guides/quickstart.md +52 -0
- package/apps/docs/src/content/docs/guides/rerun-init.md +43 -0
- package/apps/docs/src/content/docs/guides/secrets-management.md +83 -0
- package/apps/docs/src/content/docs/guides/understanding-your-bill.md +63 -0
- package/apps/docs/src/content/docs/index.mdx +103 -0
- package/apps/docs/src/content/docs/migrations/astro-vercel-to-aws.md +55 -0
- package/apps/docs/src/content/docs/migrations/heroku-procfile-to-aws.md +41 -0
- package/apps/docs/src/content/docs/migrations/nextjs-vercel-to-aws.md +51 -0
- package/apps/docs/src/content/docs/migrations/sveltekit-vercel-to-aws.md +63 -0
- package/apps/docs/src/content/docs/roadmap.md +97 -0
- package/apps/docs/src/content/docs/testing-strategy.md +32 -0
- package/apps/docs/src/content.config.ts +7 -0
- package/apps/docs/src/custom.css +14 -0
- package/apps/docs/tsconfig.json +6 -0
- package/bin/cli.js +140 -0
- package/package.json +105 -7
- package/scripts/sync-bedrock-models.js +22 -0
- package/scripts/test-iac.js +261 -0
- package/specs/add-redis-sqs-bedrock.md +128 -0
- package/specs/add-storage-dynamodb.md +106 -0
- package/specs/bedrock-model-catalog.md +131 -0
- package/specs/ci-pipeline.md +17 -0
- package/specs/cost-transparency.md +115 -0
- package/specs/custom-domains-and-ses.md +153 -0
- package/specs/database-suite-expansion.md +151 -0
- package/specs/db-connect.md +69 -0
- package/specs/db-lifecycle-migrations.md +159 -0
- package/specs/dependency-aware-init.md +176 -0
- package/specs/deploy-stack-to-grada-run-rebrand.md +45 -0
- package/specs/deployment-safety.md +170 -0
- package/specs/diagnose.md +16 -0
- package/specs/docs-hub.md +16 -0
- package/specs/dx-polish.md +46 -0
- package/specs/exec.md +25 -0
- package/specs/finops-cron-drift.md +161 -0
- package/specs/gc.md +26 -0
- package/specs/integration-suite.md +16 -0
- package/specs/logs.md +32 -0
- package/specs/rollback-live-polling.md +40 -0
- package/specs/secrets-pull-audit.md +51 -0
- package/specs/serverless-lambda-target.md +133 -0
- package/specs/status.md +31 -0
- package/specs/telemetry-and-spawn-hardening.md +69 -0
- package/specs/telemetry-hardening.md +35 -0
- package/src/commands/add.js +1111 -0
- package/src/commands/apply.js +214 -0
- package/src/commands/db/backup.js +229 -0
- package/src/commands/db/connect.js +304 -0
- package/src/commands/db/enable-vector.js +344 -0
- package/src/commands/db/import.js +604 -0
- package/src/commands/db/migrate.js +477 -0
- package/src/commands/db/restore.js +361 -0
- package/src/commands/db.js +87 -0
- package/src/commands/destroy.js +217 -0
- package/src/commands/diagnose.js +460 -0
- package/src/commands/doctor.js +109 -0
- package/src/commands/domain.js +685 -0
- package/src/commands/drift.js +243 -0
- package/src/commands/eject.js +127 -0
- package/src/commands/exec.js +222 -0
- package/src/commands/gc.js +250 -0
- package/src/commands/init.js +649 -0
- package/src/commands/logs.js +256 -0
- package/src/commands/rollback.js +323 -0
- package/src/commands/secrets.js +485 -0
- package/src/commands/sleep.js +347 -0
- package/src/commands/status.js +309 -0
- package/src/commands/sync-ai.js +115 -0
- package/src/commands/wake.js +337 -0
- package/src/core/parser.js +126 -0
- package/src/core/telemetry.js +244 -0
- package/src/data/bedrock-models.json +896 -0
- package/src/utils/addons.js +126 -0
- package/src/utils/ai-rules.js +59 -0
- package/src/utils/args.js +91 -0
- package/src/utils/aws.js +178 -0
- package/src/utils/backup.js +69 -0
- package/src/utils/bedrock-catalog.js +511 -0
- package/src/utils/capabilities.js +500 -0
- package/src/utils/command.js +65 -0
- package/src/utils/db-tunnel.js +164 -0
- package/src/utils/detector.js +298 -0
- package/src/utils/dockerCompose.js +65 -0
- package/src/utils/domains.js +73 -0
- package/src/utils/ecs-runner.js +289 -0
- package/src/utils/ecs.js +92 -0
- package/src/utils/frameworks.js +55 -0
- package/src/utils/generator.js +527 -0
- package/src/utils/hcl.js +426 -0
- package/src/utils/lambda-ecr.js +185 -0
- package/src/utils/prompts.js +278 -0
- package/src/utils/rds.js +131 -0
- package/src/utils/resolvers.js +174 -0
- package/src/utils/sleep-state.js +140 -0
- package/src/utils/sleep-targets.js +139 -0
- package/src/utils/system.js +42 -0
- package/src/utils/terraform.js +70 -0
- package/src/utils/visualizer.js +381 -0
- package/src/utils/warnings.js +49 -0
- package/templates/README.md +150 -0
- package/templates/docker/django.Dockerfile +40 -0
- package/templates/docker/go.Dockerfile +23 -0
- package/templates/docker/nestjs.Dockerfile +33 -0
- package/templates/docker/nextjs.Dockerfile +55 -0
- package/templates/docker/node.Dockerfile +24 -0
- package/templates/docker/nuxt.Dockerfile +47 -0
- package/templates/docker/python.Dockerfile +38 -0
- package/templates/docker/rails.Dockerfile +59 -0
- package/templates/docker/static.Dockerfile +32 -0
- package/templates/docker/svelte.Dockerfile +52 -0
- package/templates/github/deploy-lambda.yml +120 -0
- package/templates/github/deploy.yml +138 -0
- package/templates/github/drift.yml +112 -0
- package/templates/github/preview-lambda.yml +86 -0
- package/templates/github/preview.yml +69 -0
- package/templates/github/teardown.yml +43 -0
- package/templates/terraform/addons/bedrock.tf +34 -0
- package/templates/terraform/addons/cron-lambda.tf +78 -0
- package/templates/terraform/addons/cron.tf +101 -0
- package/templates/terraform/addons/dynamodb.tf +73 -0
- package/templates/terraform/addons/redis.tf +64 -0
- package/templates/terraform/addons/s3.tf +143 -0
- package/templates/terraform/addons/ses.tf +73 -0
- package/templates/terraform/addons/sqs.tf +67 -0
- package/templates/terraform/backend.tf +22 -0
- package/templates/terraform/cloudfront-lambda.tf +80 -0
- package/templates/terraform/cloudfront.tf +80 -0
- package/templates/terraform/database-aurora-postgresql.tf +92 -0
- package/templates/terraform/database-mysql.tf +72 -0
- package/templates/terraform/database.tf +71 -0
- package/templates/terraform/main-lambda.tf +229 -0
- package/templates/terraform/main.tf +296 -0
- package/templates/terraform/network.tf +95 -0
- package/templates/terraform/oidc.tf +64 -0
- package/templates/terraform/secrets.tf +31 -0
- package/templates/terraform/worker.tf +69 -0
- package/tests/__snapshots__/generator.test.js.snap +9633 -0
- package/tests/add.test.js +2037 -0
- package/tests/ai.test.js +94 -0
- package/tests/apply.test.js +488 -0
- package/tests/args.test.js +86 -0
- package/tests/aws.test.js +244 -0
- package/tests/capabilities.test.js +307 -0
- package/tests/cli.test.js +29 -0
- package/tests/command.test.js +100 -0
- package/tests/commands-import.test.js +74 -0
- package/tests/db.test.js +2704 -0
- package/tests/destroy.test.js +391 -0
- package/tests/detector.test.js +79 -0
- package/tests/diagnose.test.js +779 -0
- package/tests/doctor.test.js +202 -0
- package/tests/domain.test.js +899 -0
- package/tests/drift.test.js +243 -0
- package/tests/ecs.test.js +130 -0
- package/tests/eject.test.js +65 -0
- package/tests/exec.test.js +380 -0
- package/tests/gc.test.js +496 -0
- package/tests/generator.test.js +794 -0
- package/tests/headless.test.js +562 -0
- package/tests/lambda-ecr.test.js +185 -0
- package/tests/logs.test.js +447 -0
- package/tests/parser.test.js +160 -0
- package/tests/rds.test.js +244 -0
- package/tests/resolvers.test.js +282 -0
- package/tests/rollback.test.js +692 -0
- package/tests/secrets.test.js +752 -0
- package/tests/sleep-wake.test.js +1016 -0
- package/tests/status.test.js +370 -0
- package/tests/system.test.js +70 -0
- package/tests/telemetry.test.js +520 -0
- package/tests/terraform.test.js +84 -0
- package/tests/visualizer.test.js +496 -0
- package/vitest.config.js +9 -0
- package/index.js +0 -2
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { CloudWatchLogsClient, FilterLogEventsCommand, DescribeLogStreamsCommand } from '@aws-sdk/client-cloudwatch-logs';
|
|
2
|
+
import color from 'picocolors';
|
|
3
|
+
import { trackEvent, flushTelemetry, trackSuccess, trackFailure } from '../core/telemetry.js';
|
|
4
|
+
import { parseFlags, normalizeOptions, normalizeArgv } from '../utils/args.js';
|
|
5
|
+
import { isAuthError, handleAuthErrorBranch, resolveClient } from '../utils/aws.js';
|
|
6
|
+
import { resolveRegion, resolveProjectName, resolveLogGroup, resolveCwd, readTerraformComputeTarget } from '../utils/resolvers.js';
|
|
7
|
+
import { failProjectNotInitialized } from '../utils/command.js';
|
|
8
|
+
import { sleep } from '../utils/system.js';
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_TAIL_LINES = 50;
|
|
11
|
+
export const DEFAULT_SINCE = '1h';
|
|
12
|
+
export const FOLLOW_POLL_INTERVAL_MS = 2000;
|
|
13
|
+
export const ERROR_KEYWORDS = ['ERROR', 'FATAL', 'Exception', 'fail', '500', '502'];
|
|
14
|
+
|
|
15
|
+
const ERROR_PATTERN = /ERROR|FATAL|Exception|fail|500|502/i;
|
|
16
|
+
const HIGHLIGHT_ERROR_PATTERN = /error|fatal|exception|fail|5\d\d/i;
|
|
17
|
+
const WARN_PATTERN = /warn/i;
|
|
18
|
+
|
|
19
|
+
const SINCE_RE = /^(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks)?$/i;
|
|
20
|
+
const UNIT_MS = { s: 1000, m: 60 * 1000, h: 60 * 60 * 1000, d: 24 * 60 * 60 * 1000, w: 7 * 24 * 60 * 60 * 1000 };
|
|
21
|
+
|
|
22
|
+
export function resolveServiceName(options = {}, cwd = process.cwd()) {
|
|
23
|
+
for (const key of ['service', 'serviceName', 'container']) {
|
|
24
|
+
if (typeof options[key] === 'string' && options[key].trim()) {
|
|
25
|
+
return options[key].trim();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return resolveProjectName(options, cwd);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function hasExplicitService(options = {}) {
|
|
32
|
+
return ['service', 'serviceName', 'container'].some(
|
|
33
|
+
(key) => typeof options[key] === 'string' && options[key].trim()
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function normalizeTailLines(value) {
|
|
38
|
+
const n = typeof value === 'string' && value.trim() === '' ? NaN : parseInt(value, 10);
|
|
39
|
+
if (!Number.isFinite(n) || n < 1) return DEFAULT_TAIL_LINES;
|
|
40
|
+
return n;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parseSinceDuration(input) {
|
|
44
|
+
if (input === undefined || input === null || input === '') return UNIT_MS.h;
|
|
45
|
+
if (typeof input === 'number' && Number.isFinite(input)) return Math.max(0, input * 1000);
|
|
46
|
+
const match = String(input).trim().match(SINCE_RE);
|
|
47
|
+
if (!match) return UNIT_MS.h;
|
|
48
|
+
const amount = parseInt(match[1], 10);
|
|
49
|
+
const unit = (match[2] || 's').toLowerCase()[0];
|
|
50
|
+
return amount * (UNIT_MS[unit] || UNIT_MS.h);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Alias kept for convenience; both names are part of the public surface.
|
|
54
|
+
export const parseSince = parseSinceDuration;
|
|
55
|
+
|
|
56
|
+
export function isErrorLine(line) {
|
|
57
|
+
return ERROR_PATTERN.test(String(line ?? ''));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Alias kept for convenience.
|
|
61
|
+
export const matchesErrorFilter = isErrorLine;
|
|
62
|
+
|
|
63
|
+
export function extractTaskId(logStreamName) {
|
|
64
|
+
if (!logStreamName) return '';
|
|
65
|
+
const parts = String(logStreamName).split('/');
|
|
66
|
+
return parts[parts.length - 1] || '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatLogLine(event = {}) {
|
|
70
|
+
const timestamp = event.timestamp ? new Date(event.timestamp).toISOString() : new Date().toISOString();
|
|
71
|
+
const taskId = extractTaskId(event.logStreamName);
|
|
72
|
+
const message = String(event.message ?? '').replace(/\n$/, '');
|
|
73
|
+
let body = message;
|
|
74
|
+
if (HIGHLIGHT_ERROR_PATTERN.test(message)) {
|
|
75
|
+
body = color.red(message);
|
|
76
|
+
} else if (WARN_PATTERN.test(message)) {
|
|
77
|
+
body = color.yellow(message);
|
|
78
|
+
}
|
|
79
|
+
return taskId ? `${color.dim(timestamp)} ${color.dim(taskId)} ${body}` : `${color.dim(timestamp)} ${body}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Alias kept for convenience.
|
|
83
|
+
export const formatLogEvent = formatLogLine;
|
|
84
|
+
|
|
85
|
+
export function parseLogsArgs(argv = []) {
|
|
86
|
+
const args = normalizeArgv(argv);
|
|
87
|
+
if (args[0] === 'logs') args.shift();
|
|
88
|
+
const { options, rest } = parseFlags(args, {
|
|
89
|
+
string: ['since', 'region'],
|
|
90
|
+
number: ['tail'],
|
|
91
|
+
bareBoolean: ['follow', 'error'],
|
|
92
|
+
alias: { f: 'follow' },
|
|
93
|
+
});
|
|
94
|
+
const positionals = rest.filter((arg) => typeof arg === 'string' && !arg.startsWith('-'));
|
|
95
|
+
if (positionals.length > 0) options.service = positionals[0];
|
|
96
|
+
return options;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function eventKey(event) {
|
|
100
|
+
if (event.eventId) return `id:${event.eventId}`;
|
|
101
|
+
return `${event.timestamp}:${event.message}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function isNotFoundError(error) {
|
|
105
|
+
return error && (
|
|
106
|
+
error.name === 'ResourceNotFoundException' ||
|
|
107
|
+
/log group .* (does not exist|not found|cannot be found)/i.test(error.message || '')
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// CloudWatch log stream for one container run. Mirrors the `awslogs`
|
|
112
|
+
// configuration in `templates/terraform/main.tf` (`stream-prefix "ecs"`):
|
|
113
|
+
// `<prefix>/<container-name>/<task-id>`. Inverse of `extractTaskId`.
|
|
114
|
+
export function buildLogStreamName(containerName, taskId, prefix = 'ecs') {
|
|
115
|
+
return `${prefix}/${containerName}/${taskId}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function printMissingLogGroupGuidance(logGroup, service, region) {
|
|
119
|
+
const prefix = `/${String(logGroup).split('/').filter(Boolean).slice(0, 2).join('/')}/`;
|
|
120
|
+
console.log(color.yellow(`\n⚠ No log group found for "${service}" (expected ${logGroup}).`));
|
|
121
|
+
console.log(color.dim(`List matching groups with: aws logs describe-log-groups --log-group-name-prefix "${prefix}" --region ${region}`));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function runLogs(input = {}) {
|
|
125
|
+
const options = normalizeOptions(input);
|
|
126
|
+
let cwd;
|
|
127
|
+
let region;
|
|
128
|
+
let service;
|
|
129
|
+
let logGroup;
|
|
130
|
+
try {
|
|
131
|
+
cwd = resolveCwd(options);
|
|
132
|
+
region = resolveRegion(options, cwd);
|
|
133
|
+
service = resolveServiceName(options, cwd);
|
|
134
|
+
logGroup = resolveLogGroup(options, cwd);
|
|
135
|
+
} catch {
|
|
136
|
+
return failProjectNotInitialized({ event: 'logs_streamed' });
|
|
137
|
+
}
|
|
138
|
+
const tail = normalizeTailLines(options.tail ?? options.tailLines ?? options.lines);
|
|
139
|
+
const follow = Boolean(options.follow ?? options.f);
|
|
140
|
+
const onlyErrors = Boolean(options.error ?? options.onlyErrors ?? options.filterErrors);
|
|
141
|
+
// Lambda stream names are date/request-id based, not container names, so
|
|
142
|
+
// a service stream-prefix filter would hide every event on that target.
|
|
143
|
+
const isLambda = readTerraformComputeTarget(cwd) === 'lambda';
|
|
144
|
+
const explicitService = hasExplicitService(options) && !isLambda;
|
|
145
|
+
|
|
146
|
+
const sinceRaw = options.since ?? options.sinceDuration ?? (follow ? undefined : DEFAULT_SINCE);
|
|
147
|
+
const startTime = sinceRaw === undefined ? undefined : Date.now() - parseSinceDuration(sinceRaw);
|
|
148
|
+
|
|
149
|
+
const pollIntervalMs = options.pollIntervalMs ?? FOLLOW_POLL_INTERVAL_MS;
|
|
150
|
+
const maxPolls = options.maxPolls ?? (follow ? Infinity : 1);
|
|
151
|
+
|
|
152
|
+
if (process.env.CI_MOCK_AWS === 'true' && !options.logsClient && !options.client) {
|
|
153
|
+
const mocked = ['INFO service started', 'ERROR mocked failure for offline mode'];
|
|
154
|
+
const visible = onlyErrors ? mocked.filter(isErrorLine) : mocked;
|
|
155
|
+
for (const line of visible.slice(-tail)) console.log(formatLogLine({ timestamp: Date.now(), message: line }));
|
|
156
|
+
return { logs: visible.slice(-tail), logGroup, region, service, mocked: true };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const logsClient = resolveClient(options.logsClient ?? options.client ?? options.cloudwatchClient, CloudWatchLogsClient, { region });
|
|
160
|
+
|
|
161
|
+
let stopped = false;
|
|
162
|
+
const onSigint = () => {
|
|
163
|
+
stopped = true;
|
|
164
|
+
console.log(color.dim('\nStopped following logs.'));
|
|
165
|
+
};
|
|
166
|
+
if (follow) process.once('SIGINT', onSigint);
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
try {
|
|
170
|
+
await logsClient.send(new DescribeLogStreamsCommand({ logGroupName: logGroup, limit: 1 }));
|
|
171
|
+
} catch (verifyError) {
|
|
172
|
+
if (isAuthError(verifyError)) throw verifyError;
|
|
173
|
+
if (isNotFoundError(verifyError)) {
|
|
174
|
+
printMissingLogGroupGuidance(logGroup, service, region);
|
|
175
|
+
return { logs: [], logGroup, region, service };
|
|
176
|
+
}
|
|
177
|
+
// Any other verification failure is non-fatal: the fetch below is authoritative.
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const seen = new Set();
|
|
181
|
+
const collected = [];
|
|
182
|
+
let nextToken;
|
|
183
|
+
let polls = 0;
|
|
184
|
+
let firstPoll = true;
|
|
185
|
+
|
|
186
|
+
do {
|
|
187
|
+
const input = { logGroupName: logGroup, limit: Math.min(Math.max(tail, 1), 10000) };
|
|
188
|
+
if (startTime !== undefined) input.startTime = startTime;
|
|
189
|
+
if (explicitService) input.logStreamNamePrefix = service;
|
|
190
|
+
if (nextToken) input.nextToken = nextToken;
|
|
191
|
+
|
|
192
|
+
const resp = await logsClient.send(new FilterLogEventsCommand(input));
|
|
193
|
+
if (resp.nextToken) nextToken = resp.nextToken;
|
|
194
|
+
|
|
195
|
+
if (seen.size > 5000) seen.clear();
|
|
196
|
+
|
|
197
|
+
let fresh = (resp.events || []).filter((e) => {
|
|
198
|
+
const key = eventKey(e);
|
|
199
|
+
if (seen.has(key)) return false;
|
|
200
|
+
seen.add(key);
|
|
201
|
+
return true;
|
|
202
|
+
});
|
|
203
|
+
if (onlyErrors) fresh = fresh.filter((e) => isErrorLine(e.message));
|
|
204
|
+
if (firstPoll) fresh = fresh.slice(-tail);
|
|
205
|
+
|
|
206
|
+
for (const event of fresh) {
|
|
207
|
+
console.log(formatLogLine(event));
|
|
208
|
+
collected.push(String(event.message ?? ''));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
firstPoll = false;
|
|
212
|
+
polls += 1;
|
|
213
|
+
if (!follow || stopped || polls >= maxPolls) break;
|
|
214
|
+
await sleep(pollIntervalMs);
|
|
215
|
+
} while (true);
|
|
216
|
+
|
|
217
|
+
if (collected.length === 0) {
|
|
218
|
+
console.log(color.cyan(`\nℹ Waiting for logs...`));
|
|
219
|
+
console.log(` The log group "${logGroup}" exists, but no application logs have been written yet.`);
|
|
220
|
+
console.log(` This is perfectly normal immediately after running 'apply' while the container boots.`);
|
|
221
|
+
console.log(` Try again in a minute, or run ${color.green('npx grada-run logs -f')} to watch the stream live.\n`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
await trackSuccess('logs_streamed', {
|
|
225
|
+
projectName: resolveProjectName(options, cwd),
|
|
226
|
+
is_following: follow,
|
|
227
|
+
filtered_errors: onlyErrors,
|
|
228
|
+
tail_lines: tail
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
return { logs: collected, logGroup, region, service };
|
|
232
|
+
} catch (error) {
|
|
233
|
+
|
|
234
|
+
await trackFailure('logs_streamed', {
|
|
235
|
+
projectName: resolveProjectName(options, cwd),
|
|
236
|
+
error_type: error.name || 'UNKNOWN'
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (handleAuthErrorBranch(error, null, options)) {
|
|
240
|
+
return { logs: [], logGroup, region, service };
|
|
241
|
+
}
|
|
242
|
+
if (isNotFoundError(error)) {
|
|
243
|
+
printMissingLogGroupGuidance(logGroup, service, region);
|
|
244
|
+
return { logs: [], logGroup, region, service };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
throw error;
|
|
248
|
+
} finally {
|
|
249
|
+
if (follow) process.removeListener('SIGINT', onSigint);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Convenience alias mirroring the CLI verb.
|
|
254
|
+
export const logsCommand = runLogs;
|
|
255
|
+
|
|
256
|
+
export default runLogs;
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ECSClient,
|
|
3
|
+
DescribeServicesCommand,
|
|
4
|
+
ListTaskDefinitionsCommand,
|
|
5
|
+
DescribeTaskDefinitionCommand,
|
|
6
|
+
UpdateServiceCommand,
|
|
7
|
+
} from '@aws-sdk/client-ecs';
|
|
8
|
+
import color from 'picocolors';
|
|
9
|
+
import { intro, outro, spinner, select, isCancel } from '@clack/prompts';
|
|
10
|
+
import { trackEvent, flushTelemetry, trackSuccess, trackFailure } from '../core/telemetry.js';
|
|
11
|
+
import { failCommand, failProjectNotInitialized } from '../utils/command.js';
|
|
12
|
+
import { parseFlags, normalizeOptions, normalizeArgv } from '../utils/args.js';
|
|
13
|
+
import { isAuthError, handleAwsAuthError, resolveClient } from '../utils/aws.js';
|
|
14
|
+
import { resolveRegion, resolveProjectName, resolveCluster, resolveService, readFileSafe, resolveWorkspaceSuffix, resolveCwd, readTerraformComputeTarget } from '../utils/resolvers.js';
|
|
15
|
+
import { sleep } from '../utils/system.js';
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_POLL_INTERVAL_MS = 5000;
|
|
18
|
+
export const DEFAULT_TIMEOUT_MS = 300000;
|
|
19
|
+
|
|
20
|
+
export function parseRollbackArgs(argv = []) {
|
|
21
|
+
const args = normalizeArgv(argv);
|
|
22
|
+
if (args[0] === 'rollback') args.shift();
|
|
23
|
+
const { options: parsed, rest } = parseFlags(args, {
|
|
24
|
+
string: ['cluster', 'service', 'region', 'workspace'],
|
|
25
|
+
boolean: ['skip-wait'],
|
|
26
|
+
});
|
|
27
|
+
const options = { skipWait: false, ...parsed };
|
|
28
|
+
for (const arg of rest) {
|
|
29
|
+
if (typeof arg === 'string' && !arg.startsWith('-') && options.revision === undefined) {
|
|
30
|
+
options.revision = arg;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return options;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseFamilyAndRevision(taskDefArn) {
|
|
37
|
+
const familyAndRev = String(taskDefArn).split('/').pop();
|
|
38
|
+
const [family, revStr] = familyAndRev.split(':');
|
|
39
|
+
return { family, revNum: Number(revStr) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatRegisteredAt(registeredAt) {
|
|
43
|
+
if (registeredAt instanceof Date) return registeredAt.toISOString().slice(0, 10);
|
|
44
|
+
if (registeredAt) return String(registeredAt).slice(0, 10);
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function runRollback(input = {}) {
|
|
49
|
+
const options = normalizeOptions(input);
|
|
50
|
+
let cwd;
|
|
51
|
+
let region;
|
|
52
|
+
let projectName;
|
|
53
|
+
let cluster;
|
|
54
|
+
let service;
|
|
55
|
+
try {
|
|
56
|
+
cwd = resolveCwd(options);
|
|
57
|
+
region = resolveRegion(options, cwd);
|
|
58
|
+
projectName = resolveProjectName(options, cwd);
|
|
59
|
+
|
|
60
|
+
// Namespace cluster/service for PR-preview workspaces; explicit
|
|
61
|
+
// --cluster/--service flags still win inside the resolvers.
|
|
62
|
+
const namespacedProject = `${projectName}${resolveWorkspaceSuffix(options, cwd)}`;
|
|
63
|
+
const namespacedOptions = { ...options, projectName: namespacedProject };
|
|
64
|
+
cluster = resolveCluster(namespacedOptions, cwd);
|
|
65
|
+
service = resolveService(namespacedOptions, cwd);
|
|
66
|
+
} catch {
|
|
67
|
+
return failProjectNotInitialized({ event: 'rollback_run' });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const headless = typeof options.isHeadless === 'boolean'
|
|
71
|
+
? options.isHeadless
|
|
72
|
+
: Boolean(process.env.CI || !process.stdin.isTTY);
|
|
73
|
+
|
|
74
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
75
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
76
|
+
|
|
77
|
+
const ecsClient = resolveClient(options.ecsClient, ECSClient, { region });
|
|
78
|
+
|
|
79
|
+
intro(color.bgCyan(color.black(' grada rollback ⏪ ')));
|
|
80
|
+
|
|
81
|
+
if (readTerraformComputeTarget(cwd) === 'lambda') {
|
|
82
|
+
const functionName = `${projectName}-fn`;
|
|
83
|
+
return failCommand({
|
|
84
|
+
print: () => {
|
|
85
|
+
console.log(color.red(`\n✖ Rollback targets ECS task revisions, but "${projectName}" is a Lambda project.`));
|
|
86
|
+
console.log(` Redeploy a previous image instead: ${color.green(`aws lambda update-function-code --function-name ${functionName} --image-uri <ecr-repo>:<sha-tag> --region ${region}`)}`);
|
|
87
|
+
console.log(` List past image tags with: ${color.green(`aws ecr describe-images --repository-name ${projectName}-repo --region ${region}`)}\n`);
|
|
88
|
+
},
|
|
89
|
+
event: 'rollback_run',
|
|
90
|
+
telemetry: { projectName, error_code: 'LAMBDA_TARGET_UNSUPPORTED' },
|
|
91
|
+
reason: 'lambda-target-unsupported',
|
|
92
|
+
resultExtra: { cluster, service, region },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const s = spinner();
|
|
97
|
+
s.start('Inspecting service and task revisions...');
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
// 1. Fetch current service state.
|
|
101
|
+
const svcResp = await ecsClient.send(new DescribeServicesCommand({ cluster, services: [service] }));
|
|
102
|
+
const serviceDesc = (svcResp.services || [])[0] || null;
|
|
103
|
+
|
|
104
|
+
if (!serviceDesc || serviceDesc.status !== 'ACTIVE') {
|
|
105
|
+
s.stop(color.yellow('Service not found.'));
|
|
106
|
+
return failCommand({
|
|
107
|
+
print: () => {
|
|
108
|
+
console.log(`\n The ECS service ${color.cyan(service)} does not exist or is inactive.`);
|
|
109
|
+
console.log(` Run ${color.green('npx grada-run apply')} to provision your infrastructure.\n`);
|
|
110
|
+
},
|
|
111
|
+
event: 'rollback_run',
|
|
112
|
+
telemetry: { projectName, error_code: 'SERVICE_NOT_FOUND' },
|
|
113
|
+
reason: 'service-not-found',
|
|
114
|
+
resultExtra: { cluster, service, region },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const currentTaskDefArn = serviceDesc.taskDefinition;
|
|
119
|
+
const { family, revNum: currentRevNum } = parseFamilyAndRevision(currentTaskDefArn);
|
|
120
|
+
|
|
121
|
+
let targetTaskDefArn;
|
|
122
|
+
let targetRevisionNum;
|
|
123
|
+
|
|
124
|
+
if (options.revision !== undefined && options.revision !== null && String(options.revision).trim() !== '') {
|
|
125
|
+
// Case A: explicit revision. A request for the currently deployed
|
|
126
|
+
// revision is accepted silently; UpdateService with
|
|
127
|
+
// forceNewDeployment still redeploys it.
|
|
128
|
+
const rawRevision = String(options.revision).trim();
|
|
129
|
+
const targetInput = /^\d+$/.test(rawRevision) ? `${family}:${rawRevision}` : rawRevision;
|
|
130
|
+
let descResp;
|
|
131
|
+
try {
|
|
132
|
+
descResp = await ecsClient.send(new DescribeTaskDefinitionCommand({ taskDefinition: targetInput }));
|
|
133
|
+
} catch {
|
|
134
|
+
descResp = null;
|
|
135
|
+
}
|
|
136
|
+
if (!descResp?.taskDefinition || descResp.taskDefinition.status === 'INACTIVE') {
|
|
137
|
+
s.stop(color.red('Revision not found.'));
|
|
138
|
+
return failCommand({
|
|
139
|
+
message: `\n✖ Task definition "${targetInput}" was not found in family "${family}".`,
|
|
140
|
+
hint: 'List recent revisions with: aws ecs list-task-definitions --family-prefix ' + family + ` --region ${region}\n`,
|
|
141
|
+
event: 'rollback_run',
|
|
142
|
+
telemetry: { projectName, error_code: 'REVISION_NOT_FOUND' },
|
|
143
|
+
reason: 'revision-not-found',
|
|
144
|
+
resultExtra: { cluster, service, region },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
targetTaskDefArn = descResp.taskDefinition.taskDefinitionArn;
|
|
148
|
+
targetRevisionNum = String(descResp.taskDefinition.revision);
|
|
149
|
+
s.stop(`Resolved revision ${targetRevisionNum}.`);
|
|
150
|
+
} else {
|
|
151
|
+
// Case B: discover eligible older revisions.
|
|
152
|
+
const listResp = await ecsClient.send(new ListTaskDefinitionsCommand({
|
|
153
|
+
familyPrefix: family,
|
|
154
|
+
status: 'ACTIVE',
|
|
155
|
+
sort: 'DESC',
|
|
156
|
+
maxResults: 10,
|
|
157
|
+
}));
|
|
158
|
+
const eligibleArns = (listResp.taskDefinitionArns || []).filter(
|
|
159
|
+
(arn) => Number(arn.split(':').pop()) < currentRevNum
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
if (eligibleArns.length === 0) {
|
|
163
|
+
s.stop(color.yellow('No previous revisions.'));
|
|
164
|
+
return failCommand({
|
|
165
|
+
message: `\n⚠ No previous task definition revisions found for family ${family}. Cannot roll back.\n`,
|
|
166
|
+
tone: 'yellow',
|
|
167
|
+
event: 'rollback_run',
|
|
168
|
+
telemetry: { projectName, error_code: 'NO_PRIOR_REVISIONS' },
|
|
169
|
+
reason: 'no-prior-revisions',
|
|
170
|
+
resultExtra: { cluster, service, region },
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (headless) {
|
|
175
|
+
targetTaskDefArn = eligibleArns[0];
|
|
176
|
+
targetRevisionNum = targetTaskDefArn.split(':').pop();
|
|
177
|
+
s.stop(`Selected previous revision ${targetRevisionNum}.`);
|
|
178
|
+
} else {
|
|
179
|
+
const candidates = eligibleArns.slice(0, 5);
|
|
180
|
+
const settled = await Promise.allSettled(
|
|
181
|
+
candidates.map((arn) => ecsClient.send(new DescribeTaskDefinitionCommand({ taskDefinition: arn })))
|
|
182
|
+
);
|
|
183
|
+
const promptOptions = candidates.map((arn, index) => {
|
|
184
|
+
const result = settled[index];
|
|
185
|
+
if (result.status === 'fulfilled') {
|
|
186
|
+
const taskDef = result.value?.taskDefinition || {};
|
|
187
|
+
const rev = taskDef.revision ?? arn.split(':').pop();
|
|
188
|
+
const rawImage = taskDef.containerDefinitions?.[0]?.image || 'unknown';
|
|
189
|
+
return {
|
|
190
|
+
value: arn,
|
|
191
|
+
label: `Revision ${rev} — ${rawImage.split('/').pop()}`,
|
|
192
|
+
hint: formatRegisteredAt(taskDef.registeredAt),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
value: arn,
|
|
197
|
+
label: `Revision ${arn.split(':').pop()} — unknown`,
|
|
198
|
+
hint: '',
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
s.stop('Found previous revisions.');
|
|
202
|
+
const selectedArn = await select({
|
|
203
|
+
message: 'Select a task definition revision to roll back to:',
|
|
204
|
+
options: promptOptions,
|
|
205
|
+
});
|
|
206
|
+
if (isCancel(selectedArn)) {
|
|
207
|
+
outro(color.yellow('Rollback cancelled.'));
|
|
208
|
+
return { ok: false, reason: 'cancelled', cluster, service, region };
|
|
209
|
+
}
|
|
210
|
+
targetTaskDefArn = selectedArn;
|
|
211
|
+
targetRevisionNum = targetTaskDefArn.split(':').pop();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 2. Trigger the rollback.
|
|
216
|
+
s.start(`Rolling back ${service} to revision ${targetRevisionNum}...`);
|
|
217
|
+
await ecsClient.send(new UpdateServiceCommand({
|
|
218
|
+
cluster,
|
|
219
|
+
service,
|
|
220
|
+
taskDefinition: targetTaskDefArn,
|
|
221
|
+
forceNewDeployment: true,
|
|
222
|
+
}));
|
|
223
|
+
|
|
224
|
+
if (options.skipWait === true) {
|
|
225
|
+
s.stop(`Rollback to revision ${targetRevisionNum} triggered.`);
|
|
226
|
+
await trackSuccess('rollback_run', { projectName, targetRevision: String(targetRevisionNum), skipWait: true });
|
|
227
|
+
outro(color.green(`Rollback to revision ${targetRevisionNum} initiated! 🚀`));
|
|
228
|
+
return { ok: true, targetTaskDefArn, targetRevision: targetRevisionNum, cluster, service, region };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 3. Monitor the deployment until it stabilizes or times out,
|
|
232
|
+
// with live spinner updates on every non-terminal tick.
|
|
233
|
+
const startTime = Date.now();
|
|
234
|
+
const deadline = startTime + timeoutMs;
|
|
235
|
+
while (true) {
|
|
236
|
+
const pollResp = await ecsClient.send(new DescribeServicesCommand({ cluster, services: [service] }));
|
|
237
|
+
const svc = (pollResp.services || [])[0] || null;
|
|
238
|
+
const primary = svc?.deployments?.find(
|
|
239
|
+
(d) => d.status === 'PRIMARY' && d.taskDefinition === targetTaskDefArn
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
if (primary && (primary.rolloutState === 'COMPLETED'
|
|
243
|
+
|| (primary.runningCount === primary.desiredCount
|
|
244
|
+
&& primary.desiredCount > 0
|
|
245
|
+
&& (svc.deployments || []).length === 1))) {
|
|
246
|
+
s.stop(color.green(`Rolled back to revision ${targetRevisionNum}.`));
|
|
247
|
+
await trackSuccess('rollback_run', { projectName, targetRevision: String(targetRevisionNum) });
|
|
248
|
+
outro(color.green(`Service successfully rolled back to revision ${targetRevisionNum}! 🚀`));
|
|
249
|
+
return { ok: true, targetTaskDefArn, targetRevision: targetRevisionNum, cluster, service, region };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (primary?.rolloutState === 'FAILED') {
|
|
253
|
+
s.stop(color.red('❌ Rollback deployment failed.'));
|
|
254
|
+
return failCommand({
|
|
255
|
+
print: () => {
|
|
256
|
+
console.log(color.red('\n✖ The rollback deployment failed to stabilize.'));
|
|
257
|
+
console.log(` Check service health with ${color.green('npx grada-run status')} and recent output with ${color.green('npx grada-run logs')}.\n`);
|
|
258
|
+
},
|
|
259
|
+
event: 'rollback_run',
|
|
260
|
+
telemetry: { projectName, error_code: 'ROLLOUT_FAILED', targetRevision: String(targetRevisionNum) },
|
|
261
|
+
reason: 'rollout-failed',
|
|
262
|
+
resultExtra: { cluster, service, region },
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const elapsedSec = Math.floor((Date.now() - startTime) / 1000);
|
|
267
|
+
if (!primary) {
|
|
268
|
+
s.message(`Rolling back ${service} to revision ${targetRevisionNum}... [${elapsedSec}s] (registering deployment...)`);
|
|
269
|
+
} else {
|
|
270
|
+
const runningCount = primary.runningCount ?? 0;
|
|
271
|
+
const desiredCount = primary.desiredCount ?? 0;
|
|
272
|
+
const pendingCount = primary.pendingCount ?? 0;
|
|
273
|
+
const failedTasks = primary.failedTasks ?? 0;
|
|
274
|
+
if (failedTasks > 0) {
|
|
275
|
+
s.message(`Rolling back ${service} to revision ${targetRevisionNum}... [${elapsedSec}s] (${runningCount}/${desiredCount} running, ${pendingCount} pending, ${failedTasks} failed ⚠️ — container crashing)`);
|
|
276
|
+
} else if (runningCount >= desiredCount && desiredCount > 0 && pendingCount === 0) {
|
|
277
|
+
s.message(`Rolling back ${service} to revision ${targetRevisionNum}... [${elapsedSec}s] (${runningCount}/${desiredCount} running — draining previous tasks)`);
|
|
278
|
+
} else {
|
|
279
|
+
s.message(`Rolling back ${service} to revision ${targetRevisionNum}... [${elapsedSec}s] (${runningCount}/${desiredCount} running, ${pendingCount} pending)`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (Date.now() >= deadline) {
|
|
284
|
+
s.stop(color.yellow('⚠ Rollback timed out waiting for ECS stabilization.'));
|
|
285
|
+
return failCommand({
|
|
286
|
+
print: () => {
|
|
287
|
+
console.log(color.yellow('\n⚠ The rollback is still in progress.'));
|
|
288
|
+
console.log(` Check progress with ${color.green('npx grada-run status')}.\n`);
|
|
289
|
+
},
|
|
290
|
+
event: 'rollback_run',
|
|
291
|
+
telemetry: { projectName, error_code: 'ROLLOUT_TIMEOUT', targetRevision: String(targetRevisionNum) },
|
|
292
|
+
reason: 'rollout-timeout',
|
|
293
|
+
resultExtra: { cluster, service, region },
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
await sleep(pollIntervalMs);
|
|
298
|
+
}
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (isAuthError(error)) {
|
|
301
|
+
await trackFailure('rollback_run', { projectName, error_code: 'AUTH_EXPIRED' });
|
|
302
|
+
handleAwsAuthError(error, s, options);
|
|
303
|
+
return { ok: false, reason: 'auth-error', cluster, service, region };
|
|
304
|
+
}
|
|
305
|
+
await trackFailure('rollback_run', {
|
|
306
|
+
projectName,
|
|
307
|
+
error_code: error?.name || 'UNKNOWN',
|
|
308
|
+
error_message: error?.message,
|
|
309
|
+
});
|
|
310
|
+
s.stop(color.red('❌ Rollback failed.'));
|
|
311
|
+
return failCommand({
|
|
312
|
+
message: `✖ ${error?.message || error}`,
|
|
313
|
+
hint: 'Check your AWS credentials and region, then try again.',
|
|
314
|
+
reason: 'error',
|
|
315
|
+
resultExtra: { cluster, service, region },
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Convenience alias mirroring the CLI verb.
|
|
321
|
+
export const rollbackCommand = runRollback;
|
|
322
|
+
|
|
323
|
+
export default runRollback;
|